├── .clinerules ├── .env.example ├── .github ├── CODEOWNERS ├── auto-merge.yml ├── aws-role │ ├── README.md │ ├── role-policy.json │ └── trust-policy.json ├── stale.yml └── workflows │ ├── push-main.yml │ └── push-v2.yml ├── .gitignore ├── CLAUDE.md ├── README.md ├── architecture.md ├── handler.py ├── images └── bot.png ├── package-lock.json ├── package.json ├── requirements.txt ├── serverless.yml └── src ├── __init__.py ├── api ├── __init__.py ├── openai_api.py └── slack_api.py ├── config ├── __init__.py └── settings.py ├── handlers ├── __init__.py └── message_handler.py └── utils ├── __init__.py ├── context_manager.py └── logger.py /.clinerules: -------------------------------------------------------------------------------- 1 | # Project Code Guidelines 2 | 3 | ## Core Principles 4 | 5 | - **Solve the right problem**: Avoid unnecessary complexity or scope creep. 6 | - **Favor standard solutions**: Use well-known libraries and documented patterns before writing custom code. 7 | - **Keep code clean and readable**: Use clear naming, logical structure, and avoid deeply nested logic. 8 | - **Ensure consistent style**: Apply formatters (e.g. Prettier, Black) and linters (e.g. ESLint, Flake8) across the codebase. 9 | - **Handle errors thoughtfully**: Consider edge cases and fail gracefully. 10 | - **Comment with intent**: Use comments to clarify non-obvious logic. Prefer expressive code over excessive comments. 11 | - **Design for change**: Structure code to be modular and adaptable to future changes. 12 | - **Keep dependencies shallow**: Minimize tight coupling between modules. Maintain clear boundaries. 13 | - **Fail fast and visibly**: Surface errors early with meaningful messages or logs. 14 | - **Automate where practical**: Use automation for formatting, testing, and deployment to reduce manual effort and error. 15 | 16 | ## Documentation Standards 17 | 18 | - Keep all documentation up to date and version-controlled. 19 | - Each document should serve a clear purpose: 20 | 21 | ### `README.md` 22 | - Project overview and purpose 23 | - Setup and installation steps 24 | - Usage instructions or examples 25 | 26 | ### `ARCHITECTURE.md` 27 | - High-level system design 28 | - Major components and their responsibilities 29 | - Data flow and integration points 30 | 31 | ## Testing Strategy 32 | 33 | - Write comprehensive unit tests for individual functions and modules 34 | - Implement integration tests to verify component interactions 35 | - Ensure adequate test coverage for critical code paths 36 | - Set up automated testing as part of the development workflow 37 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | BOT_CURSOR=":loading:" 2 | 3 | SLACK_BOT_TOKEN="xoxb-xxxx" 4 | SLACK_SIGNING_SECRET="xxxx" 5 | 6 | DYNAMODB_TABLE_NAME="slack-ai-bot-context" 7 | 8 | OPENAI_ORG_ID="org-xxxx" 9 | OPENAI_API_KEY="sk-xxxx" 10 | OPENAI_MODEL="gpt-4o" 11 | 12 | IMAGE_MODEL="dall-e-3" 13 | IMAGE_SIZE="1024x1024" 14 | IMAGE_QUALITY="standard" 15 | 16 | SYSTEM_MESSAGE="너는 최대한 정확하고 신뢰할 수 있는 정보를 알려줘. 너는 항상 사용자를 존중해." 17 | 18 | TEMPERATURE="0.5" 19 | 20 | MAX_LEN_SLACK="3000" 21 | MAX_LEN_OPENAI="4000" 22 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @nalbam @nalbam-me @nalbam-bot 2 | -------------------------------------------------------------------------------- /.github/auto-merge.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-auto-merge - https://github.com/bobvanderlinden/probot-auto-merge 2 | 3 | updateBranch: true 4 | deleteBranchAfterMerge: true 5 | reportStatus: true 6 | 7 | minApprovals: 8 | COLLABORATOR: 0 9 | maxRequestedChanges: 10 | NONE: 0 11 | blockingLabels: 12 | - blocked 13 | 14 | # Will merge whenever the above conditions are met, but also 15 | # the owner has approved or merge label was added. 16 | rules: 17 | - minApprovals: 18 | OWNER: 1 19 | - requiredLabels: 20 | - merge 21 | -------------------------------------------------------------------------------- /.github/aws-role/README.md: -------------------------------------------------------------------------------- 1 | # aws role 2 | 3 | ```bash 4 | export NAME="lambda-slack-ai-bot" 5 | ``` 6 | 7 | ## create role 8 | 9 | ```bash 10 | export DESCRIPTION="${NAME} role" 11 | 12 | aws iam create-role --role-name "${NAME}" --description "${DESCRIPTION}" --assume-role-policy-document file://trust-policy.json | jq . 13 | 14 | aws iam get-role --role-name "${NAME}" | jq . 15 | ``` 16 | 17 | ## create policy 18 | 19 | ```bash 20 | export DESCRIPTION="${NAME} policy" 21 | 22 | aws iam create-policy --policy-name "${NAME}" --policy-document file://role-policy.json | jq . 23 | 24 | export ACCOUNT_ID=$(aws sts get-caller-identity | jq .Account -r) 25 | export POLICY_ARN="arn:aws:iam::${ACCOUNT_ID}:policy/${NAME}" 26 | 27 | aws iam get-policy --policy-arn "${POLICY_ARN}" | jq . 28 | 29 | aws iam create-policy-version --policy-arn "${POLICY_ARN}" --policy-document file://role-policy.json --set-as-default | jq . 30 | ``` 31 | 32 | ## attach role policy 33 | 34 | ```bash 35 | aws iam attach-role-policy --role-name "${NAME}" --policy-arn "${POLICY_ARN}" 36 | # aws iam attach-role-policy --role-name "${NAME}" --policy-arn "arn:aws:iam::aws:policy/PowerUserAccess" 37 | # aws iam attach-role-policy --role-name "${NAME}" --policy-arn "arn:aws:iam::aws:policy/AdministratorAccess" 38 | ``` 39 | 40 | ## add role-assume 41 | 42 | ```yaml 43 | 44 | - name: configure aws credentials 45 | uses: aws-actions/configure-aws-credentials@v1.7.0 46 | with: 47 | role-to-assume: "arn:aws:iam::968005369378:role/lambda-slack-ai-bot" 48 | role-session-name: github-actions-ci-bot 49 | aws-region: ${{ env.AWS_REGION }} 50 | 51 | - name: Sts GetCallerIdentity 52 | run: | 53 | aws sts get-caller-identity 54 | 55 | ``` 56 | -------------------------------------------------------------------------------- /.github/aws-role/role-policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Action": [ 7 | "cloudformation:ValidateTemplate" 8 | ], 9 | "Resource": "*" 10 | }, 11 | { 12 | "Effect": "Allow", 13 | "Action": [ 14 | "cloudformation:*" 15 | ], 16 | "Resource": "arn:aws:cloudformation:*:*:stack/lambda-slack-ai-bot-*" 17 | }, 18 | { 19 | "Effect": "Allow", 20 | "Action": [ 21 | "lambda:*" 22 | ], 23 | "Resource": [ 24 | "arn:aws:lambda:*:*:function:lambda-slack-ai-bot-*", 25 | "arn:aws:lambda:*:*:function:gurumi-ai-bot-*" 26 | ] 27 | }, 28 | { 29 | "Effect": "Allow", 30 | "Action": [ 31 | "iam:*" 32 | ], 33 | "Resource": "arn:aws:iam::*:role/lambda-slack-ai-bot-*" 34 | }, 35 | { 36 | "Effect": "Allow", 37 | "Action": [ 38 | "s3:*" 39 | ], 40 | "Resource": [ 41 | "arn:aws:s3:::lambda-slack-ai-bot-*", 42 | "arn:aws:s3:::gurumi-ai-bot-*" 43 | ] 44 | }, 45 | { 46 | "Effect": "Allow", 47 | "Action": [ 48 | "dynamodb:*" 49 | ], 50 | "Resource": [ 51 | "arn:aws:dynamodb:*:*:table/lambda-slack-ai-bot-*", 52 | "arn:aws:dynamodb:*:*:table/gurumi-ai-bot-*" 53 | ] 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /.github/aws-role/trust-policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Principal": { 7 | "Federated": "arn:aws:iam::968005369378:oidc-provider/token.actions.githubusercontent.com" 8 | }, 9 | "Action": "sts:AssumeRoleWithWebIdentity", 10 | "Condition": { 11 | "StringEquals": { 12 | "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" 13 | }, 14 | "StringLike": { 15 | "token.actions.githubusercontent.com:sub": "repo:nalbam/lambda-slack-ai-bot:*" 16 | } 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/push-main.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to AWS Lambda 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | repository_dispatch: 9 | types: 10 | - deploy 11 | 12 | env: 13 | AWS_REGION: "us-east-1" 14 | AWS_ROLE_NAME: "lambda-slack-ai-bot" 15 | 16 | STAGE: "prod" 17 | 18 | BOT_CURSOR: ${{ vars.BOT_CURSOR }} 19 | OPENAI_MODEL: ${{ vars.OPENAI_MODEL }} 20 | SYSTEM_MESSAGE: ${{ vars.SYSTEM_MESSAGE }} 21 | TEMPERATURE: ${{ vars.TEMPERATURE }} 22 | 23 | AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} 24 | OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} 25 | OPENAI_ORG_ID: ${{ secrets.OPENAI_ORG_ID }} 26 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} 27 | SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }} 28 | 29 | # Permission can be added at job level or workflow level 30 | permissions: 31 | id-token: write # This is required for requesting the JWT 32 | contents: read # This is required for actions/checkout 33 | 34 | jobs: 35 | deploy: 36 | runs-on: ubuntu-24.04 37 | 38 | steps: 39 | - name: Checkout 🛎️ 40 | uses: actions/checkout@v4 41 | with: 42 | fetch-depth: 0 43 | 44 | - name: Setup Python 3.12 🐍 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: '3.12' 48 | 49 | - name: Install dependencies 50 | run: npm install 51 | 52 | - name: Install Python dependencies 53 | run: npx serverless plugin install --name serverless-python-requirements 54 | 55 | - name: Install dotenv plugin 56 | run: npx serverless plugin install --name serverless-dotenv-plugin 57 | 58 | - name: Install dependencies 59 | run: pip install -r requirements.txt 60 | 61 | - name: Set up environment variables 📝 62 | run: | 63 | echo "STAGE=${STAGE}" >> .env 64 | echo "BOT_CURSOR=${BOT_CURSOR}" >> .env 65 | echo "OPENAI_API_KEY=${OPENAI_API_KEY}" >> .env 66 | echo "OPENAI_MODEL=${OPENAI_MODEL}" >> .env 67 | echo "OPENAI_ORG_ID=${OPENAI_ORG_ID}" >> .env 68 | echo "SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN}" >> .env 69 | echo "SLACK_SIGNING_SECRET=${SLACK_SIGNING_SECRET}" >> .env 70 | echo "SYSTEM_MESSAGE=${SYSTEM_MESSAGE}" >> .env 71 | echo "TEMPERATURE=${TEMPERATURE}" >> .env 72 | 73 | - name: configure aws credentials 74 | uses: aws-actions/configure-aws-credentials@v4 75 | with: 76 | role-to-assume: "arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.AWS_ROLE_NAME }}" 77 | role-session-name: github-actions-ci-bot 78 | aws-region: ${{ env.AWS_REGION }} 79 | 80 | - name: Deploy to AWS Lambda 🚀 81 | run: npx serverless deploy --stage ${{ env.STAGE }} --region ${{ env.AWS_REGION }} 82 | -------------------------------------------------------------------------------- /.github/workflows/push-v2.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to AWS Lambda v2 2 | 3 | on: 4 | push: 5 | branches: 6 | - v2 7 | repository_dispatch: 8 | types: 9 | - deploy 10 | 11 | env: 12 | AWS_REGION: "us-east-1" 13 | AWS_ROLE_NAME: "lambda-slack-ai-bot" 14 | 15 | STAGE: "v2" 16 | 17 | BOT_CURSOR: ${{ vars.BOT_CURSOR }} 18 | OPENAI_MODEL: ${{ vars.OPENAI_MODEL }} 19 | SYSTEM_MESSAGE: ${{ vars.SYSTEM_MESSAGE }} 20 | TEMPERATURE: ${{ vars.TEMPERATURE }} 21 | 22 | AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} 23 | OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} 24 | OPENAI_ORG_ID: ${{ secrets.OPENAI_ORG_ID }} 25 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} 26 | SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }} 27 | 28 | # Permission can be added at job level or workflow level 29 | permissions: 30 | id-token: write # This is required for requesting the JWT 31 | contents: read # This is required for actions/checkout 32 | 33 | jobs: 34 | deploy: 35 | runs-on: ubuntu-24.04 36 | 37 | steps: 38 | - name: Checkout 🛎️ 39 | uses: actions/checkout@v4 40 | with: 41 | fetch-depth: 0 42 | 43 | - name: Setup Python 3.12 🐍 44 | uses: actions/setup-python@v5 45 | with: 46 | python-version: '3.12' 47 | 48 | - name: Install dependencies 49 | run: npm install 50 | 51 | - name: Install Python dependencies 52 | run: npx serverless plugin install --name serverless-python-requirements 53 | 54 | - name: Install dotenv plugin 55 | run: npx serverless plugin install --name serverless-dotenv-plugin 56 | 57 | - name: Install dependencies 58 | run: pip install -r requirements.txt 59 | 60 | - name: Set up environment variables 📝 61 | run: | 62 | echo "STAGE=${STAGE}" >> .env 63 | echo "BOT_CURSOR=${BOT_CURSOR}" >> .env 64 | echo "OPENAI_API_KEY=${OPENAI_API_KEY}" >> .env 65 | echo "OPENAI_MODEL=${OPENAI_MODEL}" >> .env 66 | echo "OPENAI_ORG_ID=${OPENAI_ORG_ID}" >> .env 67 | echo "SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN}" >> .env 68 | echo "SLACK_SIGNING_SECRET=${SLACK_SIGNING_SECRET}" >> .env 69 | echo "SYSTEM_MESSAGE=${SYSTEM_MESSAGE}" >> .env 70 | echo "TEMPERATURE=${TEMPERATURE}" >> .env 71 | 72 | - name: configure aws credentials 73 | uses: aws-actions/configure-aws-credentials@v4 74 | with: 75 | role-to-assume: "arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.AWS_ROLE_NAME }}" 76 | role-session-name: github-actions-ci-bot 77 | aws-region: ${{ env.AWS_REGION }} 78 | 79 | - name: Deploy to AWS Lambda 🚀 80 | run: npx serverless deploy --stage ${{ env.STAGE }} --region ${{ env.AWS_REGION }} 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Windows image file caches 2 | Thumbs.db 3 | ehthumbs.db 4 | 5 | # Folder config file 6 | Desktop.ini 7 | 8 | # Recycle Bin used on file shares 9 | $RECYCLE.BIN/ 10 | 11 | # Windows shortcuts 12 | *.lnk 13 | 14 | # Mac 15 | .DS_Store 16 | 17 | # JetBrains 18 | .idea/ 19 | *.iml 20 | 21 | # Eclipse 22 | .settings/ 23 | .metadata/ 24 | 25 | # Build 26 | target/ 27 | build/ 28 | dist/ 29 | 30 | # Temp 31 | *.pid 32 | *.log 33 | *.tmp 34 | 35 | # serverless 36 | .serverless 37 | 38 | # python 39 | venv 40 | *.pyc 41 | staticfiles 42 | db.sqlite3 43 | __pycache__ 44 | 45 | # node 46 | node_modules 47 | 48 | .env 49 | .env*.development 50 | .env*.local 51 | .env*.production 52 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # lambda-slack-ai-bot Development Guide 2 | 3 | ## Basic Guidelines 4 | - Focus on solving the specific problem - avoid unnecessary complexity or scope creep. 5 | - Use standard libraries and documented patterns first before creating custom solutions. 6 | - Write clean, well-structured code with meaningful names and clear organization. 7 | - Handle errors and edge cases properly to ensure code robustness. 8 | - Include helpful comments for complex logic while keeping code self-documenting. 9 | 10 | ## Commands 11 | - Deploy: `sls deploy --region us-east-1` 12 | - Install dependencies: `python -m pip install --upgrade -r requirements.txt` 13 | - Install serverless plugins: `sls plugin install -n serverless-python-requirements && sls plugin install -n serverless-dotenv-plugin` 14 | 15 | ## Code Style Guidelines 16 | - **Imports**: Standard library imports first, then third-party packages, then local modules 17 | - **Environment Variables**: Use `os.environ.get("VAR_NAME", "default")` pattern for optional vars 18 | - **Error Handling**: Use try/except blocks with specific exception types and logging 19 | - **Naming Conventions**: 20 | - Functions: snake_case (e.g., `lambda_handler`, `get_context`) 21 | - Constants: UPPER_CASE (e.g., `SLACK_BOT_TOKEN`, `IMAGE_MODEL`) 22 | - **Comments**: Add descriptive comments for functions and complex logic 23 | - **String Formatting**: Prefer f-strings or `format()` over string concatenation 24 | - **Function Structure**: Keep functions small and focused on a single responsibility 25 | 26 | ## Architecture 27 | - AWS Lambda + API Gateway for handling Slack events 28 | - DynamoDB for conversation context storage 29 | - OpenAI API integration for chat and image generation 30 | - Slack API for messaging and file handling 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lambda-slack-ai-bot 2 | 3 | A serverless Slack bot using AWS Lambda, API Gateway, and DynamoDB. 4 | 5 | ![Bot](images/bot.png) 6 | 7 | ## Install 8 | 9 | ```bash 10 | $ brew install python@3.9 11 | 12 | $ npm install -g serverless@3.38.0 13 | 14 | $ sls plugin install -n serverless-python-requirements 15 | $ sls plugin install -n serverless-dotenv-plugin 16 | 17 | $ python -m pip install --upgrade -r requirements.txt 18 | ``` 19 | 20 | ## Setup 21 | 22 | Setup a Slack app by following the guide at https://slack.dev/bolt-js/tutorial/getting-started 23 | 24 | Set scopes to Bot Token Scopes in OAuth & Permission: 25 | 26 | ``` 27 | app_mentions:read 28 | channels:history 29 | channels:join 30 | channels:read 31 | chat:write 32 | files:read 33 | files:write 34 | im:read 35 | im:write 36 | ``` 37 | 38 | Set scopes in Event Subscriptions - Subscribe to bot events 39 | 40 | ``` 41 | app_mention 42 | message.im 43 | ``` 44 | 45 | ## Credentials 46 | 47 | ```bash 48 | $ cp .env.example .env 49 | ``` 50 | 51 | ### Slack Bot 52 | 53 | ```bash 54 | SLACK_BOT_TOKEN="xoxb-xxxx" 55 | SLACK_SIGNING_SECRET="xxxx" 56 | ``` 57 | 58 | ### OpenAi API 59 | 60 | * 61 | 62 | ```bash 63 | OPENAI_ORG_ID="org-xxxx" 64 | OPENAI_API_KEY="sk-xxxx" 65 | ``` 66 | 67 | ## Deployment 68 | 69 | In order to deploy the example, you need to run the following command: 70 | 71 | ```bash 72 | $ sls deploy --stage dev --region us-east-1 73 | ``` 74 | 75 | ## Slack Test 76 | 77 | ```bash 78 | curl -X POST -H "Content-Type: application/json" \ 79 | -d " \ 80 | { \ 81 | \"token\": \"Jhj5dZrVaK7ZwHHjRyZWjbDl\", \ 82 | \"challenge\": \"3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P\", \ 83 | \"type\": \"url_verification\" \ 84 | }" \ 85 | https://xxxx.execute-api.us-east-1.amazonaws.com/dev/slack/events 86 | ``` 87 | 88 | ## OpenAi API Test 89 | 90 | ```bash 91 | curl https://api.openai.com/v1/chat/completions \ 92 | -H "Content-Type: application/json" \ 93 | -H "Authorization: Bearer $OPENAI_API_KEY" \ 94 | -d '{ 95 | "model": "gpt-4o", 96 | "messages": [ 97 | { 98 | "role": "system", 99 | "content": "You are a helpful assistant." 100 | }, 101 | { 102 | "role": "user", 103 | "content": "Hello!" 104 | } 105 | ] 106 | }' 107 | ``` 108 | 109 | ```bash 110 | curl https://api.openai.com/v1/images/generations \ 111 | -H "Content-Type: application/json" \ 112 | -H "Authorization: Bearer $OPENAI_API_KEY" \ 113 | -d '{ 114 | "model": "dall-e-3", 115 | "prompt": "꽁꽁 얼어붙은 한강위로 고양이가 걸어갑니다.", 116 | "size": "1024x1024", 117 | "n": 1 118 | }' 119 | ``` 120 | 121 | ## References 122 | 123 | * 124 | -------------------------------------------------------------------------------- /architecture.md: -------------------------------------------------------------------------------- 1 | # Lambda Slack AI Bot - Architecture 2 | 3 | This document outlines the architecture of the Lambda Slack AI Bot, a serverless application that integrates Slack with OpenAI's GPT and DALL-E models to provide conversational AI and image generation capabilities. 4 | 5 | ## System Overview 6 | 7 | The Lambda Slack AI Bot is a serverless application built on AWS that connects Slack with OpenAI's AI models. It allows users to interact with AI through Slack by mentioning the bot in channels or sending direct messages. The bot can engage in conversations using OpenAI's GPT models and generate images using DALL-E. 8 | 9 | ![Architecture Overview](images/bot.png) 10 | 11 | ## Key Components 12 | 13 | ### 1. AWS Services 14 | 15 | - **AWS Lambda**: Executes the serverless function that processes Slack events and communicates with OpenAI APIs 16 | - **API Gateway**: Provides HTTP endpoints for Slack to send events to the Lambda function 17 | - **DynamoDB**: Stores conversation context to maintain continuity in threaded discussions 18 | 19 | ### 2. External Services 20 | 21 | - **Slack API**: Receives and sends messages through the Slack platform 22 | - **OpenAI API**: Provides access to GPT models for text generation and DALL-E models for image generation 23 | 24 | ### 3. Application Components 25 | 26 | - **Slack Bolt Framework**: Handles Slack event processing and message formatting 27 | - **OpenAI Client**: Manages communication with OpenAI's API services 28 | - **DynamoDB Client**: Manages conversation context storage and retrieval 29 | 30 | ## Data Flow 31 | 32 | 1. **Event Reception**: 33 | - Slack sends events (mentions or direct messages) to the API Gateway endpoint 34 | - API Gateway forwards these events to the Lambda function 35 | 36 | 2. **Event Processing**: 37 | - Lambda function validates the Slack event 38 | - For new conversations, it stores a record in DynamoDB to prevent duplicate processing 39 | - It extracts the message content and any attached files (particularly images) 40 | 41 | 3. **AI Processing**: 42 | - For text conversations: 43 | - The function retrieves conversation history from the thread (if applicable) 44 | - It sends the conversation to OpenAI's GPT model 45 | - It streams the response back to Slack in chunks for a better user experience 46 | 47 | - For image generation (triggered by the keyword "그려줘"): 48 | - If an image is attached, it describes the image using GPT 49 | - It generates an optimized prompt for DALL-E based on the conversation context 50 | - It sends the prompt to DALL-E to generate an image 51 | - It uploads the generated image to the Slack thread 52 | 53 | 4. **Response Handling**: 54 | - The function formats and posts responses back to the appropriate Slack channel or thread 55 | - For long responses, it splits the message into multiple parts to comply with Slack's message size limits 56 | 57 | ## Technical Details 58 | 59 | ### Serverless Configuration 60 | 61 | The application is deployed using the Serverless Framework with the following configuration: 62 | - Runtime: Python 3.9 63 | - Timeout: 600 seconds (10 minutes) 64 | - Memory: Configurable (commented out in serverless.yml) 65 | - Region: us-east-1 (default, can be changed during deployment) 66 | 67 | ### DynamoDB Schema 68 | 69 | - **Table Name**: slack-ai-bot-context (configurable) 70 | - **Primary Key**: id (String) 71 | - **TTL Field**: expire_at (set to 1 hour after creation) 72 | - **Attributes**: 73 | - id: Thread ID or user ID 74 | - conversation: Stored conversation content 75 | - expire_dt: Human-readable expiration datetime 76 | - expire_at: Unix timestamp for TTL 77 | 78 | ### Environment Variables 79 | 80 | The application uses several environment variables for configuration: 81 | - Slack credentials (bot token, signing secret) 82 | - OpenAI credentials and model settings 83 | - DynamoDB table name 84 | - System message for the AI 85 | - Temperature setting for AI responses 86 | - Message length limits 87 | 88 | ### Security Considerations 89 | 90 | - AWS IAM roles limit the Lambda function's permissions to only what's necessary 91 | - Environment variables store sensitive credentials 92 | - DynamoDB records have a TTL to automatically clean up old conversation contexts 93 | 94 | ## Deployment Process 95 | 96 | The application is deployed using the Serverless Framework: 97 | 1. Install dependencies (serverless, plugins, Python packages) 98 | 2. Configure environment variables in .env file 99 | 3. Deploy using `sls deploy --region us-east-1` 100 | 101 | ## Extension Points 102 | 103 | The architecture allows for several extension possibilities: 104 | - Adding more AI models or capabilities 105 | - Implementing additional Slack event handlers 106 | - Enhancing the conversation context management 107 | - Adding authentication or rate limiting 108 | - Implementing monitoring and analytics 109 | -------------------------------------------------------------------------------- /handler.py: -------------------------------------------------------------------------------- 1 | """ 2 | Lambda Slack AI Bot - Lambda 핸들러 3 | """ 4 | import json 5 | import sys 6 | from typing import Dict, Any, Optional 7 | 8 | from slack_bolt.adapter.aws_lambda import SlackRequestHandler 9 | 10 | from src.config import settings 11 | from src.utils import logger, context_manager 12 | from src.api import slack_api 13 | from src.handlers.message_handler import MessageHandler 14 | 15 | # 환경 변수 검증 16 | settings.validate_env_vars() 17 | 18 | # DynamoDB 테이블 확인 19 | context_manager.check_table_exists() 20 | 21 | # Slack 앱 초기화 22 | app = slack_api.initialize_slack_app() 23 | 24 | # Slack 핸들러 초기화 25 | slack_handler = SlackRequestHandler(app=app) 26 | 27 | # 메시지 핸들러 초기화 28 | message_handler = MessageHandler(app) 29 | 30 | # Bot ID 저장 31 | bot_id = slack_api.get_bot_id(app) 32 | 33 | # 이벤트 핸들러 등록 34 | @app.event("app_mention") 35 | def handle_mention(body: Dict[str, Any], say): 36 | """앱 멘션 이벤트 핸들러""" 37 | try: 38 | logger.log_info("앱 멘션 이벤트 처리", {"event_id": body.get("event_id")}) 39 | message_handler.handle_mention(body, say) 40 | except Exception as e: 41 | logger.log_error("앱 멘션 처리 중 오류 발생", e) 42 | 43 | @app.event("message") 44 | def handle_message(body: Dict[str, Any], say): 45 | """메시지 이벤트 핸들러""" 46 | try: 47 | event = body.get("event", {}) 48 | 49 | # 봇 메시지 무시 50 | if "bot_id" in event: 51 | return 52 | 53 | logger.log_info("메시지 이벤트 처리", {"event_id": body.get("event_id")}) 54 | message_handler.handle_message(body, say) 55 | except Exception as e: 56 | logger.log_error("메시지 처리 중 오류 발생", e) 57 | 58 | def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: 59 | """AWS Lambda 함수 핸들러 60 | 61 | Args: 62 | event: Lambda 이벤트 데이터 63 | context: Lambda 컨텍스트 64 | 65 | Returns: 66 | Lambda 응답 67 | """ 68 | try: 69 | # JSON 파싱 70 | if "body" in event: 71 | body = json.loads(event["body"]) 72 | 73 | # Slack 이벤트 확인 요청 처리 74 | if "challenge" in body: 75 | logger.log_info("Slack 이벤트 확인 요청 처리") 76 | return { 77 | "statusCode": 200, 78 | "headers": {"Content-type": "application/json"}, 79 | "body": json.dumps({"challenge": body["challenge"]}), 80 | } 81 | 82 | # 이벤트 검증 83 | if "event" not in body or "client_msg_id" not in body.get("event", {}): 84 | logger.log_info("이벤트 데이터 누락 또는 중복 요청") 85 | return { 86 | "statusCode": 200, 87 | "headers": {"Content-type": "application/json"}, 88 | "body": json.dumps({"status": "Success"}), 89 | } 90 | 91 | # 중복 요청 방지를 위한 컨텍스트 확인 92 | token = body["event"]["client_msg_id"] 93 | user = body["event"]["user"] 94 | prompt = context_manager.get_context(token, user) 95 | 96 | if prompt != "": 97 | logger.log_info("중복 요청 감지", {"token": token, "user": user}) 98 | return { 99 | "statusCode": 200, 100 | "headers": {"Content-type": "application/json"}, 101 | "body": json.dumps({"status": "Success"}), 102 | } 103 | 104 | # 컨텍스트 저장 105 | context_manager.put_context(token, user, body["event"]["text"]) 106 | 107 | # Slack 이벤트 처리 108 | return slack_handler.handle(event, context) 109 | 110 | except Exception as e: 111 | logger.log_error("Lambda 핸들러 오류", e, {"event": event}) 112 | 113 | return { 114 | "statusCode": 500, 115 | "headers": {"Content-type": "application/json"}, 116 | "body": json.dumps({"status": "Error", "message": str(e)}), 117 | } 118 | -------------------------------------------------------------------------------- /images/bot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nalbam/lambda-slack-ai-bot/9166f63f6d3a1ba648f286f721c4442a09b90d36/images/bot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "serverless-dotenv-plugin": "^6.0.0", 4 | "serverless-python-requirements": "^6.1.2" 5 | }, 6 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 7 | } 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | boto3>=1.28.0 2 | openai>=1.6.0 3 | slack-bolt>=1.18.0 4 | slack-sdk>=3.23.0 5 | requests>=2.31.0 6 | tenacity>=8.2.3 7 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | # org: nalbam 2 | app: lambda-slack-ai-bot 3 | service: lambda-slack-ai-bot 4 | provider: 5 | name: aws 6 | region: us-east-1 7 | runtime: python3.12 8 | stage: ${opt:stage, 'dev'} 9 | memorySize: 5120 10 | timeout: 29 11 | environment: 12 | BASE_NAME: slack-ai-bot 13 | iamRoleStatements: 14 | - Effect: Allow 15 | Action: 16 | - dynamodb:* 17 | Resource: 18 | - "arn:aws:dynamodb:*:*:table/${self:provider.environment.BASE_NAME}-*" 19 | 20 | functions: 21 | mention: 22 | handler: handler.lambda_handler 23 | events: 24 | - http: 25 | method: post 26 | path: /slack/events 27 | tags: 28 | Project: ${self:provider.environment.BASE_NAME} 29 | 30 | resources: 31 | Resources: 32 | DynamoDBTable: 33 | Type: AWS::DynamoDB::Table 34 | Properties: 35 | TableName: ${self:provider.environment.BASE_NAME}-${self:provider.stage} 36 | AttributeDefinitions: 37 | - AttributeName: id 38 | AttributeType: S 39 | KeySchema: 40 | - AttributeName: id 41 | KeyType: HASH 42 | ProvisionedThroughput: 43 | ReadCapacityUnits: 5 44 | WriteCapacityUnits: 5 45 | TimeToLiveSpecification: 46 | AttributeName: expire_at 47 | Enabled: true 48 | Tags: 49 | - Key: Project 50 | Value: ${self:provider.environment.BASE_NAME} 51 | 52 | plugins: 53 | - serverless-python-requirements 54 | - serverless-dotenv-plugin 55 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | # src 패키지 초기화 2 | -------------------------------------------------------------------------------- /src/api/__init__.py: -------------------------------------------------------------------------------- 1 | # API 모듈 패키지 초기화 2 | -------------------------------------------------------------------------------- /src/api/openai_api.py: -------------------------------------------------------------------------------- 1 | """ 2 | OpenAI API 래퍼 모듈 3 | """ 4 | import functools 5 | from typing import List, Dict, Any, Optional, Generator, Tuple, Union 6 | 7 | from openai import OpenAI 8 | from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type 9 | import requests 10 | 11 | from src.config import settings 12 | from src.utils import logger 13 | 14 | # OpenAI 클라이언트 초기화 15 | openai_client = OpenAI( 16 | organization=settings.OPENAI_ORG_ID if settings.OPENAI_ORG_ID != "None" else None, 17 | api_key=settings.OPENAI_API_KEY, 18 | ) 19 | 20 | class OpenAIApiError(Exception): 21 | """OpenAI API 오류 클래스""" 22 | pass 23 | 24 | # OpenAI API 호출에 재시도 데코레이터 적용 25 | @retry( 26 | stop=stop_after_attempt(3), 27 | wait=wait_exponential(multiplier=1, min=2, max=10), 28 | retry=retry_if_exception_type((OpenAIApiError, requests.RequestException, ConnectionError)), 29 | reraise=True 30 | ) 31 | def generate_chat_completion( 32 | messages: List[Dict[str, str]], 33 | user: str, 34 | stream: bool = True, 35 | temperature: float = settings.TEMPERATURE 36 | ) -> Union[Generator[Dict[str, Any], None, None], Dict[str, Any]]: 37 | """OpenAI 채팅 API를 사용하여 응답을 생성합니다. 38 | 39 | Args: 40 | messages: 대화 메시지 목록 (역할 및 내용) 41 | user: 사용자 ID 42 | stream: 스트리밍 사용 여부 43 | temperature: 생성 온도 (0.0~1.0) 44 | 45 | Returns: 46 | OpenAI API 응답 또는 스트림 객체 47 | 48 | Raises: 49 | OpenAIApiError: API 호출 중 오류 발생 시 50 | """ 51 | try: 52 | # 메시지가 10건 이상이면 간소화된 로그 53 | log_messages = messages 54 | if len(messages) > 10: 55 | log_messages = [messages[0], "...", messages[-1]] 56 | 57 | logger.log_debug(f"OpenAI API 요청", { 58 | "model": settings.OPENAI_MODEL, 59 | "messages_count": len(messages), 60 | "messages": log_messages, 61 | "user": user, 62 | "stream": stream 63 | }) 64 | 65 | response = openai_client.chat.completions.create( 66 | model=settings.OPENAI_MODEL, 67 | messages=messages, 68 | temperature=temperature, 69 | stream=stream, 70 | user=user, 71 | ) 72 | 73 | return response 74 | 75 | except Exception as e: 76 | logger.log_error("OpenAI 채팅 API 호출 중 오류 발생", e) 77 | raise OpenAIApiError(f"OpenAI API 오류: {str(e)}") 78 | 79 | @retry( 80 | stop=stop_after_attempt(3), 81 | wait=wait_exponential(multiplier=1, min=2, max=10), 82 | retry=retry_if_exception_type((OpenAIApiError, requests.RequestException, ConnectionError)), 83 | reraise=True 84 | ) 85 | def generate_image(prompt: str) -> Dict[str, Any]: 86 | """OpenAI DALL-E API를 사용하여 이미지를 생성합니다. 87 | 88 | Args: 89 | prompt: 이미지 생성을 위한 프롬프트 90 | 91 | Returns: 92 | 이미지 URL과 수정된 프롬프트를 포함한 결과 93 | 94 | Raises: 95 | OpenAIApiError: API 호출 중 오류 발생 시 96 | """ 97 | try: 98 | logger.log_debug(f"OpenAI 이미지 생성 요청", { 99 | "model": settings.IMAGE_MODEL, 100 | "prompt": prompt 101 | }) 102 | 103 | response = openai_client.images.generate( 104 | model=settings.IMAGE_MODEL, 105 | prompt=prompt, 106 | quality=settings.IMAGE_QUALITY, 107 | size=settings.IMAGE_SIZE, 108 | style=settings.IMAGE_STYLE, 109 | n=1, 110 | ) 111 | 112 | result = { 113 | "image_url": response.data[0].url, 114 | "revised_prompt": response.data[0].revised_prompt 115 | } 116 | 117 | logger.log_debug("OpenAI 이미지 생성 성공", { 118 | "revised_prompt": response.data[0].revised_prompt[:50] + "..." 119 | }) 120 | 121 | return result 122 | 123 | except Exception as e: 124 | logger.log_error("OpenAI 이미지 생성 중 오류 발생", e, { 125 | "prompt": prompt[:100] + "..." if len(prompt) > 100 else prompt 126 | }) 127 | raise OpenAIApiError(f"이미지 생성 오류: {str(e)}") 128 | 129 | def extract_content_from_stream(stream: Generator[Dict[str, Any], None, None]) -> Tuple[str, int]: 130 | """스트림에서 콘텐츠를 추출합니다. 131 | 132 | Args: 133 | stream: OpenAI 스트림 객체 134 | 135 | Returns: 136 | (추출된 텍스트, 추출된 청크 수)의 튜플 137 | """ 138 | message = "" 139 | chunk_count = 0 140 | 141 | try: 142 | for chunk in stream: 143 | chunk_count += 1 144 | content = chunk.choices[0].delta.content or "" 145 | message += content 146 | 147 | return message, chunk_count 148 | 149 | except Exception as e: 150 | logger.log_error("스트림에서 콘텐츠 추출 중 오류 발생", e) 151 | return message, chunk_count 152 | -------------------------------------------------------------------------------- /src/api/slack_api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Slack API 래퍼 모듈 3 | """ 4 | import re 5 | import functools 6 | import requests 7 | import base64 8 | from typing import Dict, Any, List, Optional, Tuple, Callable 9 | 10 | from slack_bolt import App, Say 11 | from slack_sdk.errors import SlackApiError 12 | 13 | from src.config import settings 14 | from src.utils import logger 15 | 16 | # 사용자 정보 캐시 17 | _user_info_cache = {} 18 | 19 | class SlackApiError(Exception): 20 | """Slack API 오류 클래스""" 21 | pass 22 | 23 | def initialize_slack_app() -> App: 24 | """Slack 앱을 초기화합니다. 25 | 26 | Returns: 27 | 초기화된 Slack 앱 인스턴스 28 | """ 29 | app = App( 30 | token=settings.SLACK_BOT_TOKEN, 31 | signing_secret=settings.SLACK_SIGNING_SECRET, 32 | process_before_response=True, 33 | ) 34 | 35 | try: 36 | # Slack API 연결 확인 및 봇 ID 가져오기 37 | auth_test = app.client.api_call("auth.test") 38 | bot_id = auth_test["user_id"] 39 | logger.log_info(f"Slack 앱 초기화 성공", {"bot_id": bot_id}) 40 | return app 41 | except Exception as e: 42 | logger.log_error("Slack 앱 초기화 중 오류 발생", e) 43 | raise SlackApiError(f"Slack 앱 초기화 오류: {str(e)}") 44 | 45 | def get_bot_id(app: App) -> str: 46 | """봇 ID를 가져옵니다. 47 | 48 | Args: 49 | app: Slack 앱 인스턴스 50 | 51 | Returns: 52 | 봇 사용자 ID 53 | """ 54 | try: 55 | auth_test = app.client.api_call("auth.test") 56 | return auth_test["user_id"] 57 | except SlackApiError as e: 58 | logger.log_error("봇 ID 조회 중 오류 발생", e) 59 | raise SlackApiError(f"봇 ID 조회 오류: {str(e)}") 60 | 61 | @functools.lru_cache(maxsize=100) 62 | def get_user_info(app: App, user_id: str) -> Dict[str, Any]: 63 | """사용자 정보를 가져옵니다 (캐싱). 64 | 65 | Args: 66 | app: Slack 앱 인스턴스 67 | user_id: Slack 사용자 ID 68 | 69 | Returns: 70 | 사용자 정보 딕셔너리 71 | """ 72 | # 캐시 확인 73 | if user_id in _user_info_cache: 74 | return _user_info_cache[user_id] 75 | 76 | try: 77 | user_info = app.client.users_info(user=user_id) 78 | _user_info_cache[user_id] = user_info.get("user", {}) 79 | return _user_info_cache[user_id] 80 | except SlackApiError as e: 81 | logger.log_error("사용자 정보 조회 중 오류 발생", e, {"user_id": user_id}) 82 | return {} 83 | 84 | def get_user_display_name(app: App, user_id: str) -> str: 85 | """사용자의 표시 이름을 가져옵니다. 86 | 87 | Args: 88 | app: Slack 앱 인스턴스 89 | user_id: Slack 사용자 ID 90 | 91 | Returns: 92 | 사용자 표시 이름 93 | """ 94 | user_info = get_user_info(app, user_id) 95 | return user_info.get("profile", {}).get("display_name", "Unknown User") 96 | 97 | def send_message(app: App, channel: str, text: str, thread_ts: Optional[str] = None) -> Dict[str, Any]: 98 | """Slack 채널에 메시지를 보냅니다. 99 | 100 | Args: 101 | app: Slack 앱 인스턴스 102 | channel: 채널 ID 103 | text: 보낼 메시지 텍스트 104 | thread_ts: 스레드 타임스탬프 (선택 사항) 105 | 106 | Returns: 107 | Slack API 응답 108 | """ 109 | try: 110 | kwargs = { 111 | "channel": channel, 112 | "text": text 113 | } 114 | 115 | if thread_ts: 116 | kwargs["thread_ts"] = thread_ts 117 | 118 | response = app.client.chat_postMessage(**kwargs) 119 | return response 120 | except SlackApiError as e: 121 | logger.log_error("메시지 전송 중 오류 발생", e, { 122 | "channel": channel, 123 | "thread_ts": thread_ts 124 | }) 125 | raise SlackApiError(f"메시지 전송 오류: {str(e)}") 126 | 127 | def update_message(app: App, channel: str, ts: str, text: str) -> Dict[str, Any]: 128 | """기존 Slack 메시지를 업데이트합니다. 129 | 130 | Args: 131 | app: Slack 앱 인스턴스 132 | channel: 채널 ID 133 | ts: 업데이트할 메시지의 타임스탬프 134 | text: 새 메시지 텍스트 135 | 136 | Returns: 137 | Slack API 응답 138 | """ 139 | try: 140 | response = app.client.chat_update( 141 | channel=channel, 142 | ts=ts, 143 | text=text 144 | ) 145 | return response 146 | except SlackApiError as e: 147 | logger.log_error("메시지 업데이트 중 오류 발생", e, { 148 | "channel": channel, 149 | "ts": ts 150 | }) 151 | raise SlackApiError(f"메시지 업데이트 오류: {str(e)}") 152 | 153 | def delete_message(app: App, channel: str, ts: str) -> Dict[str, Any]: 154 | """Slack 메시지를 삭제합니다. 155 | 156 | Args: 157 | app: Slack 앱 인스턴스 158 | channel: 채널 ID 159 | ts: 삭제할 메시지의 타임스탬프 160 | 161 | Returns: 162 | Slack API 응답 163 | """ 164 | try: 165 | response = app.client.chat_delete( 166 | channel=channel, 167 | ts=ts 168 | ) 169 | return response 170 | except SlackApiError as e: 171 | logger.log_error("메시지 삭제 중 오류 발생", e, { 172 | "channel": channel, 173 | "ts": ts 174 | }) 175 | raise SlackApiError(f"메시지 삭제 오류: {str(e)}") 176 | 177 | def upload_file(app: App, channel: str, file_data: bytes, filename: str, thread_ts: Optional[str] = None) -> Dict[str, Any]: 178 | """Slack 채널에 파일을 업로드합니다. 179 | 180 | Args: 181 | app: Slack 앱 인스턴스 182 | channel: 채널 ID 183 | file_data: 파일 바이너리 데이터 184 | filename: 파일 이름 185 | thread_ts: 스레드 타임스탬프 (선택 사항) 186 | 187 | Returns: 188 | Slack API 응답 189 | """ 190 | try: 191 | kwargs = { 192 | "channel": channel, 193 | "filename": filename, 194 | "file": file_data 195 | } 196 | 197 | if thread_ts: 198 | kwargs["thread_ts"] = thread_ts 199 | 200 | response = app.client.files_upload_v2(**kwargs) 201 | return response 202 | except SlackApiError as e: 203 | logger.log_error("파일 업로드 중 오류 발생", e, { 204 | "channel": channel, 205 | "filename": filename, 206 | "thread_ts": thread_ts 207 | }) 208 | raise SlackApiError(f"파일 업로드 오류: {str(e)}") 209 | 210 | def get_image_from_slack(image_url: str) -> Optional[bytes]: 211 | """Slack에서 이미지를 가져옵니다. 212 | 213 | Args: 214 | image_url: Slack 이미지 URL 215 | 216 | Returns: 217 | 이미지 바이너리 데이터 또는 None 218 | """ 219 | try: 220 | headers = {"Authorization": f"Bearer {settings.SLACK_BOT_TOKEN}"} 221 | response = requests.get(image_url, headers=headers) 222 | 223 | if response.status_code == 200: 224 | return response.content 225 | else: 226 | logger.log_error(f"이미지 다운로드 실패", None, { 227 | "url": image_url, 228 | "status_code": response.status_code 229 | }) 230 | return None 231 | 232 | except requests.RequestException as e: 233 | logger.log_error("이미지 다운로드 중 오류 발생", e, {"url": image_url}) 234 | return None 235 | 236 | def get_encoded_image_from_slack(image_url: str) -> Optional[str]: 237 | """Slack에서 이미지를 가져와 base64로 인코딩합니다. 238 | 239 | Args: 240 | image_url: Slack 이미지 URL 241 | 242 | Returns: 243 | base64로 인코딩된 이미지 문자열 또는 None 244 | """ 245 | image = get_image_from_slack(image_url) 246 | 247 | if image: 248 | return base64.b64encode(image).decode("utf-8") 249 | 250 | return None 251 | 252 | def get_thread_messages(app: App, channel: str, thread_ts: str, client_msg_id: Optional[str] = None) -> List[Dict[str, Any]]: 253 | """스레드 메시지를 가져옵니다. 254 | 255 | Args: 256 | app: Slack 앱 인스턴스 257 | channel: 채널 ID 258 | thread_ts: 스레드 타임스탬프 259 | client_msg_id: 제외할 메시지의 클라이언트 ID (선택 사항) 260 | 261 | Returns: 262 | 메시지 목록 263 | """ 264 | try: 265 | response = app.client.conversations_replies(channel=channel, ts=thread_ts) 266 | 267 | if not response.get("ok"): 268 | logger.log_error("스레드 메시지 조회 실패", None, { 269 | "channel": channel, 270 | "thread_ts": thread_ts, 271 | "response": response 272 | }) 273 | return [] 274 | 275 | messages = response.get("messages", []) 276 | 277 | # 첫 번째 메시지는 스레드 부모 메시지이므로 제외 278 | if messages and len(messages) > 0: 279 | messages = messages[1:] 280 | 281 | # client_msg_id로 메시지 필터링 (있는 경우) 282 | if client_msg_id: 283 | messages = [m for m in messages if m.get("client_msg_id") != client_msg_id] 284 | 285 | # 최신 메시지가 먼저 오도록 역순 정렬 286 | messages.reverse() 287 | 288 | return messages 289 | 290 | except SlackApiError as e: 291 | logger.log_error("스레드 메시지 조회 중 오류 발생", e, { 292 | "channel": channel, 293 | "thread_ts": thread_ts 294 | }) 295 | return [] 296 | 297 | def get_reactions(app: App, reactions: List[Dict[str, Any]]) -> str: 298 | """반응(이모지) 정보를 텍스트로 변환합니다. 299 | 300 | Args: 301 | app: Slack 앱 인스턴스 302 | reactions: 반응 목록 303 | 304 | Returns: 305 | 반응 텍스트 306 | """ 307 | try: 308 | reaction_map = {} 309 | reaction_users_cache = {} 310 | 311 | for reaction in reactions: 312 | reaction_name = ":" + reaction.get("name").split(":")[0] + ":" 313 | if reaction_name not in reaction_map: 314 | reaction_map[reaction_name] = [] 315 | 316 | reaction_users = reaction.get("users", []) 317 | for reaction_user in reaction_users: 318 | if reaction_user not in reaction_users_cache: 319 | user_name = get_user_display_name(app, reaction_user) 320 | reaction_users_cache[reaction_user] = user_name 321 | 322 | reaction_map[reaction_name].append(reaction_users_cache[reaction_user]) 323 | 324 | reaction_texts = [] 325 | for reaction_name, reaction_users in reaction_map.items(): 326 | reaction_texts.append(f"[{settings.KEYWARD_EMOJI} '{reaction_name}' reaction users: {','.join(reaction_users)}]") 327 | 328 | return " ".join(reaction_texts) 329 | 330 | except Exception as e: 331 | logger.log_error("반응 정보 처리 중 오류 발생", e) 332 | return "" 333 | 334 | def replace_emoji_pattern(text: str) -> str: 335 | """이모지 패턴을 정리합니다. 336 | 337 | Args: 338 | text: 원본 텍스트 339 | 340 | Returns: 341 | 정리된 텍스트 342 | """ 343 | # 패턴: :로 시작하고, 문자 그룹이 있고, :가 오고, 문자 그룹이 있고, :로 끝나는 패턴 344 | pattern = r":([^:]+):([^:]+):" 345 | 346 | # 첫 번째 그룹만 유지하고 두 번째 그룹은 제거 347 | replacement = r":\1:" 348 | 349 | # 치환 실행 350 | result = re.sub(pattern, replacement, text) 351 | return result 352 | 353 | def replace_text(text: str) -> str: 354 | """텍스트 형식을 변환합니다. 355 | 356 | Args: 357 | text: 원본 텍스트 358 | 359 | Returns: 360 | 변환된 텍스트 361 | """ 362 | for old, new in settings.CONVERSION_ARRAY: 363 | text = text.replace(old, new) 364 | return text 365 | 366 | def parse_slack_event(event: Dict[str, Any], bot_id: str) -> Dict[str, Any]: 367 | """Slack 이벤트를 파싱합니다. 368 | 369 | Args: 370 | event: Slack 이벤트 데이터 371 | bot_id: 봇 사용자 ID 372 | 373 | Returns: 374 | 파싱된 이벤트 정보 375 | """ 376 | # 스레드 정보 파싱 377 | thread_ts = event.get("thread_ts", event.get("ts")) 378 | 379 | # 텍스트 정보 파싱 380 | text = event.get("text", "").strip() 381 | 382 | # 멘션 제거 383 | text = re.sub(f"<@{bot_id}>", "", text).strip() 384 | 385 | return { 386 | "thread_ts": thread_ts, 387 | "text": text, 388 | "channel": event.get("channel"), 389 | "user": event.get("user"), 390 | "client_msg_id": event.get("client_msg_id"), 391 | "ts": event.get("ts"), 392 | "has_files": "files" in event 393 | } 394 | -------------------------------------------------------------------------------- /src/config/__init__.py: -------------------------------------------------------------------------------- 1 | # 설정 모듈 패키지 초기화 2 | -------------------------------------------------------------------------------- /src/config/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | 설정 및 환경 변수 관리 모듈 3 | """ 4 | import os 5 | from typing import Optional, Dict, Any 6 | 7 | # 환경 변수 설정 8 | STAGE = os.environ.get("STAGE", "dev") 9 | 10 | # Slack 설정 11 | SLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"] 12 | SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"] 13 | BOT_CURSOR = os.environ.get("BOT_CURSOR", ":robot_face:") 14 | 15 | # DynamoDB 설정 16 | BASE_NAME = os.environ.get("BASE_NAME", "slack-ai-bot") 17 | DYNAMODB_TABLE_NAME = os.environ.get("DYNAMODB_TABLE_NAME", f"{BASE_NAME}-{STAGE}") 18 | 19 | # OpenAI 설정 20 | OPENAI_ORG_ID = os.environ.get("OPENAI_ORG_ID", None) 21 | OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", None) 22 | OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o") 23 | 24 | # 이미지 생성 설정 25 | IMAGE_MODEL = os.environ.get("IMAGE_MODEL", "dall-e-3") 26 | IMAGE_QUALITY = os.environ.get("IMAGE_QUALITY", "hd") # standard, hd 27 | IMAGE_SIZE = os.environ.get("IMAGE_SIZE", "1024x1024") 28 | IMAGE_STYLE = os.environ.get("IMAGE_STYLE", "vivid") # vivid, natural 29 | 30 | # 시스템 메시지 31 | SYSTEM_MESSAGE = os.environ.get("SYSTEM_MESSAGE", "None") 32 | 33 | # 생성 설정 34 | TEMPERATURE = float(os.environ.get("TEMPERATURE", 0)) 35 | 36 | # 메시지 길이 제한 37 | MAX_LEN_SLACK = int(os.environ.get("MAX_LEN_SLACK", 3000)) 38 | MAX_LEN_OPENAI = int(os.environ.get("MAX_LEN_OPENAI", 4000)) 39 | 40 | # 키워드 41 | KEYWARD_IMAGE = os.environ.get("KEYWARD_IMAGE", "그려줘") 42 | KEYWARD_EMOJI = os.environ.get("KEYWARD_EMOJI", "이모지") 43 | 44 | # 메시지 템플릿 45 | MSG_PREVIOUS = f"이전 대화 내용 확인 중... {BOT_CURSOR}" 46 | MSG_IMAGE_DESCRIBE = f"이미지 감상 중... {BOT_CURSOR}" 47 | MSG_IMAGE_GENERATE = f"이미지 생성 준비 중... {BOT_CURSOR}" 48 | MSG_IMAGE_DRAW = f"이미지 그리는 중... {BOT_CURSOR}" 49 | MSG_RESPONSE = f"응답 기다리는 중... {BOT_CURSOR}" 50 | 51 | # 명령어 52 | COMMAND_DESCRIBE = "Describe the image in great detail as if viewing a photo." 53 | COMMAND_GENERATE = "Convert the above sentence into a command for DALL-E to generate an image within 1000 characters. Just give me a prompt." 54 | 55 | # 텍스트 변환 설정 56 | CONVERSION_ARRAY = [ 57 | ["**", "*"], 58 | # ["#### ", "🔸 "], 59 | # ["### ", "🔶 "], 60 | # ["## ", "🟠 "], 61 | # ["# ", "🟡 "], 62 | ] 63 | 64 | def validate_env_vars() -> None: 65 | """필수 환경 변수의 존재 여부를 확인합니다.""" 66 | required_vars = [ 67 | "SLACK_BOT_TOKEN", 68 | "SLACK_SIGNING_SECRET", 69 | "OPENAI_API_KEY" 70 | ] 71 | 72 | missing_vars = [var for var in required_vars if not os.environ.get(var)] 73 | if missing_vars: 74 | raise EnvironmentError(f"Missing required environment variables: {', '.join(missing_vars)}") 75 | -------------------------------------------------------------------------------- /src/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | # 핸들러 모듈 패키지 초기화 2 | -------------------------------------------------------------------------------- /src/handlers/message_handler.py: -------------------------------------------------------------------------------- 1 | """ 2 | Slack 메시지 처리 핸들러 3 | """ 4 | import sys 5 | from typing import Dict, Any, List, Optional, Tuple, Union 6 | 7 | from slack_bolt import App, Say 8 | 9 | from src.config import settings 10 | from src.utils import logger 11 | from src.api import slack_api, openai_api 12 | from src.utils import context_manager 13 | 14 | class MessageHandler: 15 | """Slack 메시지 처리 핸들러 클래스""" 16 | 17 | def __init__(self, app: App): 18 | """ 19 | Args: 20 | app: Slack 앱 인스턴스 21 | """ 22 | self.app = app 23 | self.bot_id = slack_api.get_bot_id(app) 24 | 25 | def chat_update(self, say: Say, channel: str, thread_ts: str, latest_ts: str, 26 | message: str, continue_thread: bool = False) -> Tuple[str, str]: 27 | """메시지를 Slack에 업데이트합니다. 28 | 29 | Args: 30 | say: Slack Say 객체 31 | channel: 채널 ID 32 | thread_ts: 스레드 타임스탬프 33 | latest_ts: 최신 메시지 타임스탬프 34 | message: 메시지 내용 35 | continue_thread: 계속 진행 중인지 여부 36 | 37 | Returns: 38 | (메시지, 새 타임스탬프) 튜플 39 | """ 40 | try: 41 | # 메시지 길이 제한 확인 42 | if sys.getsizeof(message) > settings.MAX_LEN_SLACK: 43 | # 코드 블록으로 분할하는 것이 가능한지 확인 44 | split_key = "\n\n" 45 | if "```" in message: 46 | split_key = "```" 47 | 48 | parts = message.split(split_key) 49 | last_one = parts.pop() 50 | 51 | # 분할 균형 확인 52 | if len(parts) % 2 == 0: 53 | text = split_key.join(parts) + split_key 54 | message = last_one 55 | else: 56 | text = split_key.join(parts) 57 | message = split_key + last_one 58 | 59 | # 텍스트 포맷팅 및 업데이트 60 | text = slack_api.replace_text(text) 61 | slack_api.update_message(self.app, channel, latest_ts, text) 62 | 63 | # 계속 진행 중인 경우 커서 추가 64 | if continue_thread: 65 | text = f"{slack_api.replace_text(message)} {settings.BOT_CURSOR}" 66 | else: 67 | text = slack_api.replace_text(message) 68 | 69 | # 새 메시지 전송 70 | result = say(text=text, thread_ts=thread_ts) 71 | latest_ts = result["ts"] 72 | else: 73 | # 계속 진행 중인 경우 커서 추가 74 | if continue_thread: 75 | text = f"{slack_api.replace_text(message)} {settings.BOT_CURSOR}" 76 | else: 77 | text = slack_api.replace_text(message) 78 | 79 | # 메시지 업데이트 80 | slack_api.update_message(self.app, channel, latest_ts, text) 81 | 82 | return message, latest_ts 83 | 84 | except Exception as e: 85 | logger.log_error("메시지 업데이트 중 오류 발생", e, { 86 | "channel": channel, 87 | "thread_ts": thread_ts, 88 | "latest_ts": latest_ts 89 | }) 90 | return message, latest_ts 91 | 92 | def reply_text(self, messages: List[Dict[str, str]], say: Say, channel: str, 93 | thread_ts: str, latest_ts: str, user: str) -> str: 94 | """텍스트 응답을 생성하고 전송합니다. 95 | 96 | Args: 97 | messages: 대화 메시지 목록 98 | say: Slack Say 객체 99 | channel: 채널 ID 100 | thread_ts: 스레드 타임스탬프 101 | latest_ts: 최신 메시지 타임스탬프 102 | user: 사용자 ID 103 | 104 | Returns: 105 | 생성된 응답 메시지 106 | """ 107 | try: 108 | # OpenAI API 스트림 생성 109 | stream = openai_api.generate_chat_completion( 110 | messages=messages, 111 | user=user, 112 | stream=True 113 | ) 114 | 115 | counter = 0 116 | message = "" 117 | buffer_size = 0 118 | update_threshold = 800 # 약 800자마다 업데이트 119 | 120 | # 스트림에서 응답 처리 121 | for part in stream: 122 | reply = part.choices[0].delta.content or "" 123 | 124 | if reply: 125 | message += reply 126 | buffer_size += len(reply) 127 | 128 | # 버퍼 크기 또는 카운터 기반으로 업데이트 129 | if buffer_size >= update_threshold or (counter > 0 and counter % 24 == 0): 130 | message, latest_ts = self.chat_update( 131 | say, channel, thread_ts, latest_ts, message, True 132 | ) 133 | buffer_size = 0 134 | 135 | counter += 1 136 | 137 | # 최종 메시지 업데이트 138 | self.chat_update(say, channel, thread_ts, latest_ts, message) 139 | 140 | return message 141 | 142 | except Exception as e: 143 | logger.log_error("텍스트 응답 생성 중 오류 발생", e) 144 | error_message = f"```오류 발생: {str(e)}```" 145 | self.chat_update(say, channel, thread_ts, latest_ts, error_message) 146 | return error_message 147 | 148 | def reply_image(self, prompt: str, say: Say, channel: str, thread_ts: str, latest_ts: str) -> str: 149 | """이미지를 생성하고 전송합니다. 150 | 151 | Args: 152 | prompt: 이미지 생성 프롬프트 153 | say: Slack Say 객체 154 | channel: 채널 ID 155 | thread_ts: 스레드 타임스탬프 156 | latest_ts: 최신 메시지 타임스탬프 157 | 158 | Returns: 159 | 이미지 URL 160 | """ 161 | try: 162 | # 이미지 생성 163 | image_result = openai_api.generate_image(prompt) 164 | 165 | image_url = image_result["image_url"] 166 | revised_prompt = image_result["revised_prompt"] 167 | 168 | # 이미지 다운로드 169 | file_ext = image_url.split(".")[-1].split("?")[0] 170 | filename = f"{settings.IMAGE_MODEL}.{file_ext}" 171 | 172 | file_data = slack_api.get_image_from_slack(image_url) 173 | if not file_data: 174 | # 직접 다운로드 시도 175 | try: 176 | import requests 177 | response = requests.get(image_url) 178 | if response.status_code == 200: 179 | file_data = response.content 180 | except Exception as e: 181 | logger.log_error("이미지 다운로드 중 오류 발생", e) 182 | self.chat_update(say, channel, thread_ts, latest_ts, "이미지를 다운로드할 수 없습니다.") 183 | return "" 184 | 185 | # 파일 업로드 186 | slack_api.upload_file( 187 | self.app, channel, file_data, filename, thread_ts 188 | ) 189 | 190 | # 프롬프트 업데이트 191 | self.chat_update(say, channel, thread_ts, latest_ts, revised_prompt) 192 | 193 | return image_url 194 | 195 | except Exception as e: 196 | logger.log_error("이미지 응답 생성 중 오류 발생", e) 197 | error_message = f"```이미지 생성 오류: {str(e)}```" 198 | self.chat_update(say, channel, thread_ts, latest_ts, error_message) 199 | return "" 200 | 201 | def process_thread_messages(self, channel: str, thread_ts: str, client_msg_id: str, 202 | type_keyword: Optional[str] = None) -> List[Dict[str, str]]: 203 | """스레드 메시지를 처리하여 OpenAI 메시지 형식으로 변환합니다. 204 | 205 | Args: 206 | channel: 채널 ID 207 | thread_ts: 스레드 타임스탬프 208 | client_msg_id: 클라이언트 메시지 ID 209 | type_keyword: 메시지 유형 키워드 (선택 사항) 210 | 211 | Returns: 212 | OpenAI 메시지 형식의 목록 213 | """ 214 | messages = [] 215 | 216 | try: 217 | # 스레드 메시지 가져오기 218 | thread_messages = slack_api.get_thread_messages(self.app, channel, thread_ts, client_msg_id) 219 | 220 | # 첫 번째 메시지의 타임스탬프 221 | first_ts = thread_messages[0].get("ts") if thread_messages else None 222 | 223 | # 메시지 처리 224 | for message in thread_messages: 225 | # 역할 결정 226 | role = "assistant" if message.get("bot_id", "") else "user" 227 | 228 | # 이모지 키워드가 있고 첫 번째 메시지에 리액션이 있는 경우 229 | if type_keyword == "emoji" and first_ts == message.get("ts") and "reactions" in message: 230 | reactions = slack_api.get_reactions(self.app, message.get("reactions", [])) 231 | if reactions: 232 | messages.append({ 233 | "role": role, 234 | "content": f"reactions {reactions}" 235 | }) 236 | 237 | # 사용자 이름 가져오기 238 | user_name = slack_api.get_user_display_name(self.app, message.get("user")) 239 | 240 | # 메시지 추가 241 | messages.append({ 242 | "role": role, 243 | "content": f"{user_name}: {message.get('text', '')}" 244 | }) 245 | 246 | # 메시지 크기 제한 확인 247 | if sys.getsizeof(messages) > settings.MAX_LEN_OPENAI: 248 | messages.pop(0) # 가장 오래된 메시지 제거 249 | break 250 | 251 | # 시스템 메시지 추가 252 | if settings.SYSTEM_MESSAGE != "None": 253 | messages.append({ 254 | "role": "system", 255 | "content": settings.SYSTEM_MESSAGE 256 | }) 257 | 258 | return messages 259 | 260 | except Exception as e: 261 | logger.log_error("스레드 메시지 처리 중 오류 발생", e, { 262 | "channel": channel, 263 | "thread_ts": thread_ts 264 | }) 265 | return messages 266 | 267 | def content_from_message(self, text: str, event: Dict[str, Any], user: str) -> Tuple[List[Dict[str, Any]], str]: 268 | """메시지 내용을 OpenAI API 형식으로 변환합니다. 269 | 270 | Args: 271 | text: 메시지 텍스트 272 | event: Slack 이벤트 데이터 273 | user: 사용자 ID 274 | 275 | Returns: 276 | (OpenAI API 형식의 콘텐츠, 메시지 유형) 튜플 277 | """ 278 | # 메시지 유형 결정 279 | content_type = "text" 280 | 281 | if settings.KEYWARD_IMAGE in text: 282 | content_type = "image" 283 | elif settings.KEYWARD_EMOJI in text: 284 | content_type = "emoji" 285 | text = slack_api.replace_emoji_pattern(text) 286 | 287 | # 사용자 이름 가져오기 288 | user_name = slack_api.get_user_display_name(self.app, user) 289 | 290 | # 기본 텍스트 콘텐츠 추가 291 | content = [{"type": "text", "text": f"{user_name}: {text}"}] 292 | 293 | # 첨부 파일 처리 294 | if "files" in event: 295 | files = event.get("files", []) 296 | for file in files: 297 | mimetype = file.get("mimetype", "") 298 | if mimetype and mimetype.startswith("image"): 299 | image_url = file.get("url_private") 300 | base64_image = slack_api.get_encoded_image_from_slack(image_url) 301 | if base64_image: 302 | content.append({ 303 | "type": "image_url", 304 | "image_url": { 305 | "url": f"data:{mimetype};base64,{base64_image}" 306 | } 307 | }) 308 | 309 | return content, content_type 310 | 311 | def conversation(self, say: Say, thread_ts: Optional[str], content: List[Dict[str, Any]], 312 | channel: str, user: str, client_msg_id: str, type_keyword: Optional[str] = None) -> None: 313 | """대화 처리 메인 함수입니다. 314 | 315 | Args: 316 | say: Slack Say 객체 317 | thread_ts: 스레드 타임스탬프 (없는 경우 DM) 318 | content: 메시지 콘텐츠 319 | channel: 채널 ID 320 | user: 사용자 ID 321 | client_msg_id: 클라이언트 메시지 ID 322 | type_keyword: 메시지 유형 키워드 (선택 사항) 323 | """ 324 | logger.log_debug("대화 처리 시작", {"content": content, "thread_ts": thread_ts}) 325 | 326 | # 초기 메시지와 타임스탬프 327 | result = say(text=settings.BOT_CURSOR, thread_ts=thread_ts) 328 | latest_ts = result["ts"] 329 | 330 | # OpenAI API 메시지 형식 준비 331 | messages = [{"role": "user", "content": content}] 332 | 333 | # 스레드 메시지 처리 334 | if thread_ts: 335 | self.chat_update(say, channel, thread_ts, latest_ts, settings.MSG_PREVIOUS) 336 | thread_messages = self.process_thread_messages(channel, thread_ts, client_msg_id, type_keyword) 337 | 338 | # 역순으로 변환하여 시간 순서대로 정렬 339 | messages = thread_messages[::-1] + messages 340 | 341 | try: 342 | logger.log_debug("OpenAI API 요청 준비", {"messages_count": len(messages)}) 343 | 344 | # OpenAI API 호출 및 응답 처리 345 | self.chat_update(say, channel, thread_ts, latest_ts, settings.MSG_RESPONSE) 346 | message = self.reply_text(messages, say, channel, thread_ts, latest_ts, user) 347 | 348 | logger.log_debug("응답 생성 완료", {"response_length": len(message)}) 349 | 350 | except Exception as e: 351 | logger.log_error("대화 처리 중 오류 발생", e) 352 | error_message = f"```오류 발생: {str(e)}```" 353 | self.chat_update(say, channel, thread_ts, latest_ts, error_message) 354 | 355 | def image_generate(self, say: Say, thread_ts: Optional[str], content: List[Dict[str, Any]], 356 | channel: str, client_msg_id: str, type_keyword: Optional[str] = None) -> None: 357 | """이미지 생성 처리 메인 함수입니다. 358 | 359 | Args: 360 | say: Slack Say 객체 361 | thread_ts: 스레드 타임스탬프 (없는 경우 DM) 362 | content: 메시지 콘텐츠 363 | channel: 채널 ID 364 | client_msg_id: 클라이언트 메시지 ID 365 | type_keyword: 메시지 유형 키워드 (선택 사항) 366 | """ 367 | logger.log_debug("이미지 생성 처리 시작", {"content_type": type_keyword}) 368 | 369 | # 초기 메시지와 타임스탬프 370 | result = say(text=settings.BOT_CURSOR, thread_ts=thread_ts) 371 | latest_ts = result["ts"] 372 | 373 | # 기본 프롬프트 추출 374 | prompt = content[0]["text"] 375 | prompts = [] 376 | 377 | # 스레드 메시지 처리 378 | if thread_ts: 379 | self.chat_update(say, channel, thread_ts, latest_ts, settings.MSG_PREVIOUS) 380 | 381 | # 스레드 메시지 가져오기 382 | thread_messages = self.process_thread_messages(channel, thread_ts, client_msg_id, type_keyword) 383 | thread_messages = thread_messages[::-1] # 역순으로 변환 384 | 385 | # 프롬프트 형식으로 변환 386 | prompts = [ 387 | f"{msg['role']}: {msg['content']}" 388 | for msg in thread_messages 389 | if msg['content'].strip() 390 | ] 391 | 392 | # 이미지 콘텐츠 처리 393 | if len(content) > 1: 394 | self.chat_update(say, channel, thread_ts, latest_ts, settings.MSG_IMAGE_DESCRIBE) 395 | 396 | # 이미지 묘사 요청 397 | try: 398 | content_copy = content.copy() 399 | content_copy[0]["text"] = settings.COMMAND_DESCRIBE 400 | 401 | # 이미지 묘사 요청 402 | response = openai_api.generate_chat_completion( 403 | messages=[{"role": "user", "content": content_copy}], 404 | user=client_msg_id, 405 | stream=False 406 | ) 407 | 408 | # 묘사 내용 추가 409 | image_description = response.choices[0].message.content 410 | prompts.append(image_description) 411 | 412 | except Exception as e: 413 | logger.log_error("이미지 묘사 중 오류 발생", e) 414 | 415 | # 기본 프롬프트 추가 416 | prompts.append(prompt) 417 | 418 | # 이미지 생성 프롬프트 준비 419 | try: 420 | self.chat_update(say, channel, thread_ts, latest_ts, settings.MSG_IMAGE_GENERATE) 421 | 422 | # DALL-E 프롬프트 준비 423 | prompts.append(settings.COMMAND_GENERATE) 424 | 425 | # OpenAI API를 통해 DALL-E 프롬프트 생성 426 | response = openai_api.generate_chat_completion( 427 | messages=[{ 428 | "role": "user", 429 | "content": [{"type": "text", "text": "\n\n\n".join(prompts)}] 430 | }], 431 | user=client_msg_id, 432 | stream=False 433 | ) 434 | 435 | # 최종 DALL-E 프롬프트 436 | dalle_prompt = response.choices[0].message.content 437 | 438 | # 프롬프트 표시 439 | self.chat_update(say, channel, thread_ts, latest_ts, f"{dalle_prompt} {settings.BOT_CURSOR}") 440 | 441 | # 이미지 생성 및 업로드 442 | self.reply_image(dalle_prompt, say, channel, thread_ts, latest_ts) 443 | 444 | except Exception as e: 445 | logger.log_error("이미지 생성 프롬프트 준비 중 오류 발생", e) 446 | error_message = f"```이미지 생성 오류: {str(e)}```" 447 | self.chat_update(say, channel, thread_ts, latest_ts, error_message) 448 | 449 | def handle_mention(self, body: Dict[str, Any], say: Say) -> None: 450 | """앱 멘션 이벤트 핸들러입니다. 451 | 452 | Args: 453 | body: 이벤트 본문 454 | say: Slack Say 객체 455 | """ 456 | event = body.get("event", {}) 457 | 458 | # 이벤트 파싱 459 | parsed_event = slack_api.parse_slack_event(event, self.bot_id) 460 | thread_ts = parsed_event["thread_ts"] 461 | text = parsed_event["text"] 462 | channel = parsed_event["channel"] 463 | user = parsed_event["user"] 464 | client_msg_id = parsed_event["client_msg_id"] 465 | 466 | # 메시지 콘텐츠 준비 467 | content, content_type = self.content_from_message(text, event, user) 468 | 469 | # 이미지 생성 또는 대화 처리 470 | if content_type == "image": 471 | self.image_generate(say, thread_ts, content, channel, client_msg_id, content_type) 472 | else: 473 | self.conversation(say, thread_ts, content, channel, user, client_msg_id, content_type) 474 | 475 | def handle_message(self, body: Dict[str, Any], say: Say) -> None: 476 | """다이렉트 메시지 이벤트 핸들러입니다. 477 | 478 | Args: 479 | body: 이벤트 본문 480 | say: Slack Say 객체 481 | """ 482 | event = body.get("event", {}) 483 | 484 | # 봇 메시지 무시 485 | if "bot_id" in event: 486 | return 487 | 488 | # 이벤트 파싱 489 | parsed_event = slack_api.parse_slack_event(event, self.bot_id) 490 | text = parsed_event["text"] 491 | channel = parsed_event["channel"] 492 | user = parsed_event["user"] 493 | client_msg_id = parsed_event["client_msg_id"] 494 | 495 | # DM은 스레드 없음 496 | thread_ts = None 497 | 498 | # 메시지 콘텐츠 준비 499 | content, content_type = self.content_from_message(text, event, user) 500 | 501 | # 이미지 생성 또는 대화 처리 502 | if content_type == "image": 503 | self.image_generate(say, thread_ts, content, channel, client_msg_id, content_type) 504 | else: 505 | self.conversation(say, thread_ts, content, channel, user, client_msg_id, content_type) 506 | -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # 유틸리티 모듈 패키지 초기화 2 | -------------------------------------------------------------------------------- /src/utils/context_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | DynamoDB 컨텍스트 관리 유틸리티 3 | """ 4 | import time 5 | import datetime 6 | from typing import Optional, Dict, Any, List 7 | import boto3 8 | from boto3.dynamodb.conditions import Key 9 | from botocore.exceptions import ClientError 10 | 11 | from src.config import settings 12 | from src.utils import logger 13 | 14 | # DynamoDB 리소스 초기화 15 | dynamodb = boto3.resource("dynamodb") 16 | dynamodb_client = boto3.client('dynamodb') 17 | table = dynamodb.Table(settings.DYNAMODB_TABLE_NAME) 18 | 19 | # 캐시 관리 (인메모리 캐싱) 20 | _context_cache = {} 21 | 22 | def check_table_exists() -> bool: 23 | """DynamoDB 테이블이 존재하는지 확인합니다. 24 | 25 | Returns: 26 | 테이블 존재 여부 27 | """ 28 | try: 29 | logger.log_info(f"DynamoDB 테이블 확인: {settings.DYNAMODB_TABLE_NAME}") 30 | response = dynamodb_client.describe_table(TableName=settings.DYNAMODB_TABLE_NAME) 31 | table_status = response['Table']['TableStatus'] 32 | logger.log_info(f"DynamoDB 테이블 상태: {table_status}", { 33 | "table_name": settings.DYNAMODB_TABLE_NAME 34 | }) 35 | return True 36 | except ClientError as e: 37 | if e.response['Error']['Code'] == 'ResourceNotFoundException': 38 | logger.log_error(f"DynamoDB 테이블을 찾을 수 없음", e, { 39 | "table_name": settings.DYNAMODB_TABLE_NAME 40 | }) 41 | return False 42 | else: 43 | logger.log_error(f"DynamoDB 테이블 확인 중 오류 발생", e, { 44 | "table_name": settings.DYNAMODB_TABLE_NAME 45 | }) 46 | return False 47 | 48 | def get_context(thread_ts: Optional[str], user: str, default: str = "") -> str: 49 | """대화 컨텍스트를 DynamoDB에서 가져옵니다. 50 | 51 | Args: 52 | thread_ts: 스레드 타임스탬프 (None인 경우 DM으로 간주) 53 | user: 사용자 ID 54 | default: 컨텍스트가 없을 경우 반환할 기본값 55 | 56 | Returns: 57 | 대화 컨텍스트 문자열 58 | """ 59 | # 캐시 확인 60 | cache_key = thread_ts if thread_ts else f"dm_{user}" 61 | if cache_key in _context_cache: 62 | logger.log_debug("캐시에서 컨텍스트 조회됨", {"cache_key": cache_key}) 63 | return _context_cache[cache_key] 64 | 65 | try: 66 | # DynamoDB에서 아이템 조회 67 | item_id = thread_ts if thread_ts else user 68 | logger.log_debug("DynamoDB 컨텍스트 조회 시도", { 69 | "item_id": item_id, 70 | "table_name": settings.DYNAMODB_TABLE_NAME 71 | }) 72 | 73 | response = table.get_item(Key={"id": item_id}) 74 | item = response.get("Item") 75 | 76 | if item: 77 | # 캐시에 저장 78 | _context_cache[cache_key] = item["conversation"] 79 | logger.log_debug("DynamoDB에서 컨텍스트 조회됨", {"item_id": item_id}) 80 | return item["conversation"] 81 | 82 | logger.log_debug("DynamoDB에서 컨텍스트를 찾을 수 없음", {"item_id": item_id}) 83 | return default 84 | 85 | except ClientError as e: 86 | error_code = e.response['Error']['Code'] if 'Error' in e.response else 'Unknown' 87 | error_msg = e.response['Error']['Message'] if 'Error' in e.response else str(e) 88 | 89 | logger.log_error(f"DynamoDB 컨텍스트 조회 중 오류 발생: {error_code}", e, { 90 | "thread_ts": thread_ts, 91 | "user": user, 92 | "table_name": settings.DYNAMODB_TABLE_NAME, 93 | "error": error_msg 94 | }) 95 | return default 96 | 97 | def put_context(thread_ts: Optional[str], user: str, conversation: str = "") -> bool: 98 | """대화 컨텍스트를 DynamoDB에 저장합니다. 99 | 100 | Args: 101 | thread_ts: 스레드 타임스탬프 (None인 경우 DM으로 간주) 102 | user: 사용자 ID 103 | conversation: 저장할 대화 컨텍스트 104 | 105 | Returns: 106 | 성공 여부 107 | """ 108 | try: 109 | # TTL 계산 (1시간) 110 | expire_at = int(time.time()) + 3600 111 | expire_dt = datetime.datetime.fromtimestamp(expire_at).isoformat() 112 | 113 | # 아이템 준비 114 | item_id = thread_ts if thread_ts else user 115 | item = { 116 | "id": item_id, 117 | "conversation": conversation, 118 | "user_id": user, 119 | "expire_dt": expire_dt, 120 | "expire_at": expire_at, 121 | } 122 | 123 | logger.log_debug("DynamoDB에 컨텍스트 저장 시도", { 124 | "item_id": item_id, 125 | "table_name": settings.DYNAMODB_TABLE_NAME 126 | }) 127 | 128 | # DynamoDB에 저장 129 | table.put_item(Item=item) 130 | 131 | # 캐시 업데이트 132 | cache_key = thread_ts if thread_ts else f"dm_{user}" 133 | _context_cache[cache_key] = conversation 134 | 135 | logger.log_debug("DynamoDB에 컨텍스트 저장 성공", {"item_id": item_id}) 136 | return True 137 | 138 | except ClientError as e: 139 | error_code = e.response['Error']['Code'] if 'Error' in e.response else 'Unknown' 140 | error_msg = e.response['Error']['Message'] if 'Error' in e.response else str(e) 141 | 142 | logger.log_error(f"DynamoDB에 컨텍스트 저장 중 오류 발생: {error_code}", e, { 143 | "thread_ts": thread_ts, 144 | "user": user, 145 | "table_name": settings.DYNAMODB_TABLE_NAME, 146 | "error": error_msg 147 | }) 148 | return False 149 | 150 | def batch_put_contexts(items: List[Dict[str, Any]]) -> None: 151 | """여러 컨텍스트를 배치로 저장합니다. 152 | 153 | Args: 154 | items: 저장할 아이템 목록 155 | """ 156 | try: 157 | logger.log_debug("DynamoDB 배치 저장 시도", { 158 | "items_count": len(items), 159 | "table_name": settings.DYNAMODB_TABLE_NAME 160 | }) 161 | 162 | with table.batch_writer() as batch: 163 | for item in items: 164 | batch.put_item(Item=item) 165 | 166 | # 캐시 업데이트 167 | for item in items: 168 | thread_ts = item.get("id") 169 | user = item.get("user_id") 170 | conversation = item.get("conversation") 171 | if thread_ts and user and conversation: 172 | cache_key = thread_ts if thread_ts != user else f"dm_{user}" 173 | _context_cache[cache_key] = conversation 174 | 175 | logger.log_debug("DynamoDB 배치 저장 성공", {"items_count": len(items)}) 176 | 177 | except ClientError as e: 178 | error_code = e.response['Error']['Code'] if 'Error' in e.response else 'Unknown' 179 | error_msg = e.response['Error']['Message'] if 'Error' in e.response else str(e) 180 | 181 | logger.log_error(f"DynamoDB 배치 저장 중 오류 발생: {error_code}", e, { 182 | "items_count": len(items), 183 | "table_name": settings.DYNAMODB_TABLE_NAME, 184 | "error": error_msg 185 | }) 186 | 187 | def clear_cache() -> None: 188 | """컨텍스트 캐시를 비웁니다.""" 189 | global _context_cache 190 | logger.log_debug("컨텍스트 캐시 비우기", {"cache_size": len(_context_cache)}) 191 | _context_cache = {} 192 | -------------------------------------------------------------------------------- /src/utils/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | 로깅 유틸리티 모듈 3 | """ 4 | import logging 5 | import json 6 | from typing import Any, Dict, Optional 7 | 8 | # 로깅 설정 9 | logging.basicConfig( 10 | level=logging.INFO, 11 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 12 | ) 13 | 14 | # 로거 인스턴스 생성 15 | logger = logging.getLogger('lambda-slack-ai-bot') 16 | 17 | def setup_logger(level: int = logging.INFO) -> None: 18 | """로거 레벨을 설정합니다. 19 | 20 | Args: 21 | level: 로깅 레벨 (logging.DEBUG, logging.INFO 등) 22 | """ 23 | logger.setLevel(level) 24 | 25 | def log_info(message: str, extra: Optional[Dict[str, Any]] = None) -> None: 26 | """INFO 레벨 로그를 기록합니다. 27 | 28 | Args: 29 | message: 로그 메시지 30 | extra: 추가 로그 데이터 (딕셔너리) 31 | """ 32 | if extra: 33 | logger.info(f"{message} - {json.dumps(extra)}") 34 | else: 35 | logger.info(message) 36 | 37 | def log_error(message: str, error: Optional[Exception] = None, extra: Optional[Dict[str, Any]] = None) -> None: 38 | """ERROR 레벨 로그를 기록합니다. 39 | 40 | Args: 41 | message: 에러 메시지 42 | error: 예외 객체 43 | extra: 추가 로그 데이터 (딕셔너리) 44 | """ 45 | if error: 46 | if extra: 47 | logger.error(f"{message}: {str(error)} - {json.dumps(extra)}", exc_info=True) 48 | else: 49 | logger.error(f"{message}: {str(error)}", exc_info=True) 50 | else: 51 | if extra: 52 | logger.error(f"{message} - {json.dumps(extra)}") 53 | else: 54 | logger.error(message) 55 | 56 | def log_debug(message: str, extra: Optional[Dict[str, Any]] = None) -> None: 57 | """DEBUG 레벨 로그를 기록합니다. 58 | 59 | Args: 60 | message: 로그 메시지 61 | extra: 추가 로그 데이터 (딕셔너리) 62 | """ 63 | if extra: 64 | logger.debug(f"{message} - {json.dumps(extra)}") 65 | else: 66 | logger.debug(message) 67 | 68 | def log_warning(message: str, extra: Optional[Dict[str, Any]] = None) -> None: 69 | """WARNING 레벨 로그를 기록합니다. 70 | 71 | Args: 72 | message: 경고 메시지 73 | extra: 추가 로그 데이터 (딕셔너리) 74 | """ 75 | if extra: 76 | logger.warning(f"{message} - {json.dumps(extra)}") 77 | else: 78 | logger.warning(message) 79 | --------------------------------------------------------------------------------