diff --git a/lambda-df-slack/.gitignore b/lambda-df-slack/.gitignore new file mode 100644 index 0000000000..d17ee79192 --- /dev/null +++ b/lambda-df-slack/.gitignore @@ -0,0 +1,44 @@ +# Python caches +__pycache__/ +*.pyc +*.pyo + +# Vendored dependencies (prevent re-commit) +src/boto3/ +src/botocore/ +src/urllib3/ +src/s3transfer/ +src/jmespath/ +src/dateutil/ +src/bin/ +src/*.dist-info/ +src/six.py + +# Build artifacts +build/ +terraform/build/ + +# Terraform +terraform/lambda_deployment.zip +terraform/*.txt +terraform/.terraform/ +terraform/terraform.tfstate +terraform/terraform.tfstate.backup +terraform/*.tfplan +.terraform.lock.hcl + +# Generated files +*.zip + +# OS files +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp + +# Environment +.env +*.env diff --git a/lambda-df-slack/Architecture.png b/lambda-df-slack/Architecture.png new file mode 100644 index 0000000000..8b6223954c Binary files /dev/null and b/lambda-df-slack/Architecture.png differ diff --git a/lambda-df-slack/README.md b/lambda-df-slack/README.md new file mode 100644 index 0000000000..02a75d25c2 --- /dev/null +++ b/lambda-df-slack/README.md @@ -0,0 +1,168 @@ +# AWS Lambda Durable Functions to Slack via Bedrock AgentCore + +This pattern demonstrates a Slack chatbot that uses AWS Lambda durable functions for stateful, multi-turn conversations with human-in-the-loop interactions. The bot collects travel preferences from users via Slack, generates personalized itineraries using Amazon Bedrock (Claude) through AgentCore, and delivers results back to the user — all with automatic state persistence across invocations. + +Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/lambda-df-slack + +Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +## Requirements + +* [Create an AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. +* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) v2.30.0+ installed and configured (v2.30.0+ required for Lambda durable functions) +* [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +* [Terraform](https://developer.hashicorp.com/terraform/downloads) >= 1.5.0 installed +* [Finch](https://github.com/runfinch/finch) installed (for building the AgentCore agent container). Docker users can alias `finch` to `docker` or modify `terraform/main.tf` provisioners. +* Amazon Bedrock access enabled for **Anthropic Claude Sonnet 4** (`us.anthropic.claude-sonnet-4-6`) in us-east-2 +* A Slack workspace where you can create apps + +## Slack Bot Setup + +Follow these steps to create a Slack bot and obtain the required credentials. + +### Create Slack App + +1. Go to https://api.slack.com/apps +2. Click **"Create New App"** → **"From scratch"** +3. App Name: `Travel Assistant` (or your choice) +4. Select your workspace → Click **"Create App"** + +### Add Bot Token Scopes + +1. In the left sidebar, click **"OAuth & Permissions"** +2. Scroll to **"Scopes"** → **"Bot Token Scopes"** +3. Add these scopes: + - `app_mentions:read` + - `channels:history` + - `chat:write` + - `chat:write.public` + - `im:history` + - `im:read` + - `im:write` + - `users:read` + +### Install App to Workspace + +1. Scroll up to **"OAuth Tokens"** → Click **"Install to Workspace"** +2. Review permissions → Click **"Allow"** +3. Copy the **Bot User OAuth Token** (starts with `xoxb-`) + +### Get Signing Secret + +1. Go to **"Basic Information"** in the left sidebar +2. Under **"App Credentials"**, copy the **Signing Secret** + +Save both values — you'll need them during deployment. + +## Deployment Instructions + +1. Clone the repository and navigate to the project directory: + ```bash + git clone https://github.com/aws-samples/serverless-patterns + cd serverless-patterns/lambda-df-slack + cd terraform + ``` + +2. Initialize and deploy: + ```bash + terraform init + terraform apply -auto-approve + ``` + > **Note:** The build script (`terraform/build.sh`) automatically installs Python dependencies into a `build/` directory during `terraform apply`. No manual dependency installation is needed. + + When prompted, enter: + - **prefix** - this will be the prefix for all resource names + - **slack_bot_token** - **Bot User OAuth Token** (starts with `xoxb-`) + - **slack_signing_secret** - **Signing Secret** copied earlier from **"App Credentials"** + +3. Get the API Gateway URL from the output: + ```bash + terraform output api_gateway_url + ``` + +4. Configure Slack Event Subscriptions: + - Go to https://api.slack.com/apps → Select your app + - Click **"Event Subscriptions"** → Toggle **Enable Events** to ON + - Set **Request URL** to your API Gateway URL (e.g., `https://abc123.execute-api.us-east-2.amazonaws.com/prod/slack/events`) + - Wait for **"Verified ✓"** + - Under **"Subscribe to bot events"**, add: `app_mention`,`message.channels`,`message.im` + - Click **"Save Changes"** + - Go to **"Install App"** → Click **"Reinstall to Workspace"** → **"Allow"** + +## How it works + +![Architecture](Architecture.png) + +1. **Slack Handler Lambda** receives webhook events from Slack via API Gateway, verifies the request signature, deduplicates events, and starts a new durable function execution for new conversations. + +2. **Orchestrator (Durable Function)** manages the multi-turn conversation flow. It uses `wait_for_callback()` to pause execution while waiting for user responses — the Lambda is not running during the wait. When the user replies, the callback resumes the orchestrator exactly where it left off. + +3. **DynamoDB Callbacks Table** stores pending callback IDs mapped to execution IDs, enabling the Slack Handler to route incoming user messages back to the correct waiting orchestrator. + +4. **AgentCore Agent** receives the collected travel preferences, invokes Amazon Bedrock (Claude) via the Strands framework to generate a personalized itinerary, and sends the result back via a durable execution callback. + +5. **Slack Handler** posts the final itinerary back to the user in Slack. + +The key innovation is the **wait-for-callback pattern**: the orchestrator suspends (costs nothing while waiting) and automatically resumes when the user responds — enabling multi-turn conversations without managing state manually. + +## Testing + +### Find Your Bot + +1. Open Slack → Go to **"Apps"** in the sidebar +2. Click **"Travel Assistant"** + +### Start a Conversation + +Send a DM to your bot: +``` +Plan a trip for me +``` + +**Expected response:** +``` +Great! I'll help you plan an amazing trip. Let me ask you a few questions... +📍 Where would you like to go? (e.g., Japan, Paris, New York) +``` + +### Complete the Flow + +Answer the bot's questions: +1. **Destination:** `Tokyo` +2. **Dates:** `June 1-10` +3. **Budget:** `$3000` +4. **Interests:** `food` + +Wait for a ~2 minutes for Bedrock to generate the itinerary. + +### Verify via CLI + +```bash +# Check Slack Handler logs +aws logs tail /aws/lambda/-slack-handler --follow --region us-east-2 + +# Check Orchestrator logs +aws logs tail /aws/lambda/-orchestrator --follow --region us-east-2 + +# Check DynamoDB for conversation state +aws dynamodb scan --table-name -callbacks --region us-east-2 +``` + +## Cleanup + +1. Delete all created resources + ```bash + terraform destroy -auto-approve + ``` + +1. During the prompts, enter all details as entered during creation. + +1. Confirm all created resources has been deleted + ``` + terraform show + ``` + +---- +Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/lambda-df-slack/agentcore-agent/.dockerignore b/lambda-df-slack/agentcore-agent/.dockerignore new file mode 100644 index 0000000000..69a6f9c851 --- /dev/null +++ b/lambda-df-slack/agentcore-agent/.dockerignore @@ -0,0 +1,3 @@ +__pycache__ +*.pyc +.DS_Store diff --git a/lambda-df-slack/agentcore-agent/Dockerfile b/lambda-df-slack/agentcore-agent/Dockerfile new file mode 100644 index 0000000000..801442457e --- /dev/null +++ b/lambda-df-slack/agentcore-agent/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.13-slim + +WORKDIR /app + +# Create non-root user first +RUN useradd -m -u 1000 bedrock_agentcore + +# Install dependencies as root (system-wide) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Switch to non-root user +USER bedrock_agentcore + +# Expose ports for AgentCore +EXPOSE 8080 8000 + +# Copy application code +COPY --chown=bedrock_agentcore:bedrock_agentcore . . + +# Run the agent as a module +CMD ["python", "-m", "agent"] diff --git a/lambda-df-slack/agentcore-agent/__main__.py b/lambda-df-slack/agentcore-agent/__main__.py new file mode 100644 index 0000000000..4ba49aa486 --- /dev/null +++ b/lambda-df-slack/agentcore-agent/__main__.py @@ -0,0 +1,5 @@ +"""Entry point for AgentCore agent""" +from agent import app + +if __name__ == "__main__": + app.run() diff --git a/lambda-df-slack/agentcore-agent/agent.py b/lambda-df-slack/agentcore-agent/agent.py new file mode 100644 index 0000000000..9f9b60a443 --- /dev/null +++ b/lambda-df-slack/agentcore-agent/agent.py @@ -0,0 +1,143 @@ +""" +AgentCore Agent for Travel Itinerary Generation. +Accepts prompt + callback info, processes with Bedrock, and sends result via callback. +""" +import os +import json +import logging +import threading +import time + +import boto3 +from strands import Agent +from strands.models import BedrockModel +from bedrock_agentcore.runtime import BedrockAgentCoreApp + +# Configure CloudWatch Logs +logger = logging.getLogger(__name__) +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +# CloudWatch logging is handled by the AgentCore runtime itself +# No need for watchtower - just use standard logging +LAMBDA_REGION = os.environ.get("AWS_REGION", "us-east-2") +logger.info("Agent module loaded") + +app = BedrockAgentCoreApp() + + +def send_callback_success(callback_id: str, result: dict): + """Send the result back to the durable function via callback.""" + logger.info(f"Sending callback success for {callback_id[:20]}...") + lambda_client = boto3.client("lambda", region_name=LAMBDA_REGION) + lambda_client.send_durable_execution_callback_success( + CallbackId=callback_id, + Result=json.dumps(result), + ) + logger.info("Callback sent successfully") + + +def send_callback_failure(callback_id: str, error: str): + """Send error back to the durable function.""" + logger.error(f"Sending callback failure: {error}") + lambda_client = boto3.client("lambda", region_name=LAMBDA_REGION) + lambda_client.send_durable_execution_callback_failure( + CallbackId=callback_id, + Error={"errorMessage": error, "errorType": "AgentError"}, + ) + + +def run_agent(prompt: str, model_id: str, callback_id: str, task_id: str, system_prompt: str = None): + """Invoke Bedrock via Strands Agent and send result back via durable callback.""" + try: + logger.info(f"Starting agent with model {model_id}") + + model = BedrockModel( + model_id=model_id, + max_tokens=8192, # Increased for detailed itineraries + temperature=0.8, + ) + + default_system = """You are a knowledgeable travel advisor who creates detailed, +personalized travel itineraries. Provide practical, specific recommendations with +clear day-by-day plans. Be helpful, enthusiastic, and concise.""" + + agent = Agent( + model=model, + system_prompt=system_prompt or default_system, + ) + + logger.info("Invoking Bedrock model...") + result = agent(prompt) + answer = str(result) + + logger.info(f"LLM completed, generated {len(answer)} characters") + send_callback_success(callback_id, {"itinerary": answer}) + + except Exception as e: + logger.error(f"Agent failed: {e}", exc_info=True) + send_callback_failure(callback_id, str(e)) + finally: + app.complete_async_task(task_id) + + +@app.entrypoint +def entrypoint(payload): + """ + Main entrypoint invoked by AgentCore Runtime. + + Expects payload: + - prompt: travel planning prompt + - callbackId: durable execution callback ID + - model (optional): { modelId: "..." } + - systemPrompt (optional): custom system prompt + + Returns confirmation immediately, then processes in background. + """ + prompt = payload.get("prompt", "") + callback_id = payload.get("callbackId") + model_config = payload.get("model", {}) + system_prompt = payload.get("systemPrompt") + + model_id = model_config.get( + "modelId", + "us.anthropic.claude-sonnet-4-6" + ) + + if not callback_id: + logger.error("Missing callbackId in payload") + return {"error": "Missing callbackId in payload"} + + if not prompt: + logger.error("Missing prompt in payload") + return {"error": "Missing prompt in payload"} + + logger.info(f"Received request with callback {callback_id[:20]}...") + + # Track the async task so /ping reports HealthyBusy + task_id = app.add_async_task("itinerary_generation", { + "prompt": prompt[:100] + "..." if len(prompt) > 100 else prompt, + "callbackId": callback_id[:20] + "...", + }) + + # Run the LLM work in background thread + threading.Thread( + target=run_agent, + args=(prompt, model_id, callback_id, task_id, system_prompt), + daemon=True, + ).start() + + logger.info("Request accepted, processing in background") + + # Return confirmation immediately + return { + "status": "accepted", + "message": "Generating itinerary, will callback when complete", + "callbackId": callback_id, + } + + +if __name__ == "__main__": + app.run() diff --git a/lambda-df-slack/agentcore-agent/requirements.txt b/lambda-df-slack/agentcore-agent/requirements.txt new file mode 100644 index 0000000000..cca38a0019 --- /dev/null +++ b/lambda-df-slack/agentcore-agent/requirements.txt @@ -0,0 +1,4 @@ +strands-agents +bedrock-agentcore +boto3 +aws-durable-execution-sdk-python diff --git a/lambda-df-slack/example-pattern.json b/lambda-df-slack/example-pattern.json new file mode 100644 index 0000000000..e9fc39d891 --- /dev/null +++ b/lambda-df-slack/example-pattern.json @@ -0,0 +1,70 @@ +{ + "title": "AWS Lambda Durable Functions to Slack via Bedrock AgentCore", + "description": "A Slack chatbot using AWS Lambda Durable Functions for stateful, multi-turn conversations with human-in-the-loop interactions, generating travel itineraries via Amazon Bedrock through AgentCore.", + "language": "Python", + "level": "400", + "framework": "Terraform", + "introBox": { + "headline": "How it works", + "text": [ + "The Slack Handler Lambda receives webhook events from Slack via API Gateway, verifies the request signature, deduplicates events, and starts a new Durable Function execution for new conversations.", + "The Orchestrator (Durable Function) manages the multi-turn conversation flow using wait_for_callback() to pause execution while waiting for user responses — the Lambda is not running during the wait.", + "When the user replies, the callback resumes the orchestrator exactly where it left off. The AgentCore Agent invokes Amazon Bedrock (Claude) via the Strands framework to generate a personalized itinerary.", + "The key innovation is the wait-for-callback pattern: the orchestrator suspends (costs nothing while waiting) and automatically resumes when the user responds — enabling multi-turn conversations without managing state manually." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/lambda-df-slack", + "templateURL": "serverless-patterns/lambda-df-slack", + "projectFolder": "lambda-df-slack", + "templateFile": "terraform/main.tf" + } + }, + "resources": { + "bullets": [ + { + "text": "AWS Lambda Durable Functions", + "link": "https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html" + }, + { + "text": "Amazon Bedrock AgentCore", + "link": "https://aws.amazon.com/bedrock/agentcore/" + }, + { + "text": "Amazon Bedrock", + "link": "https://aws.amazon.com/bedrock/" + }, + { + "text": "Slack API Documentation", + "link": "https://api.slack.com/" + } + ] + }, + "deploy": { + "text": [ + "terraform init", + "terraform apply -auto-approve" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Change directory: cd terraform", + "Delete the stack: terraform destroy -auto-approve", + "Confirm all resources have been deleted: terraform show" + ] + }, + "authors": [ + { + "name": "Rajil Paloth", + "image": "https://i.ibb.co/r2TsqGf6/Passport-size.jpg", + "bio": "ProServ Delivery Consultant at AWS", + "linkedin": "paloth" + } + ] +} diff --git a/lambda-df-slack/requirements.txt b/lambda-df-slack/requirements.txt new file mode 100644 index 0000000000..90c7536bf5 --- /dev/null +++ b/lambda-df-slack/requirements.txt @@ -0,0 +1,13 @@ +# AWS SDK +boto3>=1.28.0 + +# AWS Lambda Durable Functions SDK +aws-durable-execution-sdk-python>=1.0.0 + +# Testing +aws-durable-execution-sdk-python-testing>=1.0.0 + +# No additional dependencies needed - using standard library for HTTP requests +# In production, consider: +# - slack-sdk for official Slack client +# - requests for more robust HTTP handling diff --git a/lambda-df-slack/src/activities.py b/lambda-df-slack/src/activities.py new file mode 100644 index 0000000000..0c457a0a6f --- /dev/null +++ b/lambda-df-slack/src/activities.py @@ -0,0 +1,81 @@ +""" +Activity Functions using AWS Lambda Durable Functions +These are durable steps that can be called within orchestrator functions +""" +import os +from typing import Dict, Any + +from aws_durable_execution_sdk_python import durable_step, StepContext + +from utils.slack_client import SlackClient +# from utils.bedrock_client import BedrockClient # Not used - AgentCore handles AI + +# NOTE: Bedrock functions removed - we use AgentCore for AI instead + + +@durable_step +def post_to_slack(step_ctx: StepContext, channel: str, text: str, blocks: list = None) -> Dict[str, Any]: + """ + Durable step for posting to Slack + + Args: + step_ctx: Step context + channel: Slack channel ID + text: Message text + blocks: Optional Block Kit blocks + + Returns: + Slack API response + """ + step_ctx.logger.info(f"Posting to Slack channel: {channel}") + + slack = SlackClient() + result = slack.post_message( + channel=channel, + text=text, + blocks=blocks + ) + + step_ctx.logger.info("Message posted to Slack successfully") + return result + + +def format_itinerary_blocks(itinerary_text: str) -> list: + """ + Format itinerary text as Slack Block Kit blocks + + This is a pure function (not a step) since it doesn't interact with external services. + """ + blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "🎉 Your Personalized Travel Itinerary" + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": itinerary_text + } + }, + { + "type": "divider" + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "_Have an amazing trip! Feel free to ask me to plan another one anytime._" + } + ] + } + ] + + return blocks diff --git a/lambda-df-slack/src/agentcore_client.py b/lambda-df-slack/src/agentcore_client.py new file mode 100644 index 0000000000..684ec6bac9 --- /dev/null +++ b/lambda-df-slack/src/agentcore_client.py @@ -0,0 +1,110 @@ +""" +AgentCore Client - Invokes AgentCore agent runtime +""" +import os +import json +import logging +import boto3 +from typing import Optional, Dict, Any + +logger = logging.getLogger(__name__) + + +class AgentCoreClient: + """Client for invoking AgentCore agent runtime""" + + def __init__(self, agent_runtime_arn: Optional[str] = None): + """ + Initialize AgentCore client + + Args: + agent_runtime_arn: ARN of the AgentCore agent runtime + """ + self.agent_runtime_arn = agent_runtime_arn or os.environ.get('AGENT_RUNTIME_ARN') + if not self.agent_runtime_arn: + raise ValueError("AGENT_RUNTIME_ARN environment variable or parameter required") + + # Initialize Bedrock AgentCore client + self.client = boto3.client('bedrock-agentcore', region_name=os.environ['AWS_REGION']) + + logger.info("Initialized with runtime: %s", self.agent_runtime_arn) + + def invoke_agent(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """ + Invoke the AgentCore agent runtime + + Args: + payload: Payload to send to agent (must include callbackId and prompt) + + Returns: + Response from agent runtime (immediate confirmation) + """ + logger.info("Invoking agent with payload: %s", json.dumps(payload, default=str)[:200]) + + try: + response = self.client.invoke_agent_runtime( + agentRuntimeArn=self.agent_runtime_arn, + payload=json.dumps(payload).encode('utf-8') + ) + + # Parse response + response_body = response['response'].read().decode('utf-8') + result = json.loads(response_body) + + logger.info("Agent response: %s", result) + return result + + except Exception as e: + logger.error("Error invoking agent: %s", e) + raise + + def generate_itinerary( + self, + destination: str, + dates: str, + budget: str, + interests: str, + callback_id: str + ) -> Dict[str, Any]: + """ + Generate travel itinerary via AgentCore agent + + Args: + destination: Travel destination + dates: Travel dates + budget: Budget amount + interests: User interests + callback_id: Durable callback ID + + Returns: + Confirmation from agent (actual result comes via callback) + """ + prompt = f"""Create a detailed travel itinerary with the following details: + +**Destination:** {destination} +**Dates:** {dates} +**Budget:** {budget} +**Interests:** {interests} + +Please provide: +1. **Day-by-Day Itinerary** - Specific activities and timing for each day +2. **Accommodations** - Recommended hotels/areas to stay with price ranges +3. **Must-See Attractions** - Top sights aligned with their interests +4. **Food Recommendations** - Local cuisine and specific restaurant suggestions +5. **Budget Breakdown** - Estimated costs (accommodation, food, activities, transport) +6. **Travel Tips** - Local customs, transportation, best times to visit attractions + +Make it practical and actionable. Use bullet points and clear sections.""" + + payload = { + "prompt": prompt, + "callbackId": callback_id, + "model": { + "modelId": os.environ.get('BEDROCK_MODEL_ID', 'us.anthropic.claude-sonnet-4-6') + }, + "systemPrompt": """You are a knowledgeable travel advisor who creates detailed, +personalized travel itineraries. Provide practical, specific recommendations with +clear day-by-day plans. Format output to be readable in Slack.""" + } + + return self.invoke_agent(payload) diff --git a/lambda-df-slack/src/dedup.py b/lambda-df-slack/src/dedup.py new file mode 100644 index 0000000000..43856ee17e --- /dev/null +++ b/lambda-df-slack/src/dedup.py @@ -0,0 +1,49 @@ +""" +DynamoDB-based Slack event deduplication. +Uses conditional writes for atomic check-and-record across concurrent Lambda instances. +""" +import os +import time +import logging + +import boto3 +from botocore.exceptions import ClientError + +logger = logging.getLogger(__name__) + +dynamodb = boto3.resource('dynamodb') +dedup_table = dynamodb.Table(os.environ['CALLBACKS_TABLE_NAME']) + +# TTL for dedup entries: 5 minutes +DEDUP_TTL_SECONDS = 300 + + +def is_duplicate_event(event_id: str) -> bool: + """ + Check if a Slack event has already been processed using DynamoDB conditional write. + Works correctly across concurrent Lambda instances. + + Uses a conditional PutItem that fails if the item already exists, + providing atomic deduplication. + + Args: + event_id: The Slack event_id to check + + Returns: + True if event was already processed (duplicate), False if new + """ + try: + dedup_table.put_item( + Item={ + 'user_id': f'DEDUP#{event_id}', + 'step': 'event', + 'ttl': int(time.time()) + DEDUP_TTL_SECONDS, + }, + ConditionExpression='attribute_not_exists(user_id)', + ) + return False # Successfully wrote — this is a new event + except ClientError as e: + if e.response['Error']['Code'] == 'ConditionalCheckFailedException': + return True # Already exists — duplicate + logger.error("DynamoDB error during dedup check: %s", e) + raise diff --git a/lambda-df-slack/src/orchestrator.py b/lambda-df-slack/src/orchestrator.py new file mode 100644 index 0000000000..d279ecbde6 --- /dev/null +++ b/lambda-df-slack/src/orchestrator.py @@ -0,0 +1,215 @@ +""" +Durable Function Orchestrator for Travel Planning Conversation +Uses AWS Lambda Durable Functions for stateful, long-running workflows with human-in-the-loop +""" +import json +import os +import boto3 +from typing import Dict, Any + +from aws_durable_execution_sdk_python import durable_execution, DurableContext +from aws_durable_execution_sdk_python.config import WaitForCallbackConfig, Duration + +from activities import post_to_slack +from agentcore_client import AgentCoreClient + +dynamodb = boto3.resource('dynamodb') +callbacks_table = dynamodb.Table(os.environ['CALLBACKS_TABLE_NAME']) + +agentcore_client = AgentCoreClient() + + +@durable_execution +def travel_planning_orchestrator(event: Dict[str, Any], context: DurableContext) -> Dict[str, Any]: + """ + Main orchestrator function - runs as a Durable Function + + This demonstrates: + 1. State persistence across multiple invocations (automatic) + 2. Wait-for-callback pattern for user responses + 3. Automatic replay safety - steps execute only once + 4. Integration with external services (Slack, Bedrock) + + Args: + event: Lambda event containing user_id, channel, execution_id + context: Durable execution context + + Returns: + Result dictionary with status and details + """ + user_id = event['user_id'] + channel = event['channel'] + execution_id = event['execution_id'] + + context.logger.info(f"Starting travel planning orchestration for user {user_id}") + + # Step 1: Ask for destination + context.step(post_to_slack( + channel=channel, + text="📍 Where would you like to go? (e.g., Japan, Paris, New York)" + )) + + # Wait for user response + def request_destination(callback_id: str, ctx): + ctx.logger.info(f"Callback ID for destination: {callback_id}") + callbacks_table.put_item(Item={ + 'user_id': user_id, + 'callback_id': callback_id, + 'step': 'destination', + }) + + destination = context.wait_for_callback( + submitter=request_destination, + name='wait-for-destination', + config=WaitForCallbackConfig(timeout=Duration.from_hours(1)) + ) + + context.logger.info(f"Received destination: {destination}") + + # Step 2: Ask for dates + context.step(post_to_slack( + channel=channel, + text=f"Great choice! {destination} is beautiful. 📅 When are you planning to travel? (e.g., May 15-22, 2026)" + )) + + def request_dates(callback_id: str, ctx): + ctx.logger.info(f"Callback ID for dates: {callback_id}") + callbacks_table.put_item(Item={ + 'user_id': user_id, + 'callback_id': callback_id, + 'step': 'dates', + }) + + dates = context.wait_for_callback( + submitter=request_dates, + name='wait-for-dates', + config=WaitForCallbackConfig(timeout=Duration.from_hours(1)) + ) + + context.logger.info(f"Received dates: {dates}") + + # Step 3: Ask for budget + context.step(post_to_slack( + channel=channel, + text="💰 What's your approximate budget for this trip? (e.g., $2000, $5000)" + )) + + def request_budget(callback_id: str, ctx): + ctx.logger.info(f"Callback ID for budget: {callback_id}") + callbacks_table.put_item(Item={ + 'user_id': user_id, + 'callback_id': callback_id, + 'step': 'budget', + }) + + budget = context.wait_for_callback( + submitter=request_budget, + name='wait-for-budget', + config=WaitForCallbackConfig(timeout=Duration.from_hours(1)) + ) + + context.logger.info(f"Received budget: {budget}") + + # Step 4: Ask for interests + context.step(post_to_slack( + channel=channel, + text="🎯 What are you most interested in? (e.g., food, history, adventure, beaches, culture)" + )) + + def request_interests(callback_id: str, ctx): + ctx.logger.info(f"Callback ID for interests: {callback_id}") + callbacks_table.put_item(Item={ + 'user_id': user_id, + 'callback_id': callback_id, + 'step': 'interests', + }) + + interests = context.wait_for_callback( + submitter=request_interests, + name='wait-for-interests', + config=WaitForCallbackConfig(timeout=Duration.from_hours(1)) + ) + + context.logger.info(f"Received interests: {interests}") + + # Step 5: Notify user we're generating itinerary + context.step(post_to_slack( + channel=channel, + text="✨ Generating your personalized itinerary... This may take a moment." + )) + + # Step 6: Generate itinerary via AgentCore (async with callback) + def invoke_agentcore(callback_id: str, ctx): + ctx.logger.info(f"Invoking AgentCore with callback {callback_id[:20]}...") + # AgentCore will process and send callback when done + confirmation = agentcore_client.generate_itinerary( + destination=destination, + dates=dates, + budget=budget, + interests=interests, + callback_id=callback_id + ) + ctx.logger.info(f"AgentCore confirmed: {confirmation}") + + # Wait for AgentCore to send back the itinerary via callback + itinerary_response = context.wait_for_callback( + submitter=invoke_agentcore, + name='wait-for-agentcore-itinerary', + config=WaitForCallbackConfig(timeout=Duration.from_minutes(5)) + ) + + # Parse the response from AgentCore + if isinstance(itinerary_response, str): + itinerary_data = json.loads(itinerary_response) + else: + itinerary_data = itinerary_response + + itinerary = itinerary_data.get('itinerary', str(itinerary_data)) + + context.logger.info("Itinerary generated, posting to Slack") + + # Step 7: Post final itinerary + context.step(post_to_slack( + channel=channel, + text=f"🎉 *Your Personalized Travel Itinerary*\n\n{itinerary}\n\n_Have an amazing trip!_" + )) + + context.logger.info("Orchestration completed successfully") + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'status': 'completed', + 'execution_id': execution_id, + 'destination': destination, + 'dates': dates + }) + } + + +def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + """ + Lambda handler wrapper - entry point for AWS Lambda + + This handles both: + 1. New orchestration requests + 2. Callback responses from users + + Args: + event: Lambda event + context: Lambda context + + Returns: + Response dictionary + """ + # Check if this is a callback response + if event.get('callback_id'): + # This is a callback response - the SDK will automatically handle it + # and resume the waiting orchestration + return { + 'statusCode': 200, + 'body': json.dumps({'message': 'Callback processed'}) + } + + # This is a new orchestration request + return travel_planning_orchestrator(event, context) diff --git a/lambda-df-slack/src/secrets.py b/lambda-df-slack/src/secrets.py new file mode 100644 index 0000000000..c5055d945d --- /dev/null +++ b/lambda-df-slack/src/secrets.py @@ -0,0 +1,34 @@ +""" +Secrets Manager integration for Slack credentials. +Fetches and caches secrets for the Lambda container lifetime. +""" +import json +import os +import logging +from typing import Optional + +import boto3 + +logger = logging.getLogger(__name__) + +_secrets_cache: Optional[dict] = None + + +def get_slack_secrets() -> dict: + """ + Fetch Slack bot token and signing secret from AWS Secrets Manager. + Caches result for Lambda container lifetime to avoid repeated API calls. + + Returns: + dict with keys 'SLACK_BOT_TOKEN' and 'SLACK_SIGNING_SECRET' + """ + global _secrets_cache + if _secrets_cache is not None: + return _secrets_cache + + secret_arn = os.environ['SLACK_SECRETS_ARN'] + client = boto3.client('secretsmanager') + response = client.get_secret_value(SecretId=secret_arn) + _secrets_cache = json.loads(response['SecretString']) + logger.info("Slack secrets loaded from Secrets Manager") + return _secrets_cache diff --git a/lambda-df-slack/src/slack_handler.py b/lambda-df-slack/src/slack_handler.py new file mode 100644 index 0000000000..279d53c25b --- /dev/null +++ b/lambda-df-slack/src/slack_handler.py @@ -0,0 +1,216 @@ +""" +Slack Event Handler - Entry point for Slack webhooks +Handles Slack events and initiates Durable Function orchestrations +""" +import json +import os +import hashlib +import hmac +import time +import logging +import boto3 +import boto3.dynamodb.conditions +from typing import Dict, Any + +from utils.slack_client import SlackClient +from secrets import get_slack_secrets +from dedup import is_duplicate_event + +logger = logging.getLogger(__name__) + +ORCHESTRATOR_FUNCTION_ARN = os.environ['ORCHESTRATOR_FUNCTION_ARN'] + +lambda_client = boto3.client('lambda') +dynamodb = boto3.resource('dynamodb') +callbacks_table = dynamodb.Table(os.environ['CALLBACKS_TABLE_NAME']) + + +def verify_slack_request(event: Dict[str, Any]) -> bool: + """Verify request is from Slack using signing secret""" + secrets = get_slack_secrets() + signing_secret = secrets['SLACK_SIGNING_SECRET'] + + # Normalize headers to lowercase for API Gateway proxy integration + headers = {k.lower(): v for k, v in event.get('headers', {}).items()} + timestamp = headers.get('x-slack-request-timestamp', '') + signature = headers.get('x-slack-signature', '') + + if not timestamp or not signature: + logger.warning("Missing Slack headers - timestamp or signature not found") + return False + + # Prevent replay attacks + try: + if abs(time.time() - int(timestamp)) > 60 * 5: + return False + except ValueError: + logger.warning("Invalid timestamp format: %s", timestamp) + return False + + body = event.get('body', '') + sig_basestring = f"v0:{timestamp}:{body}" + + my_signature = 'v0=' + hmac.new( + signing_secret.encode(), + sig_basestring.encode(), + hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(my_signature, signature) + + +def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + """ + Main Lambda handler for Slack events + """ + logger.info("Received event: %s", json.dumps(event)) + + body = json.loads(event.get('body', '{}')) + + # Handle Slack URL verification challenge FIRST + if body.get('type') == 'url_verification': + return { + 'statusCode': 200, + 'headers': {'Content-Type': 'application/json'}, + 'body': json.dumps({'challenge': body['challenge']}) + } + + # Verify Slack signature + if not verify_slack_request(event): + return { + 'statusCode': 401, + 'body': json.dumps({'error': 'Invalid signature'}) + } + + # Handle events + if body.get('type') == 'event_callback': + # Deduplicate Slack retries + event_id = body.get('event_id', '') + if event_id and is_duplicate_event(event_id): + logger.info("Duplicate event %s, skipping", event_id) + return {'statusCode': 200, 'body': json.dumps({'ok': True})} + + slack_event = body.get('event', {}) + event_type = slack_event.get('type') + + # Ignore bot messages to prevent loops + if slack_event.get('bot_id'): + return {'statusCode': 200, 'body': json.dumps({'ok': True})} + + # Ignore message subtypes (edits, deletes, etc.) + if slack_event.get('subtype'): + return {'statusCode': 200, 'body': json.dumps({'ok': True})} + + if event_type in ['message', 'app_mention']: + handle_message_event(slack_event) + + return {'statusCode': 200, 'body': json.dumps({'ok': True})} + + return {'statusCode': 200, 'body': json.dumps({'ok': True})} + + +def handle_message_event(event: Dict[str, Any]): + """Handle new Slack messages""" + user_id = event.get('user') + channel = event.get('channel') + text = event.get('text', '').strip().lower() + + if not user_id or not text: + return + + logger.info("Message from %s in %s: %s", user_id, channel, text) + + # Check if this is a new trip planning request + if any(keyword in text for keyword in ['plan a trip', 'plan trip', 'travel planning', 'help me plan']): + # Check if user already has an active orchestration + response = callbacks_table.query( + KeyConditionExpression=boto3.dynamodb.conditions.Key('user_id').eq(user_id) + ) + if response.get('Items'): + slack_client = SlackClient() + slack_client.post_message( + channel=channel, + text="You already have a trip planning session in progress. Please answer the current question or say 'cancel' to start over." + ) + return + + # Start new durable function orchestration + execution_id = f"{user_id}_{int(time.time())}" + logger.info("Starting new orchestration: %s", execution_id) + + slack_client = SlackClient() + slack_client.post_message( + channel=channel, + text="Great! I'll help you plan an amazing trip. Let me ask you a few questions..." + ) + + try: + response = lambda_client.invoke( + FunctionName=ORCHESTRATOR_FUNCTION_ARN, + InvocationType='Event', + Payload=json.dumps({ + 'execution_id': execution_id, + 'user_id': user_id, + 'channel': channel + }) + ) + logger.info("Invoked orchestrator: %s", response['StatusCode']) + except Exception as e: + logger.error("Error invoking orchestrator: %s", e) + slack_client.post_message( + channel=channel, + text="Sorry, I encountered an error starting the trip planning. Please try again." + ) + elif text == 'cancel': + # Cancel active orchestration by clearing callbacks + response = callbacks_table.query( + KeyConditionExpression=boto3.dynamodb.conditions.Key('user_id').eq(user_id) + ) + for item in response.get('Items', []): + callbacks_table.delete_item(Key={'user_id': user_id, 'step': item['step']}) + + slack_client = SlackClient() + slack_client.post_message( + channel=channel, + text="Trip planning cancelled. Say 'plan a trip' to start over." + ) + else: + # This is a response to an ongoing conversation + send_callback_to_orchestration(user_id, channel, text) + + +def send_callback_to_orchestration(user_id: str, channel: str, message: str): + """Send user's message as callback to waiting orchestration""" + + try: + # Query all callbacks for this user, get the most recent one + response = callbacks_table.query( + KeyConditionExpression=boto3.dynamodb.conditions.Key('user_id').eq(user_id), + ScanIndexForward=False + ) + + items = response.get('Items', []) + if not items: + logger.warning("No active callback for user %s", user_id) + return + + # Use the most recently written callback (last item by timestamp) + item = max(items, key=lambda x: x.get('timestamp', 0)) + callback_id = item['callback_id'] + step = item.get('step', 'unknown') + logger.info("Found callback_id for %s (step: %s): %s...", user_id, step, callback_id[:50]) + + # Send callback to resume orchestration + lambda_client.send_durable_execution_callback_success( + CallbackId=callback_id, + Result=json.dumps(message) + ) + + logger.info("Sent callback successfully for %s", user_id) + + # Delete the callback entry (it's been used) + callbacks_table.delete_item(Key={'user_id': user_id, 'step': step}) + + except Exception: + logger.exception("Failed to send callback for user %s", user_id) + raise diff --git a/lambda-df-slack/src/utils/__init__.py b/lambda-df-slack/src/utils/__init__.py new file mode 100644 index 0000000000..f255893087 --- /dev/null +++ b/lambda-df-slack/src/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules for Slack and Bedrock integration""" diff --git a/lambda-df-slack/src/utils/slack_client.py b/lambda-df-slack/src/utils/slack_client.py new file mode 100644 index 0000000000..49a4665b89 --- /dev/null +++ b/lambda-df-slack/src/utils/slack_client.py @@ -0,0 +1,91 @@ +""" +Slack API Client Wrapper +Handles posting messages to Slack channels +""" +import json +import logging +import urllib.request +import urllib.error +from typing import Optional, Dict, Any + +from secrets import get_slack_secrets + +logger = logging.getLogger(__name__) + + +class SlackClient: + """Simple Slack API client""" + + def __init__(self): + secrets = get_slack_secrets() + self.bot_token = secrets['SLACK_BOT_TOKEN'] + + def post_message(self, channel: str, text: str, blocks: Optional[list] = None) -> Dict[str, Any]: + """ + Post a message to a Slack channel + + Args: + channel: Channel ID or DM ID + text: Message text (fallback if blocks not rendered) + blocks: Optional Block Kit blocks for rich formatting + + Returns: + API response dict + """ + url = 'https://slack.com/api/chat.postMessage' + + payload = { + 'channel': channel, + 'text': text + } + + if blocks: + payload['blocks'] = blocks + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.bot_token}' + } + + try: + req = urllib.request.Request( + url, + data=json.dumps(payload).encode('utf-8'), + headers=headers, + method='POST' + ) + + with urllib.request.urlopen(req) as response: + result = json.loads(response.read().decode('utf-8')) + + if not result.get('ok'): + logger.error("Slack API error: %s", result.get('error')) + raise Exception(f"Slack API error: {result.get('error')}") + + logger.info("Posted message to %s: %s...", channel, text[:50]) + return result + + except urllib.error.HTTPError as e: + error_body = e.read().decode('utf-8') + logger.error("HTTP error posting to Slack: %s - %s", e.code, error_body) + raise + + except Exception as e: + logger.error("Error posting to Slack: %s", e) + raise + + def post_rich_message(self, channel: str, text: str, sections: list) -> Dict[str, Any]: + """ + Post a rich message using Block Kit + + Args: + channel: Channel ID + text: Fallback text + sections: List of section blocks + + Example: + sections = [ + {"type": "section", "text": {"type": "mrkdwn", "text": "*Bold* text"}} + ] + """ + return self.post_message(channel, text, blocks=sections) diff --git a/lambda-df-slack/terraform/build.sh b/lambda-df-slack/terraform/build.sh new file mode 100755 index 0000000000..d25fe8dfbb --- /dev/null +++ b/lambda-df-slack/terraform/build.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -e + +# Resolve script directory so paths work regardless of where the script is called from +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD_DIR="${SCRIPT_DIR}/build" +SRC_DIR="${SCRIPT_DIR}/../src" +REQUIREMENTS="${SCRIPT_DIR}/../requirements.txt" + +echo "Building Lambda deployment package..." + +# Step 1: Clean build directory +if [ -d "$BUILD_DIR" ]; then + echo "Cleaning existing build directory..." + rm -rf "$BUILD_DIR" +fi +mkdir -p "$BUILD_DIR" + +# Step 2: Install runtime dependencies (exclude testing packages) +echo "Installing dependencies..." +grep -v "testing" "$REQUIREMENTS" | grep -v "^#" | grep -v "^$" | \ + pip install --target "$BUILD_DIR" -r /dev/stdin --quiet + +# Step 3: Copy application source files +echo "Copying application source..." +cp "$SRC_DIR"/*.py "$BUILD_DIR"/ +cp -r "$SRC_DIR/utils" "$BUILD_DIR/utils" + +echo "Build complete. Output: $BUILD_DIR" diff --git a/lambda-df-slack/terraform/main.tf b/lambda-df-slack/terraform/main.tf new file mode 100644 index 0000000000..2b7fcc2c7c --- /dev/null +++ b/lambda-df-slack/terraform/main.tf @@ -0,0 +1,1025 @@ +######################################## +# Terraform Configuration & Providers +######################################## + +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + archive = { + source = "hashicorp/archive" + version = "~> 2.4" + } + null = { + source = "hashicorp/null" + version = "~> 3.0" + } + local = { + source = "hashicorp/local" + version = "~> 2.0" + } + } +} + +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = var.prefix + ManagedBy = "terraform" + } + } +} + +data "aws_caller_identity" "current" {} + +######################################## +# Variables +######################################## + +variable "aws_region" { + description = "AWS region to deploy resources" + type = string + default = "us-east-2" +} + + +variable "prefix" { + description = "Prefix for all resource names" + type = string +} + +variable "slack_bot_token" { + description = "Slack Bot User OAuth Token (xoxb-...)" + type = string + sensitive = true +} + +variable "slack_signing_secret" { + description = "Slack App Signing Secret" + type = string + sensitive = true +} + + +variable "lambda_runtime" { + description = "Lambda runtime version" + type = string + default = "python3.13" +} + +variable "lambda_timeout" { + description = "Lambda function timeout in seconds" + type = number + default = 60 +} + +variable "lambda_memory_size" { + description = "Lambda function memory size in MB" + type = number + default = 512 +} + +variable "orchestrator_timeout" { + description = "Orchestrator Lambda timeout in seconds" + type = number + default = 900 +} + +variable "durable_execution_timeout" { + description = "Durable execution timeout in seconds" + type = number + default = 3600 +} + +variable "durable_retention_days" { + description = "Durable execution history retention in days" + type = number + default = 7 +} + +variable "bedrock_model_id" { + description = "Bedrock model ID for Claude" + type = string + default = "us.anthropic.claude-sonnet-4-6" +} + +variable "dynamodb_billing_mode" { + description = "DynamoDB billing mode" + type = string + default = "PAY_PER_REQUEST" +} + +variable "enable_xray_tracing" { + description = "Enable AWS X-Ray tracing" + type = bool + default = false +} + +variable "log_retention_days" { + description = "CloudWatch Logs retention period in days" + type = number + default = 7 +} + +######################################## +# Locals +######################################## + +locals { + name_prefix = var.prefix + lambda_source_dir = "${path.module}/../src" + + common_lambda_env = { + SLACK_SECRETS_ARN = aws_secretsmanager_secret.slack_secrets.arn + BEDROCK_MODEL_ID = var.bedrock_model_id + BEDROCK_REGION = var.aws_region + AGENT_RUNTIME_ARN = try(trimspace(data.local_file.agent_runtime_arn.content), "") + CALLBACKS_TABLE_NAME = aws_dynamodb_table.callbacks.name + } + + common_tags = { + Project = var.prefix + } +} + +######################################## +# DynamoDB - Callbacks Table +######################################## + +resource "aws_dynamodb_table" "callbacks" { + name = "${local.name_prefix}-callbacks" + billing_mode = var.dynamodb_billing_mode + hash_key = "user_id" + range_key = "step" + + attribute { + name = "user_id" + type = "S" + } + + attribute { + name = "step" + type = "S" + } + + ttl { + attribute_name = "ttl" + enabled = true + } + + server_side_encryption { + enabled = true + } + + tags = merge( + local.common_tags, + { + Name = "${local.name_prefix}-callbacks" + } + ) +} + +######################################## +# Secrets Manager - Slack Secrets +######################################## + +resource "aws_secretsmanager_secret" "slack_secrets" { + name = "${local.name_prefix}-slack-secrets" + description = "Slack bot token and signing secret for the travel assistant" + recovery_window_in_days = 0 + + tags = local.common_tags +} + +resource "aws_secretsmanager_secret_version" "slack_secrets" { + secret_id = aws_secretsmanager_secret.slack_secrets.id + secret_string = jsonencode({ + SLACK_BOT_TOKEN = var.slack_bot_token + SLACK_SIGNING_SECRET = var.slack_signing_secret + }) +} + +######################################## +# IAM Roles & Policies +######################################## + +# Slack Handler Role +resource "aws_iam_role" "slack_handler_role" { + name = "${local.name_prefix}-slack-handler-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + }] + }) + + tags = local.common_tags +} + +# Orchestrator Role +resource "aws_iam_role" "orchestrator_role" { + name = "${local.name_prefix}-orchestrator-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + }] + }) + + tags = local.common_tags +} + +# CloudWatch Logs +resource "aws_iam_role_policy_attachment" "slack_handler_logs" { + role = aws_iam_role.slack_handler_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy_attachment" "orchestrator_logs" { + role = aws_iam_role.orchestrator_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# Durable Execution Policy +resource "aws_iam_role_policy_attachment" "orchestrator_durable_execution" { + role = aws_iam_role.orchestrator_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicDurableExecutionRolePolicy" +} + +# Bedrock & AgentCore Access +resource "aws_iam_role_policy" "orchestrator_bedrock" { + name = "${local.name_prefix}-orchestrator-bedrock" + role = aws_iam_role.orchestrator_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["bedrock:InvokeModel"] + Resource = [ + "arn:aws:bedrock:*::foundation-model/anthropic.claude-*", + "arn:aws:bedrock:*:*:inference-profile/*" + ] + }, + { + Effect = "Allow" + Action = ["bedrock-agentcore:InvokeAgentRuntime"] + Resource = [try(trimspace(data.local_file.agent_runtime_arn.content), "*")] + }, + { + Effect = "Allow" + Action = ["dynamodb:PutItem"] + Resource = aws_dynamodb_table.callbacks.arn + } + ] + }) +} + +# Lambda Invoke Permission +resource "aws_iam_role_policy" "slack_handler_invoke" { + name = "${local.name_prefix}-slack-handler-invoke" + role = aws_iam_role.slack_handler_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["lambda:InvokeFunction"] + Resource = [ + aws_lambda_function.orchestrator.arn, + "${aws_lambda_function.orchestrator.arn}:*" + ] + }] + }) +} + +# Durable Execution Callback Permissions +resource "aws_iam_role_policy" "slack_handler_callback" { + name = "${local.name_prefix}-slack-handler-callback" + role = aws_iam_role.slack_handler_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "lambda:SendDurableExecutionCallbackSuccess", + "lambda:SendDurableExecutionCallbackFailure", + "lambda:SendDurableExecutionCallbackHeartbeat" + ] + Resource = [ + aws_lambda_function.orchestrator.arn, + "${aws_lambda_function.orchestrator.arn}:*" + ] + }, + { + Effect = "Allow" + Action = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem", + "dynamodb:Query" + ] + Resource = aws_dynamodb_table.callbacks.arn + } + ] + }) +} + +# Secrets Manager Access +resource "aws_iam_role_policy" "slack_handler_secrets" { + name = "${local.name_prefix}-slack-handler-secrets" + role = aws_iam_role.slack_handler_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = [aws_secretsmanager_secret.slack_secrets.arn] + }] + }) +} + +resource "aws_iam_role_policy" "orchestrator_secrets" { + name = "${local.name_prefix}-orchestrator-secrets" + role = aws_iam_role.orchestrator_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = [aws_secretsmanager_secret.slack_secrets.arn] + }] + }) +} + +# X-Ray Tracing +resource "aws_iam_role_policy_attachment" "slack_handler_xray" { + count = var.enable_xray_tracing ? 1 : 0 + role = aws_iam_role.slack_handler_role.name + policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess" +} + +resource "aws_iam_role_policy_attachment" "orchestrator_xray" { + count = var.enable_xray_tracing ? 1 : 0 + role = aws_iam_role.orchestrator_role.name + policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess" +} + +######################################## +# Lambda Functions +######################################## + +# Build Lambda deployment package (install deps + copy source) +resource "null_resource" "lambda_build" { + triggers = { + source_hash = sha1(join("", [for f in fileset("${path.module}/../src", "**/*.py") : filemd5("${path.module}/../src/${f}")])) + requirements_hash = filemd5("${path.module}/../requirements.txt") + build_script_hash = filemd5("${path.module}/build.sh") + } + + provisioner "local-exec" { + command = "bash ${path.module}/build.sh" + } +} + +# Create deployment package from build directory +data "archive_file" "lambda_zip" { + type = "zip" + source_dir = "${path.module}/build" + output_path = "${path.module}/lambda_deployment.zip" + + depends_on = [null_resource.lambda_build] +} + +# CloudWatch Log Groups +resource "aws_cloudwatch_log_group" "slack_handler" { + name = "/aws/lambda/${local.name_prefix}-slack-handler" + retention_in_days = var.log_retention_days + tags = local.common_tags +} + +resource "aws_cloudwatch_log_group" "orchestrator" { + name = "/aws/lambda/${local.name_prefix}-orchestrator" + retention_in_days = var.log_retention_days + tags = local.common_tags +} + +# Lambda Function: Slack Handler +resource "aws_lambda_function" "slack_handler" { + filename = data.archive_file.lambda_zip.output_path + function_name = "${local.name_prefix}-slack-handler" + role = aws_iam_role.slack_handler_role.arn + handler = "slack_handler.lambda_handler" + source_code_hash = data.archive_file.lambda_zip.output_base64sha256 + runtime = var.lambda_runtime + timeout = var.lambda_timeout + memory_size = var.lambda_memory_size + + environment { + variables = merge( + local.common_lambda_env, + { + ORCHESTRATOR_FUNCTION_ARN = "${aws_lambda_function.orchestrator.arn}:live" + } + ) + } + + tracing_config { + mode = var.enable_xray_tracing ? "Active" : "PassThrough" + } + + depends_on = [aws_cloudwatch_log_group.slack_handler] + tags = local.common_tags +} + +# Lambda Function: Orchestrator (Durable) +resource "aws_lambda_function" "orchestrator" { + filename = data.archive_file.lambda_zip.output_path + function_name = "${local.name_prefix}-orchestrator" + role = aws_iam_role.orchestrator_role.arn + handler = "orchestrator.lambda_handler" + source_code_hash = data.archive_file.lambda_zip.output_base64sha256 + runtime = var.lambda_runtime + timeout = var.orchestrator_timeout + memory_size = var.lambda_memory_size + publish = true + + environment { + variables = local.common_lambda_env + } + + logging_config { + log_format = "JSON" + system_log_level = "INFO" + application_log_level = "INFO" + } + + tracing_config { + mode = var.enable_xray_tracing ? "Active" : "PassThrough" + } + + depends_on = [ + aws_cloudwatch_log_group.orchestrator, + aws_iam_role_policy_attachment.orchestrator_durable_execution + ] + + tags = local.common_tags + + lifecycle { + ignore_changes = [source_code_hash] + } +} + +# Make function DURABLE via AWS CLI +resource "null_resource" "make_durable" { + depends_on = [aws_lambda_function.orchestrator] + + triggers = { + function_name = aws_lambda_function.orchestrator.function_name + source_code_hash = data.archive_file.lambda_zip.output_base64sha256 + execution_timeout = var.durable_execution_timeout + retention_days = var.durable_retention_days + role_arn = aws_iam_role.orchestrator_role.arn + timeout = var.orchestrator_timeout + memory_size = var.lambda_memory_size + runtime = var.lambda_runtime + region = var.aws_region + bedrock_model_id = var.bedrock_model_id + prefix = var.prefix + callbacks_table = aws_dynamodb_table.callbacks.name + } + + provisioner "local-exec" { + command = <<-EOT + set -e + + FUNCTION_NAME="${aws_lambda_function.orchestrator.function_name}" + echo "🔧 Making function DURABLE: $FUNCTION_NAME" + + # Check AWS CLI supports durable functions + if ! aws lambda create-function help 2>&1 | grep -q "durable-config"; then + echo "❌ AWS CLI DOES NOT SUPPORT DURABLE FUNCTIONS" + echo "Your AWS CLI version: $(aws --version)" + echo "Lambda Durable Functions require AWS CLI v2.30.0+" + exit 1 + fi + + # Check if already durable + CURRENT_TIMEOUT=$(aws lambda get-function-configuration \ + --function-name "$FUNCTION_NAME" \ + --query 'DurableConfig.ExecutionTimeout' \ + --output text \ + --region ${var.aws_region} 2>/dev/null || echo "None") + + if [ "$CURRENT_TIMEOUT" = "${var.durable_execution_timeout}" ]; then + echo "✅ Function is already DURABLE" + exit 0 + fi + + echo "⚠️ Function is STANDARD - recreating as DURABLE..." + + # Delete alias if exists + aws lambda delete-alias \ + --function-name "$FUNCTION_NAME" \ + --name live \ + --region ${var.aws_region} 2>/dev/null || true + + # Delete function + aws lambda delete-function \ + --function-name "$FUNCTION_NAME" \ + --region ${var.aws_region} + + echo "Waiting for deletion..." + sleep 10 + + # Write environment variables to a temp file to avoid secrets in command line + cat > /tmp/lambda-env-vars.json < /dev/null + + echo "✓ Alias 'live' created" + + # Verify + echo "✅ Durable Config:" + aws lambda get-function-configuration \ + --function-name "$FUNCTION_NAME" \ + --query 'DurableConfig' \ + --region ${var.aws_region} + EOT + } +} + +# Lambda Permissions +resource "aws_lambda_permission" "api_gateway" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.slack_handler.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_api_gateway_rest_api.slack_api.execution_arn}/*/*" +} + +resource "aws_lambda_permission" "slack_invoke_orchestrator" { + statement_id = "AllowSlackHandlerInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.orchestrator.function_name + principal = "lambda.amazonaws.com" + source_arn = aws_lambda_function.slack_handler.arn + qualifier = "live" + + depends_on = [null_resource.make_durable] +} + +######################################## +# API Gateway +######################################## + +resource "aws_api_gateway_rest_api" "slack_api" { + name = "${local.name_prefix}-api" + description = "API Gateway for Slack webhooks" + + endpoint_configuration { + types = ["REGIONAL"] + } + + tags = local.common_tags +} + +resource "aws_api_gateway_resource" "slack" { + rest_api_id = aws_api_gateway_rest_api.slack_api.id + parent_id = aws_api_gateway_rest_api.slack_api.root_resource_id + path_part = "slack" +} + +resource "aws_api_gateway_resource" "events" { + rest_api_id = aws_api_gateway_rest_api.slack_api.id + parent_id = aws_api_gateway_resource.slack.id + path_part = "events" +} + +resource "aws_api_gateway_method" "post_events" { + rest_api_id = aws_api_gateway_rest_api.slack_api.id + resource_id = aws_api_gateway_resource.events.id + http_method = "POST" + authorization = "NONE" +} + +resource "aws_api_gateway_integration" "lambda" { + rest_api_id = aws_api_gateway_rest_api.slack_api.id + resource_id = aws_api_gateway_resource.events.id + http_method = aws_api_gateway_method.post_events.http_method + integration_http_method = "POST" + type = "AWS_PROXY" + uri = aws_lambda_function.slack_handler.invoke_arn +} + +resource "aws_api_gateway_method_response" "response_200" { + rest_api_id = aws_api_gateway_rest_api.slack_api.id + resource_id = aws_api_gateway_resource.events.id + http_method = aws_api_gateway_method.post_events.http_method + status_code = "200" + + response_parameters = { + "method.response.header.Access-Control-Allow-Origin" = true + } +} + +resource "aws_api_gateway_deployment" "deployment" { + rest_api_id = aws_api_gateway_rest_api.slack_api.id + + triggers = { + redeployment = sha1(jsonencode([ + aws_api_gateway_resource.events.id, + aws_api_gateway_method.post_events.id, + aws_api_gateway_integration.lambda.id, + ])) + } + + lifecycle { + create_before_destroy = true + } + + depends_on = [aws_api_gateway_integration.lambda] +} + +resource "aws_api_gateway_stage" "prod" { + deployment_id = aws_api_gateway_deployment.deployment.id + rest_api_id = aws_api_gateway_rest_api.slack_api.id + stage_name = "prod" + xray_tracing_enabled = var.enable_xray_tracing + + access_log_settings { + destination_arn = aws_cloudwatch_log_group.api_gateway.arn + format = jsonencode({ + requestId = "$context.requestId" + ip = "$context.identity.sourceIp" + requestTime = "$context.requestTime" + httpMethod = "$context.httpMethod" + resourcePath = "$context.resourcePath" + status = "$context.status" + protocol = "$context.protocol" + responseLength = "$context.responseLength" + }) + } + + tags = local.common_tags +} + +resource "aws_cloudwatch_log_group" "api_gateway" { + name = "/aws/apigateway/${local.name_prefix}" + retention_in_days = var.log_retention_days + tags = local.common_tags +} + +resource "aws_api_gateway_account" "account" { + cloudwatch_role_arn = aws_iam_role.api_gateway_cloudwatch.arn + reset_on_delete = true +} + +resource "aws_iam_role" "api_gateway_cloudwatch" { + name = "${local.name_prefix}-api-gateway-cloudwatch" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "apigateway.amazonaws.com" + } + }] + }) + + tags = local.common_tags +} + +resource "aws_iam_role_policy_attachment" "api_gateway_cloudwatch" { + role = aws_iam_role.api_gateway_cloudwatch.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" +} + +######################################## +# AgentCore +######################################## + +resource "aws_ecr_repository" "agentcore_agent" { + name = "${local.name_prefix}-agentcore-agent" + image_tag_mutability = "MUTABLE" + force_delete = true + + image_scanning_configuration { + scan_on_push = false + } + + tags = local.common_tags +} + +resource "null_resource" "agentcore_image_build" { + depends_on = [aws_ecr_repository.agentcore_agent] + + triggers = { + agent_py_hash = filemd5("${path.module}/../agentcore-agent/agent.py") + dockerfile_hash = filemd5("${path.module}/../agentcore-agent/Dockerfile") + requirements_hash = filemd5("${path.module}/../agentcore-agent/requirements.txt") + ecr_repo_url = aws_ecr_repository.agentcore_agent.repository_url + } + + provisioner "local-exec" { + command = <<-EOT + set -e + + # Login to ECR using Finch + aws ecr get-login-password --region ${var.aws_region} | \ + finch login --username AWS --password-stdin ${aws_ecr_repository.agentcore_agent.repository_url} + + # Build image with Finch + cd ${path.module}/../agentcore-agent + finch build --no-cache --platform linux/arm64 \ + -t ${aws_ecr_repository.agentcore_agent.repository_url}:latest . + + # Push to ECR + finch push ${aws_ecr_repository.agentcore_agent.repository_url}:latest + + # Save image digest + DIGEST=$(finch images --no-trunc --format '{{.ID}}' ${aws_ecr_repository.agentcore_agent.repository_url}:latest) + echo "$DIGEST" > ${path.module}/image_digest.txt + echo "Image digest saved: $DIGEST" + EOT + } +} + +resource "aws_iam_role" "agentcore_runtime" { + name = "${local.name_prefix}-agentcore-runtime-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { + Service = "bedrock-agentcore.amazonaws.com" + } + Action = "sts:AssumeRole" + }] + }) + + tags = local.common_tags +} + +resource "aws_iam_role_policy" "agentcore_runtime" { + name = "${local.name_prefix}-agentcore-runtime-policy" + role = aws_iam_role.agentcore_runtime.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ] + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "lambda:SendDurableExecutionCallbackSuccess", + "lambda:SendDurableExecutionCallbackFailure", + "lambda:SendDurableExecutionCallbackHeartbeat" + ] + Resource = [ + "arn:aws:lambda:${var.aws_region}:${data.aws_caller_identity.current.account_id}:function:${local.name_prefix}-orchestrator", + "arn:aws:lambda:${var.aws_region}:${data.aws_caller_identity.current.account_id}:function:${local.name_prefix}-orchestrator:*" + ] + }, + { + Effect = "Allow" + Action = [ + "ecr:GetAuthorizationToken" + ] + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ] + Resource = [aws_ecr_repository.agentcore_agent.arn] + }, + { + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogStreams" + ] + Resource = [ + "arn:aws:logs:*:*:log-group:/aws/bedrock-agentcore/runtimes/*", + "arn:aws:logs:*:*:log-group:/aws/bedrock-agentcore/runtimes/*:*" + ] + } + ] + }) +} + +resource "null_resource" "agentcore_runtime" { + depends_on = [ + null_resource.agentcore_image_build, + aws_iam_role_policy.agentcore_runtime + ] + + triggers = { + image_url = "${aws_ecr_repository.agentcore_agent.repository_url}:latest" + runtime_id = replace("${local.name_prefix}_travel_agent", "-", "_") + region = var.aws_region + } + + provisioner "local-exec" { + command = <<-EOT + set -e + + # Wait for IAM role to propagate + sleep 10 + + # Create AgentCore runtime + aws bedrock-agentcore-control create-agent-runtime \ + --agent-runtime-name ${self.triggers.runtime_id} \ + --description "Travel planning agent using Strands and Bedrock" \ + --role-arn ${aws_iam_role.agentcore_runtime.arn} \ + --agent-runtime-artifact '{"containerConfiguration":{"containerUri":"${aws_ecr_repository.agentcore_agent.repository_url}:latest"}}' \ + --network-configuration '{"networkMode":"PUBLIC"}' \ + --region ${self.triggers.region} 2>&1 | tee /tmp/agentcore-create.log || true + + # Get ARN + RUNTIME_ARN=$(aws bedrock-agentcore-control list-agent-runtimes \ + --region ${self.triggers.region} \ + --query "agentRuntimes[?agentRuntimeName=='${self.triggers.runtime_id}'].agentRuntimeArn" \ + --output text 2>/dev/null || echo "") + + if [ -z "$RUNTIME_ARN" ]; then + echo "ERROR: Failed to create or find AgentCore runtime" + cat /tmp/agentcore-create.log + exit 1 + fi + + echo "$RUNTIME_ARN" > ${path.module}/agent_runtime_arn.txt + echo "AgentCore runtime created: $RUNTIME_ARN" + EOT + } + + provisioner "local-exec" { + when = destroy + command = <<-EOT + set -e + + # Get runtime ID from list + RUNTIME_ID=$(aws bedrock-agentcore-control list-agent-runtimes \ + --region ${self.triggers.region} \ + --query "agentRuntimes[?agentRuntimeName=='${self.triggers.runtime_id}'].agentRuntimeId" \ + --output text 2>/dev/null || echo "") + + if [ -n "$RUNTIME_ID" ] && [ "$RUNTIME_ID" != "None" ]; then + echo "Deleting AgentCore runtime: $RUNTIME_ID" + aws bedrock-agentcore-control delete-agent-runtime \ + --agent-runtime-id "$RUNTIME_ID" \ + --region ${self.triggers.region} 2>/dev/null || true + echo "AgentCore runtime deletion initiated" + else + echo "No AgentCore runtime found to delete" + fi + + rm -f ${path.module}/agent_runtime_arn.txt + EOT + } +} + +data "local_file" "agent_runtime_arn" { + depends_on = [null_resource.agentcore_runtime] + filename = "${path.module}/agent_runtime_arn.txt" +} + +######################################## +# Outputs +######################################## + +output "api_gateway_url" { + description = "API Gateway endpoint URL for Slack webhooks" + value = "${aws_api_gateway_stage.prod.invoke_url}/slack/events" +} + +output "api_gateway_id" { + description = "API Gateway REST API ID" + value = aws_api_gateway_rest_api.slack_api.id +} + +output "slack_handler_function_name" { + description = "Slack Handler Lambda function name" + value = aws_lambda_function.slack_handler.function_name +} + +output "orchestrator_function_name" { + description = "Orchestrator Lambda function name" + value = aws_lambda_function.orchestrator.function_name +} + +output "orchestrator_qualified_arn" { + description = "Orchestrator Lambda ARN with 'live' alias" + value = "${aws_lambda_function.orchestrator.arn}:live" +} + +output "region" { + description = "AWS region" + value = var.aws_region +} + +output "agentcore_runtime_arn" { + value = try(trimspace(data.local_file.agent_runtime_arn.content), "") + description = "ARN of the AgentCore runtime" +} + +output "deployment_instructions" { + description = "Next steps after deployment" + value = <<-EOT + + ✅ Deployment Complete! Orchestrator is DURABLE ✓ + + Next steps: + + 1. Configure Slack Event Subscriptions: + - Go to https://api.slack.com/apps + - Select your app + - Navigate to "Event Subscriptions" + - Set Request URL to: ${aws_api_gateway_stage.prod.invoke_url}/slack/events + - Subscribe to events: message.im, message.channels, app_mention + - Save changes and reinstall app + + 2. Test the bot: + - Open Slack and DM your bot + - Type: "Plan a trip for me" + - Bot should respond and start asking questions + + 3. Monitor logs: + - Slack Handler: aws logs tail ${aws_cloudwatch_log_group.slack_handler.name} --follow --region ${var.aws_region} + - Orchestrator: aws logs tail ${aws_cloudwatch_log_group.orchestrator.name} --follow --region ${var.aws_region} + + 🎉 Your bot is ready to use! + EOT +}