diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 5e76b96..35576ac 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -12,12 +12,5 @@ reviews: enabled: true drafts: false path_filters: ['!cassettes/**'] - path_instructions: - - path: 'src/usecases/**/*.ts' - instructions: 'Clean Architecture 원칙과 ServiceResult 패턴을 따르는지 확인' - - path: '**/*.test.ts' - instructions: 'AAA 패턴(Arrange-Act-Assert)과 한국어 테스트 설명을 확인' - - path: 'src/domains/**/*.ts' - instructions: '도메인 모델이 순수하고 외부 의존성이 없는지 확인' chat: auto_reply: true diff --git a/.env.example b/.env.example index ee9c624..5695673 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,54 @@ -BOT_CURSOR=":loading:" +# --- Slack (required at runtime) --- +SLACK_BOT_TOKEN="xoxb-your-slack-bot-token" +SLACK_SIGNING_SECRET="your-slack-signing-secret" -SLACK_BOT_TOKEN="xoxb-xxxx" -SLACK_SIGNING_SECRET="xxxx" +# --- LLM / text (default: openai / gpt-4o-mini) --- +LLM_PROVIDER="openai" # openai | bedrock | xai +LLM_MODEL="gpt-4o-mini" # OpenAI: gpt-4o-mini / gpt-5 / o3 | Bedrock: us.anthropic.claude-opus-4-6-v1 / amazon.nova-pro-v1:0 | xAI: grok-4-1-fast-reasoning / grok-4.20-0309-reasoning -ALLOWED_CHANNEL_IDS="C000000,C000001" -ALLOWED_CHANNEL_MESSAGE="구루미에게 질문은 {} 채널을 이용해 주세요~" +# --- Image generation (default: openai / gpt-image-1) --- +IMAGE_PROVIDER="openai" # openai | bedrock | xai +IMAGE_MODEL="gpt-image-1" # OpenAI: gpt-image-1 / dall-e-3 | Bedrock: amazon.nova-canvas-v1:0 / amazon.titan-image-generator-v2:0 | xAI: grok-imagine-image / grok-imagine-image-pro -PERSONAL_MESSAGE="너는 AWSKRUG(AWS Korea User Group)의 친절하고 전문적인 AI 비서 구루미(Gurumi)야." -SYSTEM_MESSAGE="참고한 링크가 있다면 링크도 알려주세요. 한국어로 응답해주세요." +# --- Providers (required by provider in use) --- +OPENAI_API_KEY="sk-your-openai-api-key" +XAI_API_KEY="" # required when LLM_PROVIDER=xai or IMAGE_PROVIDER=xai. Get at https://console.x.ai +TAVILY_API_KEY="" # optional, enables richer web search -REACTION_EMOJIS="refund-done" +# --- Agent behavior --- +AGENT_MAX_STEPS=3 +MAX_OUTPUT_TOKENS=4096 # per-LLM-hop output token cap (>=256) +RESPONSE_LANGUAGE="ko" # ko | en + +# --- Slack UX --- +BOT_CURSOR=":robot_face:" +MAX_LEN_SLACK=3000 # per-message character cap, >=500 +SYSTEM_MESSAGE="" # optional extra operator policy appended to task rules +PERSONA_MESSAGE="" # optional answer style / tone (e.g. "자연스러운 한국어로 핵심부터 답한다") + +# --- Access control / throttle --- +ALLOWED_CHANNEL_IDS="" # comma-separated channel IDs, empty = allow all +ALLOWED_CHANNEL_MESSAGE="" # reply sent when channel not allowed; "{}" → first allowed channel +ALLOWED_USER_IDS="" # comma-separated user IDs, empty = allow all (applies to DMs too) +ALLOWED_USER_MESSAGE="" # reply sent when user not allowed; "{}" → first allowed user +MAX_THROTTLE_COUNT=100 # per-user active request cap + +# --- Storage (DynamoDB — created by serverless deploy) --- +DYNAMODB_TABLE_NAME="lambda-gurumi-bot-dev" +MAX_HISTORY_CHARS=4000 +AWS_REGION="us-east-1" + +# --- Observability --- +LOG_LEVEL="INFO" + +# --- Document / time --- +DEFAULT_TIMEZONE="Asia/Seoul" # IANA TZ for get_current_time default +MAX_DOC_CHARS=20000 # per-document extracted-text cap (>=1000) +MAX_DOC_PAGES=50 # per-document PDF page cap (>=1) +MAX_DOC_BYTES=26214400 # per-document download cap, 25MB (>=65536) + +# --- Web fetch (fetch_webpage tool) --- +MAX_WEB_CHARS=8000 # per-fetch returned content char cap (>=500) +MAX_WEB_BYTES=2097152 # per-fetch download cap, 2MB (>=65536) +MAX_WEB_LINKS=20 # per-fetch outbound-link cap (>=0) +JINA_READER_BASE="https://r.jina.ai" # override for self-hosted Jina Reader diff --git a/.github/aws-role/README.md b/.github/aws-role/README.md index 6e1cffa..948ea9b 100644 --- a/.github/aws-role/README.md +++ b/.github/aws-role/README.md @@ -1,7 +1,7 @@ # aws role ```bash -export NAME="lambda-gurumi-ai-bot" +export NAME="lambda-gurumi-bot" ``` ## create role @@ -44,7 +44,7 @@ aws iam attach-role-policy --role-name "${NAME}" --policy-arn "${POLICY_ARN}" - name: configure aws credentials uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: "arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/lambda-gurumi-ai-bot" + role-to-assume: "arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/lambda-gurumi-bot" role-session-name: github-actions-ci-bot aws-region: ${{ env.AWS_REGION }} diff --git a/.github/aws-role/role-policy.json b/.github/aws-role/role-policy.json index 0822a22..1d96557 100644 --- a/.github/aws-role/role-policy.json +++ b/.github/aws-role/role-policy.json @@ -26,7 +26,7 @@ "cloudformation:DeleteChangeSet", "cloudformation:DescribeChangeSet" ], - "Resource": "arn:aws:cloudformation:*:*:stack/lambda-gurumi-ai-bot-*" + "Resource": "arn:aws:cloudformation:*:*:stack/lambda-gurumi-bot-*" }, { "Sid": "Lambda", @@ -47,7 +47,7 @@ "lambda:ListTags" ], "Resource": [ - "arn:aws:lambda:*:*:function:lambda-gurumi-ai-bot-*" + "arn:aws:lambda:*:*:function:lambda-gurumi-bot-*" ] }, { @@ -65,7 +65,7 @@ "iam:UntagRole", "iam:ListRoleTags" ], - "Resource": "arn:aws:iam::*:role/lambda-gurumi-ai-bot-*" + "Resource": "arn:aws:iam::*:role/lambda-gurumi-bot-*" }, { "Sid": "S3", @@ -89,10 +89,8 @@ "s3:DeleteObject" ], "Resource": [ - "arn:aws:s3:::lambda-gurumi-ai-bot-*", - "arn:aws:s3:::lambda-gurumi-ai-bot-*/*", - "arn:aws:s3:::gurumi-ai-bot-*", - "arn:aws:s3:::gurumi-ai-bot-*/*" + "arn:aws:s3:::lambda-gurumi-bot-*", + "arn:aws:s3:::lambda-gurumi-bot-*/*" ] }, { @@ -110,7 +108,7 @@ "dynamodb:ListTagsOfResource" ], "Resource": [ - "arn:aws:dynamodb:*:*:table/gurumi-ai-bot-*" + "arn:aws:dynamodb:*:*:table/lambda-gurumi-bot-*" ] }, { @@ -121,11 +119,16 @@ "apigateway:POST", "apigateway:PUT", "apigateway:PATCH", - "apigateway:DELETE" + "apigateway:DELETE", + "apigateway:UpdateRestApiPolicy", + "apigateway:GetRestApiPolicy", + "apigateway:DeleteRestApiPolicy" ], "Resource": [ "arn:aws:apigateway:*::/restapis", - "arn:aws:apigateway:*::/restapis/*" + "arn:aws:apigateway:*::/restapis/*", + "arn:aws:apigateway:*::/tags", + "arn:aws:apigateway:*::/tags/*" ] }, { @@ -141,7 +144,7 @@ "logs:UntagResource", "logs:ListTagsForResource" ], - "Resource": "arn:aws:logs:*:*:log-group:/aws/lambda/lambda-gurumi-ai-bot-*" + "Resource": "arn:aws:logs:*:*:log-group:/aws/lambda/lambda-gurumi-bot-*" }, { "Sid": "S3Vectors", @@ -160,8 +163,8 @@ "s3vectors:ListTagsForResource" ], "Resource": [ - "arn:aws:s3vectors:*:*:bucket/gurumi-ai-bot-*", - "arn:aws:s3vectors:*:*:bucket/gurumi-ai-bot-*/index/*" + "arn:aws:s3vectors:*:*:bucket/lambda-gurumi-bot-*", + "arn:aws:s3vectors:*:*:bucket/lambda-gurumi-bot-*/index/*" ] }, { diff --git a/.github/aws-role/trust-policy.json b/.github/aws-role/trust-policy.json index 8617927..595f219 100644 --- a/.github/aws-role/trust-policy.json +++ b/.github/aws-role/trust-policy.json @@ -12,7 +12,10 @@ "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" }, "StringLike": { - "token.actions.githubusercontent.com:sub": "repo:awskrug/lambda-gurumi-ai-bot:*" + "token.actions.githubusercontent.com:sub": [ + "repo:awskrug/lambda-gurumi-bot:*", + "repo:nalbam/lambda-gurumi-bot:*" + ] } } } diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index e1eb80d..487d93e 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -2,107 +2,93 @@ name: Deploy to AWS Lambda on: push: - branches: - - main - repository_dispatch: - types: - - deploy - workflow_dispatch: + branches: [main] + workflow_dispatch: {} + repository_dispatch: {} -env: - AWS_REGION: "us-east-1" - AWS_ROLE_NAME: "lambda-gurumi-ai-bot" - - STAGE: "dev" - - FOUNDATION_MODEL: ${{ vars.FOUNDATION_MODEL }} - - ALLOWED_CHANNEL_IDS: ${{ vars.ALLOWED_CHANNEL_IDS }} - ALLOWED_CHANNEL_MESSAGE: ${{ vars.ALLOWED_CHANNEL_MESSAGE }} - BOT_CURSOR: ${{ vars.BOT_CURSOR }} - MAX_LEN_BEDROCK: ${{ vars.MAX_LEN_BEDROCK }} - MAX_LEN_SLACK: ${{ vars.MAX_LEN_SLACK }} - MAX_THROTTLE_COUNT: ${{ vars.MAX_THROTTLE_COUNT }} - PERSONAL_MESSAGE: ${{ vars.PERSONAL_MESSAGE }} - REACTION_EMOJIS: ${{ vars.REACTION_EMOJIS }} - SLACK_SAY_INTERVAL: ${{ vars.SLACK_SAY_INTERVAL }} - SYSTEM_MESSAGE: ${{ vars.SYSTEM_MESSAGE }} - - AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }} - -# Permission can be added at job level or workflow level permissions: - id-token: write # This is required for requesting the JWT - contents: read # This is required for actions/checkout + id-token: write + contents: read jobs: deploy: runs-on: ubuntu-24.04 - steps: - name: Checkout 🛎️ - uses: actions/checkout@v4 - with: - fetch-depth: 0 + uses: actions/checkout@v5 - - name: Setup Python 3.12 🐍 - uses: actions/setup-python@v5 + - name: Set up Python 🐍 + uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: "3.12" - - name: Install dependencies - run: npm install - - - name: Install Python dependencies - run: npx serverless plugin install --name serverless-python-requirements + - name: Set up Node.js 🟢 + uses: actions/setup-node@v5 + with: + node-version: "24" - - name: Install dotenv plugin - run: npx serverless plugin install --name serverless-dotenv-plugin + - name: Install Python deps 📦 + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt - - name: Install dependencies - run: pip install -r requirements.txt + - name: Run tests and check coverage ✅ + run: pytest --cov=src --cov-report=term-missing - - name: Set up environment variables 📝 + - name: Install Serverless + plugins 🛠️ run: | - echo "FOUNDATION_MODEL=${FOUNDATION_MODEL}" >> .env - echo "ALLOWED_CHANNEL_IDS=${ALLOWED_CHANNEL_IDS}" >> .env - echo "ALLOWED_CHANNEL_MESSAGE=${ALLOWED_CHANNEL_MESSAGE}" >> .env - echo "BOT_CURSOR=${BOT_CURSOR}" >> .env - echo "MAX_LEN_BEDROCK=${MAX_LEN_BEDROCK}" >> .env - echo "MAX_LEN_SLACK=${MAX_LEN_SLACK}" >> .env - echo "MAX_THROTTLE_COUNT=${MAX_THROTTLE_COUNT}" >> .env - echo "PERSONAL_MESSAGE=${PERSONAL_MESSAGE}" >> .env - echo "REACTION_EMOJIS=${REACTION_EMOJIS}" >> .env - echo "SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN}" >> .env - echo "SLACK_SAY_INTERVAL=${SLACK_SAY_INTERVAL}" >> .env - echo "SLACK_SIGNING_SECRET=${SLACK_SIGNING_SECRET}" >> .env - echo "SYSTEM_MESSAGE=${SYSTEM_MESSAGE}" >> .env + npm install -g serverless@3 + npm install serverless-python-requirements - - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v4 + - name: Configure AWS credentials (OIDC) 🔑 + uses: aws-actions/configure-aws-credentials@v6 with: - role-to-assume: "arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.AWS_ROLE_NAME }}" - role-session-name: github-actions-ci-bot - aws-region: ${{ env.AWS_REGION }} + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/lambda-gurumi-bot + aws-region: us-east-1 - name: Deploy to AWS Lambda 🚀 - run: npx serverless deploy --stage ${{ env.STAGE }} --region ${{ env.AWS_REGION }} - - - name: Update Bedrock Agent Alias 🔄 - run: | - AGENT_ID=$(aws cloudformation describe-stacks \ - --stack-name lambda-gurumi-ai-bot-${{ env.STAGE }} \ - --query "Stacks[0].Outputs[?OutputKey=='AgentId'].OutputValue" \ - --output text) - ALIAS_ID=$(aws cloudformation describe-stacks \ - --stack-name lambda-gurumi-ai-bot-${{ env.STAGE }} \ - --query "Stacks[0].Outputs[?OutputKey=='AgentAliasId'].OutputValue" \ - --output text) - - aws bedrock-agent update-agent-alias \ - --agent-id "${AGENT_ID}" \ - --agent-alias-id "${ALIAS_ID}" \ - --agent-alias-name live \ - --region ${{ env.AWS_REGION }} + env: + # --- secrets: Slack --- + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }} + # --- secrets: LLM provider keys --- + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + # --- secrets: Web search --- + TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }} + # --- vars: LLM / Image --- + IMAGE_MODEL: ${{ vars.IMAGE_MODEL }} + IMAGE_PROVIDER: ${{ vars.IMAGE_PROVIDER }} + LLM_MODEL: ${{ vars.LLM_MODEL }} + LLM_PROVIDER: ${{ vars.LLM_PROVIDER }} + # --- vars: Agent behavior --- + AGENT_MAX_STEPS: ${{ vars.AGENT_MAX_STEPS }} + MAX_OUTPUT_TOKENS: ${{ vars.MAX_OUTPUT_TOKENS }} + RESPONSE_LANGUAGE: ${{ vars.RESPONSE_LANGUAGE }} + # --- vars: Slack UX --- + BOT_CURSOR: ${{ vars.BOT_CURSOR }} + MAX_LEN_SLACK: ${{ vars.MAX_LEN_SLACK }} + SYSTEM_MESSAGE: ${{ vars.SYSTEM_MESSAGE }} + PERSONA_MESSAGE: ${{ vars.PERSONA_MESSAGE }} + # --- vars: Access control / throttle --- + ALLOWED_CHANNEL_IDS: ${{ vars.ALLOWED_CHANNEL_IDS }} + ALLOWED_CHANNEL_MESSAGE: ${{ vars.ALLOWED_CHANNEL_MESSAGE }} + ALLOWED_USER_IDS: ${{ vars.ALLOWED_USER_IDS }} + ALLOWED_USER_MESSAGE: ${{ vars.ALLOWED_USER_MESSAGE }} + MAX_THROTTLE_COUNT: ${{ vars.MAX_THROTTLE_COUNT }} + # --- vars: Storage --- + MAX_HISTORY_CHARS: ${{ vars.MAX_HISTORY_CHARS }} + # --- vars: Observability --- + LOG_LEVEL: ${{ vars.LOG_LEVEL }} + # --- vars: Document / time --- + DEFAULT_TIMEZONE: ${{ vars.DEFAULT_TIMEZONE }} + MAX_DOC_BYTES: ${{ vars.MAX_DOC_BYTES }} + MAX_DOC_CHARS: ${{ vars.MAX_DOC_CHARS }} + MAX_DOC_PAGES: ${{ vars.MAX_DOC_PAGES }} + # --- vars: Web fetch --- + JINA_READER_BASE: ${{ vars.JINA_READER_BASE }} + MAX_WEB_BYTES: ${{ vars.MAX_WEB_BYTES }} + MAX_WEB_CHARS: ${{ vars.MAX_WEB_CHARS }} + MAX_WEB_LINKS: ${{ vars.MAX_WEB_LINKS }} + run: npx serverless deploy --stage dev --region us-east-1 diff --git a/.github/workflows/sync-awsdocs.yml b/.github/workflows/sync-awsdocs.yml index 87670eb..bbf09da 100644 --- a/.github/workflows/sync-awsdocs.yml +++ b/.github/workflows/sync-awsdocs.yml @@ -7,8 +7,8 @@ on: env: AWS_REGION: "us-east-1" - AWS_ROLE_NAME: "lambda-gurumi-ai-bot" - STACK_NAME: "lambda-gurumi-ai-bot-dev" + AWS_ROLE_NAME: "lambda-gurumi-bot" + STACK_NAME: "lambda-gurumi-bot-dev" ENABLE_SYNC_AWSDOCS: ${{ vars.ENABLE_SYNC_AWSDOCS }} @@ -25,19 +25,19 @@ jobs: steps: - name: Checkout 🛎️ - uses: actions/checkout@v4 + uses: actions/checkout@v5 - - name: Install qpdf + - name: Install qpdf 📄 run: sudo apt-get install -y qpdf - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + - name: Configure AWS credentials (OIDC) 🔑 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: "arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.AWS_ROLE_NAME }}" role-session-name: github-actions-sync-awsdocs aws-region: ${{ env.AWS_REGION }} - - name: Get stack outputs + - name: Get stack outputs 📝 id: stack run: | BUCKET=$(aws cloudformation describe-stack-resource \ @@ -59,10 +59,10 @@ jobs: --output text) echo "ds_id=${DS_ID}" >> "$GITHUB_OUTPUT" - - name: Sync AWS docs to S3 + - name: Sync AWS docs to S3 📂 run: ./scripts/awsdocs/sync.sh ${{ steps.stack.outputs.bucket }} - - name: Start Knowledge Base ingestion + - name: Start Knowledge Base ingestion 🚀 run: | aws bedrock-agent start-ingestion-job \ --knowledge-base-id ${{ steps.stack.outputs.kb_id }} \ diff --git a/.github/workflows/sync-notion.yml b/.github/workflows/sync-notion.yml index 643b5cb..1c1a9e7 100644 --- a/.github/workflows/sync-notion.yml +++ b/.github/workflows/sync-notion.yml @@ -7,8 +7,8 @@ on: env: AWS_REGION: "us-east-1" - AWS_ROLE_NAME: "lambda-gurumi-ai-bot" - STACK_NAME: "lambda-gurumi-ai-bot-dev" + AWS_ROLE_NAME: "lambda-gurumi-bot" + STACK_NAME: "lambda-gurumi-bot-dev" ENABLE_SYNC_NOTION: ${{ vars.ENABLE_SYNC_NOTION }} @@ -28,29 +28,29 @@ jobs: steps: - name: Checkout 🛎️ - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Python 3.12 🐍 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" - - name: Install dependencies + - name: Install dependencies 📦 run: pip install notion-exporter - - name: Export Notion pages to Markdown + - name: Export Notion pages to Markdown 📝 env: NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} run: python scripts/notion/export.py - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + - name: Configure AWS credentials (OIDC) 🔑 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: "arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.AWS_ROLE_NAME }}" role-session-name: github-actions-sync-notion aws-region: ${{ env.AWS_REGION }} - - name: Get S3 bucket name from CloudFormation + - name: Get S3 bucket name from CloudFormation 📝 id: stack run: | BUCKET=$(aws cloudformation describe-stack-resource \ @@ -72,13 +72,13 @@ jobs: --output text) echo "ds_id=${DS_ID}" >> "$GITHUB_OUTPUT" - - name: Sync documents to S3 + - name: Sync documents to S3 📂 run: | aws s3 sync --delete \ build/${{ env.NOTION_PAGE_NAME }}/ \ s3://${{ steps.stack.outputs.bucket }}/documents/${{ env.NOTION_PAGE_NAME }}/ - - name: Start Knowledge Base ingestion + - name: Start Knowledge Base ingestion 🚀 run: | aws bedrock-agent start-ingestion-job \ --knowledge-base-id ${{ steps.stack.outputs.kb_id }} \ diff --git a/.gitignore b/.gitignore index ab0bd05..b284bea 100644 --- a/.gitignore +++ b/.gitignore @@ -37,10 +37,13 @@ dist/ # python venv -*.pyc staticfiles db.sqlite3 -__pycache__ +__pycache__/ +*.py[cod] +.coverage +.pytest_cache/ +.uploads/ # node node_modules diff --git a/CLAUDE.md b/CLAUDE.md index e35bbcd..68ce8c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,164 +1,205 @@ # CLAUDE.md -이 파일은 Claude Code가 이 저장소의 코드를 수정할 때 참고하는 개발 가이드입니다. +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## 주요 명령어 +## Commands ```bash -# 배포 -sls deploy --region us-east-1 +pip install -r requirements.txt +pip install -r requirements-dev.txt +cp .env.example .env.local # fill in values + +# Local CLI runner (no Slack connection needed; streaming is default) +python localtest.py "질문" +python localtest.py --no-stream "질문" # wait for full answer, then print +python localtest.py --quiet-steps "질문" # hide intermediate step logs +python localtest.py # interactive stdin (Ctrl+D) + +# Tests +python -m pytest +python -m pytest --cov=src --cov-report=term-missing +python -m pytest tests/test_agent.py::test_agent_runs_tool_then_returns_text -v +python -m pytest tests/llms/test_bedrock.py -v # LLM provider unit tests +python -m pytest tests/tools/test_web.py -v # fetch_webpage + SSRF guard + +# Deploy (requires IAM OIDC role `lambda-gurumi-bot`) +npm i -g serverless@3 +npm i serverless-python-requirements +# export SLACK_BOT_TOKEN / SLACK_SIGNING_SECRET / OPENAI_API_KEY / ... first +serverless deploy --stage dev --region us-east-1 +``` + +Lambda entrypoint: `app.lambda_handler`. Slack events land at `POST /slack/events` via API Gateway. + +## Core agent pipeline — DO NOT bypass or shortcut -# 배포 제거 -sls remove --region us-east-1 +Every user turn flows through the same four phases, in order: -# Bedrock 테스트 -cd scripts/bedrock && python invoke_agent.py -p "프롬프트" +``` +질문 (user message) + ↓ +의도·계획 (intent + plan — one LLM hop; native function calling emits + tool_calls in the same response when tools are needed) + ↓ +툴 사용 (tool execution — repeats as the LLM keeps calling tools) + ↓ +응답 (compose the final answer once the LLM stops requesting tools) ``` -## 코드 구조 +"의도 파악" and "계획" are a single step in code: one call to +`LLMProvider.chat(..., tools=registry.specs())`. The LLM's response +carries both the interpretation of the user request AND the proposed +tool_calls (if any) in one shot. Do NOT split this into a separate +intent-classifier hop — that adds a full LLM roundtrip for no gain +and diverges from native function-calling semantics. -### handler.py - 단일 파일 구조 +**Design rules — invariants for future changes:** -모든 핵심 로직이 `handler.py`에 포함되어 있습니다. +1. **Intent is always an LLM decision.** Never use keyword heuristics + (e.g., `"그려"`/`"draw"` → image generator) to bypass the agent. + The LLM reads the message and emits `tool_calls` to reflect intent. +2. **No phase shortcuts.** Even for "obvious" image requests, we still + go through the full hop: LLM plan → `generate_image` tool_call → tool + execution → LLM compose. Skipping the compose step to save seconds + means the bot can't caption, follow up, or react to tool errors. +3. **Tool orchestration happens inside the agent loop**, not in + `app.py`. `app.py` wires Slack concerns (placeholder, streaming, + history). `src/agent.py` owns the loop. Don't push intent + detection out of the agent. +4. **Slowness is a streaming / infrastructure problem, not a + pipeline-shortcut problem.** If the loop is slow, fix it with + async invocation, model choice, or streaming UX — not by + stripping phases. -#### 클래스 +## Architecture — the non-obvious parts -| 클래스 | 역할 | 주요 메서드 | -|--------|------|------------| -| `Config` | 환경 변수 기반 설정 (16개 항목) | `validate()`, `get_reaction_emojis()` | -| `DynamoDBManager` | 컨텍스트 저장/조회, 중복 감지, 쓰로틀링 | `put_context()`, `get_context()`, `count_user_contexts()` | -| `MessageFormatter` | 응답 분할 (코드 블록, 문단, 문장 단위) | `split_message()` | -| `SlackManager` | 메시지 업데이트, 스레드 히스토리 조회 | `update_message()`, `get_thread_history()` | -| `BedrockManager` | Agent 호출, 프롬프트 구성 | `invoke_agent()`, `create_prompt()` | +### Agent loop uses NATIVE function calling, not JSON prompting -#### 핸들러/함수 +`src/agent.py` passes `registry.specs()` directly to `LLMProvider.chat(tools=...)`. The provider (`src/llms/`) translates that to OpenAI `tools=[{type:"function",function:{...}}]` or Bedrock `tools=[{name, description, input_schema}]` (Claude) / `toolConfig` (Nova). There is **no JSON-in-prompt parsing** — tool calls arrive as structured objects. Loop terminates when `stop_reason != "tool_use"` or `max_steps` hit. On max_steps, a forced compose step (`_compose_without_tools`) runs with `tools=None`. -| 함수 | 트리거 | 설명 | -|------|--------|------| -| `lambda_handler` | HTTP POST `/slack/events` | Slack 이벤트 진입점 | -| `handle_mention` | `app_mention` 이벤트 | 앱 멘션 처리 | -| `handle_message` | `message` 이벤트 | 다이렉트 메시지 처리 | -| `handle_reaction_added` | `reaction_added` 이벤트 | 이모지 리액션 처리 | -| `conversation` | 내부 호출 | AI 응답 생성 및 전송 | -| `process_refund_done` | `:refund-done:` 리액션 | 계좌번호 마스킹, 환불일시 추가 | +Duplicate tool-call suppression: `_call_signature` = `name + sha1(args_json)`. A repeated signature within the loop is short-circuited with `{"ok": False, "error": "duplicate call skipped"}` and handed back to the LLM so it can move on. -#### 데이터 흐름 +### Three LLM provider families, one Protocol -``` -Slack 이벤트 → lambda_handler → handle_mention/handle_message - → 중복 감지 (client_msg_id, DynamoDB) - → 쓰로틀링 체크 (MAX_THROTTLE_COUNT) - → conversation() - → SlackManager.get_thread_history() → 대화 컨텍스트 수집 - → BedrockManager.create_prompt() → 프롬프트 구성 - - PERSONAL_MESSAGE (페르소나) - - SYSTEM_MESSAGE (시스템 지시) - - 태그 (대화 기록) - - 태그 (현재 질문) - → BedrockManager.invoke_agent() → Bedrock Agent 호출 - - Agent가 Knowledge Base를 자동으로 쿼리 (RAG) - → MessageFormatter.split_message() → 응답 분할 (MAX_LEN_SLACK) - → SlackManager.update_message() → Slack 전송 -``` +`LLMProvider` is a Protocol implemented by `OpenAIProvider`, `XAIProvider`, and `BedrockProvider`. OpenAI and xAI share the OpenAI wire format, so they both extend `_OpenAICompatProvider` and reuse the module-level helpers (`_to_openai_wire_messages`, `_parse_openai_completion`, `_consume_openai_stream`) rather than duplicating stream/tool_calls handling. -### serverless.yml - AWS 리소스 +- **OpenAIProvider**: default OpenAI endpoint. `_token_params` switches between `max_tokens` (legacy chat) and `max_completion_tokens` (gpt-5 / o1 / o3 / o4 reasoning). +- **XAIProvider**: `base_url="https://api.x.ai/v1"`, explicit `api_key`. Grok chat models accept the legacy `max_tokens + temperature` combo, so we never use `max_completion_tokens` here. Image generation omits `size` (xAI uses `aspect_ratio` / `resolution`) and always requests `response_format="b64_json"` so we can decode bytes locally. +- **BedrockProvider**: routes internally on model family prefix (Bedrock IDs and their `us./eu./apac./global.` inference-profile variants are both accepted): + - `anthropic.claude*` → `invoke_model` with Messages API shape, `content[].type=="tool_use"` parsing. + - `amazon.nova*` → `converse` / `converse_stream` with `toolConfig` + `output.message.content[].toolUse`. + - Unknown → Claude path without tools. -#### CloudFormation 리소스 +`_to_anthropic_messages` / `_to_nova_messages` translate our canonical role/tool_calls/tool messages to each backend's shape. `tool` role becomes an Anthropic `tool_result` content block inside a user message; Nova becomes a `toolResult` content block. -| 리소스 | 타입 | 이름 패턴 | -|--------|------|-----------| -| DynamoDBTable | `AWS::DynamoDB::Table` | `gurumi-ai-bot-{stage}` | -| S3Bucket | `AWS::S3::Bucket` | `gurumi-ai-bot-{account-id}` | -| S3VectorBucket | `AWS::S3Vectors::VectorBucket` | `gurumi-ai-bot-vectors-{account-id}` | -| S3VectorIndex | `AWS::S3Vectors::Index` | `gurumi-ai-bot-index` (1024dim, cosine, float32) | -| BedrockKBRole | `AWS::IAM::Role` | `lambda-gurumi-ai-bot-kb-role` | -| BedrockKnowledgeBase | `AWS::Bedrock::KnowledgeBase` | `gurumi-ai-bot-kb` | -| BedrockDataSource | `AWS::Bedrock::DataSource` | `gurumi-ai-bot-datasource` | -| BedrockAgentRole | `AWS::IAM::Role` | `lambda-gurumi-ai-bot-agent-role` | -| BedrockAgent | `AWS::Bedrock::Agent` | `gurumi-ai-bot` (Claude Sonnet 4.5, KB 연결 포함) | -| BedrockAgentAlias | `AWS::Bedrock::AgentAlias` | `live` | +Image generation is family-routed too: Titan/Nova-Canvas use `TEXT_IMAGE` task; Stability uses `text_prompts`. See `_build_image_body`. -#### Lambda IAM 권한 (iamRoleStatements) +`_CompositeProvider` wraps two providers when text and image providers differ (e.g., OpenAI text + Bedrock image). -- `dynamodb:GetItem/PutItem/Query` → `gurumi-ai-bot-*` 테이블 -- `bedrock:InvokeAgent` → `agent-alias/*` +### Receiver / worker split via Lambda async self-invoke -#### RAG 파이프라인 +`lambda_handler` routes one of two ways based on the event shape: -``` -S3 documents/ → BedrockDataSource (고정 크기 청킹: 300 토큰, 20% 오버랩) - → Titan Embeddings V2 (1024차원) → S3VectorIndex - → Bedrock Agent가 자동으로 Knowledge Base 쿼리 -``` +- **Receiver path** (Slack → API Gateway → Lambda): Bolt verifies the Slack signature and dispatches to the `app_mention` / `message` handlers. Each handler `ack()`s, then calls `_enqueue_worker(event, is_dm)` which issues a single `lambda:Invoke` against this same function with `InvocationType=Event` and a `{"_worker": True, ...}` payload, then returns. The receiver path is back to HTTP 200 within a few hundred ms. That's why Lambda timeout (`serverless.yml: timeout: 300`) can be much larger than the API Gateway REST integration limit (29s) — the receiver never runs long enough to care, and the HTTP response is already home before the worker even starts. Fallback: if `AWS_LAMBDA_FUNCTION_NAME` is unset (local harness) or `lambda.invoke` raises, `_enqueue_worker` runs `_process_worker` inline so the message isn't dropped. +- **Worker path** (Lambda async self-invoke): `lambda_handler` short-circuits on `event["_worker"] is True` and calls `_process_worker`, which rebuilds a `WebClient` from the bot token (Bolt's injected client is gone — the receiver process exited) and calls `_process(...)`. The full agent run — streaming, tool calls, image generation — happens here, with Lambda's 300s budget all to itself. + +### Slack retry → DynamoDB conditional put dedup + +Three converging retry sources all funnel through one key: Slack's own 3-attempt retry schedule on the receiver, AWS Lambda's built-in 2x retry on async worker failure, and any accidental re-dispatch. `lambda_handler` short-circuits when `X-Slack-Retry-Num` header is present (returns 200 OK) so Slack retries never spawn a second worker. Inside the worker, the first line of `_process()` is `DedupStore.reserve(f"dedup:{client_msg_id}")` which does `put_item(ConditionExpression="attribute_not_exists(id)")`. Duplicate key raises `ConditionalCheckFailedException` → False → silent return. This is the only race-safe dedup (get-then-put has a window). TTL 1h via `expire_at`. Lambda async worker retries on the same `_worker` payload also hit the same dedup row, so a transient worker failure that Lambda retries can't produce a second reply. + +### Single table, two key prefixes + +`DYNAMODB_TABLE_NAME` stores both dedup reservations (`dedup:{msg_id}`) and thread conversation memory (`ctx:{thread_ts}`). GSI `user-index` (hash `user`, range `expire_at`) backs per-user throttle via `count_user_active(user)`. `ConversationStore.put` trims with `truncate_to_chars(messages, max_chars)` (drop oldest until serialized size fits). + +### Message splitting is code-fence-aware + +`MessageFormatter.split_message` (in `src/slack_helpers.py`) splits on `\`\`\`` first (so complete code blocks survive), then on `\n\n`, then on `.!?` sentence boundaries, then hard slice. `_merge_small` rejoins adjacent small chunks up to `max_len`. First chunk goes via `chat_update` on the placeholder message; the rest via `chat_postMessage(thread_ts=…)`. If `chat_update` fails (`msg_too_long` etc.), that chunk falls back to a new message. + +### Public web fetching is SSRF-gated + +`fetch_webpage` uses `_validate_public_https_url` (in `src/tools/web.py`) to enforce `https`, reject IP literals, and drop DNS results resolving to any non-public address (private / loopback / link-local / reserved / multicast / unspecified / non-global — CGNAT `100.64.0.0/10` included). The Jina Reader path (`{JINA_READER_BASE}/{percent-encoded url}`) does the actual network hop against the target; the raw fallback (and only the raw fallback, since the Jina path uses Jina's own fetch) goes direct with a `_NoRedirectHandler` that refuses 3xx, so a redirect into RFC1918 space can't slip past the pre-flight DNS check. Body size is capped by `MAX_WEB_BYTES` on both paths; if Jina exceeds the cap we fall through to raw (the direct fetch may be smaller than Jina's markdown-ified output). Web helpers live in `src/tools/web.py`. Every tool submodule registers itself into `default_registry` at import time; `src/tools/__init__.py` imports the submodules so importing the package is enough to make every built-in tool available. + +### Config is lazy, not import-time + +`Settings.from_env()` runs at module load but does NOT validate Slack credentials. `Settings.require_slack_credentials()` is called from `_get_bolt_app()` so the first request fails cleanly, but tests and tooling can import `app` without `SLACK_BOT_TOKEN`. + +Enum/int validation quietly falls back to defaults with a warning: invalid `LLM_PROVIDER=mystery` → `openai`, `AGENT_MAX_STEPS=not-int` → `3`, below-minimum values clamp up. + +### Streaming runs on every LLM hop + +`OpenAIProvider.chat(on_delta=...)` switches into `stream=True` and forwards content deltas as they arrive. When the model starts a `tool_calls` delta (preamble like "Let me search..."), forwarding is suppressed — that pre-tool commentary would leak into the final reply. Tool_calls are accumulated across chunks and returned alongside the content. The agent passes `self.on_stream` into every `chat()` call, so when the LLM decides to answer directly (no tools) the user sees tokens immediately. A separate `stream_chat()` path still exists for the forced compose at `max_steps` and for Bedrock paths that don't yet support tool+stream natively. + +Stream throttling is handled inside `StreamingMessage.append()` (`min_interval=0.6s`), not by a wrapper in `app.py`. `StreamingMessage` also rolls into a fresh `chat_postMessage` when the fallback buffer approaches `max_len`, and `stop()` splits an oversized final answer using `MessageFormatter` so no single update hits Slack's `msg_too_long` error. + +### Structured logging with request_id + +`src/logging_utils.py` installs a JSON handler on root. `set_request_id(uuid)` is called at the start of each `_process`. `log_event(logger, "agent.done", steps=..., tokens_in=...)` emits records whose `extra_fields` dict survives into the JSON payload — useful for CloudWatch Insights queries. Because `logging.LoggerAdapter.process()` in Python 3.12 overwrites `extra=`, `log_event` dispatches via `logger.logger` (the underlying `Logger`) instead of the adapter. + +### Extension points + +**Add a new tool.** Create `src/tools/.py` with one or more functions decorated by `@tool(default_registry, name="...", description="...", parameters={...})`. Add `` to the side-effect import block in `src/tools/__init__.py`. Add `tests/tools/test_.py`. That's it — the agent loop sees the new tool because `default_registry` is populated at import time. + +**Add a new LLM provider.** Create `src/llms/.py` with a class that satisfies the `LLMProvider` Protocol (`chat`, `stream_chat`, `describe_image`, `generate_image`). Add a branch to `src/llms/factory.py`'s `get_llm`, and if the provider introduces new model families extend `_VALID_PROVIDERS` in `src/config.py`. Add `tests/llms/test_.py`. + +Neither extension requires editing the registry or the agent loop. + +## Deployment + +`serverless.yml` provisions: +- Lambda: python3.12, x86_64, 5120MB, 300s timeout. The 300s value only applies to the worker path — the receiver path returns HTTP 200 within a few hundred ms via `_enqueue_worker`'s async self-invoke. The API Gateway REST integration timeout (29s) is only relevant to the receiver, which finishes long before that, so extending the worker budget to 300s (or further, up to Lambda's 900s cap) is safe. Tool timeouts (e.g. `generate_image` 240s) are sized so that compose + upload + history-save fit in the worker's remaining budget. (x86_64 matches the Ubuntu GitHub Actions runner so pip installs wheels — including native ones like `pydantic_core` — that run on the Lambda runtime. Switching to arm64 requires a Docker-based build path via serverless-python-requirements and is deferred.) +- IAM (runtime Lambda role) also grants `lambda:InvokeFunction` on this function's own ARN so `_enqueue_worker` can fire the async self-invoke. +- DynamoDB: hash `id`, GSI `user-index` (user + expire_at, KEYS_ONLY), TTL `expire_at`. +- IAM (runtime Lambda role): `dynamodb:GetItem/PutItem/Query` on table + GSI, `bedrock:InvokeModel*`/`Converse*`. + +### GitHub Actions workflows + +Three files under `.github/workflows/`: + +- `push.yml` — on `push` to `main` (and `workflow_dispatch`). Runs `pytest --cov=src`, sets up Node 20 + Serverless v3, assumes the OIDC role `lambda-gurumi-bot`, then `serverless deploy --stage dev --region us-east-1`. +- `sync-notion.yml`, `sync-awsdocs.yml` — `workflow_dispatch` only (schedule commented out), each gated by `vars.ENABLE_SYNC_NOTION` / `ENABLE_SYNC_AWSDOCS`. Both call `aws cloudformation describe-stacks` expecting outputs `S3Bucket` / `KnowledgeBaseId` / `DataSourceId` that `serverless.yml` does not define, and invoke ingestion scripts (`scripts/notion/export.py`, `scripts/awsdocs/sync.sh`) that have been deleted. **They fail if enabled.** See "Excluded (Phase 2+)". + +### OIDC role (`.github/aws-role/`) + +Separate from the Lambda runtime role. `trust-policy.json` allows both `repo:awskrug/lambda-gurumi-bot:*` and `repo:nalbam/lambda-gurumi-bot:*`. `role-policy.json` is intentionally wider than current needs — it already grants `s3vectors:*`, `bedrock:*KnowledgeBase*`, `bedrock:*DataSource*`, `bedrock:*Agent*` (scoped to `lambda-gurumi-bot-*`) so Phase-2 KB work can land without IAM changes. + +## Testing + +206 tests, 84% overall coverage (the drop vs. the 89% of `src/*`-only measurement is just `app.py` now being in scope: its `_process` path is exercised by live Slack traffic, not unit tests). `pytest.ini` pins `testpaths = tests`, `filterwarnings = ignore::DeprecationWarning`. Key approach: + +- Tests mirror source layout: `tests/llms/` for each `src/llms/*` submodule, `tests/tools/` for each `src/tools/*` submodule. Top-level `tests/test_agent.py`, `test_config.py`, `test_dedup.py`, `test_logging_utils.py`, `test_slack_helpers.py` cover the non-packaged modules. +- Shared tool-test fixtures (`_ctx`, `_settings`, `_streamed_read`) live in `tests/tools/_helpers.py` — individual test files import from there instead of redefining them. +- `moto[dynamodb]` for `DedupStore` / `ConversationStore` integration tests. +- Network patches target the submodule where `urllib` / `socket` is imported, not the package: `patch("src.tools.slack.urllib.request.urlopen")` for Slack file fetch, `patch("src.tools.search.urllib.request.urlopen")` for `search_web`, `patch("src.tools.web.urllib.request.urlopen")` and `monkeypatch.setattr("src.tools.web.socket.getaddrinfo", …)` for `fetch_webpage`. +- `ScriptedLLM` (in `tests/test_agent.py`) emits predefined `LLMResult` sequences to drive the agent loop without any network. +- Provider tests use `MagicMock` clients — no real OpenAI / Bedrock / xAI calls. +- `tests/test_config.py` builds `Settings` from `monkeypatch`-controlled env without reloading the module. +- `reportlab` (dev-only) synthesizes real PDFs for `read_attached_document` parser coverage. + +Per-module coverage: + +- `app.py` 35% (routing and worker fan-out covered by `tests/test_app.py`; `_process` is only hit in production) +- `agent.py` 96%, `config.py` 97%, `dedup.py` 80%, `slack_helpers.py` 86%, `logging_utils.py` 97% +- `llms/`: `base.py` 70%, `openai_wire.py` 96%, `openai.py` 100%, `xai.py` 100%, `bedrock.py` 78%, `composite.py` 87%, `factory.py` 94% +- `tools/`: `registry.py` 100%, `slack.py` 87%, `search.py` 93%, `web.py` 97%, `image.py` 100%, `time.py` 100% + +## Things that are easy to break + +- **Dropping the `_CompositeProvider` branch** in `get_llm` breaks mixed-provider setups (OpenAI text + Bedrock image). +- **Changing `DedupStore.reserve` to a read-then-write pattern** reintroduces the retry race. +- **Losing the `id` prefix scheme** (`dedup:` vs `ctx:`) collides the two store types. +- **Switching to `LoggerAdapter.info(extra=…)`** — in Python 3.12 the adapter's `process()` overwrites `extra`; keep going through `logger.logger` for `extra_fields`. +- **Removing the SSRF host allowlist** (`SLACK_FILE_HOSTS`) shared by `read_attached_images` and `read_attached_document` (`_fetch_slack_file`) opens up arbitrary URL fetch with the bot token. +- **Adding a tool without updating `ToolRegistry.specs()`** — the `@tool` decorator handles both dispatch and LLM schema from a single declaration; inline dict tricks will silently desync. +- **Removing the SSRF guard (`_validate_public_https_url`) on `fetch_webpage`** re-opens fetch to RFC1918 space and cloud-metadata endpoints (e.g. `169.254.169.254`). +- **Enabling redirects on the `fetch_webpage` raw fallback** — a 302 to a private host bypasses the pre-flight DNS check; keep `_NoRedirectHandler` installed. +- **DNS rebinding on `fetch_webpage` raw fallback**. The pre-flight `getaddrinfo` check and the eventual TCP connect are two separate DNS lookups; a TTL=0 attacker can flip between them. Lambda's environment makes the attack hard and impact is bounded (no VPC by default), but don't treat `_validate_public_https_url` as a guarantee that the actual connection hits the same IP. If you ever add VPC/private-subnet egress, revisit this. +- **Dropping the Nova branch in `BedrockProvider.describe_image`**. Nova chat models speak the Converse API with an `image` content block — sending Claude's Messages body at a Nova model ID fails with `ValidationException`. `chat()` already family-routes; the vision entrypoint must do the same. +- **Removing `SlackMentionAgent`'s `finally: self.executor.close()`**. The agent creates its own `ToolExecutor` (and hence a `ThreadPoolExecutor`) unless one is injected. Without the close, every Lambda warm invocation adds new non-daemon workers to the process registry that never unwind until interpreter exit. +- **Narrowing `ToolExecutor.execute`'s exception catch back to a stdlib allowlist**. Provider SDKs raise their own (`openai.APIError`, `anthropic.APIError`, `httpx.HTTPError`) that don't inherit from `ValueError`/`TypeError`; when they escape the executor the whole agent loop aborts instead of handing the failure back to the LLM as `{"ok": False, ...}` for recovery. +- **Applying channel allowlist to DMs**. `_process()` skips `channel_allowed` when `is_dm=True` — DM channel IDs are D-prefixed and not normally in `ALLOWED_CHANNEL_IDS`, so enforcing there would instantly lock out every user's direct-message path the moment an operator set a channel allowlist. + +## Excluded (Phase 2+) -#### Outputs - -- `KnowledgeBaseId` - Bedrock Knowledge Base ID -- `DataSourceId` - Bedrock Data Source ID -- `AgentId` - Bedrock Agent ID -- `AgentAliasId` - Bedrock Agent Alias ID - -### .github/workflows/ - CI/CD - -#### push.yml - 인프라 배포 - -`main` 브랜치 푸시 시 자동 실행: -1. Python 3.12 + 의존성 설치 -2. GitHub Variables(비민감) / Secrets(민감)에서 `.env` 생성 -3. AWS OIDC 인증 (역할: `lambda-gurumi-ai-bot`) -4. `serverless deploy` (Lambda, DynamoDB, S3, S3 Vectors, KB, Agent 전체 배포) - -#### sync-notion.yml - Notion 문서 동기화 - -매일 UTC 00:00 스케줄 + 수동 실행(`workflow_dispatch`): -1. Notion 페이지를 Markdown으로 내보내기 (`notion-exporter`, 공식 Notion API) -2. S3 `documents/{page_name}/` 프리픽스로 동기화 (`aws s3 sync --delete`) -3. Knowledge Base Ingestion 실행 (문서 → 임베딩 → S3 Vectors) -4. KB/DS ID는 CloudFormation Output에서 자동 조회 -5. GitHub Secrets: `NOTION_TOKEN` (Notion Integration API 키) -6. 활성화: GitHub Variables `ENABLE_SYNC_NOTION=true` - -#### sync-awsdocs.yml - AWS 공식 문서 동기화 - -매일 UTC 01:00 스케줄 + 수동 실행(`workflow_dispatch`): -1. `scripts/awsdocs/docs.txt`에 정의된 AWS 공식 PDF 다운로드 (19개 서비스) -2. 50MB 초과 PDF는 `qpdf`로 100페이지 단위 자동 분할 -3. S3 `documents/{service}/` 프리픽스로 동기화 -4. Knowledge Base Ingestion 실행 -5. 활성화: GitHub Variables `ENABLE_SYNC_AWSDOCS=true` -6. 문서 추가/제거: `scripts/awsdocs/docs.txt` 편집 - -### .github/aws-role/role-policy.json - 배포 IAM 정책 - -배포 역할의 최소 권한 정책. 서비스별 필요 액션만 포함: -- CloudFormation, Lambda, IAM, S3, DynamoDB, API Gateway, CloudWatch Logs -- S3 Vectors, Bedrock (Knowledge Base, Data Source, Agent) - -## 환경 변수 - -| 변수명 | 기본값 | 용도 | -|--------|--------|------| -| `SLACK_BOT_TOKEN` | (필수) | Slack Bot OAuth 토큰 | -| `SLACK_SIGNING_SECRET` | (필수) | 요청 서명 검증 | -| `AGENT_ID` | (CF 자동) | Bedrock Agent ID (CloudFormation `Fn::GetAtt` 참조) | -| `AGENT_ALIAS_ID` | (CF 자동) | Bedrock Agent Alias ID (CloudFormation `Fn::GetAtt` 참조) | -| `DYNAMODB_TABLE_NAME` | `gurumi-ai-bot-dev` | DynamoDB 테이블명 | -| `AWS_REGION` | `us-east-1` | AWS 리전 | -| `ALLOWED_CHANNEL_IDS` | `None` | 허용 채널 (쉼표 구분, None=전체 허용) | -| `ALLOWED_CHANNEL_MESSAGE` | 영문 메시지 | 비허용 채널 응답 메시지 | -| `PERSONAL_MESSAGE` | `You are a friendly and professional AI assistant.` | 페르소나 프롬프트 | -| `SYSTEM_MESSAGE` | `None` | 시스템 지시사항 | -| `MAX_LEN_SLACK` | `2000` | Slack 메시지 분할 길이 | -| `MAX_LEN_BEDROCK` | `4000` | Bedrock 컨텍스트 최대 길이 | -| `MAX_THROTTLE_COUNT` | `100` | 사용자별 동시 활성 컨텍스트 수 제한 | -| `SLACK_SAY_INTERVAL` | `0` | 분할 메시지 전송 간격 (초) | -| `BOT_CURSOR` | `:robot_face:` | 로딩 표시 이모지 | -| `REACTION_EMOJIS` | `refund-done` | 허용 이모지 리액션 (쉼표 구분) | - -## 코드 수정 시 주의사항 - -- `handler.py`는 단일 파일 구조. 800줄 이상 시 클래스 단위 분리 검토 -- `serverless.yml`의 리소스 이름 패턴(`gurumi-ai-bot-*`, `lambda-gurumi-ai-bot-*`)은 `role-policy.json`의 IAM 리소스 패턴과 일치해야 함 -- Bedrock Agent는 CloudFormation으로 관리 (`AWS::Bedrock::Agent`). `AGENT_ID`/`AGENT_ALIAS_ID`는 `Fn::GetAtt` 참조 -- Knowledge Base는 Agent에 연결되므로 `handler.py`에서 직접 Retrieve API를 호출하지 않음 -- DynamoDB TTL은 `expire_at` (Unix timestamp) 속성 사용, 1시간 기본 +- **Bedrock Knowledge Base (S3 Vectors + RAG) ingestion pipeline.** Scaffolding exists (IAM policy + `sync-notion.yml` / `sync-awsdocs.yml`), but `serverless.yml` does not provision `S3Bucket` / `KnowledgeBase` / `DataSource`, and the ingestion scripts (`scripts/notion/export.py`, `scripts/awsdocs/sync.sh`) were removed. Both must be restored to re-enable the workflows. +- `reaction_added` event wiring and domain-specific handlers. +- CloudWatch Alarms, X-Ray tracing, languages other than `ko` / `en`. diff --git a/README.md b/README.md index b8f50e9..0421e47 100644 --- a/README.md +++ b/README.md @@ -1,176 +1,245 @@ -# lambda-gurumi-ai-bot +# lambda-gurumi-bot -AWS Lambda, Amazon Bedrock, S3 Vectors를 활용한 서버리스 AI 챗봇입니다. RAG(Retrieval-Augmented Generation) 기반으로 문서를 검색하여 답변합니다. +Slack 멘션·DM 을 AWS Lambda 에서 처리하고, OpenAI · AWS Bedrock · xAI(Grok) LLM 으로 네이티브 **function calling** 기반 툴 오케스트레이션을 수행하는 봇입니다. ![Gurumi Bot](images/gurumi-bot.png) -## 주요 기능 - -- **RAG 지원**: S3 Vectors + Bedrock Knowledge Base 기반 문서 검색 및 답변 -- **서버리스 아키텍처**: AWS Lambda + API Gateway + DynamoDB -- **대화 히스토리**: DynamoDB를 통한 스레드 컨텍스트 유지 (1시간 TTL) -- **Slack 통합**: 앱 멘션, 다이렉트 메시지, 이모지 리액션 지원 -- **채널 접근 제어**: 허용된 채널 화이트리스트 -- **사용자 쓰로틀링**: 남용 방지를 위한 요청 제한 -- **응답 분할**: 긴 응답을 코드 블록/문단 단위로 분할 전송 +## 봇의 처리 흐름 (절대 생략하지 않는다) -## 아키텍처 +모든 사용자 메시지는 다음 네 단계를 **순서대로** 통과합니다: ``` -┌──────────┐ ┌─────────────┐ ┌─────────────┐ -│ Slack │────▶│ API Gateway │────▶│ Lambda │ -└──────────┘ └─────────────┘ └──────┬──────┘ - │ - ┌─────────────────────────────┼──────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ - │ DynamoDB │ │ Bedrock Agent │ │ S3 │ - │ (Context) │ │ │ │ │ (Documents) │ - └─────────────┘ │ Knowledge Base │ └──────┬──────┘ - │ │ │ │ - │ S3 Vectors │◀────────┘ - │ (Embeddings) │ Titan Embeddings V2 - └──────────────────┘ +질문 ── 의도·계획 ── 툴 사용 (반복) ── 응답 + (user) (LLM) (tools) (LLM) ``` -## 설치 +**의도 파악과 계획은 한 번의 LLM 호출로 통합**되어 있습니다 (OpenAI / Claude / Nova 의 native function calling). 같은 응답에 "무슨 요청인지 파악한 결과" 와 "다음에 부를 tool_calls" 가 함께 담겨 옵니다. 별도의 intent 분류 hop 을 추가하지 않습니다. -```bash -# Python 3.12 설치 -brew install python@3.12 - -# Serverless Framework 설치 -npm install -g serverless@3.38.0 +- **의도·계획은 LLM 이 한다.** 키워드 매칭(예: `"그려"` → 이미지)으로 우회하지 않는다. LLM 이 메시지를 읽고 `tool_calls` 로 의도를 표현한다. +- **단계 단축 금지.** 이미지 요청처럼 명확해 보여도 `LLM 판단 → generate_image tool → LLM 응답 합성` 전 과정을 거친다. 응답 합성 단계를 건너뛰면 caption·후속 대응·에러 처리가 사라진다. +- **Agent 루프는 `src/agent.py` 안에** 있고, `app.py` 는 Slack 관련 부분(placeholder, streaming, 히스토리) 만 담당한다. +- **속도 문제는 파이프라인 단축이 아닌** 스트리밍·비동기·모델 선택으로 해결한다. -# 프로젝트 의존성 설치 -npm install -sls plugin install -n serverless-python-requirements -sls plugin install -n serverless-dotenv-plugin -python -m pip install --upgrade -r requirements.txt -``` +## 주요 기능 -## 설정 +- **이벤트**: `app_mention`, DM(`message.im`) +- **Provider**: OpenAI · AWS Bedrock(Anthropic Claude 3/3.5/4.x · Amazon Nova) · xAI(Grok) 선택 가능 +- **Tools (네이티브 function calling)** + - `read_attached_images` — 첨부 이미지 Vision 요약 + - `read_attached_document` — 첨부 PDF/텍스트 파일 추출 (페이지·바이트·문자 상한 적용) + - `fetch_thread_history` — 스레드 히스토리 조회 + - `search_web` — Tavily (TAVILY_API_KEY 설정 시) 또는 DuckDuckGo + - `fetch_webpage` — 공개 HTTPS 웹 페이지 본문·링크 추출 (Jina Reader 우선 + raw fallback, SSRF 가드) + - `generate_image` — 이미지 생성 후 Slack 업로드 + - `get_current_time` — 서버 기본 TZ(또는 `timezone` 인자) 로 현재 시각/요일 반환 +- **Production 기반** + - DynamoDB 조건부 put 으로 Slack 재시도 **중복 제거** + - 채널 allowlist · 유저당 동시 요청 **throttle** + - DynamoDB 기반 **스레드 대화 메모리** (TTL 1h) + - 긴 응답 **계층적 분할** 전송 (코드블록 → 문단 → 문장 → hard slice) + `MAX_LEN_SLACK` 기반 rolling 스트리밍 + - `chat_postMessage` + `chat_update` 반복으로 스트리밍 (네이티브 `chat.startStream` 계열은 `enable_native=True` 옵션). 툴 실행 구간에는 `assistant_threads_setStatus` 타이핑 인디케이터만 표시, **첫 content delta 도착 시점에** placeholder 메시지를 지연 posting — 상태 UI 와 placeholder 중복 표시 방지 + - 구조화 JSON 로깅 + request_id, agent 루프 관찰값 기록 + - 에러 메시지 sanitize (토큰·경로 redaction) + +## 환경 변수 + +| 변수 | 필수 | 기본값 | 설명 | +|------|------|--------|------| +| `SLACK_BOT_TOKEN` | ✅ | — | `xoxb-…` | +| `SLACK_SIGNING_SECRET` | ✅ | — | Slack Signing Secret | +| `OPENAI_API_KEY` | OpenAI 사용 시 | — | OpenAI API 키 | +| `XAI_API_KEY` | xAI 사용 시 | — | xAI (Grok) API 키 — https://console.x.ai | +| `TAVILY_API_KEY` | | — | 설정 시 Tavily 웹 검색 활성화 | +| `LLM_PROVIDER` | | `openai` | `openai` / `bedrock` / `xai` | +| `LLM_MODEL` | | `gpt-4o-mini` | 텍스트 모델 | +| `IMAGE_PROVIDER` | | `openai` | `openai` / `bedrock` / `xai` | +| `IMAGE_MODEL` | | `gpt-image-1` | 이미지 모델 | +| `AGENT_MAX_STEPS` | | `3` | tool 루프 최대 iteration | +| `RESPONSE_LANGUAGE` | | `ko` | `ko` / `en` | +| `DYNAMODB_TABLE_NAME` | | `lambda-gurumi-bot-dev` | dedup / 대화 저장 테이블 | +| `AWS_REGION` | | `us-east-1` | AWS 리전 | +| `ALLOWED_CHANNEL_IDS` | | (empty) | 콤마 구분. 비어있으면 모든 채널 허용. **DM(`message.im`) 은 허용 리스트 대상이 아님** — allowlist 를 설정해도 DM 경로는 항상 허용됨 | +| `ALLOWED_CHANNEL_MESSAGE` | | — | 비허용 채널 응답 메시지 (DM 에는 적용되지 않음). `{}` 가 있으면 `ALLOWED_CHANNEL_IDS` 의 첫 채널을 `<#ID>` 멘션 형태로 치환 | +| `ALLOWED_USER_IDS` | | (empty) | 콤마 구분. 비어있으면 모든 유저 허용. **채널·DM 모든 경로에 적용** — DM 도 차단 | +| `ALLOWED_USER_MESSAGE` | | — | 비허용 유저 응답 메시지. `{}` 가 있으면 `ALLOWED_USER_IDS` 의 첫 유저를 `<@ID>` 멘션 형태로 치환 | +| `MAX_LEN_SLACK` | | `3000` | 메시지 분할 기준 (≥500). `.env.example` · `serverless.yml` 기본 `3000`, 미지정 시 `config.py` 폴백 `2000`. | +| `MAX_OUTPUT_TOKENS` | | `4096` | LLM hop 당 출력 토큰 상한 (≥256) | +| `MAX_THROTTLE_COUNT` | | `100` | 유저별 동시 요청 상한 | +| `MAX_HISTORY_CHARS` | | `4000` | 저장되는 대화 직렬화 최대 길이 | +| `DEFAULT_TIMEZONE` | | `Asia/Seoul` | `get_current_time` 기본 TZ (IANA). 잘못된 이름이면 기본값으로 폴백 + 경고 | +| `MAX_DOC_CHARS` | | `20000` | `read_attached_document` 추출 텍스트 최대 문자수 (≥1000) | +| `MAX_DOC_PAGES` | | `50` | `read_attached_document` PDF 최대 페이지수 (≥1) | +| `MAX_DOC_BYTES` | | `26214400` | `read_attached_document` 다운로드 최대 바이트 (기본 25MB, ≥65536) | +| `MAX_WEB_CHARS` | | `8000` | `fetch_webpage` 반환 본문 최대 문자수 (≥500) | +| `MAX_WEB_BYTES` | | `2097152` | `fetch_webpage` 다운로드 최대 바이트 (기본 2MB, ≥65536) | +| `MAX_WEB_LINKS` | | `20` | `fetch_webpage` 반환 링크 최대 개수 (≥0) | +| `JINA_READER_BASE` | | `https://r.jina.ai` | `fetch_webpage` 가 호출하는 Jina Reader 베이스 URL. `https://` 가 아니면 기본값으로 폴백 | +| `BOT_CURSOR` | | `:robot_face:` | 플레이스홀더·스트림 인디케이터 이모지 | +| `SYSTEM_MESSAGE` | | — | 작업 규칙에 append 되는 추가 운영 정책 (예: 조직·채널 제약). base 를 덮어쓰지 않음 | +| `PERSONA_MESSAGE` | | — | 답변 스타일/톤 (예: `"자연스러운 한국어로 핵심부터 답한다"`) | +| `LOG_LEVEL` | | `INFO` | 로그 레벨 | + +## 모델 매트릭스 + +| 용도 | OpenAI | Bedrock | xAI (Grok) | +|------|--------|---------|------------| +| 텍스트 + tool calling | `gpt-4o-mini`, `gpt-4o`, `gpt-5-*`, `o1/o3/o4` | `us.anthropic.claude-opus-4-6-v1`, `us.anthropic.claude-sonnet-4-5-...`, `amazon.nova-pro-v1:0` | `grok-4-1-fast-reasoning`, `grok-4.20-0309-reasoning`, `grok-4.20-multi-agent-0309` | +| 이미지 생성 | `gpt-image-1`, `dall-e-3` | `amazon.nova-canvas-v1:0`, `amazon.titan-image-generator-v2:0` | `grok-imagine-image`, `grok-imagine-image-pro` | + +- Claude 는 Messages API (`tools=[{name, description, input_schema}]`), Nova 는 Converse API (`toolConfig`) 로 자동 분기됩니다. +- xAI 는 OpenAI wire 호환이라 OpenAI Python SDK 에 `base_url="https://api.x.ai/v1"` 만 swap 해서 호출합니다. 별도 `XAIProvider` 클래스로 분리되어 있습니다. +- Bedrock 최신 모델은 `us./eu./apac./global.` inference-profile prefix 가 붙은 ID 로만 호출됩니다. `BedrockProvider` 가 자동 인식합니다. + +## 로컬 개발 -### Slack 앱 설정 +```bash +pip install -r requirements.txt +pip install -r requirements-dev.txt -[Slack Bolt for Python 시작 가이드](https://slack.dev/bolt-python/tutorial/getting-started)를 참고하여 Slack 앱을 생성합니다. +cp .env.example .env.local # 값 채우기 -#### OAuth & Permissions - Bot Token Scopes +# CLI 실행 (스트리밍이 기본값) +python localtest.py "오늘 서울 날씨" +python localtest.py --no-stream "React 훅 설명해줘" # 전체 답변을 한 번에 출력 +python localtest.py --quiet-steps "…" # 중간 step 로그 숨김 +python localtest.py # 대화형 (stdin, Ctrl+D) -```text -app_mentions:read -channels:history -channels:join -channels:read -chat:write -files:read -files:write -im:read -im:write -reactions:read -users:read +# 테스트 (189 테스트, 커버리지 89% — `pytest.ini` 기준) +python -m pytest --cov=src --cov-report=term-missing +python -m pytest tests/llms/test_bedrock.py -v # 패키지 단위 +python -m pytest tests/tools/test_web.py::test_fetch_webpage_jina_happy_path -v # 단일 케이스 ``` -#### Event Subscriptions - Subscribe to bot events +`.env.local` 은 `src/config.py` 가 python-dotenv 로 자동 로드합니다. `SLACK_BOT_TOKEN` 이 placeholder 이면 `localtest.py` 가 Slack 호출을 stub 으로 대체하고 `generate_image` 결과물은 `./.uploads/` 에 파일로 저장됩니다. -```text -app_mention -message.im -reaction_added -``` +## 배포 (Serverless Framework v3) -### 환경 변수 +### 1. IAM OIDC role 준비 (한 번만) + +`role/lambda-gurumi-bot` 을 AWS 계정에 생성하고 GitHub OIDC trust + 배포용 policy 를 연결합니다. 템플릿과 상세 절차는 `.github/aws-role/` 에 있습니다: ```bash -cp .env.example .env.local +cd .github/aws-role +export NAME="lambda-gurumi-bot" +aws iam create-role --role-name "${NAME}" --assume-role-policy-document file://trust-policy.json +aws iam create-policy --policy-name "${NAME}" --policy-document file://role-policy.json +export ACCOUNT_ID=$(aws sts get-caller-identity | jq -r .Account) +aws iam attach-role-policy --role-name "${NAME}" --policy-arn "arn:aws:iam::${ACCOUNT_ID}:policy/${NAME}" ``` -#### 필수 설정 (GitHub Secrets) - -| 변수명 | 설명 | -|--------|------| -| `SLACK_BOT_TOKEN` | Slack Bot OAuth 토큰 (`xoxb-xxxx`) | -| `SLACK_SIGNING_SECRET` | Slack 요청 서명 검증용 시크릿 | -| `NOTION_TOKEN` | Notion Integration API 키 ([생성](https://www.notion.so/my-integrations)) | - -> `AGENT_ID`/`AGENT_ALIAS_ID`는 CloudFormation에서 자동 관리됩니다. +### 2. GitHub 저장소 설정 -#### 선택적 설정 +- **Secrets**: `AWS_ACCOUNT_ID`, `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `OPENAI_API_KEY`, `XAI_API_KEY`(xAI 사용 시), `TAVILY_API_KEY`(선택) +- **Variables**: `LLM_PROVIDER`, `LLM_MODEL`, `IMAGE_PROVIDER`, `IMAGE_MODEL`, `RESPONSE_LANGUAGE`, `ALLOWED_CHANNEL_IDS`, `ALLOWED_CHANNEL_MESSAGE`, `ALLOWED_USER_IDS`, `ALLOWED_USER_MESSAGE`, `SYSTEM_MESSAGE`, `PERSONA_MESSAGE`, `BOT_CURSOR`, `MAX_LEN_SLACK`, `MAX_OUTPUT_TOKENS`, `MAX_THROTTLE_COUNT`, `MAX_HISTORY_CHARS`, `AGENT_MAX_STEPS`, `LOG_LEVEL`, `DEFAULT_TIMEZONE`, `MAX_DOC_CHARS`, `MAX_DOC_PAGES`, `MAX_DOC_BYTES`, `MAX_WEB_CHARS`, `MAX_WEB_BYTES`, `MAX_WEB_LINKS`, `JINA_READER_BASE` -| 변수명 | 기본값 | 설명 | -|--------|--------|------| -| `AWS_REGION` | `us-east-1` | AWS 리전 | -| `DYNAMODB_TABLE_NAME` | `gurumi-ai-bot-dev` | DynamoDB 테이블명 | -| `ALLOWED_CHANNEL_IDS` | `None` | 허용 채널 ID (쉼표 구분) | -| `PERSONAL_MESSAGE` | 일반 AI 어시스턴트 | AI 페르소나 설정 | -| `SYSTEM_MESSAGE` | `None` | 추가 시스템 지시사항 | -| `MAX_LEN_SLACK` | `2000` | Slack 메시지 최대 길이 | -| `MAX_LEN_BEDROCK` | `4000` | Bedrock 컨텍스트 최대 길이 | -| `MAX_THROTTLE_COUNT` | `100` | 사용자별 요청 제한 수 | -| `BOT_CURSOR` | `:robot_face:` | 로딩 표시 이모지 | -| `REACTION_EMOJIS` | `refund-done` | 허용 이모지 리액션 (쉼표 구분) | +### 3. 배포 -## 배포 +`main` 브랜치에 push 하면 `.github/workflows/push.yml` 이 pytest (`--cov=src`) → Serverless v3 deploy 순으로 수행합니다. 수동 실행은 `workflow_dispatch`. ```bash -# 기본 배포 (dev 스테이지) -sls deploy --region us-east-1 - -# 프로덕션 배포 -sls deploy --stage prod --region us-east-1 - -# 배포 제거 -sls remove --region us-east-1 +# 로컬 배포 (선택) +npm i -g serverless@3 && npm i serverless-python-requirements +# Secrets + Variables 를 현재 셸에 export 한 뒤 +serverless deploy --stage dev --region us-east-1 ``` -### RAG 문서 추가 +DynamoDB 테이블 (해시키 `id`, GSI `user-index`, TTL `expire_at`) 은 CloudFormation 이 생성합니다. -배포 후 S3 버킷의 `documents/` 프리픽스에 문서를 업로드하고 동기화합니다. +### 4. 추가 워크플로 -```bash -# 문서 업로드 (PDF, TXT, MD, HTML, DOCX, CSV 지원) -aws s3 cp my-document.pdf s3://gurumi-ai-bot-{account-id}/documents/ +| 파일 | 역할 | 상태 | +|------|------|------| +| `push.yml` | 테스트 + Lambda 배포 | 활성 | +| `sync-notion.yml` | Notion → S3 → Bedrock KB ingestion | `workflow_dispatch` 전용, `vars.ENABLE_SYNC_NOTION == 'true'` gating. **Phase 2 미완성** (아래 참조) | +| `sync-awsdocs.yml` | AWS 공식 문서 → S3 → KB ingestion | 위와 동일 패턴, `ENABLE_SYNC_AWSDOCS` gating | -# Knowledge Base 동기화 -aws bedrock-agent start-ingestion-job \ - --knowledge-base-id \ - --data-source-id -``` +## 코드 구조 -### 문서 동기화 +``` +app.py Lambda 엔트리 · Slack Bolt 핸들러 · `_process()` 흐름 +src/ +├── agent.py Agent 루프 (native function calling 반복) +├── config.py Settings (env → dataclass, lazy validation) +├── dedup.py DynamoDB 기반 중복 제거 / 대화 메모리 +├── logging_utils.py 구조화 JSON 로깅 + request_id +├── slack_helpers.py 메시지 분할·스트리밍·사용자 캐시 +├── llms/ LLM provider 패키지 +│ ├── base.py Protocol + 공통 타입 + _with_retry +│ ├── openai_wire.py OpenAI wire 공통 (OpenAI·xAI 공유) +│ ├── openai.py OpenAIProvider +│ ├── xai.py XAIProvider +│ ├── bedrock.py BedrockProvider (Anthropic·Nova·Stability) +│ ├── composite.py _CompositeProvider (text+image 분리 설정) +│ └── factory.py get_llm +└── tools/ Tool 패키지 + ├── registry.py ToolDef · ToolRegistry · @tool · ToolExecutor + ├── slack.py read_attached_images · read_attached_document · fetch_thread_history + ├── search.py search_web (DuckDuckGo / Tavily) + ├── web.py fetch_webpage + SSRF 가드 + HTML/Jina 파서 + ├── image.py generate_image + └── time.py get_current_time +``` -두 가지 자동 동기화 워크플로우가 있으며, GitHub Variables로 활성화합니다. +테스트는 소스 구조를 그대로 미러링한 `tests/llms/`, `tests/tools/` 에 있습니다. -| 워크플로우 | 소스 | 활성화 변수 | -|-----------|------|------------| -| `sync-notion.yml` | Notion 페이지 → Markdown | `ENABLE_SYNC_NOTION=true` | -| `sync-awsdocs.yml` | AWS 공식 PDF (19개 서비스) | `ENABLE_SYNC_AWSDOCS=true` | +## 확장하기 -AWS 문서 목록은 `scripts/awsdocs/docs.txt`에서 관리합니다. +새로운 tool 이나 LLM provider 는 파일 하나를 추가하는 것으로 끝납니다. 자세한 단계는 [`docs/extending.md`](docs/extending.md) 를 참고하세요. -### CI/CD +짧게 말해: -GitHub Actions (`push.yml`)로 `main` 브랜치 푸시 시 자동 배포됩니다. +- **새 tool**: `src/tools/.py` 에 `@tool(default_registry, ...)` 로 데코레이트된 함수를 정의하고, `src/tools/__init__.py` 의 side-effect import 블록에 이름을 추가하면 `default_registry` 가 자동으로 등록합니다. +- **새 LLM provider**: `src/llms/.py` 에 `LLMProvider` Protocol 을 만족하는 클래스를 작성하고 `src/llms/factory.py` 의 `get_llm` 분기에 연결합니다. -## 테스트 +## 아키텍처 -```bash -# Slack URL 검증 -curl -X POST \ - -H "Content-Type: application/json" \ - -d '{"token": "test", "challenge": "test_challenge", "type": "url_verification"}' \ - https://your-api-url/dev/slack/events - -# Bedrock Agent 직접 테스트 -cd scripts/bedrock -python invoke_agent.py -p "프롬프트 입력" -python invoke_knowledge_base.py -p "지식 베이스 쿼리" ``` - -## 참고 자료 - -- [AWS Bedrock Documentation](https://docs.aws.amazon.com/bedrock/) -- [Amazon S3 Vectors](https://aws.amazon.com/s3/features/vectors/) -- [Slack Bolt for Python](https://slack.dev/bolt-python/) -- [Serverless Framework](https://www.serverless.com/) +┌────────────────┐ POST /slack/events +│ Slack workspace│──────────────────┐ +└────────────────┘ ▼ + ┌───────────────────────────────────┐ + │ API Gateway → Lambda (app.py) │ + │ ├─ X-Slack-Retry-Num early return │ + │ └─ SlackRequestHandler (Bolt) │ + └────────┬───────────────────┬──────┘ + │ │ + ┌──────────▼─────────┐ ┌──────▼─────────┐ + │ app_mention handler│ │ message handler│ + └──────────┬─────────┘ └──────┬─────────┘ + └──────┬────────────┘ + ▼ + ┌───────────────────────────────────────────┐ + │ _process() │ + │ 1. DedupStore.reserve (conditional put) │ + │ 2. channel_allowed / throttle │ + │ 3. set_thread_status + placeholder say │ + │ 4. ConversationStore.get → history │ + │ 5. SlackMentionAgent.run ──┐ │ + │ 6. send_long_message │ │ + │ 7. ConversationStore.put │ │ + └─────────────────────────────┼─────────────┘ + │ + ┌───────────────────────▼───────────────┐ + │ Agent loop (native function calling) │ + │ LLM.chat(messages, tools=registry) │ + │ ↓ tool_calls? │ + │ ToolExecutor.execute (per-call t/o) │ + │ ↓ role=tool result │ + │ (loop up to AGENT_MAX_STEPS) │ + │ streaming chat_update on final step │ + └────────────┬──────────────────────────┘ + │ + ┌───────────────┼────────────────┐ + ▼ ▼ ▼ + ┌───────────┐ ┌────────────┐ ┌──────────────┐ + │ OpenAI │ │ Bedrock │ │ Slack Web API│ + │ Chat API │ │ Messages / │ │ (tools) │ + │ Vision │ │ Converse │ └──────────────┘ + └───────────┘ └────────────┘ + ▲ + │ + ┌──────┴─────┐ + │ DynamoDB │ + │ (dedup+ctx)│ + └────────────┘ +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000..6f8f8ed --- /dev/null +++ b/app.py @@ -0,0 +1,434 @@ +"""AWS Lambda entrypoint for the Slack mention bot. + +Two execution paths share this handler via a `_worker` flag in the event +payload: + + Receiver path (Slack → API Gateway → Lambda): + 1. lambda_handler short-circuits Slack retries (X-Slack-Retry-Num header). + 2. Bolt verifies the Slack signature and dispatches to app_mention / + message handlers. + 3. Handler acks, then fires a fire-and-forget `lambda:Invoke` against + this same function with `InvocationType=Event` and `_worker=True`. + 4. HTTP response returns to API Gateway within a few hundred ms, so + the 29s API Gateway integration timeout is irrelevant — even a + 10-minute agent run never blocks the HTTP response. + + Worker path (Lambda async self-invoke): + 1. `_worker=True` routes straight into _process_worker. + 2. Deduplicates on client_msg_id via DynamoDB conditional put. This + same dedup absorbs both Slack's own retry burst (on the receiver + side) AND Lambda async's built-in 2x retry on worker failure — all + paths converge on the same dedup:{msg_id} key. + 3. Checks channel allowlist + per-user throttle. + 4. Sets typing status + sends a placeholder message. + 5. Loads thread history from DynamoDB and runs the native-tool-calling + agent, then streams/posts the reply back via Slack Web API. + 6. Persists updated conversation back to DynamoDB. +""" +from __future__ import annotations + +import json +import os +import re +import uuid + +import boto3 +from slack_bolt import App +from slack_bolt.adapter.aws_lambda import SlackRequestHandler +from slack_sdk import WebClient + +from src.agent import SlackMentionAgent +from src.config import Settings +from src.dedup import ConversationStore, DedupStore +from src.llms import get_llm +from src.logging_utils import get_logger, log_event, set_request_id +from src.slack_helpers import ( + MessageFormatter, + StreamingMessage, + channel_allowed, + sanitize_error, + set_thread_status, + user_name_cache, +) + + +from src.tools import ToolContext, default_registry + +settings = Settings.from_env() +logger = get_logger("app") + +_llm = None +_dedup: DedupStore | None = None +_conversations: ConversationStore | None = None +_bolt_app: App | None = None +_lambda_client = None + + +LABELS = { + "ko": { + "generated_image": "생성된 이미지", + "error_prefix": "요청 처리 중 오류가 발생했습니다", + "throttled": "잠시 후 다시 시도해주세요. 처리 중인 요청이 많습니다.", + "thinking": "생각 중... ", + "max_steps": "답변 정리 중... ", + "using_tools": "도구 사용 중: {tools}", + "tool_ok": "도구 완료: {tool}", + "tool_failed": "도구 실패: {tool}", + "composing": "답변 작성 중...", + }, + "en": { + "generated_image": "Generated image", + "error_prefix": "An error occurred while processing your request", + "throttled": "Too many in-flight requests. Please try again shortly.", + "thinking": "Thinking... ", + "max_steps": "Finalizing... ", + "using_tools": "Running tools: {tools}", + "tool_ok": "Finished: {tool}", + "tool_failed": "Failed: {tool}", + "composing": "Composing the answer...", + }, +} + + +def _labels() -> dict[str, str]: + return LABELS.get(settings.response_language, LABELS["en"]) + + +def _get_llm(): + global _llm + if _llm is None: + _llm = get_llm( + provider=settings.llm_provider, + model=settings.llm_model, + image_provider=settings.image_provider, + image_model=settings.image_model, + region=settings.aws_region, + api_keys={"xai": settings.xai_api_key}, + ) + return _llm + + +def _get_dedup() -> DedupStore: + global _dedup + if _dedup is None: + _dedup = DedupStore(table_name=settings.dynamodb_table_name, region=settings.aws_region) + return _dedup + + +def _get_conversations() -> ConversationStore: + global _conversations + if _conversations is None: + _conversations = ConversationStore(table_name=settings.dynamodb_table_name, region=settings.aws_region) + return _conversations + + +def _get_lambda_client(): + global _lambda_client + if _lambda_client is None: + _lambda_client = boto3.client("lambda", region_name=settings.aws_region) + return _lambda_client + + +def _enqueue_worker(event: dict, is_dm: bool) -> None: + """Fire-and-forget async self-invoke. + + Keeps the receiver-path handler short so the HTTP response to API + Gateway returns within a few hundred ms, independent of how long the + agent takes. When `AWS_LAMBDA_FUNCTION_NAME` isn't set (local dev / + tests), falls through to inline execution so the code path is still + exercisable outside Lambda. + """ + function_name = os.environ.get("AWS_LAMBDA_FUNCTION_NAME") + if not function_name: + _process_worker({"slack_event": event, "is_dm": is_dm}) + return + payload = json.dumps( + {"_worker": True, "slack_event": event, "is_dm": is_dm}, ensure_ascii=False + ).encode("utf-8") + try: + _get_lambda_client().invoke( + FunctionName=function_name, + InvocationType="Event", + Payload=payload, + ) + except Exception: + # If async invoke fails (IAM, throttling, network), we don't want + # to drop the user's message. Fall back to inline execution inside + # the receiver; worst case the receiver's Lambda timeout kicks in, + # but that's what we had before this refactor anyway. + logger.exception("async worker invoke failed, running inline") + _process_worker({"slack_event": event, "is_dm": is_dm}) + + +def _process_worker(payload: dict) -> None: + """Worker path: full agent run. + + Runs either as a real async Lambda invocation (payload carried across) + or inline during local/test execution. We build a fresh `WebClient` + from the bot token because the async invocation doesn't carry Bolt's + injected client — the receiver's Bolt context dies as soon as the + receiver returns HTTP 200. + """ + slack_event = payload.get("slack_event") or {} + is_dm = bool(payload.get("is_dm")) + channel = slack_event.get("channel") + client = WebClient(token=settings.slack_bot_token) + + def _say(text: str, thread_ts: str | None = None) -> None: + kwargs: dict = {"channel": channel, "text": text} + if thread_ts: + kwargs["thread_ts"] = thread_ts + client.chat_postMessage(**kwargs) + + _process(slack_event, client, _say, is_dm=is_dm) + + +def _get_bolt_app() -> App: + global _bolt_app + if _bolt_app is not None: + return _bolt_app + settings.require_slack_credentials() + app = App( + token=settings.slack_bot_token, + signing_secret=settings.slack_signing_secret, + process_before_response=True, + ) + + @app.event("app_mention") + def _on_mention(event, client, say, ack): # noqa: ANN001 + ack() + _enqueue_worker(event, is_dm=False) + + @app.event("message") + def _on_message(event, client, say, ack): # noqa: ANN001 + ack() + if event.get("channel_type") != "im": + return + if event.get("bot_id") or event.get("subtype"): + return + _enqueue_worker(event, is_dm=True) + + _bolt_app = app + return _bolt_app + + +MENTION_RE = re.compile(r"<@[^>]+>") + + +def _process(event: dict, client, say, is_dm: bool) -> None: # noqa: ANN001 + set_request_id(str(uuid.uuid4())) + labels = _labels() + text = MENTION_RE.sub("", event.get("text", "")).strip() + channel = event.get("channel") + thread_ts = event.get("thread_ts") or event.get("ts") + user = event.get("user", "") + + # Drop empty mentions (bare "@bot" with no prompt) BEFORE reserving a + # dedup slot. Otherwise every empty ping burns a 1h TTL row on the + # dedup table and, for no-op messages, shows up as a dedup.skip on any + # Slack retry even though there was never anything to do. + if not text: + return + + dedup = _get_dedup() + dedup_key = event.get("client_msg_id") or f"{channel}:{event.get('ts')}" + try: + if not dedup.reserve(f"dedup:{dedup_key}", user=user or "system"): + log_event(logger, "dedup.skip", key=dedup_key) + return + except Exception as exc: # noqa: BLE001 + logger.warning("dedup unavailable, proceeding without it: %s", exc) + + # Channel allowlist applies to public/private channels only. DMs use + # per-channel IDs (D-prefix) that aren't normally enrolled in the + # allowlist — enforcing there would lock out every user's direct-message + # path the moment an operator sets ALLOWED_CHANNEL_IDS. Slack's own + # workspace install permission already gates who can open the DM. + if not is_dm and not channel_allowed(channel, settings.allowed_channel_ids): + msg = settings.allowed_channel_message or "" + if msg and "{}" in msg and settings.allowed_channel_ids: + msg = msg.replace("{}", f"<#{settings.allowed_channel_ids[0]}>") + if msg: + say(text=msg, thread_ts=thread_ts) + log_event(logger, "channel.blocked", channel=channel) + return + + # User allowlist applies to channels AND DMs. Unlike the channel allowlist + # (which exempts DMs because DM channel IDs are D-prefixed and wouldn't + # be enrolled), restricting *who* can talk to the bot is meaningful in + # both directions — arguably more so in DMs, where there's no channel- + # level gate at all. Operator opts in via ALLOWED_USER_IDS; empty list + # means everyone is allowed. + if settings.allowed_user_ids and user not in settings.allowed_user_ids: + msg = settings.allowed_user_message or "" + if msg and "{}" in msg: + msg = msg.replace("{}", f"<@{settings.allowed_user_ids[0]}>") + if msg: + say(text=msg, thread_ts=thread_ts) + log_event(logger, "user.blocked", user=user, channel=channel) + return + + try: + active = dedup.count_user_active(user) + except Exception as exc: # noqa: BLE001 + logger.warning("throttle count unavailable: %s", exc) + active = 0 + if active >= settings.max_throttle_count: + say(text=labels["throttled"], thread_ts=thread_ts) + log_event(logger, "throttle.limit", user=user, active=active) + return + + # Show a typing-style status indicator while the bot is "working" with + # nothing to reply yet. We intentionally do NOT post a placeholder + # chat.postMessage up front: that would render as a separate UI element + # alongside the status line (a duplicate-message look on AI workspaces). + # The placeholder is posted lazily in _on_stream_wrapped once the first + # real content delta arrives. Slack auto-clears the status when the bot + # posts in the thread; we also explicitly clear it after we finalize. + set_thread_status(client, channel, thread_ts, labels["thinking"] + settings.bot_cursor) + + stream_msg = StreamingMessage( + client=client, + channel=channel, + thread_ts=thread_ts, + placeholder=settings.bot_cursor, + min_interval=0.6, + max_len=settings.max_len_slack, + ) + + def _on_stream_wrapped(delta: str) -> None: + """Defer placeholder posting until the first real content arrives.""" + if not delta: + return + if stream_msg.ts is None: + try: + stream_msg.start() + except Exception as exc: # noqa: BLE001 + logger.warning("deferred streaming message start failed: %s", exc) + return + stream_msg.append(delta) + + history_store = _get_conversations() + history = history_store.get(thread_ts) + + llm = _get_llm() + context = ToolContext( + slack_client=client, + channel=channel, + thread_ts=thread_ts, + event=event, + settings=settings, + llm=llm, + ) + + def _on_step(step_num: int, phase: str, detail: dict) -> None: + # While no message is posted yet, use assistant_threads.setStatus. + # Once the stream has started (stream_msg.ts is set), the bot message + # is already visible — skip status updates to avoid re-triggering the + # duplicate-UI problem. + if stream_msg.ts is not None: + return + if phase == "tool_use": + tools = ", ".join(detail.get("tools") or []) + status = labels["using_tools"].format(tools=tools) + elif phase == "tool_result": + key = "tool_ok" if detail.get("ok") else "tool_failed" + status = labels[key].format(tool=detail.get("tool") or "") + elif phase == "compose": + status = labels["max_steps"] if detail.get("max_steps_hit") else labels["composing"] + else: + return + set_thread_status(client, channel, thread_ts, status + " " + settings.bot_cursor) + + agent = SlackMentionAgent( + llm=llm, + context=context, + registry=default_registry, + max_steps=settings.agent_max_steps, + response_language=settings.response_language, + system_message=settings.system_message, + persona_message=settings.persona_message, + history=history, + on_stream=_on_stream_wrapped, + on_step=_on_step, + max_output_tokens=settings.max_output_tokens, + ) + + user_name = user_name_cache.get(client, user) if user else "" + log_event(logger, "agent.start", user=user_name or user, channel=channel, is_dm=is_dm) + + try: + result = agent.run(text) + except Exception as exc: # noqa: BLE001 + logger.exception("agent failure") + error_text = f"{labels['error_prefix']}: {sanitize_error(exc)}" + if stream_msg.ts is not None: + stream_msg.stop(error_text) + else: + say(text=error_text, thread_ts=thread_ts) + set_thread_status(client, channel, thread_ts, "") + return + + final_text = result.text or "(응답을 생성하지 못했습니다)" + # StreamingMessage.stop() handles split + follow-up postMessage internally, + # AND skips any prefix already sealed into earlier rolled ts'es by the + # size-overflow roll path. Pass full final_text so the slice can match. + # When no placeholder exists (no stream deltas ever arrived), post the + # chunks as fresh thread messages. + if stream_msg.ts is not None: + stream_msg.stop(final_text) + else: + chunks = MessageFormatter.split_message(final_text, max_len=settings.max_len_slack) + for chunk in chunks: + client.chat_postMessage(channel=channel, thread_ts=thread_ts, text=chunk) + # Explicitly clear the typing-style status indicator. Slack usually + # auto-clears it when the bot posts a reply, but an explicit clear + # ensures there's no stale line left over from the last on_step update. + set_thread_status(client, channel, thread_ts, "") + # NOTE: do not post `result.image_url` as a separate text message — + # the image is already uploaded inline to the thread by the + # generate_image tool, and the LLM's reply is instructed to omit + # the permalink. A trailing "생성된 이미지: " line would just + # duplicate what the user already sees. + + new_history = [ + *history, + {"role": "user", "content": text}, + {"role": "assistant", "content": final_text}, + ] + try: + history_store.put( + thread_ts, + user=user or "unknown", + messages=new_history, + max_chars=settings.max_history_chars, + ) + except Exception as exc: # noqa: BLE001 + logger.warning("conversation persist failed: %s", exc) + + log_event( + logger, + "agent.done", + steps=result.steps, + tool_calls=result.tool_calls_count, + tokens_in=result.token_usage.get("input", 0), + tokens_out=result.token_usage.get("output", 0), + ) + + +def lambda_handler(event, context): # noqa: ANN001 + # Worker path: a Lambda async self-invoke with `_worker=True` skips + # Slack signature verification entirely. The only way to land here + # with this flag is via `_enqueue_worker`, which is only callable + # from inside a successfully verified receiver invocation. + if isinstance(event, dict) and event.get("_worker"): + _process_worker(event) + return {"statusCode": 200, "body": ""} + + # Receiver path: Slack HTTP event via API Gateway. + # Short-circuit Slack retries without re-dispatching the worker. + headers = event.get("headers") or {} + normalized = {k.lower(): v for k, v in headers.items()} + if normalized.get("x-slack-retry-num"): + return {"statusCode": 200, "body": ""} + return SlackRequestHandler(_get_bolt_app()).handle(event, context) diff --git a/docs/extending.md b/docs/extending.md new file mode 100644 index 0000000..b103a1c --- /dev/null +++ b/docs/extending.md @@ -0,0 +1,293 @@ +# 확장 가이드 + +새 tool 또는 새 LLM provider 를 추가하는 방법을 step-by-step 으로 설명합니다. 둘 다 "파일 하나 추가 + `__init__.py` 한 줄" 수준으로 끝납니다 — agent 루프나 레지스트리 코드는 건드리지 않습니다. + +--- + +## 새 tool 추가 + +Tool 은 LLM 이 native function calling 으로 호출하는 Python 함수입니다. `@tool(...)` 데코레이터가 JSON Schema · 레지스트리 등록 · dispatch 테이블을 한 번에 처리합니다. + +### 1. 파일 만들기 + +예: 랜덤 정수를 반환하는 `random_int` tool. + +`src/tools/random_int.py` + +```python +"""Random integer tool — demonstrates the minimum shape of a new tool.""" +from __future__ import annotations + +import random +from typing import Any + +from src.tools.registry import ToolContext, default_registry, tool + + +@tool( + default_registry, + name="random_int", + description="Return a random integer in the inclusive range [low, high].", + parameters={ + "type": "object", + "properties": { + "low": {"type": "integer"}, + "high": {"type": "integer"}, + }, + "required": ["low", "high"], + }, +) +def random_int(ctx: ToolContext, low: int, high: int) -> dict[str, int]: + if low > high: + raise ValueError("low must be <= high") + return {"value": random.randint(low, high)} +``` + +**규칙** + +- 첫 번째 인자는 항상 `ctx: ToolContext` — LLM 에는 노출되지 않는 런타임 컨텍스트 (`slack_client`, `channel`, `thread_ts`, `event`, `settings`, `llm`). +- 나머지 인자는 `parameters` JSON Schema 가 그대로 정의. 키워드 전달되므로 LLM 이 부르는 argument 이름과 정확히 일치시킬 것. +- 반환 타입은 JSON 직렬화 가능해야 합니다 (`dict`, `list`, `str`, `int`, `float`, `bool`). Agent 루프가 이걸 `role=tool` 메시지로 다시 LLM 에 넘겨줍니다. +- 에러 처리: 복구 불가능한 실패는 `ValueError` 로 raise 합니다. `ToolExecutor` 가 잡아서 `{"ok": False, "error": "..."}` 로 감싸 LLM 이 상황을 보고 재계획할 수 있게 해줍니다. +- 외부 네트워크 I/O 가 있으면 host allowlist 또는 `_validate_public_https_url` 같은 SSRF 가드를 반드시 적용할 것 (`src/tools/web.py` 참고). + +### 2. `__init__.py` 에 등록 + +`src/tools/__init__.py` 의 side-effect import 블록에 새 모듈 이름을 추가합니다. + +```python +from . import ( # noqa: F401 (imported for side effects) + image, + random_int, # ← 추가 + search, + slack, + time, + web, +) +``` + +`default_registry` 는 module import 시점에 데코레이터 호출로 채워지므로, 이 import 한 줄로 agent 가 새 tool 을 자동 인식합니다. + +### 3. 테스트 작성 + +`tests/tools/test_random_int.py` + +```python +"""Tests for src.tools.random_int.""" +from __future__ import annotations + +import pytest + +from tests.tools._helpers import _ctx +from src.tools.random_int import random_int + + +def test_random_int_returns_value_in_range(): + ctx = _ctx() + for _ in range(20): + out = random_int(ctx, low=1, high=3) + assert out["value"] in {1, 2, 3} + + +def test_random_int_rejects_inverted_range(): + ctx = _ctx() + with pytest.raises(ValueError, match="low must be"): + random_int(ctx, low=5, high=1) +``` + +공용 fixture (`_ctx`, `_settings`, `_streamed_read`) 는 `tests/tools/_helpers.py` 에서 import 하세요 — 각 테스트 파일에 중복 정의하지 않습니다. + +### 4. `default_registry` 등록 검증 + +`tests/tools/test_registry.py` 의 `test_default_registry_has_expected_tools` 가 새 tool 이름을 assert 하도록 업데이트: + +```python +def test_default_registry_has_expected_tools(): + names = set(default_registry.names()) + assert "random_int" in names # ← 추가 +``` + +### 5. (선택) 환경 변수 / 설정 + +새 tool 이 설정값을 읽어야 하면 `src/config.py` 의 `Settings` 에 필드를 추가하고 `from_env()` 에서 env 를 읽어오세요. 기본값과 최소값 검증은 `_int_env`, `_tz_env`, `_enum_env`, `_https_url_env` 헬퍼 패턴을 따릅니다. `.env.example` 에도 새 변수를 예시와 함께 추가하세요. + +### 6. 문서 업데이트 + +- `README.md` 의 "Tools" 목록과 "환경 변수" 표에 추가. +- `CLAUDE.md` 에 비자명한 동작(SSRF 가드 · 동기화 이슈 · 외부 서비스 의존) 이 있으면 "Architecture — the non-obvious parts" 에 짧게 노트. + +--- + +## 새 LLM provider 추가 + +Provider 는 `LLMProvider` Protocol 을 구현하는 클래스입니다. 텍스트 chat · streaming chat · 이미지 describe · 이미지 generate 네 메서드만 만족하면 됩니다. + +### 1. 파일 만들기 + +예: 가상의 `MistralProvider`. + +`src/llms/mistral.py` + +```python +"""MistralProvider — OpenAI-wire compatible via https://api.mistral.ai/v1.""" +from __future__ import annotations + +from src.llms.openai_wire import _OpenAICompatProvider + + +class MistralProvider(_OpenAICompatProvider): + """Mistral 의 chat completions API 는 OpenAI wire 와 호환되므로 + `_OpenAICompatProvider` 를 재사용합니다 (xAI 와 같은 패턴). + + `_OpenAICompatProvider.__init__(model, image_model, api_key)` 를 그대로 + 쓰고, `BASE_URL` · `API_KEY_ENV_VAR` 클래스 속성으로 엔드포인트와 키 환경 + 변수 이름만 덮어씁니다. 고유 request 변형이 필요하면 `_token_params` / + `_image_generate_kwargs` 를 오버라이드하세요. + """ + + BASE_URL = "https://api.mistral.ai/v1" + API_KEY_ENV_VAR = "MISTRAL_API_KEY" +``` + +OpenAI wire 호환이 아닌 provider (예: Bedrock 처럼 고유 SDK) 는 `LLMProvider` Protocol 네 메서드를 직접 구현해야 합니다. `src/llms/bedrock.py` 가 좋은 reference 입니다. + +### 2. `factory.py` 에 분기 추가 + +`get_llm` 은 provider 문자열에 따라 인스턴스를 만드는 작은 팩토리입니다. API key 는 단일 `api_keys: dict[str, str | None]` 인자로 전달되므로, 새 provider 는 그 dict 에서 자신의 키를 꺼내 씁니다. + +`src/llms/factory.py` + +```python +from src.llms.mistral import MistralProvider # ← 추가 + + +def get_llm( + provider: str, + model: str, + image_provider: str, + image_model: str, + region: str = "us-east-1", + api_keys: dict[str, str | None] | None = None, +) -> LLMProvider: + api_keys = api_keys or {} + + def build(p: str) -> LLMProvider: + if p == "bedrock": + return BedrockProvider(model=model, image_model=image_model, region=region) + if p == "xai": + return XAIProvider(model=model, image_model=image_model, api_key=api_keys.get("xai")) + if p == "mistral": # ← 추가 + return MistralProvider(model=model, image_model=image_model, api_key=api_keys.get("mistral")) + return OpenAIProvider(model=model, image_model=image_model) + + text = build(provider) + if image_provider == provider: + return text + return _CompositeProvider(text=text, image=build(image_provider)) +``` + +### 3. Settings 에 API key 필드 + +`src/config.py` + +```python +@dataclass(frozen=True) +class Settings: + ... + mistral_api_key: str | None = None + + @classmethod + def from_env(cls) -> "Settings": + ... + mistral_key = os.getenv("MISTRAL_API_KEY", "").strip() or None + return cls( + ..., + mistral_api_key=mistral_key, + ) +``` + +### 4. `_VALID_PROVIDERS` 확장 + +같은 파일의 enum 상수에 `"mistral"` 을 추가합니다. 그래야 `LLM_PROVIDER=mistral` 이 enum 검증을 통과합니다. + +```python +_VALID_PROVIDERS = {"openai", "bedrock", "xai", "mistral"} +``` + +### 5. `get_llm` 호출부 업데이트 + +`app.py` 와 `localtest.py` 는 `api_keys` dict 에 provider-key 쌍을 담아 `get_llm` 에 넘깁니다. 새 provider 를 위해 dict 항목을 추가합니다. + +```python +# app.py / localtest.py — 기존 +llm = get_llm( + provider=settings.llm_provider, + model=settings.llm_model, + image_provider=settings.image_provider, + image_model=settings.image_model, + region=settings.aws_region, + api_keys={"xai": settings.xai_api_key}, +) + +# ↓ Mistral 지원 추가 후 +llm = get_llm( + provider=settings.llm_provider, + model=settings.llm_model, + image_provider=settings.image_provider, + image_model=settings.image_model, + region=settings.aws_region, + api_keys={ + "xai": settings.xai_api_key, + "mistral": settings.mistral_api_key, # ← 추가 + }, +) +``` + +`OPENAI_API_KEY` 는 OpenAI Python SDK 가 환경 변수에서 직접 읽으므로 `api_keys` dict 에 넣지 않습니다 — 환경 변수만 설정하면 됩니다. Bedrock 도 AWS SDK 의 credential chain 을 사용하므로 별도 key 전달이 불필요합니다. + +### 6. 테스트 작성 + +`tests/llms/test_mistral.py` + +```python +"""Tests for src.llms.mistral.""" +from __future__ import annotations + +from src.llms.mistral import MistralProvider + + +def test_mistral_provider_exposes_mistral_base_url(): + assert MistralProvider.BASE_URL == "https://api.mistral.ai/v1" + assert MistralProvider.API_KEY_ENV_VAR == "MISTRAL_API_KEY" + + +def test_mistral_provider_constructs_openai_client_with_base_url(): + provider = MistralProvider( + model="mistral-large", + image_model="", + api_key="test-key", + ) + client = provider._get_client() + assert str(client.base_url).startswith("https://api.mistral.ai/") +``` + +OpenAI-wire 호환 provider 는 `_OpenAICompatProvider` 테스트 패턴을 참고 (`tests/llms/test_xai.py` 가 좋은 예시). + +### 7. 문서 · `.env.example` 업데이트 + +- `.env.example` 에 `MISTRAL_API_KEY=""` 추가. +- `README.md` 의 "환경 변수" 표 · "모델 매트릭스" 에 Mistral 컬럼 또는 행 추가. + +--- + +## 공통 체크리스트 + +작업이 끝났다 싶을 때 한 번 확인: + +- [ ] 전체 테스트 통과 (`python -m pytest`). +- [ ] `python -c "from src.tools import default_registry; print(sorted(default_registry.names()))"` 에 새 tool 이름이 나타남. +- [ ] (provider 추가 시) `python -c "from src.config import Settings; from src.llms import get_llm; get_llm(provider='', ...)"` 가 에러 없이 객체를 반환. +- [ ] `.env.example` 에 새 env 가 반영됨. +- [ ] `README.md` 의 tool/provider 목록과 환경 변수 표가 업데이트됨. + +이 패턴은 agent 루프 (`src/agent.py`), 레지스트리 내부 (`src/tools/registry.py`), 기존 provider/tool 의 코드를 건드리지 않도록 설계됐습니다. 그 파일들을 열어야 하는 변경은 확장 범위를 벗어난 것이므로 별도 논의/설계가 필요합니다. diff --git a/handler.py b/handler.py deleted file mode 100644 index ecbcc89..0000000 --- a/handler.py +++ /dev/null @@ -1,754 +0,0 @@ -import boto3 -import json -import os -import re -import time -from datetime import datetime, timezone, timedelta -from typing import List, Optional, Dict, Any, Union - -from slack_bolt import App, Say -from slack_bolt.adapter.aws_lambda import SlackRequestHandler - -from boto3.dynamodb.conditions import Key - - -# Helper functions for environment variable parsing -def get_env_int(key: str, default: int) -> int: - """Get environment variable as integer, with fallback for empty strings""" - value = os.environ.get(key, "") - return int(value) if value else default - - -def get_env_float(key: str, default: float) -> float: - """Get environment variable as float, with fallback for empty strings""" - value = os.environ.get(key, "") - return float(value) if value else default - - -def get_env_str(key: str, default: str) -> str: - """Get environment variable as string, with fallback for empty strings""" - value = os.environ.get(key, "") - return value if value else default - - -# Environment configuration -class Config: - """Configuration settings loaded from environment variables""" - AWS_REGION = get_env_str("AWS_REGION", "us-east-1") - SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN") - SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET") - DYNAMODB_TABLE_NAME = get_env_str("DYNAMODB_TABLE_NAME", "gurumi-ai-bot-dev") - AGENT_ID = get_env_str("AGENT_ID", "None") - AGENT_ALIAS_ID = get_env_str("AGENT_ALIAS_ID", "None") - ALLOWED_CHANNEL_IDS = get_env_str("ALLOWED_CHANNEL_IDS", "None") - ALLOWED_CHANNEL_MESSAGE = get_env_str( - "ALLOWED_CHANNEL_MESSAGE", "Sorry, I'm not allowed to respond in this channel." - ) - PERSONAL_MESSAGE = get_env_str( - "PERSONAL_MESSAGE", "You are a friendly and professional AI assistant." - ) - SYSTEM_MESSAGE = get_env_str("SYSTEM_MESSAGE", "None") - MAX_LEN_SLACK = get_env_int("MAX_LEN_SLACK", 2000) - MAX_LEN_BEDROCK = get_env_int("MAX_LEN_BEDROCK", 4000) - MAX_THROTTLE_COUNT = get_env_int("MAX_THROTTLE_COUNT", 100) - SLACK_SAY_INTERVAL = get_env_float("SLACK_SAY_INTERVAL", 0) - BOT_CURSOR = get_env_str("BOT_CURSOR", ":robot_face:") - REACTION_EMOJIS = get_env_str("REACTION_EMOJIS", "refund-done") - - @classmethod - def get_reaction_emojis(cls) -> List[str]: - """Parse comma-separated reaction emojis into a list""" - if not cls.REACTION_EMOJIS or cls.REACTION_EMOJIS == "None": - return [] - return [emoji.strip() for emoji in cls.REACTION_EMOJIS.split(",") if emoji.strip()] - - @classmethod - def validate(cls) -> bool: - """Validate required configuration settings""" - required_vars = ["SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET"] - missing = [var for var in required_vars if not getattr(cls, var)] - if missing: - print(f"Missing required environment variables: {', '.join(missing)}") - return False - return True - - -# Initialize AWS clients -dynamodb = boto3.resource("dynamodb") -table = dynamodb.Table(Config.DYNAMODB_TABLE_NAME) -bedrock_agent_client = boto3.client("bedrock-agent-runtime", region_name=Config.AWS_REGION) - -# Initialize Slack app -app = App( - token=Config.SLACK_BOT_TOKEN, - signing_secret=Config.SLACK_SIGNING_SECRET, - process_before_response=True, -) - -# Lazy initialization for bot_id to avoid API call at module load time -_bot_id: Optional[str] = None - - -def get_bot_id() -> str: - """Get Slack bot ID with lazy initialization""" - global _bot_id - if _bot_id is None: - _bot_id = app.client.api_call("auth.test")["user_id"] - return _bot_id - -# Status messages -MSG_PREVIOUS = f"이전 대화 내용 확인 중... {Config.BOT_CURSOR}" -MSG_RESPONSE = f"응답 기다리는 중... {Config.BOT_CURSOR}" -MSG_ERROR = f"오류가 발생했습니다. 잠시 후 다시 시도해주세요. {Config.BOT_CURSOR}" - - -class DynamoDBManager: - """Handles DynamoDB operations for conversation context""" - - @staticmethod - def get_context(thread_ts: Optional[str], user: str, default: str = "") -> str: - """Retrieve conversation context from DynamoDB""" - try: - key = {"id": thread_ts or user} - item = table.get_item(Key=key).get("Item") - return item["conversation"] if item else default - except Exception as e: - print(f"Error retrieving context: {e}") - return default - - @staticmethod - def put_context(thread_ts: Optional[str], user: str, conversation: str = "") -> None: - """Store conversation context in DynamoDB with TTL""" - try: - expire_at = int(time.time()) + 3600 # 1 hour TTL - expire_dt = datetime.fromtimestamp(expire_at).isoformat() - - item = { - "id": thread_ts or user, - "conversation": conversation, - "expire_dt": expire_dt, - "expire_at": expire_at, - } - - if thread_ts: - item["user"] = user - - table.put_item(Item=item) - except Exception as e: - print(f"Error storing context: {e}") - - @staticmethod - def count_user_contexts(user: str) -> int: - """Count contexts belonging to a specific user using GSI""" - try: - response = table.query( - IndexName="user-index", - KeyConditionExpression=Key("user").eq(user), - Select="COUNT" - ) - return response.get("Count", 0) - except Exception as e: - print(f"Error counting contexts: {e}") - return 0 - - -class MessageFormatter: - """Handles message formatting and splitting for Slack""" - - @staticmethod - def split_message(message: str, max_len: int) -> List[str]: - """Split a message into chunks that fit within max_len""" - # If message is empty or smaller than max_len, return as is - if not message or len(message) <= max_len: - return [message] - - # First split by code blocks - parts = [] - segments = message.split("```") - - for i, segment in enumerate(segments): - if not segment: # Skip empty segments - continue - - if i % 2 == 1: # This is a code block - # Preserve the code block formatting - code_parts = MessageFormatter._split_text(f"```{segment}```", max_len) - parts.extend(code_parts) - else: - # Regular text - split by paragraphs - text_parts = MessageFormatter._split_text(segment, max_len) - parts.extend(text_parts) - - # Final cleanup to ensure no part exceeds max_len - result = [] - current = "" - - for part in parts: - if len(current) + len(part) + 2 <= max_len: - if current: - current += "\n\n" + part - else: - current = part - else: - if current: - result.append(current) - current = part - - if current: - result.append(current) - - return result - - @staticmethod - def _split_text(text: str, max_len: int) -> List[str]: - """Helper method to split text by paragraphs""" - if len(text) <= max_len: - return [text] - - parts = text.split("\n\n") - result = [] - current = "" - - for part in parts: - # If a single part is longer than max_len, split it by sentences - if len(part) > max_len: - sentences = re.split(r'(?<=[.!?])\s+', part) - for sentence in sentences: - if len(current) + len(sentence) + 2 <= max_len: - if current: - current += " " + sentence - else: - current = sentence - else: - if current: - result.append(current) - current = sentence - elif len(current) + len(part) + 2 <= max_len: - if current: - current += "\n\n" + part - else: - current = part - else: - if current: - result.append(current) - current = part - - if current: - result.append(current) - - return result - - -class SlackManager: - """Handles Slack messaging operations""" - - # Cache for user display names to avoid repeated API calls - _user_name_cache: Dict[str, str] = {} - - @classmethod - def get_user_display_name(cls, user_id: str) -> str: - """Get user display name from Slack API with caching""" - if user_id in cls._user_name_cache: - return cls._user_name_cache[user_id] - - try: - response = app.client.users_info(user=user_id) - if response.get("ok"): - user_info = response.get("user", {}) - profile = user_info.get("profile", {}) - # Prefer display_name, fall back to real_name, then user_id - display_name = ( - profile.get("display_name") - or profile.get("real_name") - or user_id - ) - cls._user_name_cache[user_id] = display_name - return display_name - except Exception as e: - print(f"Error fetching user info for {user_id}: {e}") - - return user_id - - @staticmethod - def update_message(say: Say, channel: str, thread_ts: Optional[str], - latest_ts: str, message: str) -> tuple: - """Update existing message and send additional messages if needed""" - try: - split_messages = MessageFormatter.split_message(message, Config.MAX_LEN_SLACK) - - for i, text in enumerate(split_messages): - if i == 0: - # Update the initial message - app.client.chat_update(channel=channel, ts=latest_ts, text=text) - else: - # Add delay if configured - if Config.SLACK_SAY_INTERVAL > 0: - time.sleep(Config.SLACK_SAY_INTERVAL) - - # Send additional messages in thread - result = say(text=text, thread_ts=thread_ts) - latest_ts = result["ts"] - - return message, latest_ts - except Exception as e: - print(f"Error updating message: {e}") - # Update with error message - app.client.chat_update(channel=channel, ts=latest_ts, text=MSG_ERROR) - return MSG_ERROR, latest_ts - - @classmethod - def get_thread_history(cls, channel: str, thread_ts: str, client_msg_id: str) -> List[str]: - """Retrieve conversation history from a Slack thread""" - contexts = [] - - try: - response = app.client.conversations_replies(channel=channel, ts=thread_ts) - - if not response.get("ok"): - print("Failed to retrieve thread messages") - return contexts - - messages = response.get("messages", []) - - # Slack API returns messages in chronological order (oldest first) - # Include all messages (including thread parent) - # Process from newest to oldest to prioritize recent context - thread_messages = messages.copy() - thread_messages.reverse() # Now newest first - - for message in thread_messages: - # Skip the current message being processed - if message.get("client_msg_id") == client_msg_id: - continue - - # Determine role and author (Slack mention format for users) - if message.get("bot_id"): - role = "assistant" - author = "assistant" - else: - role = "user" - user_id = message.get("user", "") - author = f"<@{user_id}>" if user_id else "unknown" - - contexts.append(f"{role}({author}): {message.get('text', '')}") - - # Check if we've reached the context length limit - context_text = "\n".join(contexts) - if len(context_text) > Config.MAX_LEN_BEDROCK: - contexts.pop(0) # Remove oldest (first added) message - break - - # Reverse back to chronological order for the prompt - contexts.reverse() - - except Exception as e: - print(f"Error retrieving thread history: {e}") - - return contexts - - -class BedrockManager: - """Handles Amazon Bedrock operations""" - - @staticmethod - def invoke_agent(prompt: str) -> str: - """Invoke Amazon Bedrock Agent with prompt and return response""" - try: - # Create a unique session ID - now = datetime.now() - session_id = str(int(now.timestamp() * 1000)) - - # Call Bedrock Agent - response = bedrock_agent_client.invoke_agent( - agentId=Config.AGENT_ID, - agentAliasId=Config.AGENT_ALIAS_ID, - sessionId=session_id, - inputText=prompt, - ) - - # Process streaming response - completion = "" - for event in response.get("completion"): - chunk = event["chunk"] - completion += chunk["bytes"].decode() - - return completion - - except Exception as e: - print(f"Error invoking Bedrock Agent: {e}") - return f"죄송합니다. 응답을 생성하는 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요. (오류: {type(e).__name__})" - - @staticmethod - def create_prompt(say: Optional[Say], query: str, thread_ts: Optional[str] = None, - channel: Optional[str] = None, client_msg_id: Optional[str] = None, - latest_ts: Optional[str] = None, user_id: Optional[str] = None) -> str: - """Create a prompt for the AI model with context and query""" - prompts = [] - prompts.append(f"User: {Config.PERSONAL_MESSAGE}") - - if Config.SYSTEM_MESSAGE != "None": - prompts.append(Config.SYSTEM_MESSAGE) - - prompts.append(" 태그로 감싸진 질문에 답변을 제공하세요.") - - try: - # Add conversation history if in a thread - if thread_ts and say and channel and client_msg_id: - # Update status message (only when using placeholder message) - if latest_ts: - SlackManager.update_message(say, channel, thread_ts, latest_ts, MSG_PREVIOUS) - - # Get thread history - contexts = SlackManager.get_thread_history(channel, thread_ts, client_msg_id) - - if contexts: - prompts.append(" 에 정보가 제공 되면, 대화 기록을 참고하여 답변해 주세요.") - prompts.append("") - prompts.append("\n\n".join(contexts)) - prompts.append("") - - # Add the current query with user_id (Slack mention format) - prompts.append("") - if user_id: - prompts.append(f"\">") - else: - prompts.append("") - prompts.append(query) - prompts.append("") - prompts.append("") - - prompts.append("Assistant:") - - return "\n".join(prompts) - - except Exception as e: - print(f"Error creating prompt: {e}") - raise e - - -def conversation(say: Say, query: str, thread_ts: Optional[str] = None, - channel: Optional[str] = None, client_msg_id: Optional[str] = None, - user_id: Optional[str] = None) -> None: - """Main conversation handler that processes queries and returns AI responses""" - print(f"conversation: query: {query}, user_id: {user_id}") - - use_assistant_status = bool(thread_ts and channel) - latest_ts = None - - try: - if use_assistant_status: - # Set assistant thread status (shows animated indicator) - try: - app.client.assistant_threads_setStatus( - channel_id=channel, - thread_ts=thread_ts, - status="is thinking...", - ) - except Exception as e: - print(f"Error setting thread status: {e}") - use_assistant_status = False - - if not use_assistant_status: - # Fallback: send placeholder message - result = say(text=Config.BOT_CURSOR, thread_ts=thread_ts) - latest_ts = result["ts"] - - # Create prompt with context and query - prompt = BedrockManager.create_prompt( - say, query, thread_ts, channel, client_msg_id, latest_ts, user_id - ) - - if not use_assistant_status and latest_ts: - # Update status while waiting for response - SlackManager.update_message(say, channel, thread_ts, latest_ts, MSG_RESPONSE) - - # Get response from AI - message = BedrockManager.invoke_agent(prompt) - - if use_assistant_status: - # Send response directly (auto-clears assistant status) - split_messages = MessageFormatter.split_message(message, Config.MAX_LEN_SLACK) - for i, text in enumerate(split_messages): - if Config.SLACK_SAY_INTERVAL > 0 and i > 0: - time.sleep(Config.SLACK_SAY_INTERVAL) - say(text=text, thread_ts=thread_ts) - else: - # Update placeholder with final response - SlackManager.update_message(say, channel, thread_ts, latest_ts, message) - - except Exception as e: - print(f"Error in conversation handler: {e}") - try: - if use_assistant_status: - say(text=MSG_ERROR, thread_ts=thread_ts) - elif latest_ts: - SlackManager.update_message(say, channel, thread_ts, latest_ts, MSG_ERROR) - except Exception: - pass - - -@app.event("app_mention") -def handle_mention(body: Dict[str, Any], say: Say) -> None: - """Handle mentions of the bot in channels""" - print(f"handle_mention: {body}") - - event = body["event"] - thread_ts = event.get("thread_ts", event.get("ts")) - channel = event.get("channel") - client_msg_id = event.get("client_msg_id") - user_id = event.get("user") - - # Check if the channel is allowed - if Config.ALLOWED_CHANNEL_IDS != "None": - allowed_channel_ids = Config.ALLOWED_CHANNEL_IDS.split(",") - if channel not in allowed_channel_ids: - first_channel = f"<#{allowed_channel_ids[0]}>" - message = Config.ALLOWED_CHANNEL_MESSAGE.format(first_channel) - say(text=message, thread_ts=thread_ts) - print(f"handle_mention: {message}") - return - - # Extract query text (remove the bot mention) - prompt = re.sub(f"<@{get_bot_id()}>", "", event["text"]).strip() - - # Process the conversation - conversation(say, prompt, thread_ts, channel, client_msg_id, user_id) - - -@app.event("message") -def handle_message(body: Dict[str, Any], say: Say) -> None: - """Handle direct messages to the bot""" - print(f"handle_message: {body}") - - event = body["event"] - - # Ignore messages from bots (including this bot) - if event.get("bot_id"): - return - - channel = event["channel"] - client_msg_id = event["client_msg_id"] - user_id = event.get("user") - prompt = event["text"].strip() - - # Process the conversation (thread_ts=None for DMs) - conversation(say, prompt, None, channel, client_msg_id, user_id) - - -def mask_account_number(account: str) -> str: - """Mask account number, showing only first 4 and last 2 digits""" - # Remove any non-digit characters for processing - digits_only = re.sub(r'\D', '', account) - - if len(digits_only) <= 6: - # If too short, mask middle portion - return digits_only[:2] + '*' * (len(digits_only) - 2) - - # Show first 4 and last 2 digits - return digits_only[:4] + '*' * (len(digits_only) - 6) + digits_only[-2:] - - -def process_refund_done(channel: str, message_ts: str, user: str) -> None: - """Process refund-done emoji reaction: mask account number and add refund timestamp""" - print(f"process_refund_done: channel={channel}, message_ts={message_ts}, user={user}") - - try: - # Get the original message - result = app.client.conversations_history( - channel=channel, - latest=message_ts, - limit=1, - inclusive=True - ) - - if not result.get("ok") or not result.get("messages"): - print("Failed to retrieve message") - return - - message = result["messages"][0] - blocks = message.get("blocks", []) - - if not blocks: - print("No blocks found in message") - return - - # Check if this is a refund request message (has the header) - is_refund_message = False - for block in blocks: - if block.get("type") == "header": - header_text = block.get("text", {}).get("text", "") - if "환불 신청" in header_text: - is_refund_message = True - break - - if not is_refund_message: - print("Not a refund request message") - return - - # Process blocks to mask account number and add refund timestamp - updated_blocks = [] - refund_time_added = False - # Convert to KST (UTC+9) - kst = timezone(timedelta(hours=9)) - current_time = datetime.now(kst).strftime("%Y. %m. %d. %p %I:%M:%S").replace("AM", "오전").replace("PM", "오후") - - for block in blocks: - if block.get("type") == "section" and block.get("fields"): - new_fields = [] - for field in block["fields"]: - field_text = field.get("text", "") - - # Mask account number - if "*계좌번호:*" in field_text: - lines = field_text.split("\n") - if len(lines) >= 2: - account = lines[1] - masked = mask_account_number(account) - field = { - "type": "mrkdwn", - "text": f"*계좌번호:*\n{masked}" - } - - new_fields.append(field) - - # Add refund timestamp if not already present - has_refund_time = any("*환불일시:*" in f.get("text", "") for f in new_fields) - if not has_refund_time and not refund_time_added: - new_fields.append({ - "type": "mrkdwn", - "text": f"*환불일시:*\n{current_time}" - }) - refund_time_added = True - - block = dict(block) - block["fields"] = new_fields - - updated_blocks.append(block) - - # Update the message - app.client.chat_update( - channel=channel, - ts=message_ts, - blocks=updated_blocks, - text=message.get("text", "환불 신청이 처리되었습니다.") - ) - - print(f"Refund message updated successfully") - - except Exception as e: - print(f"Error processing refund done: {e}") - - -# Reaction handlers mapping: emoji name -> handler function -REACTION_HANDLERS = { - "refund-done": lambda channel, ts, user: process_refund_done(channel, ts, user), -} - - -@app.event("reaction_added") -def handle_reaction_added(body: Dict[str, Any]) -> None: - """Handle emoji reaction added events""" - print(f"handle_reaction_added: {body}") - - event = body["event"] - reaction = event.get("reaction", "") - user = event.get("user", "") - item = event.get("item", {}) - - # Check if this reaction is in the allowed list - allowed_emojis = Config.get_reaction_emojis() - if reaction not in allowed_emojis: - return - - # Get message details - item_type = item.get("type", "") - if item_type != "message": - return - - channel = item.get("channel", "") - message_ts = item.get("ts", "") - - if not channel or not message_ts: - return - - # Dispatch to the appropriate handler - handler = REACTION_HANDLERS.get(reaction) - if handler: - handler(channel, message_ts, user) - else: - print(f"No handler found for reaction: {reaction}") - - -def success(message: str = "") -> Dict[str, Any]: - """Return a success response for Lambda""" - return { - "statusCode": 200, - "headers": {"Content-type": "application/json"}, - "body": json.dumps({"status": "Success", "message": message}), - } - - - -def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: - """Main Lambda handler for Slack events""" - # Validate required configuration - if not Config.validate(): - return { - "statusCode": 500, - "headers": {"Content-type": "application/json"}, - "body": json.dumps({"status": "Error", "message": "Missing required configuration"}), - } - - # Parse request body - body = json.loads(event["body"]) - - # Handle Slack verification challenge - if "challenge" in body: - return { - "statusCode": 200, - "headers": {"Content-type": "application/json"}, - "body": json.dumps({"challenge": body["challenge"]}), - } - - print(f"lambda_handler: {body}") - - # Check for valid event structure - if "event" not in body: - print("lambda_handler: event not found") - return success() - - event_type = body["event"].get("type", "") - - # Handle reaction events directly (no client_msg_id, no deduplication needed) - if event_type == "reaction_added": - slack_handler = SlackRequestHandler(app=app) - return slack_handler.handle(event, context) - - # For message events, check client_msg_id - if "client_msg_id" not in body["event"]: - print("lambda_handler: client_msg_id not found") - return success() - - # Extract message identifiers - token = body["event"]["client_msg_id"] - user = body["event"]["user"] - - # Check for duplicate events (idempotency) - if DynamoDBManager.get_context(token, user) != "": - print("lambda_handler: duplicate event detected") - return success() - - # Check user throttling - count = DynamoDBManager.count_user_contexts(user) - if count >= Config.MAX_THROTTLE_COUNT: - print(f"lambda_handler: throttle limit reached: {count} >= {Config.MAX_THROTTLE_COUNT}") - return success() - - # Store context to prevent duplicate processing - DynamoDBManager.put_context(token, user, body["event"]["text"]) - - # Handle the Slack event - slack_handler = SlackRequestHandler(app=app) - return slack_handler.handle(event, context) - - diff --git a/localtest.py b/localtest.py new file mode 100644 index 0000000..73219d9 --- /dev/null +++ b/localtest.py @@ -0,0 +1,202 @@ +"""Local CLI test script — runs SlackMentionAgent without a real Slack connection. + +Streams LLM output token-by-token and prints intermediate agent steps +(tool calls, their results, compose phase) to stderr so they stay out of +the streamed answer on stdout. + +Usage: + python localtest.py "질문 내용" + python localtest.py # interactive (stdin, Ctrl+D to submit) + python localtest.py --no-stream # wait for the full answer, then print + python localtest.py --quiet-steps # hide intermediate step indicators + +Environment: + Copy .env.example to .env.local and fill in values before running. + Minimum required: OPENAI_API_KEY (for OpenAI provider). +""" +from __future__ import annotations + +import argparse +import logging +import sys +import time +from pathlib import Path +from typing import Any + + +logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s") + + +LOCAL_UPLOAD_DIR = Path("./.uploads") + + +class _StubSlackClient: + """Returns empty but structurally correct responses for every Slack call. + + `files_upload_v2` writes the received bytes to ./.uploads/ so you can + actually open generated images instead of discarding them. + """ + + def conversations_replies(self, **_): + return {"messages": []} + + def files_upload_v2(self, *, file=None, filename="generated.bin", **_): + if file is None: + return {"file": {"permalink": "", "title": filename}} + LOCAL_UPLOAD_DIR.mkdir(exist_ok=True) + ts = int(time.time() * 1000) + path = LOCAL_UPLOAD_DIR / f"{ts}-{filename}" + path.write_bytes(file) + resolved = path.resolve() + return {"file": {"permalink": resolved.as_uri(), "title": filename}} + + def users_info(self, **_): + return {"user": {"profile": {"display_name": "local-user"}}} + + +def _build_slack_client(token: str): + if token and not token.startswith("xoxb-your"): + try: + from slack_sdk import WebClient + + return WebClient(token=token) + except Exception: + pass + return _StubSlackClient() + + +def _make_on_step(quiet: bool): + if quiet: + return None + + def on_step(step: int, phase: str, detail: dict[str, Any]) -> None: + if phase == "tool_use": + tools = ", ".join(detail.get("tools") or []) + print(f"\n[step {step}] ▶ 도구 호출: {tools}", file=sys.stderr, flush=True) + elif phase == "tool_result": + mark = "✓" if detail.get("ok") else "✗" + tool = detail.get("tool") or "" + if detail.get("ok"): + print(f"[step {step}] {mark} {tool}", file=sys.stderr, flush=True) + else: + err = detail.get("error") or "" + print(f"[step {step}] {mark} {tool}: {err}", file=sys.stderr, flush=True) + elif phase == "compose": + hint = " (max_steps 도달)" if detail.get("max_steps_hit") else "" + print(f"\n[step {step}] ▶ 답변 작성 중...{hint}", file=sys.stderr, flush=True) + + return on_step + + +def main() -> None: + parser = argparse.ArgumentParser(description="Local agent test runner.") + parser.add_argument("question", nargs="*", help="Question text. If omitted, read from stdin.") + parser.add_argument("--no-stream", action="store_true", help="Disable streaming output; print the full answer at the end.") + parser.add_argument("--quiet-steps", action="store_true", help="Suppress intermediate step logs on stderr.") + args = parser.parse_args() + + stream_mode = not args.no_stream + + from src.agent import SlackMentionAgent + from src.config import Settings + from src.llms import get_llm + from src.tools import ToolContext, default_registry + + settings = Settings.from_env() + + if not settings.slack_bot_token or settings.slack_bot_token.startswith("xoxb-your"): + print("[경고] SLACK_BOT_TOKEN 미설정 — Slack 관련 도구는 빈 결과를 반환합니다.\n", file=sys.stderr) + if settings.llm_provider == "openai": + import os + + if not os.getenv("OPENAI_API_KEY"): + print("[오류] OPENAI_API_KEY가 설정되지 않았습니다. .env.local 을 확인하세요.", file=sys.stderr) + sys.exit(1) + elif settings.llm_provider == "xai": + if not settings.xai_api_key: + print("[오류] XAI_API_KEY가 설정되지 않았습니다. .env.local 을 확인하세요.", file=sys.stderr) + sys.exit(1) + + llm = get_llm( + provider=settings.llm_provider, + model=settings.llm_model, + image_provider=settings.image_provider, + image_model=settings.image_model, + region=settings.aws_region, + api_keys={"xai": settings.xai_api_key}, + ) + + slack_client = _build_slack_client(settings.slack_bot_token) + context = ToolContext( + slack_client=slack_client, + channel="local", + thread_ts="0", + event={}, + settings=settings, + llm=llm, + ) + + if args.question: + user_message = " ".join(args.question).strip() + else: + print("질문을 입력하세요 (Ctrl+D 로 종료):", file=sys.stderr) + try: + user_message = sys.stdin.read().strip() + except (EOFError, KeyboardInterrupt): + print(file=sys.stderr) + sys.exit(0) + + if not user_message: + print("[오류] 질문이 비어 있습니다.", file=sys.stderr) + sys.exit(1) + + print(f"\n▶ 질문: {user_message}", file=sys.stderr) + + on_stream = None + if stream_mode: + def on_stream(delta: str) -> None: # noqa: RUF013 + sys.stdout.write(delta) + sys.stdout.flush() + + on_step = _make_on_step(args.quiet_steps) + + agent = SlackMentionAgent( + llm=llm, + context=context, + registry=default_registry, + max_steps=settings.agent_max_steps, + response_language=settings.response_language, + system_message=settings.system_message, + on_stream=on_stream, + on_step=on_step, + max_output_tokens=settings.max_output_tokens, + ) + + try: + result = agent.run(user_message) + except Exception as exc: # noqa: BLE001 + print(f"\n[오류] {exc}", file=sys.stderr) + sys.exit(1) + + if stream_mode: + # Streaming already wrote the answer to stdout; just end the line. + sys.stdout.write("\n") + sys.stdout.flush() + else: + print("\n" + "─" * 60, file=sys.stderr) + sys.stdout.write(result.text + "\n") + sys.stdout.flush() + print("─" * 60, file=sys.stderr) + + if result.image_url: + print(f"[이미지] {result.image_url}", file=sys.stderr) + + print( + f"\nsteps={result.steps} tool_calls={result.tool_calls_count} " + f"tokens_in={result.token_usage.get('input', 0)} tokens_out={result.token_usage.get('output', 0)}", + file=sys.stderr, + ) + + +if __name__ == "__main__": + main() diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index f531c31..0000000 --- a/package-lock.json +++ /dev/null @@ -1,7814 +0,0 @@ -{ - "name": "lambda-gurumi-ai-bot", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "lambda-gurumi-ai-bot", - "devDependencies": { - "serverless-dotenv-plugin": "^6.0.0", - "serverless-python-requirements": "^6.1.2" - } - }, - "node_modules/@aws-crypto/ie11-detection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz", - "integrity": "sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^1.11.1" - } - }, - "node_modules/@aws-crypto/ie11-detection/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "peer": true - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz", - "integrity": "sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-crypto/ie11-detection": "^3.0.0", - "@aws-crypto/sha256-js": "^3.0.0", - "@aws-crypto/supports-web-crypto": "^3.0.0", - "@aws-crypto/util": "^3.0.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@aws-sdk/util-utf8-browser": "^3.0.0", - "tslib": "^1.11.1" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "peer": true - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz", - "integrity": "sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-crypto/util": "^3.0.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^1.11.1" - } - }, - "node_modules/@aws-crypto/sha256-js/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "peer": true - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz", - "integrity": "sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^1.11.1" - } - }, - "node_modules/@aws-crypto/supports-web-crypto/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "peer": true - }, - "node_modules/@aws-crypto/util": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz", - "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-utf8-browser": "^3.0.0", - "tslib": "^1.11.1" - } - }, - "node_modules/@aws-crypto/util/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "peer": true - }, - "node_modules/@aws-sdk/client-cloudformation": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.588.0.tgz", - "integrity": "sha512-iJs8mCtaPVeeyYxwIVyaesZh7a6FRJezxoN87v5g0AzjxLJshb07lTKxJFtdzhJmAYM8RfKIayaB33tlNNGsRQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-crypto/sha256-browser": "3.0.0", - "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sso-oidc": "3.588.0", - "@aws-sdk/client-sts": "3.588.0", - "@aws-sdk/core": "3.588.0", - "@aws-sdk/credential-provider-node": "3.588.0", - "@aws-sdk/middleware-host-header": "3.577.0", - "@aws-sdk/middleware-logger": "3.577.0", - "@aws-sdk/middleware-recursion-detection": "3.577.0", - "@aws-sdk/middleware-user-agent": "3.587.0", - "@aws-sdk/region-config-resolver": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@aws-sdk/util-endpoints": "3.587.0", - "@aws-sdk/util-user-agent-browser": "3.577.0", - "@aws-sdk/util-user-agent-node": "3.587.0", - "@smithy/config-resolver": "^3.0.1", - "@smithy/core": "^2.1.1", - "@smithy/fetch-http-handler": "^3.0.1", - "@smithy/hash-node": "^3.0.0", - "@smithy/invalid-dependency": "^3.0.0", - "@smithy/middleware-content-length": "^3.0.0", - "@smithy/middleware-endpoint": "^3.0.1", - "@smithy/middleware-retry": "^3.0.3", - "@smithy/middleware-serde": "^3.0.0", - "@smithy/middleware-stack": "^3.0.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/node-http-handler": "^3.0.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "@smithy/url-parser": "^3.0.0", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.3", - "@smithy/util-defaults-mode-node": "^3.0.3", - "@smithy/util-endpoints": "^2.0.1", - "@smithy/util-middleware": "^3.0.0", - "@smithy/util-retry": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "@smithy/util-waiter": "^3.0.0", - "tslib": "^2.6.2", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/client-sso": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.588.0.tgz", - "integrity": "sha512-zKS+xUkBLfwjbh77ZjtRUoG/vR/fyDteSE6rOAzwlmHQL8p+QUX+zNUNvCInvPi62zGBhEwXOvzs8zvnT4NzfQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-crypto/sha256-browser": "3.0.0", - "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/core": "3.588.0", - "@aws-sdk/middleware-host-header": "3.577.0", - "@aws-sdk/middleware-logger": "3.577.0", - "@aws-sdk/middleware-recursion-detection": "3.577.0", - "@aws-sdk/middleware-user-agent": "3.587.0", - "@aws-sdk/region-config-resolver": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@aws-sdk/util-endpoints": "3.587.0", - "@aws-sdk/util-user-agent-browser": "3.577.0", - "@aws-sdk/util-user-agent-node": "3.587.0", - "@smithy/config-resolver": "^3.0.1", - "@smithy/core": "^2.1.1", - "@smithy/fetch-http-handler": "^3.0.1", - "@smithy/hash-node": "^3.0.0", - "@smithy/invalid-dependency": "^3.0.0", - "@smithy/middleware-content-length": "^3.0.0", - "@smithy/middleware-endpoint": "^3.0.1", - "@smithy/middleware-retry": "^3.0.3", - "@smithy/middleware-serde": "^3.0.0", - "@smithy/middleware-stack": "^3.0.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/node-http-handler": "^3.0.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "@smithy/url-parser": "^3.0.0", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.3", - "@smithy/util-defaults-mode-node": "^3.0.3", - "@smithy/util-endpoints": "^2.0.1", - "@smithy/util-middleware": "^3.0.0", - "@smithy/util-retry": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/client-sso-oidc": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.588.0.tgz", - "integrity": "sha512-CTbgtLSg0y2jIOtESuQKkRIqRe/FQmKuyzFWc+Qy6yGcbk1Pyusfz2BC+GGwpYU+1BlBBSNnLQHpx3XY87+aSA==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-crypto/sha256-browser": "3.0.0", - "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.588.0", - "@aws-sdk/core": "3.588.0", - "@aws-sdk/credential-provider-node": "3.588.0", - "@aws-sdk/middleware-host-header": "3.577.0", - "@aws-sdk/middleware-logger": "3.577.0", - "@aws-sdk/middleware-recursion-detection": "3.577.0", - "@aws-sdk/middleware-user-agent": "3.587.0", - "@aws-sdk/region-config-resolver": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@aws-sdk/util-endpoints": "3.587.0", - "@aws-sdk/util-user-agent-browser": "3.577.0", - "@aws-sdk/util-user-agent-node": "3.587.0", - "@smithy/config-resolver": "^3.0.1", - "@smithy/core": "^2.1.1", - "@smithy/fetch-http-handler": "^3.0.1", - "@smithy/hash-node": "^3.0.0", - "@smithy/invalid-dependency": "^3.0.0", - "@smithy/middleware-content-length": "^3.0.0", - "@smithy/middleware-endpoint": "^3.0.1", - "@smithy/middleware-retry": "^3.0.3", - "@smithy/middleware-serde": "^3.0.0", - "@smithy/middleware-stack": "^3.0.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/node-http-handler": "^3.0.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "@smithy/url-parser": "^3.0.0", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.3", - "@smithy/util-defaults-mode-node": "^3.0.3", - "@smithy/util-endpoints": "^2.0.1", - "@smithy/util-middleware": "^3.0.0", - "@smithy/util-retry": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/client-sts": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.588.0.tgz", - "integrity": "sha512-UIMjcUikgG9NIENQxSyJNTHMD8TaTfK6Jjf1iuZSyQRyTrcGy0/xcDxrmwZQFAPkOPUf6w9KqydLkMLcYOBdPQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-crypto/sha256-browser": "3.0.0", - "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sso-oidc": "3.588.0", - "@aws-sdk/core": "3.588.0", - "@aws-sdk/credential-provider-node": "3.588.0", - "@aws-sdk/middleware-host-header": "3.577.0", - "@aws-sdk/middleware-logger": "3.577.0", - "@aws-sdk/middleware-recursion-detection": "3.577.0", - "@aws-sdk/middleware-user-agent": "3.587.0", - "@aws-sdk/region-config-resolver": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@aws-sdk/util-endpoints": "3.587.0", - "@aws-sdk/util-user-agent-browser": "3.577.0", - "@aws-sdk/util-user-agent-node": "3.587.0", - "@smithy/config-resolver": "^3.0.1", - "@smithy/core": "^2.1.1", - "@smithy/fetch-http-handler": "^3.0.1", - "@smithy/hash-node": "^3.0.0", - "@smithy/invalid-dependency": "^3.0.0", - "@smithy/middleware-content-length": "^3.0.0", - "@smithy/middleware-endpoint": "^3.0.1", - "@smithy/middleware-retry": "^3.0.3", - "@smithy/middleware-serde": "^3.0.0", - "@smithy/middleware-stack": "^3.0.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/node-http-handler": "^3.0.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "@smithy/url-parser": "^3.0.0", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.3", - "@smithy/util-defaults-mode-node": "^3.0.3", - "@smithy/util-endpoints": "^2.0.1", - "@smithy/util-middleware": "^3.0.0", - "@smithy/util-retry": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.588.0.tgz", - "integrity": "sha512-O1c2+9ce46Z+iiid+W3iC1IvPbfIo5ev9CBi54GdNB9SaI8/3+f8MJcux0D6c9toCF0ArMersN/gp8ek57e9uQ==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/core": "^2.1.1", - "@smithy/protocol-http": "^4.0.0", - "@smithy/signature-v4": "^3.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "fast-xml-parser": "4.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.587.0.tgz", - "integrity": "sha512-Hyg/5KFECIk2k5o8wnVEiniV86yVkhn5kzITUydmNGCkXdBFHMHRx6hleQ1bqwJHbBskyu8nbYamzcwymmGwmw==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.587.0.tgz", - "integrity": "sha512-Su1SRWVRCuR1e32oxX3C1V4c5hpPN20WYcRfdcr2wXwHqSvys5DrnmuCC+JoEnS/zt3adUJhPliTqpfKgSdMrA==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/fetch-http-handler": "^3.0.1", - "@smithy/node-http-handler": "^3.0.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "@smithy/util-stream": "^3.0.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.588.0.tgz", - "integrity": "sha512-tP/YmEKvYpmp7pCR2OuhoOhAOtm6BbZ1hbeG9Sw9RFZi55dbGPHqMmfvvzHFAGsJ20z4/oDS+UnHaWVhRnV82w==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/credential-provider-env": "3.587.0", - "@aws-sdk/credential-provider-http": "3.587.0", - "@aws-sdk/credential-provider-process": "3.587.0", - "@aws-sdk/credential-provider-sso": "3.588.0", - "@aws-sdk/credential-provider-web-identity": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@smithy/credential-provider-imds": "^3.1.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/shared-ini-file-loader": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-sts": "^3.588.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.588.0.tgz", - "integrity": "sha512-8s4Ruo6q1YIrj8AZKBiUQG42051ytochDMSqdVOEZGxskfvmt2XALyi5SsWd0Ve3zR95zi+EtRBNPn2EU8sQpA==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/credential-provider-env": "3.587.0", - "@aws-sdk/credential-provider-http": "3.587.0", - "@aws-sdk/credential-provider-ini": "3.588.0", - "@aws-sdk/credential-provider-process": "3.587.0", - "@aws-sdk/credential-provider-sso": "3.588.0", - "@aws-sdk/credential-provider-web-identity": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@smithy/credential-provider-imds": "^3.1.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/shared-ini-file-loader": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.587.0.tgz", - "integrity": "sha512-V4xT3iCqkF8uL6QC4gqBJg/2asd/damswP1h9HCfqTllmPWzImS+8WD3VjgTLw5b0KbTy+ZdUhKc0wDnyzkzxg==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/shared-ini-file-loader": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.588.0.tgz", - "integrity": "sha512-1GstMCyFzenVeppK7hWazMvo3P1DXKP70XkXAjH8H2ELBVg5X8Zt043cnQ7CMt4XjCV+ettHAtc9kz/gJTkDNQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/client-sso": "3.588.0", - "@aws-sdk/token-providers": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/shared-ini-file-loader": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.587.0.tgz", - "integrity": "sha512-XqIx/I2PG7kyuw3WjAP9wKlxy8IvFJwB8asOFT1xPFoVfZYKIogjG9oLP5YiRtfvDkWIztHmg5MlVv3HdJDGRw==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-sts": "^3.587.0" - } - }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.577.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.577.0.tgz", - "integrity": "sha512-9ca5MJz455CODIVXs0/sWmJm7t3QO4EUa1zf8pE8grLpzf0J94bz/skDWm37Pli13T3WaAQBHCTiH2gUVfCsWg==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.577.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.577.0.tgz", - "integrity": "sha512-aPFGpGjTZcJYk+24bg7jT4XdIp42mFXSuPt49lw5KygefLyJM/sB0bKKqPYYivW0rcuZ9brQ58eZUNthrzYAvg==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.577.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.577.0.tgz", - "integrity": "sha512-pn3ZVEd2iobKJlR3H+bDilHjgRnNrQ6HMmK9ZzZw89Ckn3Dcbv48xOv4RJvu0aU8SDLl/SNCxppKjeLDTPGBNA==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.587.0.tgz", - "integrity": "sha512-SyDomN+IOrygLucziG7/nOHkjUXES5oH5T7p8AboO8oakMQJdnudNXiYWTicQWO52R51U6CR27rcMPTGeMedYA==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@aws-sdk/util-endpoints": "3.587.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.587.0.tgz", - "integrity": "sha512-93I7IPZtulZQoRK+O20IJ4a1syWwYPzoO2gc3v+/GNZflZPV3QJXuVbIm0pxBsu0n/mzKGUKqSOLPIaN098HcQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/types": "^3.0.0", - "@smithy/util-config-provider": "^3.0.0", - "@smithy/util-middleware": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.587.0.tgz", - "integrity": "sha512-ULqhbnLy1hmJNRcukANBWJmum3BbjXnurLPSFXoGdV0llXYlG55SzIla2VYqdveQEEjmsBuTZdFvXAtNpmS5Zg==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/shared-ini-file-loader": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-sso-oidc": "^3.587.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.577.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.577.0.tgz", - "integrity": "sha512-FT2JZES3wBKN/alfmhlo+3ZOq/XJ0C7QOZcDNrpKjB0kqYoKjhVKZ/Hx6ArR0czkKfHzBBEs6y40ebIHx2nSmA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.587.0.tgz", - "integrity": "sha512-8I1HG6Em8wQWqKcRW6m358mqebRVNpL8XrrEoT4In7xqkKkmYtHRNVYP6lcmiQh5pZ/c/FXu8dSchuFIWyEtqQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/types": "^3.0.0", - "@smithy/util-endpoints": "^2.0.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.568.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.568.0.tgz", - "integrity": "sha512-3nh4TINkXYr+H41QaPelCceEB2FXP3fxp93YZXB/kqJvX0U9j0N0Uk45gvsjmEPzG8XxkPEeLIfT2I1M7A6Lig==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.577.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.577.0.tgz", - "integrity": "sha512-zEAzHgR6HWpZOH7xFgeJLc6/CzMcx4nxeQolZxVZoB5pPaJd3CjyRhZN0xXeZB0XIRCWmb4yJBgyiugXLNMkLA==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/types": "^3.0.0", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.587.0.tgz", - "integrity": "sha512-Pnl+DUe/bvnbEEDHP3iVJrOtE3HbFJBPgsD6vJ+ml/+IYk1Eq49jEG+EHZdNTPz3SDG0kbp2+7u41MKYJHR/iQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/util-utf8-browser": { - "version": "3.259.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz", - "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.3.1" - } - }, - "node_modules/@iarna/toml": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", - "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", - "dev": true - }, - "node_modules/@kwsites/file-exists": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", - "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", - "dev": true, - "peer": true, - "dependencies": { - "debug": "^4.1.1" - } - }, - "node_modules/@kwsites/promise-deferred": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", - "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", - "dev": true, - "peer": true - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "peer": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "peer": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@serverless/dashboard-plugin": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/@serverless/dashboard-plugin/-/dashboard-plugin-7.2.3.tgz", - "integrity": "sha512-Vu4TKJLEQ5F8ZipfCvd8A/LMIdH8kNGe448sX9mT4/Z0JVUaYmMc3BwkQ+zkNIh3QdBKAhocGn45TYjHV6uPWQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/client-cloudformation": "^3.410.0", - "@aws-sdk/client-sts": "^3.410.0", - "@serverless/event-mocks": "^1.1.1", - "@serverless/platform-client": "^4.5.1", - "@serverless/utils": "^6.14.0", - "child-process-ext": "^3.0.1", - "chokidar": "^3.5.3", - "flat": "^5.0.2", - "fs-extra": "^9.1.0", - "js-yaml": "^4.1.0", - "jszip": "^3.10.1", - "lodash": "^4.17.21", - "memoizee": "^0.4.15", - "ncjsm": "^4.3.2", - "node-dir": "^0.1.17", - "node-fetch": "^2.6.8", - "open": "^7.4.2", - "semver": "^7.3.8", - "simple-git": "^3.16.0", - "timers-ext": "^0.1.7", - "type": "^2.7.2", - "uuid": "^8.3.2", - "yamljs": "^0.3.0" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/child-process-ext": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/child-process-ext/-/child-process-ext-3.0.2.tgz", - "integrity": "sha512-oBePsLbQpTJFxzwyCvs9yWWF0OEM6vGGepHwt1stqmX7QQqOuDc8j2ywdvAs9Tvi44TT7d9ackqhR4Q10l1u8w==", - "dev": true, - "peer": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "es5-ext": "^0.10.62", - "log": "^6.3.1", - "split2": "^3.2.2", - "stream-promise": "^3.2.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "peer": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "peer": true, - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/open": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", - "dev": true, - "peer": true, - "dependencies": { - "is-docker": "^2.0.0", - "is-wsl": "^2.1.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "peer": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "peer": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "peer": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@serverless/event-mocks": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@serverless/event-mocks/-/event-mocks-1.1.1.tgz", - "integrity": "sha512-YAV5V/y+XIOfd+HEVeXfPWZb8C6QLruFk9tBivoX2roQLWVq145s4uxf8D0QioCueuRzkukHUS4JIj+KVoS34A==", - "dev": true, - "peer": true, - "dependencies": { - "@types/lodash": "^4.14.123", - "lodash": "^4.17.11" - } - }, - "node_modules/@serverless/platform-client": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@serverless/platform-client/-/platform-client-4.5.1.tgz", - "integrity": "sha512-XltmO/029X76zi0LUFmhsnanhE2wnqH1xf+WBt5K8gumQA9LnrfwLgPxj+VA+mm6wQhy+PCp7H5SS0ZPu7F2Cw==", - "dev": true, - "peer": true, - "dependencies": { - "adm-zip": "^0.5.5", - "archiver": "^5.3.0", - "axios": "^1.6.2", - "fast-glob": "^3.2.7", - "https-proxy-agent": "^5.0.0", - "ignore": "^5.1.8", - "isomorphic-ws": "^4.0.1", - "js-yaml": "^3.14.1", - "jwt-decode": "^2.2.0", - "minimatch": "^3.0.4", - "querystring": "^0.2.1", - "run-parallel-limit": "^1.1.0", - "throat": "^5.0.0", - "traverse": "^0.6.6", - "ws": "^7.5.3" - }, - "engines": { - "node": ">=10.0" - } - }, - "node_modules/@serverless/platform-client/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "peer": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@serverless/platform-client/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "peer": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@serverless/utils": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@serverless/utils/-/utils-6.15.0.tgz", - "integrity": "sha512-7eDbqKv/OBd11jjdZjUwFGN8sHWkeUqLeHXHQxQ1azja2IM7WIH+z/aLgzR6LhB3/MINNwtjesDpjGqTMj2JKQ==", - "dev": true, - "peer": true, - "dependencies": { - "archive-type": "^4.0.0", - "chalk": "^4.1.2", - "ci-info": "^3.8.0", - "cli-progress-footer": "^2.3.2", - "content-disposition": "^0.5.4", - "d": "^1.0.1", - "decompress": "^4.2.1", - "event-emitter": "^0.3.5", - "ext": "^1.7.0", - "ext-name": "^5.0.0", - "file-type": "^16.5.4", - "filenamify": "^4.3.0", - "get-stream": "^6.0.1", - "got": "^11.8.6", - "inquirer": "^8.2.5", - "js-yaml": "^4.1.0", - "jwt-decode": "^3.1.2", - "lodash": "^4.17.21", - "log": "^6.3.1", - "log-node": "^8.0.3", - "make-dir": "^4.0.0", - "memoizee": "^0.4.15", - "ms": "^2.1.3", - "ncjsm": "^4.3.2", - "node-fetch": "^2.6.11", - "open": "^8.4.2", - "p-event": "^4.2.0", - "supports-color": "^8.1.1", - "timers-ext": "^0.1.7", - "type": "^2.7.2", - "uni-global": "^1.0.0", - "uuid": "^8.3.2", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/@serverless/utils/node_modules/jwt-decode": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", - "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==", - "dev": true, - "peer": true - }, - "node_modules/@serverless/utils/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "peer": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@smithy/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-p6GlFGBt9K4MYLu72YuJ523NVR4A8oHlC5M2JO6OmQqN8kAc/uh1JqLE+FizTokrSJGg0CSvC+BrsmGzKtsZKA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/config-resolver": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-3.0.1.tgz", - "integrity": "sha512-hbkYJc20SBDz2qqLzttjI/EqXemtmWk0ooRznLsiXp3066KQRTvuKHa7U4jCZCJq6Dozqvy0R1/vNESC9inPJg==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/node-config-provider": "^3.1.0", - "@smithy/types": "^3.0.0", - "@smithy/util-config-provider": "^3.0.0", - "@smithy/util-middleware": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/core": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-2.1.1.tgz", - "integrity": "sha512-0vbIwwUcg0FMhTVJgMhbsRSAFL0rwduy/OQz7Xq1pJXJOyaGv+PGjj1iGawRlzBUPA5BkJv7S6q+YU2U8gk/WA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/middleware-endpoint": "^3.0.1", - "@smithy/middleware-retry": "^3.0.3", - "@smithy/middleware-serde": "^3.0.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "@smithy/util-middleware": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-3.1.0.tgz", - "integrity": "sha512-q4A4d38v8pYYmseu/jTS3Z5I3zXlEOe5Obi+EJreVKgSVyWUHOd7/yaVCinC60QG4MRyCs98tcxBH1IMC0bu7Q==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/node-config-provider": "^3.1.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/types": "^3.0.0", - "@smithy/url-parser": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-3.0.1.tgz", - "integrity": "sha512-uaH74i5BDj+rBwoQaXioKpI0SHBJFtOVwzrCpxZxphOW0ki5jhj7dXvDMYM2IJem8TpdFvS2iC08sjOblfFGFg==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/protocol-http": "^4.0.0", - "@smithy/querystring-builder": "^3.0.0", - "@smithy/types": "^3.0.0", - "@smithy/util-base64": "^3.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@smithy/hash-node": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-3.0.0.tgz", - "integrity": "sha512-84qXstNemP3XS5jcof0el6+bDfjzuvhJPQTEfro3lgtbCtKgzPm3MgiS6ehXVPjeQ5+JS0HqmTz8f/RYfzHVxw==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "@smithy/util-buffer-from": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-3.0.0.tgz", - "integrity": "sha512-F6wBBaEFgJzj0s4KUlliIGPmqXemwP6EavgvDqYwCH40O5Xr2iMHvS8todmGVZtuJCorBkXsYLyTu4PuizVq5g==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-3.0.0.tgz", - "integrity": "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-3.0.0.tgz", - "integrity": "sha512-3C4s4d/iGobgCtk2tnWW6+zSTOBg1PRAm2vtWZLdriwTroFbbWNSr3lcyzHdrQHnEXYCC5K52EbpfodaIUY8sg==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/protocol-http": "^4.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-3.0.1.tgz", - "integrity": "sha512-lQ/UOdGD4KM5kLZiAl0q8Qy3dPbynvAXKAdXnYlrA1OpaUwr+neSsVokDZpY6ZVb5Yx8jnus29uv6XWpM9P4SQ==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/middleware-serde": "^3.0.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/shared-ini-file-loader": "^3.1.0", - "@smithy/types": "^3.0.0", - "@smithy/url-parser": "^3.0.0", - "@smithy/util-middleware": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-3.0.3.tgz", - "integrity": "sha512-Wve1qzJb83VEU/6q+/I0cQdAkDnuzELC6IvIBwDzUEiGpKqXgX1v10FUuZGbRS6Ov/P+HHthcAoHOJZQvZNAkA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/node-config-provider": "^3.1.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/service-error-classification": "^3.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "@smithy/util-middleware": "^3.0.0", - "@smithy/util-retry": "^3.0.0", - "tslib": "^2.6.2", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-3.0.0.tgz", - "integrity": "sha512-I1vKG1foI+oPgG9r7IMY1S+xBnmAn1ISqployvqkwHoSb8VPsngHDTOgYGYBonuOKndaWRUGJZrKYYLB+Ane6w==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-3.0.0.tgz", - "integrity": "sha512-+H0jmyfAyHRFXm6wunskuNAqtj7yfmwFB6Fp37enytp2q047/Od9xetEaUbluyImOlGnGpaVGaVfjwawSr+i6Q==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-3.1.0.tgz", - "integrity": "sha512-ngfB8QItUfTFTfHMvKuc2g1W60V1urIgZHqD1JNFZC2tTWXahqf2XvKXqcBS7yZqR7GqkQQZy11y/lNOUWzq7Q==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/property-provider": "^3.1.0", - "@smithy/shared-ini-file-loader": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-3.0.0.tgz", - "integrity": "sha512-3trD4r7NOMygwLbUJo4eodyQuypAWr7uvPnebNJ9a70dQhVn+US8j/lCnvoJS6BXfZeF7PkkkI0DemVJw+n+eQ==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/abort-controller": "^3.0.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/querystring-builder": "^3.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-3.1.0.tgz", - "integrity": "sha512-Tj3+oVhqdZgemjCiWjFlADfhvLF4C/uKDuKo7/tlEsRQ9+3emCreR2xndj970QSRSsiCEU8hZW3/8JQu+n5w4Q==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-4.0.0.tgz", - "integrity": "sha512-qOQZOEI2XLWRWBO9AgIYuHuqjZ2csyr8/IlgFDHDNuIgLAMRx2Bl8ck5U5D6Vh9DPdoaVpuzwWMa0xcdL4O/AQ==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-3.0.0.tgz", - "integrity": "sha512-bW8Fi0NzyfkE0TmQphDXr1AmBDbK01cA4C1Z7ggwMAU5RDz5AAv/KmoRwzQAS0kxXNf/D2ALTEgwK0U2c4LtRg==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "@smithy/util-uri-escape": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/querystring-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-3.0.0.tgz", - "integrity": "sha512-UzHwthk0UEccV4dHzPySnBy34AWw3V9lIqUTxmozQ+wPDAO9csCWMfOLe7V9A2agNYy7xE+Pb0S6K/J23JSzfQ==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/service-error-classification": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-3.0.0.tgz", - "integrity": "sha512-3BsBtOUt2Gsnc3X23ew+r2M71WwtpHfEDGhHYHSDg6q1t8FrWh15jT25DLajFV1H+PpxAJ6gqe9yYeRUsmSdFA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-3.1.0.tgz", - "integrity": "sha512-dAM7wSX0NR3qTNyGVN/nwwpEDzfV9T/3AN2eABExWmda5VqZKSsjlINqomO5hjQWGv+IIkoXfs3u2vGSNz8+Rg==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-3.0.0.tgz", - "integrity": "sha512-kXFOkNX+BQHe2qnLxpMEaCRGap9J6tUGLzc3A9jdn+nD4JdMwCKTJ+zFwQ20GkY+mAXGatyTw3HcoUlR39HwmA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/is-array-buffer": "^3.0.0", - "@smithy/types": "^3.0.0", - "@smithy/util-hex-encoding": "^3.0.0", - "@smithy/util-middleware": "^3.0.0", - "@smithy/util-uri-escape": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-3.1.1.tgz", - "integrity": "sha512-tj4Ku7MpzZR8cmVuPcSbrLFVxmptWktmJMwST/uIEq4sarabEdF8CbmQdYB7uJ/X51Qq2EYwnRsoS7hdR4B7rA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/middleware-endpoint": "^3.0.1", - "@smithy/middleware-stack": "^3.0.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/types": "^3.0.0", - "@smithy/util-stream": "^3.0.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-3.0.0.tgz", - "integrity": "sha512-VvWuQk2RKFuOr98gFhjca7fkBS+xLLURT8bUjk5XQoV0ZLm7WPwWPPY3/AwzTLuUBDeoKDCthfe1AsTUWaSEhw==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-3.0.0.tgz", - "integrity": "sha512-2XLazFgUu+YOGHtWihB3FSLAfCUajVfNBXGGYjOaVKjLAuAxx3pSBY3hBgLzIgB17haf59gOG3imKqTy8mcrjw==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/querystring-parser": "^3.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@smithy/util-base64": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-3.0.0.tgz", - "integrity": "sha512-Kxvoh5Qtt0CDsfajiZOCpJxgtPHXOKwmM+Zy4waD43UoEMA+qPxxa98aE/7ZhdnBFZFXMOiBR5xbcaMhLtznQQ==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/util-buffer-from": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-body-length-browser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-3.0.0.tgz", - "integrity": "sha512-cbjJs2A1mLYmqmyVl80uoLTJhAcfzMOyPgjwAYusWKMdLeNtzmMz9YxNl3/jRLoxSS3wkqkf0jwNdtXWtyEBaQ==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@smithy/util-body-length-node": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-3.0.0.tgz", - "integrity": "sha512-Tj7pZ4bUloNUP6PzwhN7K386tmSmEET9QtQg0TgdNOnxhZvCssHji+oZTUIuzxECRfG8rdm2PMw2WCFs6eIYkA==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-3.0.0.tgz", - "integrity": "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/is-array-buffer": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-config-provider": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-3.0.0.tgz", - "integrity": "sha512-pbjk4s0fwq3Di/ANL+rCvJMKM5bzAQdE5S/6RL5NXgMExFAi6UgQMPOm5yPaIWPpr+EOXKXRonJ3FoxKf4mCJQ==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-3.0.3.tgz", - "integrity": "sha512-3DFON2bvXJAukJe+qFgPV/rorG7ZD3m4gjCXHD1V5z/tgKQp5MCTCLntrd686tX6tj8Uli3lefWXJudNg5WmCA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/property-provider": "^3.1.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-3.0.3.tgz", - "integrity": "sha512-D0b8GJXecT00baoSQ3Iieu3k3mZ7GY8w1zmg8pdogYrGvWJeLcIclqk2gbkG4K0DaBGWrO6v6r20iwIFfDYrmA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/config-resolver": "^3.0.1", - "@smithy/credential-provider-imds": "^3.1.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@smithy/util-endpoints": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-2.0.1.tgz", - "integrity": "sha512-ZRT0VCOnKlVohfoABMc8lWeQo/JEFuPWctfNRXgTHbyOVssMOLYFUNWukxxiHRGVAhV+n3c0kPW+zUqckjVPEA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/node-config-provider": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-hex-encoding": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-3.0.0.tgz", - "integrity": "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-3.0.0.tgz", - "integrity": "sha512-q5ITdOnV2pXHSVDnKWrwgSNTDBAMHLptFE07ua/5Ty5WJ11bvr0vk2a7agu7qRhrCFRQlno5u3CneU5EELK+DQ==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-retry": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-3.0.0.tgz", - "integrity": "sha512-nK99bvJiziGv/UOKJlDvFF45F00WgPLKVIGUfAK+mDhzVN2hb/S33uW2Tlhg5PVBoqY7tDVqL0zmu4OxAHgo9g==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/service-error-classification": "^3.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-stream": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-3.0.1.tgz", - "integrity": "sha512-7F7VNNhAsfMRA8I986YdOY5fE0/T1/ZjFF6OLsqkvQVNP3vZ/szYDfGCyphb7ioA09r32K/0qbSFfNFU68aSzA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/fetch-http-handler": "^3.0.1", - "@smithy/node-http-handler": "^3.0.0", - "@smithy/types": "^3.0.0", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-buffer-from": "^3.0.0", - "@smithy/util-hex-encoding": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-uri-escape": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-3.0.0.tgz", - "integrity": "sha512-LqR7qYLgZTD7nWLBecUi4aqolw8Mhza9ArpNEQ881MJJIU2sE5iHCK6TdyqqzcDLy0OPe10IY4T8ctVdtynubg==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", - "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/util-buffer-from": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-waiter": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-3.0.0.tgz", - "integrity": "sha512-+fEXJxGDLCoqRKVSmo0auGxaqbiCo+8oph+4auefYjaNxjOLKSY2MxVQfRzo65PaZv4fr+5lWg+au7vSuJJ/zw==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/abort-controller": "^3.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@szmarczak/http-timer": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "dev": true, - "peer": true, - "dependencies": { - "defer-to-connect": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", - "dev": true, - "peer": true - }, - "node_modules/@types/cacheable-request": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dev": true, - "peer": true, - "dependencies": { - "@types/http-cache-semantics": "*", - "@types/keyv": "^3.1.4", - "@types/node": "*", - "@types/responselike": "^1.0.0" - } - }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", - "dev": true, - "peer": true - }, - "node_modules/@types/keyv": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "dev": true, - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/lodash": { - "version": "4.17.4", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.4.tgz", - "integrity": "sha512-wYCP26ZLxaT3R39kiN2+HcJ4kTd3U1waI/cY7ivWYqFP6pW3ZNpvi6Wd6PHZx7T/t8z0vlkXMg3QYLa7DZ/IJQ==", - "dev": true, - "peer": true - }, - "node_modules/@types/node": { - "version": "20.14.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.1.tgz", - "integrity": "sha512-T2MzSGEu+ysB/FkWfqmhV3PLyQlowdptmmgD20C6QxsS8Fmv5SjpZ1ayXaEC0S21/h5UJ9iA6W/5vSNU5l00OA==", - "dev": true, - "peer": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/responselike": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", - "dev": true, - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/2-thenable": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/2-thenable/-/2-thenable-1.0.0.tgz", - "integrity": "sha512-HqiDzaLDFCXkcCO/SwoyhRwqYtINFHF7t9BDRq4x90TOKNAJpiqUt9X5lQ08bwxYzc067HUywDjGySpebHcUpw==", - "dev": true, - "dependencies": { - "d": "1", - "es5-ext": "^0.10.47" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, - "peer": true, - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/adm-zip": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.13.tgz", - "integrity": "sha512-4U51tTl9J8UVEcuKGr6zRzY95tWoAa9l+ureGBNmsfleszjZblm5NyEEL/ZQxkhi86co5mZhSvL2T7gkZ6feYQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "peer": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/ajv": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.15.0.tgz", - "integrity": "sha512-15BTtQUOsSrmHCy+B4VnAiJAJxJ8IFgu6fcjFQF3jQYZ78nLSQthlFg4ehp+NLIyfvFgOlxNsjKIEhydtFPVHQ==", - "dev": true, - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^2.3.0", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "peer": true, - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "peer": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/appdirectory": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/appdirectory/-/appdirectory-0.1.0.tgz", - "integrity": "sha512-DJ5DV8vZXBbusyiyPlH28xppwS8eAMRuuyMo88xeEcf4bV64lbLtbxRxqixZuJBXsZzLtXFmA13GwVjJc7vdQw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true - }, - "node_modules/archive-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/archive-type/-/archive-type-4.0.0.tgz", - "integrity": "sha512-zV4Ky0v1F8dBrdYElwTvQhweQ0P7Kwc1aluqJsYtOBP01jXcWCyW2IEfI1YiqsG+Iy7ZR+o5LF1N+PGECBxHWA==", - "dev": true, - "peer": true, - "dependencies": { - "file-type": "^4.2.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/archive-type/node_modules/file-type": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-4.4.0.tgz", - "integrity": "sha512-f2UbFQEk7LXgWpi5ntcO86OeA/cC80fuDDDaX/fZ2ZGel+AF7leRQqBBW1eJNiiQkrZlAoM6P+VYP5P6bOlDEQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/archiver": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", - "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", - "dev": true, - "peer": true, - "dependencies": { - "archiver-utils": "^2.1.0", - "async": "^3.2.4", - "buffer-crc32": "^0.2.1", - "readable-stream": "^3.6.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^2.2.0", - "zip-stream": "^4.1.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/archiver-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", - "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", - "dev": true, - "peer": true, - "dependencies": { - "glob": "^7.1.4", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^2.0.0" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/archiver-utils/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/archiver-utils/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "peer": true - }, - "node_modules/archiver-utils/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "peer": true - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", - "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.5", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", - "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", - "dev": true, - "peer": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.5", - "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.2.1", - "get-intrinsic": "^1.2.3", - "is-array-buffer": "^3.0.4", - "is-shared-array-buffer": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true, - "peer": true - }, - "node_modules/async": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", - "dev": true, - "peer": true - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "peer": true - }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "peer": true, - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/aws-sdk": { - "version": "2.1633.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1633.0.tgz", - "integrity": "sha512-9OUTdosQgZO6P6hPyEOUDvm4cymbMB0Qn1etucPql95DHjNyIex3FJOBbeRikU+5Wt9Djgdcr+BqAee2Lv/7iw==", - "dev": true, - "hasInstallScript": true, - "peer": true, - "dependencies": { - "buffer": "4.9.2", - "events": "1.1.1", - "ieee754": "1.1.13", - "jmespath": "0.16.0", - "querystring": "0.2.0", - "sax": "1.2.1", - "url": "0.10.3", - "util": "^0.12.4", - "uuid": "8.0.0", - "xml2js": "0.6.2" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/aws-sdk/node_modules/querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", - "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/aws-sdk/node_modules/uuid": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", - "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", - "dev": true, - "peer": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", - "dev": true, - "peer": true, - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "peer": true, - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true - }, - "node_modules/bowser": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", - "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", - "dev": true, - "peer": true - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "peer": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", - "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", - "dev": true, - "peer": true, - "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4", - "isarray": "^1.0.0" - } - }, - "node_modules/buffer-alloc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", - "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", - "dev": true, - "peer": true, - "dependencies": { - "buffer-alloc-unsafe": "^1.1.0", - "buffer-fill": "^1.0.0" - } - }, - "node_modules/buffer-alloc-unsafe": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", - "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", - "dev": true, - "peer": true - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "peer": true, - "engines": { - "node": "*" - } - }, - "node_modules/buffer-fill": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", - "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", - "dev": true, - "peer": true - }, - "node_modules/builtin-modules": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", - "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/builtins": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", - "integrity": "sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==", - "dev": true, - "peer": true - }, - "node_modules/cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10.6.0" - } - }, - "node_modules/cacheable-request": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", - "dev": true, - "peer": true, - "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cacheable-request/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "peer": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cachedir": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", - "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dev": true, - "peer": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true, - "peer": true - }, - "node_modules/child-process-ext": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/child-process-ext/-/child-process-ext-2.1.1.tgz", - "integrity": "sha512-0UQ55f51JBkOFa+fvR76ywRzxiPwQS3Xe8oe5bZRphpv+dIMeerW5Zn5e4cUy4COJwVtJyU0R79RMnw+aCqmGA==", - "dev": true, - "dependencies": { - "cross-spawn": "^6.0.5", - "es5-ext": "^0.10.53", - "log": "^6.0.0", - "split2": "^3.1.1", - "stream-promise": "^3.2.0" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "peer": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-color": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-2.0.4.tgz", - "integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==", - "dev": true, - "peer": true, - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.64", - "es6-iterator": "^2.0.3", - "memoizee": "^0.4.15", - "timers-ext": "^0.1.7" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "peer": true, - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-progress-footer": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/cli-progress-footer/-/cli-progress-footer-2.3.3.tgz", - "integrity": "sha512-p+hyTPxSZWG1c3Qy1DLBoGZhpeA3Y6AMlKrtbGpMMSKpezbSLel8gW4e5You4FNlHb3wS/M1JU594OAWe/Totg==", - "dev": true, - "peer": true, - "dependencies": { - "cli-color": "^2.0.4", - "d": "^1.0.1", - "es5-ext": "^0.10.64", - "mute-stream": "0.0.8", - "process-utils": "^4.0.0", - "timers-ext": "^0.1.7", - "type": "^2.7.2" - }, - "engines": { - "node": ">=10.0" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-sprintf-format": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cli-sprintf-format/-/cli-sprintf-format-1.1.1.tgz", - "integrity": "sha512-BbEjY9BEdA6wagVwTqPvmAwGB24U93rQPBFZUT8lNCDxXzre5LFHQUTJc70czjgUomVg8u8R5kW8oY9DYRFNeg==", - "dev": true, - "peer": true, - "dependencies": { - "cli-color": "^2.0.1", - "es5-ext": "^0.10.53", - "sprintf-kit": "^2.0.1", - "supports-color": "^6.1.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/cli-sprintf-format/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/cli-sprintf-format/node_modules/supports-color": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", - "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", - "dev": true, - "peer": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-width": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/clone-response": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", - "dev": true, - "peer": true, - "dependencies": { - "mimic-response": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "peer": true, - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/component-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "dev": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/compress-commons": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", - "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", - "dev": true, - "peer": true, - "dependencies": { - "buffer-crc32": "^0.2.13", - "crc32-stream": "^4.0.2", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "peer": true, - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookiejar": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true, - "peer": true - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true - }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "dev": true, - "peer": true, - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/crc32-stream": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", - "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", - "dev": true, - "peer": true, - "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^3.4.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", - "dev": true, - "dependencies": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - }, - "engines": { - "node": ">=4.8" - } - }, - "node_modules/cross-spawn/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/d": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", - "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", - "dev": true, - "dependencies": { - "es5-ext": "^0.10.64", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/data-view-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", - "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", - "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", - "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/dayjs": { - "version": "1.11.11", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", - "integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==", - "dev": true, - "peer": true - }, - "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dev": true, - "peer": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/debug/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true, - "peer": true - }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decompress": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", - "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", - "dev": true, - "peer": true, - "dependencies": { - "decompress-tar": "^4.0.0", - "decompress-tarbz2": "^4.0.0", - "decompress-targz": "^4.0.0", - "decompress-unzip": "^4.0.1", - "graceful-fs": "^4.1.10", - "make-dir": "^1.0.0", - "pify": "^2.3.0", - "strip-dirs": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "peer": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-tar": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", - "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", - "dev": true, - "peer": true, - "dependencies": { - "file-type": "^5.2.0", - "is-stream": "^1.1.0", - "tar-stream": "^1.5.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-tar/node_modules/bl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", - "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", - "dev": true, - "peer": true, - "dependencies": { - "readable-stream": "^2.3.5", - "safe-buffer": "^5.1.1" - } - }, - "node_modules/decompress-tar/node_modules/file-type": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", - "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-tar/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/decompress-tar/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "peer": true - }, - "node_modules/decompress-tar/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/decompress-tar/node_modules/tar-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", - "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", - "dev": true, - "peer": true, - "dependencies": { - "bl": "^1.0.0", - "buffer-alloc": "^1.2.0", - "end-of-stream": "^1.0.0", - "fs-constants": "^1.0.0", - "readable-stream": "^2.3.0", - "to-buffer": "^1.1.1", - "xtend": "^4.0.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/decompress-tarbz2": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", - "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", - "dev": true, - "peer": true, - "dependencies": { - "decompress-tar": "^4.1.0", - "file-type": "^6.1.0", - "is-stream": "^1.1.0", - "seek-bzip": "^1.0.5", - "unbzip2-stream": "^1.0.9" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-tarbz2/node_modules/file-type": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", - "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-targz": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", - "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", - "dev": true, - "peer": true, - "dependencies": { - "decompress-tar": "^4.1.1", - "file-type": "^5.2.0", - "is-stream": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-targz/node_modules/file-type": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", - "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-unzip": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", - "integrity": "sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==", - "dev": true, - "peer": true, - "dependencies": { - "file-type": "^3.8.0", - "get-stream": "^2.2.0", - "pify": "^2.3.0", - "yauzl": "^2.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-unzip/node_modules/file-type": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", - "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decompress-unzip/node_modules/get-stream": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", - "integrity": "sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==", - "dev": true, - "peer": true, - "dependencies": { - "object-assign": "^4.0.1", - "pinkie-promise": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decompress/node_modules/make-dir": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", - "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", - "dev": true, - "peer": true, - "dependencies": { - "pify": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress/node_modules/make-dir/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "peer": true, - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/deferred": { - "version": "0.7.11", - "resolved": "https://registry.npmjs.org/deferred/-/deferred-0.7.11.tgz", - "integrity": "sha512-8eluCl/Blx4YOGwMapBvXRKxHXhA8ejDXYzEaK8+/gtcm8hRMhSLmXSqDmNUKNc/C8HNSmuyyp/hflhqDAvK2A==", - "dev": true, - "peer": true, - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.50", - "event-emitter": "^0.3.5", - "next-tick": "^1.0.0", - "timers-ext": "^0.1.7" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "peer": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "peer": true, - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dev": true, - "peer": true, - "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "peer": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", - "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/duration": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/duration/-/duration-0.2.2.tgz", - "integrity": "sha512-06kgtea+bGreF5eKYgI/36A6pLXggY7oR4p1pq4SmdFBn1ReOL5D8RhG64VrqfTTKNucqqtBAwEj8aB88mcqrg==", - "dev": true, - "dependencies": { - "d": "1", - "es5-ext": "~0.10.46" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, - "peer": true, - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/es-abstract": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", - "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", - "dev": true, - "peer": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "arraybuffer.prototype.slice": "^1.0.3", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "data-view-buffer": "^1.0.1", - "data-view-byte-length": "^1.0.1", - "data-view-byte-offset": "^1.0.0", - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-set-tostringtag": "^2.0.3", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.4", - "get-symbol-description": "^1.0.2", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.0.3", - "has-symbols": "^1.0.3", - "hasown": "^2.0.2", - "internal-slot": "^1.0.7", - "is-array-buffer": "^3.0.4", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.1", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.3", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.13", - "is-weakref": "^1.0.2", - "object-inspect": "^1.13.1", - "object-keys": "^1.1.1", - "object.assign": "^4.1.5", - "regexp.prototype.flags": "^1.5.2", - "safe-array-concat": "^1.1.2", - "safe-regex-test": "^1.0.3", - "string.prototype.trim": "^1.2.9", - "string.prototype.trimend": "^1.0.8", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-length": "^1.0.1", - "typed-array-byte-offset": "^1.0.2", - "typed-array-length": "^1.0.6", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.15" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dev": true, - "peer": true, - "dependencies": { - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, - "peer": true, - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", - "dev": true, - "peer": true, - "dependencies": { - "get-intrinsic": "^1.2.4", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, - "peer": true, - "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "dev": true, - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-set": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.6.tgz", - "integrity": "sha512-TE3LgGLDIBX332jq3ypv6bcOpkLO0AslAQo7p2VqX/1N46YNsvIWgvjojjSEnWEGWMhr1qUbYeTSir5J6mFHOw==", - "dev": true, - "peer": true, - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "es6-iterator": "~2.0.3", - "es6-symbol": "^3.1.3", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", - "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", - "dev": true, - "dependencies": { - "d": "^1.0.2", - "ext": "^1.7.0" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/es6-weak-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", - "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", - "dev": true, - "peer": true, - "dependencies": { - "d": "1", - "es5-ext": "^0.10.46", - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "dev": true, - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "peer": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/essentials": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/essentials/-/essentials-1.2.0.tgz", - "integrity": "sha512-kP/j7Iw7KeNE8b/o7+tr9uX2s1wegElGOoGZ2Xm35qBr4BbbEcH3/bxR2nfH9l9JANCq9AUrvKw+gRuHtZp0HQ==", - "dev": true, - "peer": true, - "dependencies": { - "uni-global": "^1.0.0" - } - }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "dev": true, - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/events": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", - "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "dev": true, - "dependencies": { - "type": "^2.7.2" - } - }, - "node_modules/ext-list": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", - "integrity": "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==", - "dev": true, - "peer": true, - "dependencies": { - "mime-db": "^1.28.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ext-name": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz", - "integrity": "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==", - "dev": true, - "peer": true, - "dependencies": { - "ext-list": "^2.0.0", - "sort-keys-length": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "peer": true, - "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "peer": true - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "peer": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, - "peer": true - }, - "node_modules/fast-uri": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.3.0.tgz", - "integrity": "sha512-eel5UKGn369gGEWOqBShmFJWfq/xSJvsgDzgLYC845GneayWvXBf0lJCBn5qTABfewy1ZDPoaR5OZCP+kssfuw==", - "dev": true, - "peer": true - }, - "node_modules/fast-xml-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz", - "integrity": "sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g==", - "dev": true, - "funding": [ - { - "type": "paypal", - "url": "https://paypal.me/naturalintelligence" - }, - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "peer": true, - "dependencies": { - "strnum": "^1.0.5" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/fastest-levenshtein": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", - "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 4.9.1" - } - }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, - "peer": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "peer": true, - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "peer": true, - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/file-type": { - "version": "16.5.4", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", - "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", - "dev": true, - "peer": true, - "dependencies": { - "readable-web-to-node-stream": "^3.0.0", - "strtok3": "^6.2.4", - "token-types": "^4.1.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, - "node_modules/filename-reserved-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", - "integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/filenamify": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.3.0.tgz", - "integrity": "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==", - "dev": true, - "peer": true, - "dependencies": { - "filename-reserved-regex": "^2.0.0", - "strip-outer": "^1.0.1", - "trim-repeated": "^1.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/filesize": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.2.tgz", - "integrity": "sha512-Dx770ai81ohflojxhU+oG+Z2QGvKdYxgEr9OSA8UVrqhwNHjfH9A8f5NKfg83fEH8ZFA5N5llJo5T3PIoZ4CRA==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 10.4.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "peer": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-requires": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/find-requires/-/find-requires-1.0.0.tgz", - "integrity": "sha512-UME7hNwBfzeISSFQcBEDemEEskpOjI/shPrpJM5PI4DSdn6hX0dmz+2dL70blZER2z8tSnTRL+2rfzlYgtbBoQ==", - "dev": true, - "peer": true, - "dependencies": { - "es5-ext": "^0.10.49", - "esniff": "^1.1.0" - }, - "bin": { - "find-requires": "bin/find-requires.js" - } - }, - "node_modules/find-requires/node_modules/esniff": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-1.1.3.tgz", - "integrity": "sha512-SLBLpfE7xWgF/HbzhVuAwqnJDRqSCNZqcqaIMVm+f+PbTp1kFRWu6BuT83SATb4Tp+ovr+S+u7vDH7/UErAOkw==", - "dev": true, - "peer": true, - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "peer": true, - "bin": { - "flat": "cli.js" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "peer": true, - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dev": true, - "peer": true, - "dependencies": { - "is-callable": "^1.1.3" - } - }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "peer": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/formidable": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", - "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==", - "dev": true, - "peer": true, - "dependencies": { - "dezalgo": "^1.0.4", - "hexoid": "^1.0.0", - "once": "^1.4.0", - "qs": "^6.11.0" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "peer": true - }, - "node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "peer": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "peer": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fs2": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/fs2/-/fs2-0.3.9.tgz", - "integrity": "sha512-WsOqncODWRlkjwll+73bAxVW3JPChDgaPX3DT4iTTm73UmG4VgALa7LaFblP232/DN60itkOrPZ8kaP1feksGQ==", - "dev": true, - "peer": true, - "dependencies": { - "d": "^1.0.1", - "deferred": "^0.7.11", - "es5-ext": "^0.10.53", - "event-emitter": "^0.3.5", - "ignore": "^5.1.8", - "memoizee": "^0.4.14", - "type": "^2.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", - "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "functions-have-names": "^1.2.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dev": true, - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-stdin": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", - "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-symbol-description": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", - "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-all": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/glob-all/-/glob-all-3.3.1.tgz", - "integrity": "sha512-Y+ESjdI7ZgMwfzanHZYQ87C59jOO0i+Hd+QYtVt9PhLi6d8wlOpzQnfBxWUlaTuAoR3TkybLqqbIoWveU4Ji7Q==", - "dev": true, - "dependencies": { - "glob": "^7.2.3", - "yargs": "^15.3.1" - }, - "bin": { - "glob-all": "bin/glob-all" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "peer": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "peer": true, - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "peer": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, - "peer": true, - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/got": { - "version": "11.8.6", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", - "dev": true, - "peer": true, - "dependencies": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.2", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=10.19.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, - "node_modules/graphlib": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", - "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", - "dev": true, - "peer": true, - "dependencies": { - "lodash": "^4.17.15" - } - }, - "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "dev": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "peer": true, - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "peer": true, - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "peer": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hexoid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", - "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", - "dev": true, - "peer": true - }, - "node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, - "peer": true, - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "peer": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", - "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==", - "dev": true, - "peer": true - }, - "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "dev": true - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/inquirer": { - "version": "8.2.6", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", - "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", - "dev": true, - "peer": true, - "dependencies": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.21", - "mute-stream": "0.0.8", - "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^6.0.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", - "dev": true, - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", - "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, - "peer": true, - "dependencies": { - "has-bigints": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "peer": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", - "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", - "dev": true, - "peer": true, - "dependencies": { - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, - "peer": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "dev": true, - "peer": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "peer": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-natural-number": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", - "integrity": "sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==", - "dev": true, - "peer": true - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dev": true, - "peer": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-plain-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-primitive": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-3.0.1.tgz", - "integrity": "sha512-GljRxhWvlCNRfZyORiH77FwdFwGcMO620o37EOYC0ORWdq+WYNVqW0w2Juzew4M+L81l6/QS3t5gkkihyRqv9w==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", - "dev": true, - "peer": true - }, - "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", - "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, - "peer": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, - "peer": true, - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", - "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", - "dev": true, - "peer": true, - "dependencies": { - "which-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isomorphic-ws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", - "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", - "dev": true, - "peer": true, - "peerDependencies": { - "ws": "*" - } - }, - "node_modules/jmespath": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", - "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "peer": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "peer": true - }, - "node_modules/json-colorizer": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/json-colorizer/-/json-colorizer-2.2.2.tgz", - "integrity": "sha512-56oZtwV1piXrQnRNTtJeqRv+B9Y/dXAYLqBBaYl/COcUdoZxgLBLAO88+CnkbT6MxNs0c5E9mPBIb2sFcNz3vw==", - "dev": true, - "peer": true, - "dependencies": { - "chalk": "^2.4.1", - "lodash.get": "^4.4.2" - } - }, - "node_modules/json-colorizer/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "peer": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/json-colorizer/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "peer": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/json-colorizer/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "peer": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/json-colorizer/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true, - "peer": true - }, - "node_modules/json-colorizer/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/json-colorizer/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "peer": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/json-cycle": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/json-cycle/-/json-cycle-1.5.0.tgz", - "integrity": "sha512-GOehvd5PO2FeZ5T4c+RxobeT5a1PiGpF4u9/3+UvrMU4bhnVqzJY7hm39wg8PDCqkU91fWGH8qjWR4bn+wgq9w==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/json-refs": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/json-refs/-/json-refs-3.0.15.tgz", - "integrity": "sha512-0vOQd9eLNBL18EGl5yYaO44GhixmImes2wiYn9Z3sag3QnehWrYWlB9AFtMxCL2Bj3fyxgDYkxGFEU/chlYssw==", - "dev": true, - "peer": true, - "dependencies": { - "commander": "~4.1.1", - "graphlib": "^2.1.8", - "js-yaml": "^3.13.1", - "lodash": "^4.17.15", - "native-promise-only": "^0.8.1", - "path-loader": "^1.0.10", - "slash": "^3.0.0", - "uri-js": "^4.2.2" - }, - "bin": { - "json-refs": "bin/json-refs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/json-refs/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "peer": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/json-refs/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "peer": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "peer": true - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "dev": true, - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/jszip/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/jszip/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "node_modules/jszip/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/jwt-decode": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-2.2.0.tgz", - "integrity": "sha512-86GgN2vzfUu7m9Wcj63iUkuDzFNYFVmjeDm2GzWpUk+opB0pEpMsw6ePCMrhYkumz2C1ihqtZzOMAg7FiXcNoQ==", - "dev": true, - "peer": true - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "peer": true, - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "dev": true, - "peer": true, - "dependencies": { - "readable-stream": "^2.0.5" - }, - "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/lazystream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/lazystream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "peer": true - }, - "node_modules/lazystream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "dev": true, - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "peer": true - }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "dev": true, - "peer": true - }, - "node_modules/lodash.difference": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", - "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", - "dev": true, - "peer": true - }, - "node_modules/lodash.flatten": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", - "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", - "dev": true, - "peer": true - }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "dev": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "peer": true - }, - "node_modules/lodash.union": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", - "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", - "dev": true, - "peer": true - }, - "node_modules/lodash.uniqby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", - "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", - "dev": true - }, - "node_modules/lodash.values": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.values/-/lodash.values-4.3.0.tgz", - "integrity": "sha512-r0RwvdCv8id9TUblb/O7rYPwVy6lerCbcawrfdo9iC/1t1wsNMJknO79WNBgwkH0hIeJ08jmvvESbFpNb4jH0Q==", - "dev": true - }, - "node_modules/log": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/log/-/log-6.3.1.tgz", - "integrity": "sha512-McG47rJEWOkXTDioZzQNydAVvZNeEkSyLJ1VWkFwfW+o1knW+QSi8D1KjPn/TnctV+q99lkvJNe1f0E1IjfY2A==", - "dev": true, - "dependencies": { - "d": "^1.0.1", - "duration": "^0.2.2", - "es5-ext": "^0.10.53", - "event-emitter": "^0.3.5", - "sprintf-kit": "^2.0.1", - "type": "^2.5.0", - "uni-global": "^1.0.0" - } - }, - "node_modules/log-node": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/log-node/-/log-node-8.0.3.tgz", - "integrity": "sha512-1UBwzgYiCIDFs8A0rM2QdBFo8Wd8UQ0HrSTu/MNI+/2zN3NoHRj2fhplurAyuxTYUXu3Oohugq1jAn5s05u1MQ==", - "dev": true, - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "cli-color": "^2.0.1", - "cli-sprintf-format": "^1.1.1", - "d": "^1.0.1", - "es5-ext": "^0.10.53", - "sprintf-kit": "^2.0.1", - "supports-color": "^8.1.1", - "type": "^2.5.0" - }, - "engines": { - "node": ">=10.0" - }, - "peerDependencies": { - "log": "^6.0.0" - } - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "peer": true, - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/lru-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", - "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", - "dev": true, - "peer": true, - "dependencies": { - "es5-ext": "~0.10.2" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "peer": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/memoizee": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", - "integrity": "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==", - "dev": true, - "peer": true, - "dependencies": { - "d": "^1.0.2", - "es5-ext": "^0.10.64", - "es6-weak-map": "^2.0.3", - "event-emitter": "^0.3.5", - "is-promise": "^2.2.2", - "lru-queue": "^0.1.0", - "next-tick": "^1.1.0", - "timers-ext": "^0.1.7" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", - "dev": true, - "peer": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "peer": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "peer": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "peer": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "peer": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "peer": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "peer": true - }, - "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true, - "peer": true - }, - "node_modules/native-promise-only": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", - "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==", - "dev": true, - "peer": true - }, - "node_modules/ncjsm": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ncjsm/-/ncjsm-4.3.2.tgz", - "integrity": "sha512-6d1VWA7FY31CpI4Ki97Fpm36jfURkVbpktizp8aoVViTZRQgr/0ddmlKerALSSlzfwQRBeSq1qwwVcBJK4Sk7Q==", - "dev": true, - "peer": true, - "dependencies": { - "builtin-modules": "^3.3.0", - "deferred": "^0.7.11", - "es5-ext": "^0.10.62", - "es6-set": "^0.1.6", - "ext": "^1.7.0", - "find-requires": "^1.0.0", - "fs2": "^0.3.9", - "type": "^2.7.2" - } - }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", - "dev": true - }, - "node_modules/nice-try": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", - "dev": true - }, - "node_modules/node-dir": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz", - "integrity": "sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg==", - "dev": true, - "peer": true, - "dependencies": { - "minimatch": "^3.0.2" - }, - "engines": { - "node": ">= 0.10.5" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "peer": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-registry-utilities": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/npm-registry-utilities/-/npm-registry-utilities-1.0.0.tgz", - "integrity": "sha512-9xYfSJy2IFQw1i6462EJzjChL9e65EfSo2Cw6kl0EFeDp05VvU+anrQk3Fc0d1MbVCq7rWIxeer89O9SUQ/uOg==", - "dev": true, - "peer": true, - "dependencies": { - "ext": "^1.6.0", - "fs2": "^0.3.9", - "memoizee": "^0.4.15", - "node-fetch": "^2.6.7", - "semver": "^7.3.5", - "type": "^2.6.0", - "validate-npm-package-name": "^3.0.0" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", - "dev": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", - "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.5", - "define-properties": "^1.2.1", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "peer": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "peer": true, - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "peer": true, - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-event": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz", - "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==", - "dev": true, - "peer": true, - "dependencies": { - "p-timeout": "^3.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-timeout": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", - "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", - "dev": true, - "peer": true, - "dependencies": { - "p-finally": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/path-loader": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/path-loader/-/path-loader-1.0.12.tgz", - "integrity": "sha512-n7oDG8B+k/p818uweWrOixY9/Dsr89o2TkCm6tOTex3fpdo2+BFDgR+KpB37mGKBRsBAlR8CIJMFN0OEy/7hIQ==", - "dev": true, - "peer": true, - "dependencies": { - "native-promise-only": "^0.8.1", - "superagent": "^7.1.6" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path2": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/path2/-/path2-0.1.0.tgz", - "integrity": "sha512-TX+cz8Jk+ta7IvRy2FAej8rdlbrP0+uBIkP/5DTODez/AuL/vSb30KuAdDxGVREXzn8QfAiu5mJYJ1XjbOhEPA==", - "dev": true, - "peer": true - }, - "node_modules/peek-readable": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", - "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "peer": true - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pinkie": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", - "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pinkie-promise": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", - "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", - "dev": true, - "peer": true, - "dependencies": { - "pinkie": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", - "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true - }, - "node_modules/process-utils": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/process-utils/-/process-utils-4.0.0.tgz", - "integrity": "sha512-fMyMQbKCxX51YxR7YGCzPjLsU3yDzXFkP4oi1/Mt5Ixnk7GO/7uUTj8mrCHUwuvozWzI+V7QSJR9cZYnwNOZPg==", - "dev": true, - "peer": true, - "dependencies": { - "ext": "^1.4.0", - "fs2": "^0.3.9", - "memoizee": "^0.4.14", - "type": "^2.1.0" - }, - "engines": { - "node": ">=10.0" - } - }, - "node_modules/promise-queue": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/promise-queue/-/promise-queue-2.2.5.tgz", - "integrity": "sha512-p/iXrPSVfnqPft24ZdNNLECw/UrtLTpT3jpAAMzl/o5/rDsGCPo3/CQS2611flL6LkoEJ3oQZw7C8Q80ZISXRQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, - "peer": true - }, - "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, - "peer": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.12.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz", - "integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==", - "dev": true, - "peer": true, - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/querystring": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz", - "integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==", - "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true - }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readable-web-to-node-stream": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", - "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", - "dev": true, - "peer": true, - "dependencies": { - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", - "dev": true, - "peer": true, - "dependencies": { - "minimatch": "^5.1.0" - } - }, - "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "peer": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "peer": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", - "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.6", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "set-function-name": "^2.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true - }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true, - "peer": true - }, - "node_modules/responselike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", - "dev": true, - "peer": true, - "dependencies": { - "lowercase-keys": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "peer": true, - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "peer": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true, - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/run-parallel-limit": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/run-parallel-limit/-/run-parallel-limit-1.1.0.tgz", - "integrity": "sha512-jJA7irRNM91jaKc3Hcl1npHsFLOXOoTkPCUL1JEa1R82O2miplXXRaGdjW/KM/98YQWDhJLiSs793CnXfblJUw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true, - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", - "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4", - "has-symbols": "^1.0.3", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-array-concat/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "peer": true - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safe-regex-test": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", - "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-regex": "^1.1.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "peer": true - }, - "node_modules/sax": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", - "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==", - "dev": true, - "peer": true - }, - "node_modules/seek-bzip": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", - "integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==", - "dev": true, - "peer": true, - "dependencies": { - "commander": "^2.8.1" - }, - "bin": { - "seek-bunzip": "bin/seek-bunzip", - "seek-table": "bin/seek-bzip-table" - } - }, - "node_modules/seek-bzip/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "peer": true - }, - "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/serverless": { - "version": "3.38.0", - "resolved": "https://registry.npmjs.org/serverless/-/serverless-3.38.0.tgz", - "integrity": "sha512-NJE1vOn8XmQEqfU9UxmVhkUFaCRmx6FhYw/jITN863WlOt4Y3PQbj3hwQyIb5QS1ZrXFq5ojklwewUXH7xGpdA==", - "dev": true, - "hasInstallScript": true, - "peer": true, - "dependencies": { - "@serverless/dashboard-plugin": "^7.2.0", - "@serverless/platform-client": "^4.5.1", - "@serverless/utils": "^6.13.1", - "abort-controller": "^3.0.0", - "ajv": "^8.12.0", - "ajv-formats": "^2.1.1", - "archiver": "^5.3.1", - "aws-sdk": "^2.1404.0", - "bluebird": "^3.7.2", - "cachedir": "^2.3.0", - "chalk": "^4.1.2", - "child-process-ext": "^2.1.1", - "ci-info": "^3.8.0", - "cli-progress-footer": "^2.3.2", - "d": "^1.0.1", - "dayjs": "^1.11.8", - "decompress": "^4.2.1", - "dotenv": "^16.3.1", - "dotenv-expand": "^10.0.0", - "essentials": "^1.2.0", - "ext": "^1.7.0", - "fastest-levenshtein": "^1.0.16", - "filesize": "^10.0.7", - "fs-extra": "^10.1.0", - "get-stdin": "^8.0.0", - "globby": "^11.1.0", - "graceful-fs": "^4.2.11", - "https-proxy-agent": "^5.0.1", - "is-docker": "^2.2.1", - "js-yaml": "^4.1.0", - "json-colorizer": "^2.2.2", - "json-cycle": "^1.5.0", - "json-refs": "^3.0.15", - "lodash": "^4.17.21", - "memoizee": "^0.4.15", - "micromatch": "^4.0.5", - "node-fetch": "^2.6.11", - "npm-registry-utilities": "^1.0.0", - "object-hash": "^3.0.0", - "open": "^8.4.2", - "path2": "^0.1.0", - "process-utils": "^4.0.0", - "promise-queue": "^2.2.5", - "require-from-string": "^2.0.2", - "semver": "^7.5.3", - "signal-exit": "^3.0.7", - "stream-buffers": "^3.0.2", - "strip-ansi": "^6.0.1", - "supports-color": "^8.1.1", - "tar": "^6.1.15", - "timers-ext": "^0.1.7", - "type": "^2.7.2", - "untildify": "^4.0.0", - "uuid": "^9.0.0", - "ws": "^7.5.9", - "yaml-ast-parser": "0.0.43" - }, - "bin": { - "serverless": "bin/serverless.js", - "sls": "bin/serverless.js" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/serverless-dotenv-plugin": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/serverless-dotenv-plugin/-/serverless-dotenv-plugin-6.0.0.tgz", - "integrity": "sha512-8tLVNwHfDO0sBz6+m+DLTZquRk0AZq9rzqk3kphm1iIWKfan9R7RKt4hdq3eQ0kmDoqzudjPYBEXAJ5bUNKeGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2", - "dotenv": "^16.0.3", - "dotenv-expand": "^10.0.0" - }, - "peerDependencies": { - "serverless": "1 || 2 || pre-3 || 3" - } - }, - "node_modules/serverless-python-requirements": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/serverless-python-requirements/-/serverless-python-requirements-6.1.2.tgz", - "integrity": "sha512-pas27CBxxaLTU5XMYnCVPJc+LVdm65Ys5olNvRWRqfUaZwTfD/7KSSt2XPSRme8BeJubroslaiOtWPP+IrxTVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@iarna/toml": "^2.2.5", - "appdirectory": "^0.1.0", - "bluebird": "^3.7.2", - "child-process-ext": "^2.1.1", - "fs-extra": "^10.1.0", - "glob-all": "^3.3.1", - "is-wsl": "^2.2.0", - "jszip": "^3.10.1", - "lodash.get": "^4.4.2", - "lodash.uniqby": "^4.7.0", - "lodash.values": "^4.3.0", - "rimraf": "^3.0.2", - "semver": "^7.6.0", - "set-value": "^4.1.0", - "sha256-file": "1.0.0", - "shell-quote": "^1.8.1" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "peer": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "peer": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-value": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-4.1.0.tgz", - "integrity": "sha512-zTEg4HL0RwVrqcWs3ztF+x1vkxfm0lP+MQQFPiMJTKVceBwEV0A569Ou8l9IYQG8jOZdMVI1hGsc0tmeD2o/Lw==", - "dev": true, - "funding": [ - "https://github.com/sponsors/jonschlinkert", - "https://paypal.me/jonathanschlinkert", - "https://jonschlinkert.dev/sponsor" - ], - "dependencies": { - "is-plain-object": "^2.0.4", - "is-primitive": "^3.0.1" - }, - "engines": { - "node": ">=11.0" - } - }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "dev": true - }, - "node_modules/sha256-file": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/sha256-file/-/sha256-file-1.0.0.tgz", - "integrity": "sha512-nqf+g0veqgQAkDx0U2y2Tn2KWyADuuludZTw9A7J3D+61rKlIIl9V5TS4mfnwKuXZOH9B7fQyjYJ9pKRHIsAyg==", - "dev": true - }, - "node_modules/shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", - "dev": true, - "dependencies": { - "shebang-regex": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "peer": true - }, - "node_modules/simple-git": { - "version": "3.24.0", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.24.0.tgz", - "integrity": "sha512-QqAKee9Twv+3k8IFOFfPB2hnk6as6Y6ACUpwCtQvRYBAes23Wv3SZlHVobAzqcE8gfsisCvPw3HGW3HYM+VYYw==", - "dev": true, - "peer": true, - "dependencies": { - "@kwsites/file-exists": "^1.1.1", - "@kwsites/promise-deferred": "^1.1.1", - "debug": "^4.3.4" - }, - "funding": { - "type": "github", - "url": "https://github.com/steveukx/git-js?sponsor=1" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/sort-keys": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", - "integrity": "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==", - "dev": true, - "peer": true, - "dependencies": { - "is-plain-obj": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sort-keys-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz", - "integrity": "sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==", - "dev": true, - "peer": true, - "dependencies": { - "sort-keys": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split2": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", - "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", - "dev": true, - "dependencies": { - "readable-stream": "^3.0.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "peer": true - }, - "node_modules/sprintf-kit": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/sprintf-kit/-/sprintf-kit-2.0.2.tgz", - "integrity": "sha512-lnapdj6W4LflHZGKvl9eVkz5YF0xaTrqpRWVA4cNVOTedwqifIP8ooGImldzT/4IAN5KXFQAyXTdLidYVQdyag==", - "dev": true, - "dependencies": { - "es5-ext": "^0.10.64" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/stream-buffers": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.2.tgz", - "integrity": "sha512-DQi1h8VEBA/lURbSwFtEHnSTb9s2/pwLEaFuNhXwy1Dx3Sa0lOuYT2yNUr4/j2fs8oCAMANtrZ5OrPZtyVs3MQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/stream-promise": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/stream-promise/-/stream-promise-3.2.0.tgz", - "integrity": "sha512-P+7muTGs2C8yRcgJw/PPt61q7O517tDHiwYEzMWo1GSBCcZedUMT/clz7vUNsSxFphIlJ6QUL4GexQKlfJoVtA==", - "dev": true, - "dependencies": { - "2-thenable": "^1.0.0", - "es5-ext": "^0.10.49", - "is-stream": "^1.1.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", - "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", - "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-dirs": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", - "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", - "dev": true, - "peer": true, - "dependencies": { - "is-natural-number": "^4.0.1" - } - }, - "node_modules/strip-outer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", - "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", - "dev": true, - "peer": true, - "dependencies": { - "escape-string-regexp": "^1.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strnum": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", - "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", - "dev": true, - "peer": true - }, - "node_modules/strtok3": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", - "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", - "dev": true, - "peer": true, - "dependencies": { - "@tokenizer/token": "^0.3.0", - "peek-readable": "^4.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/superagent": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.6.tgz", - "integrity": "sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g==", - "deprecated": "Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net", - "dev": true, - "peer": true, - "dependencies": { - "component-emitter": "^1.3.0", - "cookiejar": "^2.1.3", - "debug": "^4.3.4", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^2.0.1", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.10.3", - "readable-stream": "^3.6.0", - "semver": "^7.3.7" - }, - "engines": { - "node": ">=6.4.0 <13 || >=14" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, - "peer": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "peer": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/throat": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", - "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", - "dev": true, - "peer": true - }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "peer": true - }, - "node_modules/timers-ext": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz", - "integrity": "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==", - "dev": true, - "peer": true, - "dependencies": { - "es5-ext": "^0.10.64", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "peer": true, - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/to-buffer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", - "integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==", - "dev": true, - "peer": true - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "peer": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/token-types": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", - "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", - "dev": true, - "peer": true, - "dependencies": { - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/token-types/node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "peer": true - }, - "node_modules/traverse": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.9.tgz", - "integrity": "sha512-7bBrcF+/LQzSgFmT0X5YclVqQxtv7TDJ1f8Wj7ibBu/U6BMLeOpUxuZjV7rMc44UtKxlnMFigdhFAIszSX1DMg==", - "dev": true, - "peer": true, - "dependencies": { - "gopd": "^1.0.1", - "typedarray.prototype.slice": "^1.0.3", - "which-typed-array": "^1.1.15" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/trim-repeated": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", - "integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==", - "dev": true, - "peer": true, - "dependencies": { - "escape-string-regexp": "^1.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true, - "peer": true - }, - "node_modules/type": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", - "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", - "dev": true - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", - "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", - "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", - "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", - "dev": true, - "peer": true, - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", - "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typedarray.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.3.tgz", - "integrity": "sha512-8WbVAQAUlENo1q3c3zZYuy5k9VzBQvp8AX9WOtbvyWlLM1v5JaSRmjubLjzHF4JFtptjH/5c/i95yaElvcjC0A==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-errors": "^1.3.0", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-offset": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/unbzip2-stream": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", - "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", - "dev": true, - "peer": true, - "dependencies": { - "buffer": "^5.2.1", - "through": "^2.3.8" - } - }, - "node_modules/unbzip2-stream/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "peer": true - }, - "node_modules/uni-global": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/uni-global/-/uni-global-1.0.0.tgz", - "integrity": "sha512-WWM3HP+siTxzIWPNUg7hZ4XO8clKi6NoCAJJWnuRL+BAqyFXF8gC03WNyTefGoUXYc47uYgXxpKLIEvo65PEHw==", - "dev": true, - "dependencies": { - "type": "^2.5.0" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "peer": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/url": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", - "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", - "dev": true, - "peer": true, - "dependencies": { - "punycode": "1.3.2", - "querystring": "0.2.0" - } - }, - "node_modules/url/node_modules/punycode": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==", - "dev": true, - "peer": true - }, - "node_modules/url/node_modules/querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", - "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "dev": true, - "peer": true, - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "peer": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/validate-npm-package-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", - "integrity": "sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==", - "dev": true, - "peer": true, - "dependencies": { - "builtins": "^1.0.3" - } - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "peer": true, - "dependencies": { - "defaults": "^1.0.3" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "peer": true - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "peer": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, - "peer": true, - "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "dev": true - }, - "node_modules/which-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", - "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", - "dev": true, - "peer": true, - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "peer": true, - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml2js": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", - "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", - "dev": true, - "peer": true, - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.4" - } - }, - "node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "dev": true - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "peer": true - }, - "node_modules/yaml-ast-parser": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", - "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", - "dev": true, - "peer": true - }, - "node_modules/yamljs": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz", - "integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==", - "dev": true, - "peer": true, - "dependencies": { - "argparse": "^1.0.7", - "glob": "^7.0.5" - }, - "bin": { - "json2yaml": "bin/json2yaml", - "yaml2json": "bin/yaml2json" - } - }, - "node_modules/yamljs/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "peer": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "dev": true, - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "dev": true, - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "peer": true, - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "node_modules/zip-stream": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", - "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", - "dev": true, - "peer": true, - "dependencies": { - "archiver-utils": "^3.0.4", - "compress-commons": "^4.1.2", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/zip-stream/node_modules/archiver-utils": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", - "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", - "dev": true, - "peer": true, - "dependencies": { - "glob": "^7.2.3", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 133a7b3..0000000 --- a/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "lambda-gurumi-ai-bot", - "devDependencies": { - "serverless-dotenv-plugin": "^6.0.0", - "serverless-python-requirements": "^6.1.2" - } -} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..974aea5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +python_files = test_*.py +addopts = -v --tb=short +filterwarnings = + ignore::DeprecationWarning diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..84ae17e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +pytest>=8.0 +pytest-cov>=5.0 +moto[dynamodb]>=5.0 +responses>=0.25 +reportlab>=4.0,<5.0 diff --git a/requirements.txt b/requirements.txt index ef55c65..7aa7579 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ -boto3>=1.42,<2.0 -slack-bolt>=1.27,<2.0 -slack-sdk>=3.39,<4.0 +slack-bolt>=1.20.1,<2.0 +slack-sdk>=3.31.0,<4.0 +boto3>=1.34.0,<2.0 +openai>=1.50.0,<2.0 +python-dotenv>=1.0.0,<2.0 +pypdf>=4.0,<6.0 diff --git a/scripts/awsdocs/docs.txt b/scripts/awsdocs/docs.txt deleted file mode 100644 index 6176a39..0000000 --- a/scripts/awsdocs/docs.txt +++ /dev/null @@ -1,24 +0,0 @@ -# AWS official PDF documentation to sync to Knowledge Base -# Format: name url -# Lines starting with # are ignored -# Add or remove docs as needed - -s3 https://docs.aws.amazon.com/pdfs/AmazonS3/latest/userguide/s3-userguide.pdf -dynamodb https://docs.aws.amazon.com/pdfs/amazondynamodb/latest/developerguide/dynamodb-dg.pdf -lambda https://docs.aws.amazon.com/pdfs/lambda/latest/dg/lambda-dg.pdf -eks https://docs.aws.amazon.com/pdfs/eks/latest/userguide/eks-ug.pdf -rds https://docs.aws.amazon.com/pdfs/AmazonRDS/latest/UserGuide/rds-ug.pdf -iam https://docs.aws.amazon.com/pdfs/IAM/latest/UserGuide/iam-ug.pdf -bedrock https://docs.aws.amazon.com/pdfs/bedrock/latest/userguide/bedrock-ug.pdf -ec2 https://docs.aws.amazon.com/pdfs/AWSEC2/latest/UserGuide/ec2-ug.pdf -vpc https://docs.aws.amazon.com/pdfs/vpc/latest/userguide/vpc-ug.pdf -ecs https://docs.aws.amazon.com/pdfs/AmazonECS/latest/developerguide/ecs-dg.pdf -cloudformation https://docs.aws.amazon.com/pdfs/AWSCloudFormation/latest/UserGuide/cfn-ug.pdf -well-architected https://docs.aws.amazon.com/pdfs/wellarchitected/latest/framework/wellarchitected-framework.pdf -apigateway https://docs.aws.amazon.com/pdfs/apigateway/latest/developerguide/apigateway-dg.pdf -stepfunctions https://docs.aws.amazon.com/pdfs/step-functions/latest/dg/step-functions-dg.pdf -cognito https://docs.aws.amazon.com/pdfs/cognito/latest/developerguide/cognito-dg.pdf -cloudwatch https://docs.aws.amazon.com/pdfs/AmazonCloudWatch/latest/monitoring/acw-ug.pdf -systems-manager https://docs.aws.amazon.com/pdfs/systems-manager/latest/userguide/systems-manager-ug.pdf -route53 https://docs.aws.amazon.com/pdfs/Route53/latest/DeveloperGuide/route53-dg.pdf -aurora https://docs.aws.amazon.com/pdfs/AmazonRDS/latest/AuroraUserGuide/aurora-ug.pdf diff --git a/scripts/awsdocs/sync.sh b/scripts/awsdocs/sync.sh deleted file mode 100755 index 309f427..0000000 --- a/scripts/awsdocs/sync.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Sync AWS official PDF documentation to S3 for Knowledge Base ingestion -# Usage: ./sync.sh [docs-file] -# -# docs.txt format: name url -# s3 https://docs.aws.amazon.com/pdfs/AmazonS3/latest/userguide/s3-userguide.pdf -# -# PDFs over 50MB are automatically split into smaller parts using qpdf. - -S3_BUCKET="${1:?Usage: sync.sh [docs-file]}" -DOCS_FILE="${2:-$(dirname "$0")/docs.txt}" -BUILD_DIR="build/awsdocs" -MAX_FILE_SIZE=$((50 * 1024 * 1024)) # 50MB Bedrock limit -SPLIT_PAGES=100 # Pages per split chunk - -rm -rf "${BUILD_DIR}" -mkdir -p "${BUILD_DIR}" - -# Read docs list, skip comments and empty lines -grep -v '^\s*#' "${DOCS_FILE}" | grep -v '^\s*$' | while read -r name url; do - echo "=== Downloading ${name} ===" - out_dir="${BUILD_DIR}/${name}" - mkdir -p "${out_dir}" - - curl -sL -o "${out_dir}/${name}.pdf" "${url}" - file_size=$(stat -f%z "${out_dir}/${name}.pdf" 2>/dev/null || stat -c%s "${out_dir}/${name}.pdf" 2>/dev/null) - size_mb=$(echo "scale=1; ${file_size}/1048576" | bc) - echo "Downloaded ${name}.pdf (${size_mb}MB)" - - # Split large PDFs into smaller parts - if [ "${file_size}" -gt "${MAX_FILE_SIZE}" ]; then - echo "Splitting ${name}.pdf (exceeds 50MB limit)..." - qpdf --split-pages="${SPLIT_PAGES}" "${out_dir}/${name}.pdf" "${out_dir}/${name}-%d.pdf" - rm -f "${out_dir}/${name}.pdf" - split_count=$(find "${out_dir}" -name '*.pdf' | wc -l | tr -d ' ') - echo "Split into ${split_count} parts" - fi - - # Sync to S3 - echo "Syncing to s3://${S3_BUCKET}/documents/${name}/" - aws s3 sync --delete \ - "${out_dir}/" \ - "s3://${S3_BUCKET}/documents/${name}/" - - rm -rf "${out_dir}" - echo "" -done - -echo "=== All docs synced ===" diff --git a/scripts/bedrock/README.md b/scripts/bedrock/README.md deleted file mode 100644 index 6e6fed7..0000000 --- a/scripts/bedrock/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Bedrock 예제 - -## Install - -```bash -$ brew install python@3.12 - -$ python -m pip install --upgrade -r requirements.txt -``` - -## Test - -```bash -# Bedrock Agent 테스트 -python invoke_agent.py -p "AWS DeepRacer 설명해줘" - -# Claude 3 모델 직접 호출 -python invoke_claude_3.py -p "구름이가 누구?" - -# Claude 3 이미지 분석 -python invoke_claude_3_image.py - -# 이미지 생성 (Stable Diffusion) -python invoke_stable_diffusion.py -p "Create an image of a cat walking on a fully frozen river surface on a cold winter day." - -# Knowledge Base 쿼리 -python invoke_knowledge_base.py -p "지식 베이스 쿼리" - -# 스트리밍 대화 -python converse_stream.py -p "프롬프트 입력" -``` - -## References - -* -* diff --git a/scripts/bedrock/converse_stream.py b/scripts/bedrock/converse_stream.py deleted file mode 100644 index 7603eab..0000000 --- a/scripts/bedrock/converse_stream.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import argparse -import json -import boto3 -import os - - -AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") - -MODEL_ID_TEXT = "anthropic.claude-3-5-sonnet-20240620-v1:0" - -# Set up System messages -PERSONAL_MESSAGE = os.environ.get( - "PERSONAL_MESSAGE", "당신은 친절하고 전문적인 AI 비서 입니다." -) -SYSTEM_MESSAGE = "답변을 할때 참고한 문서가 있다면 링크도 알려줘." - - -# Initialize the Amazon Bedrock runtime client -bedrock = boto3.client(service_name="bedrock", region_name=AWS_REGION) - -bedrock_runtime = boto3.client(service_name="bedrock-runtime", region_name=AWS_REGION) - - -def parse_args(): - p = argparse.ArgumentParser(description="invoke_claude_3") - p.add_argument("-p", "--prompt", default="안녕", help="prompt") - return p.parse_args() - - -def create_inference_profile(): - profile_name = "gurumi-ai-bot-inference-profile" - - model_arn = "arn:aws:bedrock:{}::foundation-model/{}".format( - AWS_REGION, MODEL_ID_TEXT - ) - - tags = [ - {"key": "Name", "value": "gurumi-ai-bot"}, - {"key": "dept", "value": "sre"}, - ] - - """Create Inference Profile using base model ARN""" - response = bedrock.create_inference_profile( - inferenceProfileName=profile_name, - description="gurumi-ai-bot Inference Profile", - modelSource={"copyFrom": model_arn}, - tags=tags, - ) - - profile_arn = response["inferenceProfileArn"] - - return profile_arn - - -def converse_stream(prompt): - try: - model_id = create_inference_profile() - - messages = [ - { - "role": "user", - "content": [{"text": prompt}], - }, - ] - - streaming_response = bedrock_runtime.converse_stream( - modelId=model_id, - messages=messages, - inferenceConfig={"maxTokens": 4096, "temperature": 0.5, "topP": 0.9}, - ) - - # Extract and print the streamed response text in real-time. - for chunk in streaming_response["stream"]: - if "contentBlockDelta" in chunk: - text = chunk["contentBlockDelta"]["delta"]["text"] - print(text, end="") - - except Exception as e: - print("converse_stream: Error: {}".format(e)) - - raise e - - -def main(): - args = parse_args() - - query = args.prompt - - prompts = [] - prompts.append("User: {}".format(PERSONAL_MESSAGE)) - prompts.append( - "If you don't know the answer, just say that you don't know, don't try to make up an answer." - ) - - if SYSTEM_MESSAGE != "None": - prompts.append(SYSTEM_MESSAGE) - - prompts.append(" 태그로 감싸진 질문에 답변을 제공하세요.") - - try: - # Add the question to the prompts - prompts.append("") - prompts.append("") - prompts.append(query) - prompts.append("") - prompts.append("") - - prompts.append("Assistant:") - - # Combine the prompts - prompt = "\n".join(prompts) - - # Send the prompt to Bedrock - converse_stream(prompt) - - except Exception as e: - print("conversation: error: {}".format(e)) - - -if __name__ == "__main__": - main() diff --git a/scripts/bedrock/invoke_agent.py b/scripts/bedrock/invoke_agent.py deleted file mode 100644 index f346dda..0000000 --- a/scripts/bedrock/invoke_agent.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import argparse -import boto3 -import os -import asyncio - -from datetime import datetime - -from botocore.exceptions import ClientError - - -AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") - -AGENT_ID = "LIKTFQ14NA" -AGENT_ALIAS_ID = "Y1M3VE0PR4" - - -# Initialize the Amazon Bedrock agent runtime client -bedrock = boto3.client(service_name="bedrock-agent-runtime", region_name=AWS_REGION) - - -def parse_args(): - p = argparse.ArgumentParser(description="invoke_agent") - p.add_argument("-p", "--prompt", default="안녕", help="prompt") - return p.parse_args() - - -def invoke_agent(prompt): - """ - Sends a prompt for the agent to process and respond to. - - :param agent_id: The unique identifier of the agent to use. - :param agent_alias_id: The alias of the agent to use. - :param session_id: The unique identifier of the session. Use the same value across requests - to continue the same conversation. - :param prompt: The prompt that you want Claude to complete. - :return: Inference response from the model. - """ - - session_id = datetime.now().strftime("%Y%m%d%H%M%S") - - try: - # Note: The execution time depends on the foundation model, complexity of the agent, - # and the length of the prompt. In some cases, it can take up to a minute or more to - # generate a response. - response = bedrock.invoke_agent( - agentId=AGENT_ID, - agentAliasId=AGENT_ALIAS_ID, - sessionId=session_id, - inputText=prompt, - ) - - completion = "" - - for event in response.get("completion"): - chunk = event["chunk"] - completion = completion + chunk["bytes"].decode() - - except ClientError as e: - print(f"Couldn't invoke agent. {e}") - raise - - return completion - - -async def main(): - args = parse_args() - - try: - # Send the prompt to Bedrock Agent - message = invoke_agent(args.prompt) - - print(message) - - except Exception as e: - print(f"error: {e}") - - -if __name__ == "__main__": - # main() - asyncio.run(main()) diff --git a/scripts/bedrock/invoke_claude_3.py b/scripts/bedrock/invoke_claude_3.py deleted file mode 100644 index ce3b2ec..0000000 --- a/scripts/bedrock/invoke_claude_3.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import argparse -import json -import boto3 -import os - - -AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") - -ANTHROPIC_VERSION = os.environ.get("ANTHROPIC_VERSION", "bedrock-2023-05-31") -ANTHROPIC_TOKENS = int(os.environ.get("ANTHROPIC_TOKENS", 1024)) - -MODEL_ID_TEXT = "anthropic.claude-3-5-sonnet-20240620-v1:0" - -# Set up System messages -PERSONAL_MESSAGE = os.environ.get( - "PERSONAL_MESSAGE", "당신은 친절하고 전문적인 AI 비서 입니다." -) -SYSTEM_MESSAGE = "답변을 할때 참고한 문서가 있다면 링크도 알려줘." - - -# Initialize the Amazon Bedrock runtime client -bedrock = boto3.client(service_name="bedrock-runtime", region_name=AWS_REGION) - - -def parse_args(): - p = argparse.ArgumentParser(description="invoke_claude_3") - p.add_argument("-p", "--prompt", default="안녕", help="prompt") - return p.parse_args() - - -def invoke_claude_3(prompt): - """ - Invokes Anthropic Claude 3 Sonnet to run an inference using the input - provided in the request body. - - :param prompt: The prompt that you want Claude 3 to complete. - :return: Inference response from the model. - """ - - try: - body = { - "anthropic_version": ANTHROPIC_VERSION, - "max_tokens": ANTHROPIC_TOKENS, - "messages": [ - { - "role": "user", - "content": [{"type": "text", "text": prompt}], - }, - ], - } - - response = bedrock.invoke_model( - modelId=MODEL_ID_TEXT, - body=json.dumps(body), - ) - - # Process and print the response - body = json.loads(response.get("body").read()) - - print("response: {}".format(body)) - - result = body.get("content", []) - - for output in result: - text = output["text"] - - return text - - except Exception as e: - print("invoke_claude_3: Error: {}".format(e)) - - raise e - - -def main(): - args = parse_args() - - query = args.prompt - - prompts = [] - prompts.append("User: {}".format(PERSONAL_MESSAGE)) - prompts.append("If you don't know the answer, just say that you don't know, don't try to make up an answer.") - - if SYSTEM_MESSAGE != "None": - prompts.append(SYSTEM_MESSAGE) - - prompts.append(" 태그로 감싸진 질문에 답변을 제공하세요.") - - try: - # Add the question to the prompts - prompts.append("") - prompts.append("") - prompts.append(query) - prompts.append("") - prompts.append("") - - prompts.append("Assistant:") - - # Combine the prompts - prompt = "\n".join(prompts) - - # Send the prompt to Bedrock - message = invoke_claude_3(prompt) - - print("conversation: message: {}".format(message)) - - except Exception as e: - print("conversation: error: {}".format(e)) - - -if __name__ == "__main__": - main() diff --git a/scripts/bedrock/invoke_claude_3_image.py b/scripts/bedrock/invoke_claude_3_image.py deleted file mode 100644 index e11453b..0000000 --- a/scripts/bedrock/invoke_claude_3_image.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import argparse -import json -import boto3 -import base64 - - -def parse_args(): - p = argparse.ArgumentParser(description="invoke_claude_3") - p.add_argument("-p", "--prompt", default="사진 설명 해줘", help="prompt") - return p.parse_args() - - -def invoke_claude_3(prompt): - """ - Invokes Anthropic Claude 3 Sonnet to run an inference using the input - provided in the request body. - - :param prompt: The prompt that you want Claude 3 to complete. - :return: Inference response from the model. - """ - - # Initialize the Amazon Bedrock runtime client - bedrock = boto3.client(service_name="bedrock-runtime", region_name="us-east-1") - - # Invoke Claude 3 with the text prompt - model_id = "anthropic.claude-3-sonnet-20240229-v1:0" - - image = "../images/gurumi-bot.png" - - # Read reference image from file and encode as base64 strings. - with open(image, "rb") as file: - encoded_image = base64.b64encode(file.read()).decode("utf8") - - try: - body = { - "anthropic_version": "bedrock-2023-05-31", - "max_tokens": 1024, - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": prompt, - }, - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/jpeg", - "data": encoded_image, - }, - }, - ], - }, - ], - } - - # print("request: {}".format(body)) - - response = bedrock.invoke_model( - modelId=model_id, - body=json.dumps(body), - ) - - # Process and print the response - body = json.loads(response.get("body").read()) - - # print("response: {}".format(body)) - - content = body.get("content", []) - - for output in content: - print(output["text"]) - - except Exception as e: - print("Error: {}".format(e)) - - -def main(): - args = parse_args() - - invoke_claude_3(args.prompt) - - -if __name__ == "__main__": - main() diff --git a/scripts/bedrock/invoke_knowledge_base.py b/scripts/bedrock/invoke_knowledge_base.py deleted file mode 100644 index 2771862..0000000 --- a/scripts/bedrock/invoke_knowledge_base.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import argparse -import json -import boto3 -import os - -from botocore.client import Config - - -AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") - -# Amazon Bedrock Knowledge Base ID -KNOWLEDGE_BASE_ID = os.environ.get("KNOWLEDGE_BASE_ID", "IYEMTLJ9MM") - -KB_RETRIEVE_COUNT = int(os.environ.get("KB_RETRIEVE_COUNT", 5)) - -# Amazon Bedrock Model ID -ANTHROPIC_VERSION = os.environ.get("ANTHROPIC_VERSION", "bedrock-2023-05-31") -ANTHROPIC_TOKENS = int(os.environ.get("ANTHROPIC_TOKENS", 1024)) - -MODEL_ID_TEXT = "anthropic.claude-3-sonnet-20240229-v1:0" - -# Set up System messages -PERSONAL_MESSAGE = os.environ.get( - "PERSONAL_MESSAGE", "당신은 친절하고 전문적인 AI 비서 입니다." -) -SYSTEM_MESSAGE = "답변을 할때 참고한 문서가 있다면 링크도 알려줘." - - -# Initialize the Amazon Bedrock runtime client -bedrock = boto3.client(service_name="bedrock-runtime", region_name=AWS_REGION) - -bedrock_config = Config( - connect_timeout=120, read_timeout=120, retries={"max_attempts": 0} -) -bedrock_agent_client = boto3.client( - "bedrock-agent-runtime", region_name=AWS_REGION, config=bedrock_config -) - - -def parse_args(): - p = argparse.ArgumentParser(description="invoke_claude_3") - p.add_argument("-p", "--prompt", default="안녕", help="prompt") - return p.parse_args() - - -def invoke_knowledge_base(content): - """ - Invokes the Amazon Bedrock Knowledge Base to retrieve information using the input - provided in the request body. - - :param content: The content that you want to use for retrieval. - :return: The retrieved contexts from the knowledge base. - """ - - contexts = [] - - if KNOWLEDGE_BASE_ID == "None": - return contexts - - try: - response = bedrock_agent_client.retrieve( - retrievalQuery={"text": content}, - knowledgeBaseId=KNOWLEDGE_BASE_ID, - retrievalConfiguration={ - "vectorSearchConfiguration": { - "numberOfResults": KB_RETRIEVE_COUNT, - # "overrideSearchType": "HYBRID", # optional - } - }, - ) - - results = response["retrievalResults"] - - contexts = [] - for result in results: - contexts.append(result["content"]["text"]) - - except Exception as e: - print("invoke_knowledge_base: Error: {}".format(e)) - - print("invoke_knowledge_base: {}".format("\n\n".join(contexts))) - - return contexts - - -def invoke_claude_3(prompt): - """ - Invokes Anthropic Claude 3 Sonnet to run an inference using the input - provided in the request body. - - :param prompt: The prompt that you want Claude 3 to complete. - :return: Inference response from the model. - """ - - try: - body = { - "anthropic_version": ANTHROPIC_VERSION, - "max_tokens": ANTHROPIC_TOKENS, - "messages": [ - { - "role": "user", - "content": [{"type": "text", "text": prompt}], - }, - ], - } - - response = bedrock.invoke_model( - modelId=MODEL_ID_TEXT, - body=json.dumps(body), - ) - - # Process and print the response - body = json.loads(response.get("body").read()) - - print("response: {}".format(body)) - - result = body.get("content", []) - - for output in result: - text = output["text"] - - return text - - except Exception as e: - print("invoke_claude_3: Error: {}".format(e)) - - raise e - - -def main(): - args = parse_args() - - query = args.prompt - - prompts = [] - prompts.append("User: {}".format(PERSONAL_MESSAGE)) - prompts.append("If you don't know the answer, just say that you don't know, don't try to make up an answer.") - - if SYSTEM_MESSAGE != "None": - prompts.append(SYSTEM_MESSAGE) - - prompts.append(" 태그로 감싸진 질문에 답변을 제공하세요.") - - try: - # Get the knowledge base contexts - if KNOWLEDGE_BASE_ID != "None": - contexts = invoke_knowledge_base(query) - - prompts.append( - " 에 정보가 제공 되면, 해당 정보를 사용하여 답변해 주세요." - ) - prompts.append("") - prompts.append("\n\n".join(contexts)) - prompts.append("") - - # Add the question to the prompts - prompts.append("") - prompts.append("") - prompts.append(query) - prompts.append("") - prompts.append("") - - prompts.append("Assistant:") - - # Combine the prompts - prompt = "\n".join(prompts) - - # Send the prompt to Bedrock - message = invoke_claude_3(prompt) - - print("conversation: message: {}".format(message)) - - except Exception as e: - print("conversation: error: {}".format(e)) - - -if __name__ == "__main__": - main() diff --git a/scripts/bedrock/invoke_stable_diffusion.py b/scripts/bedrock/invoke_stable_diffusion.py deleted file mode 100644 index 4fcdf86..0000000 --- a/scripts/bedrock/invoke_stable_diffusion.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import argparse -import json -import boto3 -import base64 -import io - -from PIL import Image - - -def parse_args(): - p = argparse.ArgumentParser(description="invoke_stable_diffusion") - p.add_argument("-p", "--prompt", default="Hello", help="prompt", required=True) - return p.parse_args() - - -def invoke_stable_diffusion(prompt, seed=0, style_preset="photographic"): - """ - Invokes the Stability.ai Stable Diffusion XL model to create an image using - the input provided in the request body. - - :param prompt: The prompt that you want Stable Diffusion to use for image generation. - :param seed: Random noise seed (omit this option or use 0 for a random seed) - :param style_preset: Pass in a style preset to guide the image model towards - a particular style. - :return: Base64-encoded inference response from the model. - """ - - # Initialize the Amazon Bedrock runtime client - bedrock = boto3.client(service_name="bedrock-runtime", region_name="us-east-1") - - # Invoke Claude 3 with the text prompt - model_id = "stability.stable-diffusion-xl-v1" - - try: - # The different model providers have individual request and response formats. - # For the format, ranges, and available style_presets of Stable Diffusion models refer to: - # https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-stability-diffusion.html - - body = { - "text_prompts": [{"text": prompt}], - "seed": seed, - "cfg_scale": 10, - "steps": 30, - "samples": 1, - } - - if style_preset: - body["style_preset"] = style_preset - - response = bedrock.invoke_model( - modelId=model_id, - body=json.dumps(body), - ) - - body = json.loads(response["body"].read()) - - # body["artifacts"][0]["base64"] = None - # print("response: {}".format(body)) - - base64_image = body.get("artifacts")[0].get("base64") - base64_bytes = base64_image.encode("ascii") - image_bytes = base64.b64decode(base64_bytes) - - image = Image.open(io.BytesIO(image_bytes)) - image.show() - - except Exception as e: - print("Error: {}".format(e)) - - -def main(): - args = parse_args() - - invoke_stable_diffusion(args.prompt) - - -if __name__ == "__main__": - main() diff --git a/scripts/bedrock/requirements.txt b/scripts/bedrock/requirements.txt deleted file mode 100644 index 0233d7a..0000000 --- a/scripts/bedrock/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -boto3 -slack-bolt -slack-sdk -pillow diff --git a/scripts/notion/.env.example b/scripts/notion/.env.example deleted file mode 100644 index 9279668..0000000 --- a/scripts/notion/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -NOTION_TOKEN="ntn_xxxx" - -NOTION_PAGE_NAME="nalbam" -NOTION_PAGE_ID="7aace0412a82431996f61a29225a95ec" diff --git a/scripts/notion/README.md b/scripts/notion/README.md deleted file mode 100644 index 8d321ee..0000000 --- a/scripts/notion/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Notion Exporter - -Notion 페이지를 Markdown으로 내보내는 스크립트입니다. 공식 Notion API를 사용합니다. - -## 설치 - -```bash -pip install -r requirements.txt -``` - -## 설정 - -```bash -cp .env.example .env -# .env 파일에 Notion Integration API 토큰 입력 -``` - -Notion Integration 생성: https://www.notion.so/my-integrations - -## 실행 - -```bash -python export.py -``` - -`build/{NOTION_PAGE_NAME}/` 디렉토리에 Markdown 파일이 생성됩니다. - -## References - -- https://github.com/indox-ai/notion-exporter -- https://developers.notion.com/ diff --git a/scripts/notion/export.py b/scripts/notion/export.py deleted file mode 100644 index 849203a..0000000 --- a/scripts/notion/export.py +++ /dev/null @@ -1,33 +0,0 @@ -import os -import pathlib - -from notion_exporter import NotionExporter - - -NOTION_TOKEN = os.getenv("NOTION_TOKEN") - -NOTION_PAGE_NAME = os.getenv("NOTION_PAGE_NAME", "demo") -NOTION_PAGE_ID = os.getenv("NOTION_PAGE_ID", "7aace0412a82431996f61a29225a95ec") - - -if __name__ == "__main__": - if not NOTION_TOKEN: - raise ValueError("NOTION_TOKEN environment variable is required") - - exporter = NotionExporter( - notion_token=NOTION_TOKEN, - export_child_pages=True, - ) - exported_pages = exporter.export_pages( - page_ids=[NOTION_PAGE_ID], - ) - - output_dir = pathlib.Path("build") / NOTION_PAGE_NAME - output_dir.mkdir(parents=True, exist_ok=True) - - for page_id, content in exported_pages.items(): - file_path = output_dir / f"{page_id}.md" - file_path.write_text(content, encoding="utf-8") - print(f"Exported: {file_path}") - - print(f"Exported {len(exported_pages)} pages to {output_dir}") diff --git a/scripts/notion/requirements.txt b/scripts/notion/requirements.txt deleted file mode 100644 index bade675..0000000 --- a/scripts/notion/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -notion-exporter diff --git a/scripts/split.py b/scripts/split.py deleted file mode 100644 index 2e93553..0000000 --- a/scripts/split.py +++ /dev/null @@ -1,124 +0,0 @@ -def split_message(message, max_len): - split_parts = [] - - # 먼저 ``` 기준으로 분리 - parts = message.split("```") - - for i, part in enumerate(parts): - if i % 2 == 1: # 코드 블록인 경우 - # 코드 블록도 "\n\n" 기준으로 자름 - split_parts.extend(split_code_block(part, max_len)) - else: # 일반 텍스트 부분 - split_parts.extend(split_by_newline(part, max_len)) - - # 전체 블록을 합친 후 max_len을 넘지 않도록 추가로 자름 - return finalize_split(split_parts, max_len) - - -def split_code_block(code, max_len): - # 코드 블록을 "\n\n" 기준으로 분리 후, 다시 ```로 감쌈 - code_parts = code.split("\n\n") - result = [] - current_part = "```\n" - - for part in code_parts: - if len(current_part) + len(part) + 2 < max_len - 6: # 6은 ``` 앞뒤 길이 - if current_part != "```\n": - current_part += "\n\n" + part - else: - current_part += part - else: - result.append(current_part + "\n```") # ```로 감쌈 - current_part = "```\n" + part - - if current_part != "```\n": - result.append(current_part + "\n```") - - return result - - -def split_by_newline(text, max_len): - # "\n\n" 기준으로 분리 - parts = text.split("\n\n") - result = [] - current_part = "" - - for part in parts: - if len(current_part) + len(part) + 2 < max_len: # 2는 "\n\n"의 길이 - if current_part != "": - current_part += "\n\n" + part - else: - current_part = part - else: - result.append(current_part) - current_part = part - if current_part != "": - result.append(current_part) - - return result - - -def finalize_split(parts, max_len): - # 각 파트를 max_len에 맞춰 추가로 자름 - result = [] - current_message = "" - - for part in parts: - if len(current_message) + len(part) < max_len: - current_message += "\n\n" + part - else: - result.append(current_message) - current_message = part - if current_message != "": - result.append(current_message) - - return result - - -# 테스트 -message = """ -JSON 데이터를 정렬하는 방법은 사용하는 프로그래밍 언어나 도구에 따라 다를 수 있습니다. 여기서는 Python을 사용한 예시를 보여드리겠습니다. Python의 json 모듈을 사용하면 쉽게 JSON 데이터를 정렬할 수 있습니다. - -```python -import json - -# 정렬되지 않은 JSON 데이터 -data = { - "name": "Alice", - "age": 30, - "city": "New York", - "hobbies": ["reading", "hiking", "coding"] -} - -# JSON 데이터를 정렬하여 문자열로 변환 -sorted_json_str = json.dumps(data, indent=4, sort_keys=True) - -# 정렬되지 않은 JSON 데이터 -data = { - "name": "Alice", - "age": 30, - "city": "New York", - "hobbies": ["reading", "hiking", "coding"] -} - -# JSON 데이터를 정렬하여 문자열로 변환 -sorted_json_str = json.dumps(data, indent=4, sort_keys=True) -``` - -```python -# 정렬된 JSON 출력 -print(sorted_json_str) -``` - -이와 같은 방법으로 JSON 데이터를 정렬할 수 있습니다. 다른 프로그래밍 언어에서도 유사한 방법으로 JSON 데이터를 정렬할 수 있으니, 사용하는 언어의 JSON 라이브러리를 참고하세요. -""" - -MAX_LEN_SLACK = 400 # 슬랙 최대 메시지 길이 설정 - -split_messages = split_message(message, MAX_LEN_SLACK) - -# 나누어진 메시지 출력 -for i, part in enumerate(split_messages, 1): - print(f"Part {i}:") - print(part) - print("-" * 40) diff --git a/serverless.yml b/serverless.yml index 6e03756..c0b14eb 100644 --- a/serverless.yml +++ b/serverless.yml @@ -1,54 +1,132 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/lalcebo/json-schema/master/serverless/reference.json -service: lambda-gurumi-ai-bot +service: lambda-gurumi-bot +frameworkVersion: "3" + provider: name: aws - region: us-east-1 runtime: python3.12 + # Keep arch aligned with the GitHub Actions Ubuntu runner so pip installs + # wheels that match the Lambda runtime (pydantic_core, etc.). Switching + # back to arm64 needs a Docker-based build (serverless-python-requirements + # dockerizePip) or a cross-platform wheel flag — deferred. + architecture: x86_64 + region: ${env:AWS_REGION, 'us-east-1'} stage: ${opt:stage, 'dev'} - architecture: arm64 memorySize: 5120 - timeout: 90 + timeout: 300 + stackTags: + katalog_id: "858" + tags: + katalog_id: "858" environment: - BASE_NAME: gurumi-ai-bot - FOUNDATION_MODEL: ${env:FOUNDATION_MODEL, 'amazon.nova-lite-v1:0'} - AGENT_ID: - Fn::GetAtt: [BedrockAgent, AgentId] - AGENT_ALIAS_ID: - Fn::GetAtt: [BedrockAgentAlias, AgentAliasId] - iamRoleStatements: - - Effect: Allow - Action: - - dynamodb:GetItem - - dynamodb:PutItem - - dynamodb:Query - Resource: - - "arn:aws:dynamodb:${self:provider.region}:*:table/${self:provider.environment.BASE_NAME}-*" - - "arn:aws:dynamodb:${self:provider.region}:*:table/${self:provider.environment.BASE_NAME}-*/index/*" - - Effect: Allow - Action: - - bedrock:InvokeAgent - Resource: - - "arn:aws:bedrock:${self:provider.region}:*:agent-alias/*" + # --- runtime-internal (serverless-provided, not from CI env) --- + # NOTE: AWS_REGION is auto-injected by the Lambda runtime and is a + # reserved name we can't override here — src/config.py reads it directly + # via os.getenv("AWS_REGION", ...). Do not add an AWS_REGION_NAME alias + # here; no code reads it, so it was removed as dead config. + DYNAMODB_TABLE_NAME: ${self:service}-${sls:stage} + # --- secrets: Slack --- + SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN} + SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET} + # --- secrets: LLM provider keys --- + OPENAI_API_KEY: ${env:OPENAI_API_KEY, ''} + XAI_API_KEY: ${env:XAI_API_KEY, ''} + # --- secrets: Web search --- + TAVILY_API_KEY: ${env:TAVILY_API_KEY, ''} + # --- vars: LLM / Image --- + IMAGE_MODEL: ${env:IMAGE_MODEL, 'gpt-image-1'} + IMAGE_PROVIDER: ${env:IMAGE_PROVIDER, 'openai'} + LLM_MODEL: ${env:LLM_MODEL, 'gpt-4o-mini'} + LLM_PROVIDER: ${env:LLM_PROVIDER, 'openai'} + # --- vars: Agent behavior --- + AGENT_MAX_STEPS: ${env:AGENT_MAX_STEPS, '3'} + MAX_OUTPUT_TOKENS: ${env:MAX_OUTPUT_TOKENS, '4096'} + RESPONSE_LANGUAGE: ${env:RESPONSE_LANGUAGE, 'ko'} + # --- vars: Slack UX --- + BOT_CURSOR: ${env:BOT_CURSOR, ':robot_face:'} + MAX_LEN_SLACK: ${env:MAX_LEN_SLACK, '3000'} + SYSTEM_MESSAGE: ${env:SYSTEM_MESSAGE, ''} + PERSONA_MESSAGE: ${env:PERSONA_MESSAGE, ''} + # --- vars: Access control / throttle --- + ALLOWED_CHANNEL_IDS: ${env:ALLOWED_CHANNEL_IDS, ''} + ALLOWED_CHANNEL_MESSAGE: ${env:ALLOWED_CHANNEL_MESSAGE, ''} + ALLOWED_USER_IDS: ${env:ALLOWED_USER_IDS, ''} + ALLOWED_USER_MESSAGE: ${env:ALLOWED_USER_MESSAGE, ''} + MAX_THROTTLE_COUNT: ${env:MAX_THROTTLE_COUNT, '100'} + # --- vars: Storage --- + MAX_HISTORY_CHARS: ${env:MAX_HISTORY_CHARS, '4000'} + # --- vars: Observability --- + LOG_LEVEL: ${env:LOG_LEVEL, 'INFO'} + # --- vars: Document / time --- + DEFAULT_TIMEZONE: ${env:DEFAULT_TIMEZONE, 'Asia/Seoul'} + MAX_DOC_BYTES: ${env:MAX_DOC_BYTES, '26214400'} + MAX_DOC_CHARS: ${env:MAX_DOC_CHARS, '20000'} + MAX_DOC_PAGES: ${env:MAX_DOC_PAGES, '50'} + # --- vars: Web fetch --- + JINA_READER_BASE: ${env:JINA_READER_BASE, 'https://r.jina.ai'} + MAX_WEB_BYTES: ${env:MAX_WEB_BYTES, '2097152'} + MAX_WEB_CHARS: ${env:MAX_WEB_CHARS, '8000'} + MAX_WEB_LINKS: ${env:MAX_WEB_LINKS, '20'} + iam: + role: + statements: + - Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:PutItem + - dynamodb:Query + Resource: + - !GetAtt ConversationTable.Arn + - !Join ["", [!GetAtt ConversationTable.Arn, "/index/user-index"]] + - Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + - bedrock:Converse + - bedrock:ConverseStream + Resource: "*" + # Self-invoke (async) so the receiver path can fire-and-forget a + # worker invocation and return HTTP 200 to API Gateway / Slack + # within a few hundred ms. Scoped to this function's own ARN. + - Effect: Allow + Action: + - lambda:InvokeFunction + Resource: + - !Sub "arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${sls:stage}-mention" + +plugins: + - serverless-python-requirements + +custom: + pythonRequirements: + slim: true + layer: false + noDeploy: + - pytest + - pytest-cov + - moto + - responses functions: mention: - handler: handler.lambda_handler + handler: app.lambda_handler events: - http: + path: slack/events method: post - path: /slack/events resources: Resources: - DynamoDBTable: + ConversationTable: Type: AWS::DynamoDB::Table Properties: - TableName: ${self:provider.environment.BASE_NAME}-${self:provider.stage} + TableName: ${self:service}-${sls:stage} AttributeDefinitions: - AttributeName: id AttributeType: S - AttributeName: user AttributeType: S + - AttributeName: expire_at + AttributeType: N KeySchema: - AttributeName: id KeyType: HASH @@ -57,282 +135,16 @@ resources: KeySchema: - AttributeName: user KeyType: HASH + - AttributeName: expire_at + KeyType: RANGE Projection: ProjectionType: KEYS_ONLY ProvisionedThroughput: ReadCapacityUnits: 5 WriteCapacityUnits: 5 - ProvisionedThroughput: - ReadCapacityUnits: 5 - WriteCapacityUnits: 5 TimeToLiveSpecification: AttributeName: expire_at Enabled: true - Tags: - - Key: Project - Value: ${self:provider.environment.BASE_NAME} - - S3Bucket: - Type: AWS::S3::Bucket - Properties: - BucketName: - Fn::Sub: ${self:provider.environment.BASE_NAME}-${AWS::AccountId} - Tags: - - Key: Project - Value: ${self:provider.environment.BASE_NAME} - - # --- RAG: S3 Vectors + Knowledge Base (uncomment to enable) --- - - # S3VectorBucket: - # Type: AWS::S3Vectors::VectorBucket - # Properties: - # VectorBucketName: - # Fn::Sub: ${self:provider.environment.BASE_NAME}-vectors-${AWS::AccountId} - # Tags: - # - Key: Project - # Value: ${self:provider.environment.BASE_NAME} - - # S3VectorIndex: - # Type: AWS::S3Vectors::Index - # DependsOn: S3VectorBucket - # Properties: - # VectorBucketName: - # Fn::Sub: ${self:provider.environment.BASE_NAME}-vectors-${AWS::AccountId} - # DataType: float32 - # Dimension: 1024 - # DistanceMetric: cosine - # MetadataConfiguration: - # NonFilterableMetadataKeys: - # - AMAZON_BEDROCK_TEXT - # - AMAZON_BEDROCK_METADATA - # Tags: - # - Key: Project - # Value: ${self:provider.environment.BASE_NAME} - - # BedrockKBRole: - # Type: AWS::IAM::Role - # Properties: - # RoleName: - # Fn::Sub: lambda-${self:provider.environment.BASE_NAME}-kb-role - # AssumeRolePolicyDocument: - # Version: "2012-10-17" - # Statement: - # - Effect: Allow - # Principal: - # Service: bedrock.amazonaws.com - # Action: sts:AssumeRole - # Condition: - # StringEquals: - # aws:SourceAccount: - # Ref: AWS::AccountId - # Policies: - # - PolicyName: BedrockKBPolicy - # PolicyDocument: - # Version: "2012-10-17" - # Statement: - # - Sid: S3VectorsAccess - # Effect: Allow - # Action: - # - s3vectors:PutVectors - # - s3vectors:GetVectors - # - s3vectors:DeleteVectors - # - s3vectors:QueryVectors - # - s3vectors:GetIndex - # Resource: - # Fn::GetAtt: [S3VectorIndex, IndexArn] - # - Sid: S3ListBucket - # Effect: Allow - # Action: - # - s3:ListBucket - # Resource: - # Fn::GetAtt: [S3Bucket, Arn] - # - Sid: S3GetObject - # Effect: Allow - # Action: - # - s3:GetObject - # Resource: - # Fn::Sub: - # - "${BucketArn}/*" - # - BucketArn: - # Fn::GetAtt: [S3Bucket, Arn] - # - Sid: BedrockInvokeModel - # Effect: Allow - # Action: - # - bedrock:InvokeModel - # Resource: - # Fn::Sub: arn:aws:bedrock:${self:provider.region}::foundation-model/amazon.titan-embed-text-v2:0 - # Tags: - # - Key: Project - # Value: ${self:provider.environment.BASE_NAME} - - # BedrockKnowledgeBase: - # Type: AWS::Bedrock::KnowledgeBase - # DependsOn: - # - S3VectorIndex - # - BedrockKBRole - # Properties: - # Name: - # Fn::Sub: ${self:provider.environment.BASE_NAME}-kb - # Description: Knowledge base for gurumi-ai-bot RAG - # RoleArn: - # Fn::GetAtt: [BedrockKBRole, Arn] - # KnowledgeBaseConfiguration: - # Type: VECTOR - # VectorKnowledgeBaseConfiguration: - # EmbeddingModelArn: - # Fn::Sub: arn:aws:bedrock:${self:provider.region}::foundation-model/amazon.titan-embed-text-v2:0 - # StorageConfiguration: - # Type: S3_VECTORS - # S3VectorsConfiguration: - # IndexArn: - # Fn::GetAtt: [S3VectorIndex, IndexArn] - # Tags: - # Project: ${self:provider.environment.BASE_NAME} - - # BedrockDataSource: - # Type: AWS::Bedrock::DataSource - # DependsOn: BedrockKnowledgeBase - # Properties: - # Name: ${self:provider.environment.BASE_NAME}-datasource - # KnowledgeBaseId: - # Fn::GetAtt: [BedrockKnowledgeBase, KnowledgeBaseId] - # DataDeletionPolicy: RETAIN - # DataSourceConfiguration: - # Type: S3 - # S3Configuration: - # BucketArn: - # Fn::GetAtt: [S3Bucket, Arn] - # InclusionPrefixes: - # - "documents/" - # VectorIngestionConfiguration: - # ChunkingConfiguration: - # ChunkingStrategy: FIXED_SIZE - # FixedSizeChunkingConfiguration: - # MaxTokens: 300 - # OverlapPercentage: 20 - - # --- End RAG --- - - BedrockAgentRole: - Type: AWS::IAM::Role - Properties: - RoleName: - Fn::Sub: lambda-${self:provider.environment.BASE_NAME}-agent-role - AssumeRolePolicyDocument: - Version: "2012-10-17" - Statement: - - Effect: Allow - Principal: - Service: bedrock.amazonaws.com - Action: sts:AssumeRole - Condition: - StringEquals: - aws:SourceAccount: - Ref: AWS::AccountId - ArnLike: - aws:SourceArn: - Fn::Sub: arn:aws:bedrock:${self:provider.region}:${AWS::AccountId}:agent/* - Policies: - - PolicyName: BedrockAgentPolicy - PolicyDocument: - Version: "2012-10-17" - Statement: - - Sid: InvokeModels - Effect: Allow - Action: - - bedrock:InvokeModel - - bedrock:InvokeModelWithResponseStream - Resource: - - "arn:aws:bedrock:*::foundation-model/*" - - Fn::Sub: arn:aws:bedrock:*:${AWS::AccountId}:inference-profile/* - - Sid: InferenceProfiles - Effect: Allow - Action: - - bedrock:GetFoundationModel - - bedrock:GetInferenceProfile - Resource: "*" - # --- RAG: uncomment when Knowledge Base is enabled --- - # - Sid: KnowledgeBaseRetrieve - # Effect: Allow - # Action: - # - bedrock:Retrieve - # Resource: - # Fn::GetAtt: [BedrockKnowledgeBase, KnowledgeBaseArn] - Tags: - - Key: Project - Value: ${self:provider.environment.BASE_NAME} - - BedrockAgent: - Type: AWS::Bedrock::Agent - DependsOn: - - BedrockAgentRole - # - BedrockKnowledgeBase # uncomment when KB is enabled - Properties: - AgentName: ${self:provider.environment.BASE_NAME} - Description: AWSKRUG AI assistant gurumi - FoundationModel: ${self:provider.environment.FOUNDATION_MODEL} - AgentResourceRoleArn: - Fn::GetAtt: [BedrockAgentRole, Arn] - Instruction: >- - You are Gurumi (구루미), the friendly and professional AI assistant of AWSKRUG (AWS Korea User Group). - Answer every user question directly using your own general knowledge. - You have no external tools, APIs, or knowledge bases available; never claim a request is out of scope, - out of domain, or beyond your tools - always attempt a helpful answer from your internal knowledge. - If you truly do not know something, say so honestly. Respond in natural, easy-to-understand Korean. - IdleSessionTTLInSeconds: 600 - AutoPrepare: true - # Override default ORCHESTRATION template to remove outOfDomain refusal behavior - # (Nova Pro with no actions/KB refuses everything under the default template). - PromptOverrideConfiguration: - PromptConfigurations: - - PromptType: ORCHESTRATION - PromptCreationMode: OVERRIDDEN - PromptState: ENABLED - BasePromptTemplate: | - {"system":"\n\n\nAgent Description:\n$instruction$\n\nAlways follow these instructions:\n- You have NO external actions, tools, APIs, or knowledge bases. Never call any action.\n- Answer every user question directly using your own general knowledge.\n- NEVER say a request is out of scope, out of domain, or beyond your tools. Always attempt a helpful answer.\n- If you truly do not know the factual answer, say so honestly in Korean, but still try to help.\n- Always generate a Thought within tags before responding. Keep it brief.\n- Provide your final response within tags. Respond in natural Korean.\n$knowledge_base_additional_guideline$\n$memory_guideline$\n$memory_content$\n$memory_action_guideline$\n$prompt_session_attributes$\n","messages":[{"role":"user","content":[{"text":"$question$"}]},{"role":"assistant","content":[{"text":"$agent_scratchpad$"}]},{"role":"assistant","content":[{"text":"Thought: \n"}]}]} - InferenceConfiguration: - Temperature: 0.3 - TopP: 0.9 - TopK: 50 - MaximumLength: 2048 - StopSequences: - - "" - ParserMode: DEFAULT - # --- RAG: uncomment when Knowledge Base is enabled --- - # KnowledgeBases: - # - KnowledgeBaseId: - # Fn::GetAtt: [BedrockKnowledgeBase, KnowledgeBaseId] - # Description: RAG knowledge base for document retrieval - # KnowledgeBaseState: ENABLED - Tags: - Project: ${self:provider.environment.BASE_NAME} - - BedrockAgentAlias: - Type: AWS::Bedrock::AgentAlias - DependsOn: BedrockAgent - Properties: - AgentId: - Fn::GetAtt: [BedrockAgent, AgentId] - AgentAliasName: live - Description: Production alias for gurumi agent - Tags: - Project: ${self:provider.environment.BASE_NAME} - - Outputs: - AgentId: - Value: - Fn::GetAtt: [BedrockAgent, AgentId] - AgentAliasId: - Value: - Fn::GetAtt: [BedrockAgentAlias, AgentAliasId] - # --- RAG: uncomment when Knowledge Base is enabled --- - # KnowledgeBaseId: - # Value: - # Fn::GetAtt: [BedrockKnowledgeBase, KnowledgeBaseId] - # DataSourceId: - # Value: - # Fn::GetAtt: [BedrockDataSource, DataSourceId] - -plugins: - - serverless-python-requirements - - serverless-dotenv-plugin + ProvisionedThroughput: + ReadCapacityUnits: 5 + WriteCapacityUnits: 5 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..4b5d1aa --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +"""Lambda Slack bot package.""" diff --git a/src/agent.py b/src/agent.py new file mode 100644 index 0000000..9bb7232 --- /dev/null +++ b/src/agent.py @@ -0,0 +1,253 @@ +"""Agent loop using native LLM function calling. + +No more JSON-in-prompt tool orchestration: we pass tool specs directly to +the LLM and let the provider emit structured tool_calls. The loop ends +when the LLM stops requesting tools (stop_reason == "end_turn") or we hit +`max_steps`. Duplicate tool calls (same name+args) within the loop are +short-circuited to prevent runaway loops. +""" +from __future__ import annotations + +import hashlib +import json +import logging +from dataclasses import dataclass, field +from typing import Any, Callable + +from src.llms import LLMProvider, ToolCall +from src.logging_utils import log_event +from src.tools import ToolContext, ToolExecutor, ToolRegistry + +logger = logging.getLogger(__name__) + + +@dataclass +class AgentResult: + text: str + image_url: str | None = None + steps: int = 0 + tool_calls_count: int = 0 + token_usage: dict[str, int] = field(default_factory=dict) + + +class SlackMentionAgent: + def __init__( + self, + llm: LLMProvider, + context: ToolContext, + registry: ToolRegistry, + max_steps: int, + tool_executor: ToolExecutor | None = None, + response_language: str = "ko", + system_message: str | None = None, + persona_message: str | None = None, + history: list[dict[str, Any]] | None = None, + on_stream: Callable[[str], None] | None = None, + on_step: Callable[[int, str, dict[str, Any]], None] | None = None, + max_output_tokens: int = 4096, + ): + self.llm = llm + self.context = context + self.registry = registry + self.max_steps = max_steps + self.executor = tool_executor or ToolExecutor(context, registry) + # Only close the executor we created ourselves — an injected one is + # owned by the caller (e.g. a test or a shared long-lived harness). + self._owns_executor = tool_executor is None + self.response_language = response_language + self.system_message = system_message + self.persona_message = persona_message + self.history = history or [] + self.on_stream = on_stream + # on_step(step_num, phase, detail) — phases: "tool_use", "tool_result", "compose" + self.on_step = on_step + self.max_output_tokens = max_output_tokens + + def run(self, user_message: str) -> AgentResult: + try: + return self._run(user_message) + finally: + if self._owns_executor: + # Release the ThreadPoolExecutor so Lambda warm containers + # don't accumulate idle workers across requests. + self.executor.close() + + def _run(self, user_message: str) -> AgentResult: + system = self._build_system_prompt() + messages: list[dict[str, Any]] = [*self.history, {"role": "user", "content": user_message}] + seen_calls: set[str] = set() + image_url: str | None = None + total_usage = {"input": 0, "output": 0} + tool_calls_total = 0 + steps = 0 + + for step in range(self.max_steps): + steps = step + 1 + # Pass on_stream as on_delta so the provider can stream content + # tokens live while still returning accumulated tool_calls. When + # the model starts a tool_call the provider stops forwarding + # content to avoid leaking the pre-tool preamble into the reply. + result = self.llm.chat( + system, + messages, + tools=self.registry.specs(), + max_tokens=self.max_output_tokens, + on_delta=self.on_stream, + ) + total_usage["input"] += result.token_usage.get("input", 0) + total_usage["output"] += result.token_usage.get("output", 0) + log_event(logger, "llm.hop", step=steps, stop=result.stop_reason, tool_calls=len(result.tool_calls)) + + if not result.tool_calls: + self._notify_step(steps, "compose", {}) + # If on_stream was provided, the content was already streamed + # during this hop — don't pay for a second LLM call to re-do it. + return AgentResult( + text=(result.content or "").strip(), + image_url=image_url, + steps=steps, + tool_calls_count=tool_calls_total, + token_usage=total_usage, + ) + + tool_names = [c.name for c in result.tool_calls] + self._notify_step(steps, "tool_use", {"tools": tool_names}) + + messages.append( + { + "role": "assistant", + "content": result.content or "", + "tool_calls": [ + {"id": c.id, "name": c.name, "arguments": c.arguments} for c in result.tool_calls + ], + } + ) + + for call in result.tool_calls: + tool_calls_total += 1 + signature = self._call_signature(call) + if signature in seen_calls: + tool_result = {"ok": False, "error": "duplicate call skipped"} + else: + seen_calls.add(signature) + tool_result = self.executor.execute(call) + log_event(logger, "tool.result", step=steps, tool=call.name, ok=tool_result.get("ok", False)) + + self._notify_step( + steps, + "tool_result", + {"tool": call.name, "ok": bool(tool_result.get("ok")), "error": tool_result.get("error")}, + ) + + if call.name == "generate_image" and tool_result.get("ok"): + permalink = (tool_result.get("result") or {}).get("permalink") + if permalink: + image_url = permalink + + messages.append( + { + "role": "tool", + "tool_call_id": call.id, + "content": json.dumps(tool_result, ensure_ascii=False), + } + ) + + # max_steps reached — force one final compose without tools. + self._notify_step(steps, "compose", {"max_steps_hit": True}) + final_text = self._compose_without_tools(system, messages) + return AgentResult(text=final_text, image_url=image_url, steps=steps, tool_calls_count=tool_calls_total, token_usage=total_usage) + + # ------------------------------------------------------------------ # + + def _build_system_prompt(self) -> str: + """Assemble the system prompt from three layers: + + 1. Task rules (owned by code, always present) — how to plan, call + tools, render Slack replies, look up attachments. + 2. Operator policy (`SYSTEM_MESSAGE`, optional) — extra organization + or channel-specific policy appended on top of task rules. + 3. Persona (`PERSONA_MESSAGE`, optional) — answer style / tone. + + The language directive is re-emphasized at the very end so the + model does not drift even if persona is in a different language. + """ + # Layer 1 — task rules. Fixed in code so operators cannot accidentally + # delete the loop's planning / parallel-call / tool guidance by + # overriding SYSTEM_MESSAGE. + task_rules = ( + "You are an assistant for Slack mention requests. Plan work, " + "call tools when needed, and provide concise helpful answers. " + "When multiple independent tools are required, emit their " + "tool_calls in parallel within a single turn instead of running " + "them one-by-one. If a tool returns `ok:false`, tell the user " + "briefly what failed (one short line) and, when it makes sense, " + "suggest an alternative — do not retry blindly with the same " + "arguments and do not fabricate a result." + ) + # Slack rendering rules. The streaming fallback path posts via plain + # chat.postMessage / chat.update with a `text` field, which Slack + # renders as mrkdwn — NOT GitHub markdown. Guide the model so replies + # don't surface raw `**bold**` or `[text](url)` strings. + slack_rules = ( + "When you call the `generate_image` tool, the generated image is " + "already uploaded inline into the Slack thread. Do NOT repeat the " + "image URL or permalink in your text reply — just describe or " + "caption the image briefly. The user sees the picture attached " + "directly; a URL line is duplicate noise.\n" + "Slack renders mrkdwn, not GitHub markdown. Use `*bold*` with " + "single asterisks, `_italic_`, `` `code` ``, and " + "`` for links. Do NOT use `**bold**` or " + "`[label](url)` — those appear as raw text in Slack." + ) + attachment_rules = ( + "If the user asks about an image or document in the current " + "thread, call `read_attached_images` / `read_attached_document` " + "first — they target files attached to the triggering message. " + "If the result is an empty list, the attachment lives on an " + "earlier message: call `fetch_thread_history`, take the " + "`url_private_download` values from that message's `files`, " + "then call `read_attached_images(urls=[...])` or " + "`read_attached_document(urls=[...])` with them. When the user's " + "reference to a file is ambiguous (e.g. '이 사진', '아까 그 " + "파일'), call `read_attached_images` (or " + "`read_attached_document`) speculatively first — it returns an " + "empty list cheaply when no file is attached to the current " + "message, and that signals you to fall back to " + "`fetch_thread_history`. Never guess or fabricate file URLs." + ) + sections = [task_rules, slack_rules, attachment_rules] + + # Layer 2 — operator policy (optional, append). + if self.system_message: + sections.append(f"Additional policy:\n{self.system_message}") + + # Layer 3 — persona / answer style (optional, append). + if self.persona_message: + sections.append(f"Response style:\n{self.persona_message}") + + sections.append(f"Respond in language: {self.response_language}.") + return "\n\n".join(sections) + + @staticmethod + def _call_signature(call: ToolCall) -> str: + args_blob = json.dumps(call.arguments or {}, sort_keys=True, ensure_ascii=False) + return f"{call.name}:{hashlib.sha1(args_blob.encode()).hexdigest()[:12]}" + + def _compose_without_tools(self, system: str, messages: list[dict[str, Any]]) -> str: + """Force a final answer when max_steps is reached — no tools permitted.""" + directive = ( + "Provide the final answer now. Do not request any more tools; summarize based on prior observations." + ) + messages = [*messages, {"role": "user", "content": directive}] + if self.on_stream: + return self.llm.stream_chat(system, messages, on_delta=self.on_stream, max_tokens=self.max_output_tokens) + result = self.llm.chat(system, messages, tools=None, max_tokens=self.max_output_tokens) + return (result.content or "").strip() + + def _notify_step(self, step: int, phase: str, detail: dict[str, Any]) -> None: + if not self.on_step: + return + try: + self.on_step(step, phase, detail) + except Exception as exc: # noqa: BLE001 + logger.debug("on_step callback failed: %s", exc) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..633452b --- /dev/null +++ b/src/config.py @@ -0,0 +1,171 @@ +import logging +import os +from dataclasses import dataclass, field +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def _load_env_local() -> None: + """Load .env.local from the project root when running locally.""" + try: + from dotenv import load_dotenv + + env_path = Path(__file__).resolve().parent.parent / ".env.local" + if env_path.exists(): + load_dotenv(env_path, override=False) + except ImportError: + pass + + +_load_env_local() + + +_VALID_LANGUAGES = {"ko", "en"} +_VALID_PROVIDERS = {"openai", "bedrock", "xai"} + + +def _int_env(name: str, default: int, minimum: int = 1) -> int: + raw = os.getenv(name) + if raw is None or raw == "": + return default + try: + value = int(raw) + except ValueError: + logger.warning("invalid int for %s=%r, using default=%d", name, raw, default) + return default + if value < minimum: + logger.warning("%s=%d below minimum %d, using minimum", name, value, minimum) + return minimum + return value + + +def _tz_env(name: str, default: str) -> str: + """Return a validated IANA timezone name, warning + falling back on bad input.""" + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + raw = os.getenv(name, "").strip() + if not raw: + return default + try: + ZoneInfo(raw) + except ZoneInfoNotFoundError: + logger.warning("invalid %s=%r, falling back to %s", name, raw, default) + return default + return raw + + +def _list_env(name: str) -> list[str]: + raw = os.getenv(name, "").strip() + if not raw or raw.lower() == "none": + return [] + return [part.strip() for part in raw.split(",") if part.strip()] + + +def _enum_env(name: str, default: str, valid: set[str]) -> str: + value = os.getenv(name, default).strip().lower() + if value not in valid: + logger.warning("invalid %s=%r, falling back to %s", name, value, default) + return default + return value + + +def _https_url_env(name: str, default: str) -> str: + """Return an https:// URL from env, falling back to default on empty + or non-https values.""" + raw = os.getenv(name, "").strip() + if not raw: + return default + if not raw.startswith("https://"): + logger.warning("invalid %s=%r (not https), falling back to %s", name, raw, default) + return default + return raw + + +@dataclass(frozen=True) +class Settings: + slack_bot_token: str + slack_signing_secret: str + llm_provider: str + llm_model: str + image_provider: str + image_model: str + agent_max_steps: int + response_language: str + dynamodb_table_name: str + aws_region: str + allowed_channel_ids: list[str] = field(default_factory=list) + allowed_channel_message: str = "" + allowed_user_ids: list[str] = field(default_factory=list) + allowed_user_message: str = "" + max_len_slack: int = 3000 + max_throttle_count: int = 100 + max_history_chars: int = 4000 + max_output_tokens: int = 4096 + bot_cursor: str = ":robot_face:" + system_message: str | None = None + persona_message: str | None = None + tavily_api_key: str | None = None + xai_api_key: str | None = None + log_level: str = "INFO" + default_timezone: str = "Asia/Seoul" + max_doc_chars: int = 20_000 + max_doc_pages: int = 50 + max_doc_bytes: int = 25 * 1024 * 1024 + max_web_chars: int = 8000 + max_web_bytes: int = 2 * 1024 * 1024 + max_web_links: int = 20 + jina_reader_base: str = "https://r.jina.ai" + + @classmethod + def from_env(cls) -> "Settings": + llm_provider = _enum_env("LLM_PROVIDER", "openai", _VALID_PROVIDERS) + image_provider = _enum_env( + "IMAGE_PROVIDER", + os.getenv("LLM_PROVIDER", "openai").strip().lower() or "openai", + _VALID_PROVIDERS, + ) + response_language = _enum_env("RESPONSE_LANGUAGE", "ko", _VALID_LANGUAGES) + system_message = os.getenv("SYSTEM_MESSAGE", "").strip() or None + persona_message = os.getenv("PERSONA_MESSAGE", "").strip() or None + tavily_key = os.getenv("TAVILY_API_KEY", "").strip() or None + xai_key = os.getenv("XAI_API_KEY", "").strip() or None + return cls( + slack_bot_token=os.getenv("SLACK_BOT_TOKEN", "").strip(), + slack_signing_secret=os.getenv("SLACK_SIGNING_SECRET", "").strip(), + llm_provider=llm_provider, + llm_model=os.getenv("LLM_MODEL", "gpt-4o-mini").strip(), + image_provider=image_provider, + image_model=os.getenv("IMAGE_MODEL", "gpt-image-1").strip(), + agent_max_steps=_int_env("AGENT_MAX_STEPS", 3, minimum=1), + response_language=response_language, + dynamodb_table_name=os.getenv("DYNAMODB_TABLE_NAME", "lambda-gurumi-bot-dev").strip(), + aws_region=os.getenv("AWS_REGION", "us-east-1").strip(), + allowed_channel_ids=_list_env("ALLOWED_CHANNEL_IDS"), + allowed_channel_message=os.getenv("ALLOWED_CHANNEL_MESSAGE", "구루미에게 질문은 {} 채널을 이용해 주세요~").strip(), + allowed_user_ids=_list_env("ALLOWED_USER_IDS"), + allowed_user_message=os.getenv("ALLOWED_USER_MESSAGE", "허용된 유저만 응답합니다.").strip(), + max_len_slack=_int_env("MAX_LEN_SLACK", 2000, minimum=500), + max_throttle_count=_int_env("MAX_THROTTLE_COUNT", 100, minimum=1), + max_history_chars=_int_env("MAX_HISTORY_CHARS", 4000, minimum=500), + max_output_tokens=_int_env("MAX_OUTPUT_TOKENS", 4096, minimum=256), + bot_cursor=os.getenv("BOT_CURSOR", ":robot_face:").strip() or ":robot_face:", + system_message=system_message, + persona_message=persona_message, + tavily_api_key=tavily_key, + xai_api_key=xai_key, + log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO", + default_timezone=_tz_env("DEFAULT_TIMEZONE", "Asia/Seoul"), + max_doc_chars=_int_env("MAX_DOC_CHARS", 20_000, minimum=1000), + max_doc_pages=_int_env("MAX_DOC_PAGES", 50, minimum=1), + max_doc_bytes=_int_env("MAX_DOC_BYTES", 25 * 1024 * 1024, minimum=64 * 1024), + max_web_chars=_int_env("MAX_WEB_CHARS", 8000, minimum=500), + max_web_bytes=_int_env("MAX_WEB_BYTES", 2 * 1024 * 1024, minimum=64 * 1024), + max_web_links=_int_env("MAX_WEB_LINKS", 20, minimum=0), + jina_reader_base=_https_url_env("JINA_READER_BASE", "https://r.jina.ai"), + ) + + def require_slack_credentials(self) -> None: + """Lazy validation — call from request handlers, not at import time.""" + if not self.slack_bot_token or not self.slack_signing_secret: + raise RuntimeError("SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET are required") diff --git a/src/dedup.py b/src/dedup.py new file mode 100644 index 0000000..99aa633 --- /dev/null +++ b/src/dedup.py @@ -0,0 +1,155 @@ +"""DynamoDB-backed idempotency and thread conversation memory. + +Single table, partition key `id`. Two key prefixes share the table: +- `dedup:{client_msg_id}` — one-shot reservation for request deduplication. +- `ctx:{thread_ts}` — conversation history for thread memory. + +A GSI on `user` + `expire_at` enables per-user active-request counting +for throttling. +""" +from __future__ import annotations + +import json +import logging +import time +from dataclasses import dataclass +from typing import Any + +import boto3 +from botocore.exceptions import ClientError + +logger = logging.getLogger(__name__) + + +@dataclass +class _BaseStore: + table_name: str + region: str + _table: Any = None + + def _get_table(self) -> Any: + if self._table is None: + self._table = boto3.resource("dynamodb", region_name=self.region).Table(self.table_name) + return self._table + + +class DedupStore(_BaseStore): + """Atomic reservation for Slack retry deduplication + user throttle count.""" + + GSI_NAME = "user-index" + + def reserve(self, event_key: str, user: str = "system", ttl_seconds: int = 3600) -> bool: + """Return True if reservation succeeds, False if already reserved. + + Uses ConditionExpression=attribute_not_exists(id) for atomicity — no + get-then-put race window. + """ + table = self._get_table() + expire_at = int(time.time()) + ttl_seconds + try: + table.put_item( + Item={"id": f"dedup:{event_key}", "user": user, "expire_at": expire_at}, + ConditionExpression="attribute_not_exists(id)", + ) + return True + except ClientError as exc: + if exc.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + return False + logger.warning("dedup reserve failed: %s", exc) + raise + + def count_user_active(self, user: str) -> int: + """Number of non-expired reservations for a user (throttle check).""" + if not user: + return 0 + table = self._get_table() + now = int(time.time()) + try: + res = table.query( + IndexName=self.GSI_NAME, + KeyConditionExpression="#u = :u AND expire_at > :now", + ExpressionAttributeNames={"#u": "user"}, + ExpressionAttributeValues={":u": user, ":now": now}, + Select="COUNT", + ) + return int(res.get("Count", 0)) + except ClientError as exc: + logger.warning("count_user_active failed: %s", exc) + return 0 + + +class ConversationStore(_BaseStore): + """Thread conversation history with TTL.""" + + def get(self, thread_ts: str) -> list[dict[str, Any]]: + if not thread_ts: + return [] + table = self._get_table() + try: + res = table.get_item(Key={"id": f"ctx:{thread_ts}"}) + except ClientError as exc: + logger.warning("conversation get failed: %s", exc) + return [] + item = res.get("Item") + if not item: + return [] + raw = item.get("conversation") or "[]" + try: + messages = json.loads(raw) + return messages if isinstance(messages, list) else [] + except json.JSONDecodeError: + logger.warning("malformed conversation blob for %s", thread_ts) + return [] + + def put( + self, + thread_ts: str, + user: str, + messages: list[dict[str, Any]], + ttl_seconds: int = 3600, + max_chars: int = 4000, + ) -> None: + if not thread_ts: + return + trimmed = self.truncate_to_chars(messages, max_chars) + table = self._get_table() + try: + table.put_item( + Item={ + "id": f"ctx:{thread_ts}", + "user": user or "unknown", + "expire_at": int(time.time()) + ttl_seconds, + "conversation": json.dumps(trimmed, ensure_ascii=False), + } + ) + except ClientError as exc: + logger.warning("conversation put failed: %s", exc) + + # json.dumps with default separators renders a list as `[item, item, ...]` + # so exact serialized size is: 2 (brackets) + sum(sizes) + 2 * (n - 1) (", ") + _JSON_ARRAY_BRACKETS = 2 + _JSON_ITEM_SEPARATOR = 2 + + @staticmethod + def truncate_to_chars(messages: list[dict[str, Any]], max_chars: int) -> list[dict[str, Any]]: + """Drop oldest messages until total serialized size <= max_chars. + + Previous implementation was O(n²) — it re-serialized the full kept + list on every pop. This walks the list once, serializes each message + individually, then accumulates from the newest end backwards until + adding the next (older) message would exceed the budget. Matches the + exact byte count of `json.dumps(kept, ensure_ascii=False)` using the + default item separator `", "`. + """ + if not messages: + return [] + sizes = [len(json.dumps(m, ensure_ascii=False)) for m in messages] + total = ConversationStore._JSON_ARRAY_BRACKETS + start = len(messages) # exclusive; empty kept set serializes to "[]" + for i in range(len(messages) - 1, -1, -1): + add_cost = sizes[i] + (ConversationStore._JSON_ITEM_SEPARATOR if start < len(messages) else 0) + if total + add_cost > max_chars: + break + total += add_cost + start = i + return messages[start:] diff --git a/src/llms/__init__.py b/src/llms/__init__.py new file mode 100644 index 0000000..b8de56e --- /dev/null +++ b/src/llms/__init__.py @@ -0,0 +1,16 @@ +"""LLM provider package. + +Public re-exports. Importing submodule internals directly is fine for +provider-specific wiring (e.g. `from src.llms.bedrock import BedrockProvider` +in tests), but callers should prefer the names exported here. +""" +from src.llms.base import LLMProvider, LLMResult, ToolCall, ToolSpec +from src.llms.factory import get_llm + +__all__ = [ + "LLMProvider", + "LLMResult", + "ToolCall", + "ToolSpec", + "get_llm", +] diff --git a/src/llms/base.py b/src/llms/base.py new file mode 100644 index 0000000..723f633 --- /dev/null +++ b/src/llms/base.py @@ -0,0 +1,80 @@ +"""Shared protocol, dataclasses, and retry helper for LLM providers.""" +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Literal, Protocol + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Types +# --------------------------------------------------------------------------- # + +ToolSpec = dict[str, Any] # {"name","description","parameters"(JSON Schema)} + + +@dataclass +class ToolCall: + id: str + name: str + arguments: dict[str, Any] + + +@dataclass +class LLMResult: + content: str + tool_calls: list[ToolCall] = field(default_factory=list) + stop_reason: Literal["end_turn", "tool_use", "max_tokens", "other"] = "end_turn" + token_usage: dict[str, int] = field(default_factory=dict) + + +class LLMProvider(Protocol): + def chat( + self, + system: str, + messages: list[dict[str, Any]], + tools: list[ToolSpec] | None = None, + max_tokens: int = 1024, + on_delta: Callable[[str], None] | None = None, + ) -> LLMResult: ... + + def stream_chat( + self, + system: str, + messages: list[dict[str, Any]], + on_delta: Callable[[str], None], + max_tokens: int = 1024, + ) -> str: ... + + def describe_image(self, image_bytes: bytes, mime_type: str) -> str: ... + + def generate_image(self, prompt: str) -> bytes: ... + + +# --------------------------------------------------------------------------- # +# Retry helper +# --------------------------------------------------------------------------- # + +_RETRYABLE_BEDROCK = {"ThrottlingException", "ServiceQuotaExceededException", "ModelTimeoutException"} + + +def _with_retry(fn: Callable[[], Any], label: str, attempts: int = 3) -> Any: + delay = 1.0 + last_exc: Exception | None = None + for attempt in range(attempts): + try: + return fn() + except Exception as exc: # noqa: BLE001 + last_exc = exc + code = getattr(getattr(exc, "response", None), "get", lambda _k, _d=None: None)("Error", {}).get("Code") if hasattr(exc, "response") else None + if code in _RETRYABLE_BEDROCK and attempt < attempts - 1: + logger.warning("%s retryable (%s), backoff %.1fs", label, code, delay) + time.sleep(delay) + delay *= 2 + continue + raise + if last_exc: + raise last_exc diff --git a/src/llms/bedrock.py b/src/llms/bedrock.py new file mode 100644 index 0000000..2d80b83 --- /dev/null +++ b/src/llms/bedrock.py @@ -0,0 +1,436 @@ +"""BedrockProvider — family-routed across Anthropic Claude, Amazon Nova, Stability.""" +from __future__ import annotations + +import base64 +import json +import logging +import uuid +from typing import Any, Callable, Literal + +import boto3 +from botocore.config import Config + +from src.llms.base import LLMResult, ToolCall, ToolSpec, _with_retry + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Bedrock +# --------------------------------------------------------------------------- # + + +_INFERENCE_PROFILE_PREFIXES = ("us.", "eu.", "apac.", "global.") + + +def _strip_inference_profile_prefix(model_id: str) -> str: + """Return the bare family id from a Bedrock model or inference-profile id. + + Inference profile IDs prefix the family with a region routing hint, e.g. + `us.anthropic.claude-haiku-4-5-20251001-v1:0`. For family-level routing + ("is this a Claude? a Nova? Titan?") we care about the bare portion. + """ + for p in _INFERENCE_PROFILE_PREFIXES: + if model_id.startswith(p): + return model_id[len(p):] + return model_id + + +class BedrockProvider: + def __init__(self, model: str, image_model: str, region: str): + self.model = model + self.image_model = image_model + self.region = region + self._client = None + + def _get_client(self): + if self._client is None: + # Claude Sonnet/Opus generations regularly run 60-120s server-side. + # botocore's default read_timeout=60s + legacy retry mode (5 attempts) + # silently re-invokes the model up to 5x and exhausts Lambda's 300s + # budget before any exception surfaces (see: 5x AWS/Bedrock + # Invocations metric for a single user turn). Keep the read_timeout + # close to the worker Lambda's own timeout, drop retries to standard + # mode with 2 attempts so a transient throttle still recovers + # without amplifying read-timeout stalls. + cfg = Config( + connect_timeout=10, + read_timeout=290, + retries={"max_attempts": 2, "mode": "standard"}, + ) + self._client = boto3.client( + "bedrock-runtime", + region_name=self.region, + config=cfg, + ) + return self._client + + @property + def _text_family(self) -> str: + return _strip_inference_profile_prefix(self.model) + + @property + def _image_family(self) -> str: + return _strip_inference_profile_prefix(self.image_model) + + # -- text / tool use ---------------------------------------------------- # + + def chat( + self, + system: str, + messages: list[dict[str, Any]], + tools: list[ToolSpec] | None = None, + max_tokens: int = 1024, + on_delta: Callable[[str], None] | None = None, + ) -> LLMResult: + # Bedrock tool_use streaming is not yet implemented in this provider; + # accept the on_delta parameter for API compatibility but use the + # blocking path, then emit the final content as a single delta so + # callers still receive *something* through the streaming channel. + family = self._text_family + if family.startswith("anthropic.claude"): + result = self._claude_chat(system, messages, tools, max_tokens) + elif family.startswith("amazon.nova"): + result = self._nova_chat(system, messages, tools, max_tokens) + else: + result = self._claude_chat(system, messages, None, max_tokens) + if on_delta is not None and result.content and not result.tool_calls: + on_delta(result.content) + return result + + def _claude_chat( + self, + system: str, + messages: list[dict[str, Any]], + tools: list[ToolSpec] | None, + max_tokens: int, + ) -> LLMResult: + body: dict[str, Any] = { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": max_tokens, + "system": system, + "messages": self._to_anthropic_messages(messages), + } + if tools: + body["tools"] = [ + {"name": t["name"], "description": t["description"], "input_schema": t["parameters"]} + for t in tools + ] + + client = self._get_client() + response = _with_retry( + lambda: client.invoke_model(modelId=self.model, body=json.dumps(body)), + label="bedrock.invoke_model", + ) + payload = json.loads(response["body"].read()) + + content_text_parts: list[str] = [] + tool_calls: list[ToolCall] = [] + for block in payload.get("content", []): + if block.get("type") == "text": + content_text_parts.append(block.get("text", "")) + elif block.get("type") == "tool_use": + tool_calls.append( + ToolCall( + id=block.get("id", str(uuid.uuid4())), + name=block.get("name", ""), + arguments=block.get("input", {}) or {}, + ) + ) + + stop_reason_raw = payload.get("stop_reason", "end_turn") + stop_reason: Literal["end_turn", "tool_use", "max_tokens", "other"] + if stop_reason_raw == "tool_use": + stop_reason = "tool_use" + elif stop_reason_raw == "max_tokens": + stop_reason = "max_tokens" + elif stop_reason_raw == "end_turn": + stop_reason = "end_turn" + else: + stop_reason = "other" + + usage = payload.get("usage") or {} + return LLMResult( + content="".join(content_text_parts), + tool_calls=tool_calls, + stop_reason=stop_reason, + token_usage={"input": usage.get("input_tokens", 0) or 0, "output": usage.get("output_tokens", 0) or 0}, + ) + + def _nova_chat( + self, + system: str, + messages: list[dict[str, Any]], + tools: list[ToolSpec] | None, + max_tokens: int, + ) -> LLMResult: + client = self._get_client() + payload: dict[str, Any] = { + "modelId": self.model, + "system": [{"text": system}], + "messages": self._to_nova_messages(messages), + "inferenceConfig": {"maxTokens": max_tokens, "temperature": 0.2}, + } + if tools: + payload["toolConfig"] = { + "tools": [ + {"toolSpec": {"name": t["name"], "description": t["description"], "inputSchema": {"json": t["parameters"]}}} + for t in tools + ] + } + + response = _with_retry(lambda: client.converse(**payload), label="bedrock.converse") + out_msg = response.get("output", {}).get("message", {}) + content_text_parts: list[str] = [] + tool_calls: list[ToolCall] = [] + for block in out_msg.get("content", []): + if "text" in block: + content_text_parts.append(block["text"]) + elif "toolUse" in block: + tu = block["toolUse"] + tool_calls.append( + ToolCall(id=tu.get("toolUseId") or str(uuid.uuid4()), name=tu.get("name", ""), arguments=tu.get("input", {}) or {}) + ) + + stop_reason_raw = response.get("stopReason", "end_turn") + stop_reason: Literal["end_turn", "tool_use", "max_tokens", "other"] + if stop_reason_raw == "tool_use": + stop_reason = "tool_use" + elif stop_reason_raw == "max_tokens": + stop_reason = "max_tokens" + elif stop_reason_raw == "end_turn": + stop_reason = "end_turn" + else: + stop_reason = "other" + + usage = response.get("usage") or {} + return LLMResult( + content="".join(content_text_parts), + tool_calls=tool_calls, + stop_reason=stop_reason, + token_usage={"input": usage.get("inputTokens", 0) or 0, "output": usage.get("outputTokens", 0) or 0}, + ) + + # -- streaming --------------------------------------------------------- # + + def stream_chat( + self, + system: str, + messages: list[dict[str, Any]], + on_delta: Callable[[str], None], + max_tokens: int = 1024, + ) -> str: + # Bedrock streaming implementation: Claude Messages stream or Converse stream. + client = self._get_client() + full = "" + family = self._text_family + if family.startswith("anthropic.claude"): + body = { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": max_tokens, + "system": system, + "messages": self._to_anthropic_messages(messages), + } + response = client.invoke_model_with_response_stream(modelId=self.model, body=json.dumps(body)) + for event in response.get("body", []): + chunk = event.get("chunk", {}) + if not chunk: + continue + payload = json.loads(chunk.get("bytes", b"{}")) + if payload.get("type") == "content_block_delta": + delta = (payload.get("delta") or {}).get("text") or "" + if delta: + full += delta + on_delta(delta) + return full + + # Nova Converse stream + response = client.converse_stream( + modelId=self.model, + system=[{"text": system}], + messages=self._to_nova_messages(messages), + inferenceConfig={"maxTokens": max_tokens, "temperature": 0.2}, + ) + for event in response.get("stream", []): + cbd = event.get("contentBlockDelta") + if cbd: + delta = (cbd.get("delta") or {}).get("text") or "" + if delta: + full += delta + on_delta(delta) + return full + + # -- vision / image ----------------------------------------------------- # + + _NOVA_IMAGE_FORMATS = { + "image/png": "png", + "image/jpeg": "jpeg", + "image/jpg": "jpeg", + "image/gif": "gif", + "image/webp": "webp", + } + + def describe_image(self, image_bytes: bytes, mime_type: str) -> str: + # Family-route the same way chat() does. Nova models speak the Converse + # API with an `image` content block; sending Claude's Messages body at a + # Nova ID raises ValidationException. Claude (and unknown families, to + # preserve prior behaviour for new Claude-compatible IDs) stays on the + # Messages API. + if self._text_family.startswith("amazon.nova"): + return self._nova_describe_image(image_bytes, mime_type) + return self._claude_describe_image(image_bytes, mime_type) + + def _claude_describe_image(self, image_bytes: bytes, mime_type: str) -> str: + encoded = base64.b64encode(image_bytes).decode("utf-8") + body = { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 512, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image for a Slack conversation."}, + {"type": "image", "source": {"type": "base64", "media_type": mime_type, "data": encoded}}, + ], + } + ], + } + client = self._get_client() + response = client.invoke_model(modelId=self.model, body=json.dumps(body)) + payload = json.loads(response["body"].read()) + for block in payload.get("content", []): + if block.get("type") == "text": + return block.get("text", "") + return "" + + def _nova_describe_image(self, image_bytes: bytes, mime_type: str) -> str: + fmt = self._NOVA_IMAGE_FORMATS.get((mime_type or "").lower(), "png") + client = self._get_client() + response = client.converse( + modelId=self.model, + messages=[ + { + "role": "user", + "content": [ + {"text": "Describe this image for a Slack conversation."}, + {"image": {"format": fmt, "source": {"bytes": image_bytes}}}, + ], + } + ], + inferenceConfig={"maxTokens": 512, "temperature": 0.2}, + ) + for block in response.get("output", {}).get("message", {}).get("content", []): + if "text" in block: + return block.get("text", "") + return "" + + def generate_image(self, prompt: str) -> bytes: + body = self._build_image_body(prompt) + client = self._get_client() + response = client.invoke_model(modelId=self.image_model, body=json.dumps(body)) + payload = json.loads(response["body"].read()) + return self._extract_image_bytes(payload) + + def _build_image_body(self, prompt: str) -> dict[str, Any]: + family = self._image_family + if family.startswith("amazon.titan-image") or family.startswith("amazon.nova-canvas"): + return { + "taskType": "TEXT_IMAGE", + "textToImageParams": {"text": prompt}, + "imageGenerationConfig": {"numberOfImages": 1, "quality": "standard", "height": 1024, "width": 1024}, + } + if family.startswith("stability."): + return {"text_prompts": [{"text": prompt}], "cfg_scale": 7, "steps": 30, "seed": 0} + raise ValueError(f"unsupported Bedrock image model: {self.image_model}") + + def _extract_image_bytes(self, payload: dict[str, Any]) -> bytes: + if "images" in payload and payload["images"]: + return base64.b64decode(payload["images"][0]) + if "artifacts" in payload and payload["artifacts"]: + return base64.b64decode(payload["artifacts"][0]["base64"]) + raise ValueError("no image returned from Bedrock") + + # -- format helpers ----------------------------------------------------- # + + @staticmethod + def _to_anthropic_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Translate our canonical messages format into Anthropic Messages API shape. + + Our format mirrors OpenAI's: role=user/assistant/tool, content can be str + or list. We map `tool` role to a user message with a tool_result block, + and `assistant` messages with tool_calls into tool_use content blocks. + """ + out: list[dict[str, Any]] = [] + for msg in messages: + role = msg.get("role") + if role == "tool": + out.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": msg.get("tool_call_id", ""), + "content": msg.get("content", ""), + } + ], + } + ) + elif role == "assistant" and msg.get("tool_calls"): + blocks: list[dict[str, Any]] = [] + if msg.get("content"): + blocks.append({"type": "text", "text": msg["content"]}) + for tc in msg["tool_calls"]: + blocks.append( + {"type": "tool_use", "id": tc["id"], "name": tc["name"], "input": tc.get("arguments", {})} + ) + out.append({"role": "assistant", "content": blocks}) + else: + out.append({"role": role or "user", "content": msg.get("content", "")}) + return out + + @staticmethod + def _coerce_nova_text(content: Any) -> str: + """Convert arbitrary content into a string suitable for Nova's + `{"text": ...}` content block. + + Strings pass through unchanged. Non-strings (dict, list — e.g. if a + future caller hands us raw tool_result dicts) are JSON-serialized + instead of going through `str(...)`, which would emit Python repr and + leave the LLM unable to parse downstream. + """ + if content is None: + return "" + if isinstance(content, str): + return content + return json.dumps(content, ensure_ascii=False) + + @staticmethod + def _to_nova_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for msg in messages: + role = msg.get("role") + if role == "tool": + out.append( + { + "role": "user", + "content": [ + { + "toolResult": { + "toolUseId": msg.get("tool_call_id", ""), + "content": [{"text": BedrockProvider._coerce_nova_text(msg.get("content"))}], + } + } + ], + } + ) + elif role == "assistant" and msg.get("tool_calls"): + blocks: list[dict[str, Any]] = [] + if msg.get("content"): + blocks.append({"text": BedrockProvider._coerce_nova_text(msg["content"])}) + for tc in msg["tool_calls"]: + blocks.append({"toolUse": {"toolUseId": tc["id"], "name": tc["name"], "input": tc.get("arguments", {})}}) + out.append({"role": "assistant", "content": blocks}) + else: + out.append({"role": role or "user", "content": [{"text": BedrockProvider._coerce_nova_text(msg.get("content"))}]}) + return out diff --git a/src/llms/composite.py b/src/llms/composite.py new file mode 100644 index 0000000..6b84395 --- /dev/null +++ b/src/llms/composite.py @@ -0,0 +1,26 @@ +"""CompositeProvider — wrap separate text and image providers.""" +from __future__ import annotations + +from dataclasses import dataclass + +from src.llms.base import LLMProvider + + +@dataclass +class _CompositeProvider: + """Delegates generate_image to a different provider than chat/vision.""" + + text: LLMProvider + image: LLMProvider + + def chat(self, system, messages, tools=None, max_tokens=1024, on_delta=None): + return self.text.chat(system, messages, tools=tools, max_tokens=max_tokens, on_delta=on_delta) + + def stream_chat(self, system, messages, on_delta, max_tokens=1024): + return self.text.stream_chat(system, messages, on_delta, max_tokens=max_tokens) + + def describe_image(self, image_bytes, mime_type): + return self.text.describe_image(image_bytes, mime_type) + + def generate_image(self, prompt): + return self.image.generate_image(prompt) diff --git a/src/llms/factory.py b/src/llms/factory.py new file mode 100644 index 0000000..098f6cf --- /dev/null +++ b/src/llms/factory.py @@ -0,0 +1,37 @@ +"""get_llm — build an LLM provider from explicit parameters.""" +from __future__ import annotations + +from src.llms.base import LLMProvider +from src.llms.bedrock import BedrockProvider +from src.llms.composite import _CompositeProvider +from src.llms.openai import OpenAIProvider +from src.llms.xai import XAIProvider + + +def get_llm( + provider: str, + model: str, + image_provider: str, + image_model: str, + region: str = "us-east-1", + api_keys: dict[str, str | None] | None = None, +) -> LLMProvider: + """Build an LLM client for the requested provider(s). + + `api_keys` carries per-provider keys that need explicit wiring (xAI today; + OpenAI reads OPENAI_API_KEY from env directly, Bedrock uses the AWS SDK + credential chain). + """ + api_keys = api_keys or {} + + def build(p: str) -> LLMProvider: + if p == "bedrock": + return BedrockProvider(model=model, image_model=image_model, region=region) + if p == "xai": + return XAIProvider(model=model, image_model=image_model, api_key=api_keys.get("xai")) + return OpenAIProvider(model=model, image_model=image_model) + + text = build(provider) + if image_provider == provider: + return text + return _CompositeProvider(text=text, image=build(image_provider)) diff --git a/src/llms/openai.py b/src/llms/openai.py new file mode 100644 index 0000000..7af4e08 --- /dev/null +++ b/src/llms/openai.py @@ -0,0 +1,18 @@ +"""OpenAIProvider — default OpenAI endpoint with vision and gpt-image-1.""" +from __future__ import annotations + +from typing import Any + +from src.llms.openai_wire import _OpenAICompatProvider, _is_new_gen_openai + + +class OpenAIProvider(_OpenAICompatProvider): + BASE_URL = None # default OpenAI endpoint + API_KEY_ENV_VAR = "OPENAI_API_KEY" + + def _token_params(self, max_tokens: int) -> dict[str, Any]: + # Newer OpenAI reasoning models only accept max_completion_tokens and + # reject `temperature`. Legacy chat models still use max_tokens. + if _is_new_gen_openai(self.model): + return {"max_completion_tokens": max_tokens} + return {"max_tokens": max_tokens, "temperature": 0.2} diff --git a/src/llms/openai_wire.py b/src/llms/openai_wire.py new file mode 100644 index 0000000..3c6cf7f --- /dev/null +++ b/src/llms/openai_wire.py @@ -0,0 +1,293 @@ +"""OpenAI-wire helpers and the OpenAICompat base provider. + +OpenAI and xAI both speak the OpenAI chat-completions wire format, so their +providers share this module. BedrockProvider does NOT use any of these. +""" +from __future__ import annotations + +import base64 +import json +import logging +import time +import uuid +from typing import Any, Callable, Literal + +from src.llms.base import LLMResult, ToolCall, ToolSpec + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# OpenAI +# --------------------------------------------------------------------------- # + + +_OPENAI_NEW_GENERATION_PREFIXES = ("gpt-5", "o1", "o3", "o4") + + +def _is_new_gen_openai(model: str) -> bool: + """Newer OpenAI models (gpt-5, o1/o3/o4 reasoning) use `max_completion_tokens` + and disallow `temperature` overrides.""" + return any(model.startswith(p) for p in _OPENAI_NEW_GENERATION_PREFIXES) + + +# --------------------------------------------------------------------------- # +# Module-level helpers shared between OpenAI-compatible providers (OpenAI, xAI) +# --------------------------------------------------------------------------- # + + +def _to_openai_wire_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Translate canonical messages (our agent's shape) to OpenAI's wire shape.""" + out: list[dict[str, Any]] = [] + for msg in messages: + if msg.get("role") == "assistant" and msg.get("tool_calls"): + out.append( + { + "role": "assistant", + "content": msg.get("content") or None, + "tool_calls": [ + { + "id": tc["id"], + "type": "function", + "function": { + "name": tc["name"], + "arguments": json.dumps(tc.get("arguments") or {}, ensure_ascii=False), + }, + } + for tc in msg["tool_calls"] + ], + } + ) + else: + out.append(msg) + return out + + +def _build_openai_tools_payload(tools: list[ToolSpec]) -> list[dict[str, Any]]: + return [ + { + "type": "function", + "function": { + "name": t["name"], + "description": t["description"], + "parameters": t["parameters"], + }, + } + for t in tools + ] + + +def _map_openai_finish_reason(finish: str | None) -> Literal["end_turn", "tool_use", "max_tokens", "other"]: + if finish == "tool_calls": + return "tool_use" + if finish == "length": + return "max_tokens" + if finish in {"stop", None}: + return "end_turn" + return "other" + + +def _extract_openai_usage(usage_obj) -> dict[str, int]: + if not usage_obj: + return {} + return { + "input": getattr(usage_obj, "prompt_tokens", 0) or 0, + "output": getattr(usage_obj, "completion_tokens", 0) or 0, + } + + +def _parse_openai_completion(completion) -> LLMResult: + choice = completion.choices[0] + msg = choice.message + tool_calls: list[ToolCall] = [] + for call in (msg.tool_calls or []): + try: + args = json.loads(call.function.arguments or "{}") + except json.JSONDecodeError: + args = {} + tool_calls.append(ToolCall(id=call.id, name=call.function.name, arguments=args)) + + return LLMResult( + content=msg.content or "", + tool_calls=tool_calls, + stop_reason=_map_openai_finish_reason(choice.finish_reason), + token_usage=_extract_openai_usage(getattr(completion, "usage", None)), + ) + + +def _consume_openai_stream(stream, on_delta: Callable[[str], None]) -> LLMResult: + """Drain an OpenAI-compatible chat completion stream. + + Stops forwarding content to `on_delta` once a tool_calls delta arrives — + any trailing commentary would otherwise leak into the final user reply. + tool_calls chunks are accumulated by index and returned as ToolCall list. + """ + content_parts: list[str] = [] + tool_calls_accum: dict[int, dict[str, Any]] = {} + saw_tool_calls = False + finish_reason: str | None = None + usage_obj = None + + for chunk in stream: + usage_obj = getattr(chunk, "usage", None) or usage_obj + if not chunk.choices: + continue + choice = chunk.choices[0] + delta = choice.delta + if getattr(delta, "tool_calls", None): + saw_tool_calls = True + for tc in delta.tool_calls: + idx = tc.index + slot = tool_calls_accum.setdefault(idx, {"id": None, "name": "", "arguments": ""}) + if getattr(tc, "id", None): + slot["id"] = tc.id + fn = getattr(tc, "function", None) + if fn is not None: + if getattr(fn, "name", None): + slot["name"] += fn.name + if getattr(fn, "arguments", None): + slot["arguments"] += fn.arguments + if getattr(delta, "content", None): + content_parts.append(delta.content) + if not saw_tool_calls: + on_delta(delta.content) + if getattr(choice, "finish_reason", None): + finish_reason = choice.finish_reason + + tool_calls: list[ToolCall] = [] + for idx in sorted(tool_calls_accum): + slot = tool_calls_accum[idx] + try: + args = json.loads(slot["arguments"] or "{}") + except json.JSONDecodeError: + args = {} + tool_calls.append(ToolCall(id=slot["id"] or "", name=slot["name"], arguments=args)) + + return LLMResult( + content="".join(content_parts), + tool_calls=tool_calls, + stop_reason=_map_openai_finish_reason(finish_reason), + token_usage=_extract_openai_usage(usage_obj), + ) + + +class _OpenAICompatProvider: + """Shared machinery for any OpenAI-wire-compatible chat/vision/image API. + + Subclasses set BASE_URL / API_KEY_ENV_VAR and override small hooks + (`_token_params`, `_image_generate_kwargs`). The heavy lifting — + payload assembly, streaming, tool_calls parsing — lives on this base + and on the module-level helpers above. + """ + + BASE_URL: str | None = None # None = OpenAI default + API_KEY_ENV_VAR: str = "OPENAI_API_KEY" + + def __init__(self, model: str, image_model: str, api_key: str | None = None): + self.model = model + self.image_model = image_model + self._api_key = api_key + self._client = None + + def _get_client(self): + if self._client is None: + from openai import OpenAI + + kwargs: dict[str, Any] = {} + if self.BASE_URL: + kwargs["base_url"] = self.BASE_URL + if self._api_key: + kwargs["api_key"] = self._api_key + self._client = OpenAI(**kwargs) + return self._client + + # -- hooks -------------------------------------------------------------- # + + def _token_params(self, max_tokens: int) -> dict[str, Any]: + """Default: OpenAI legacy models use max_tokens+temperature.""" + return {"max_tokens": max_tokens, "temperature": 0.2} + + def _image_generate_kwargs(self, prompt: str) -> dict[str, Any]: + """Default OpenAI (dall-e / gpt-image-1) image call kwargs.""" + kwargs: dict[str, Any] = { + "model": self.image_model, + "prompt": prompt, + "size": "1024x1024", + } + # gpt-image-1 rejects `response_format` (b64 is the default); only legacy + # DALL-E models need the explicit flag. + if self.image_model.startswith("dall-e"): + kwargs["response_format"] = "b64_json" + return kwargs + + # -- LLMProvider surface ----------------------------------------------- # + + def chat( + self, + system: str, + messages: list[dict[str, Any]], + tools: list[ToolSpec] | None = None, + max_tokens: int = 1024, + on_delta: Callable[[str], None] | None = None, + ) -> LLMResult: + client = self._get_client() + payload: dict[str, Any] = { + "model": self.model, + "messages": [{"role": "system", "content": system}, *_to_openai_wire_messages(messages)], + **self._token_params(max_tokens), + } + if tools: + payload["tools"] = _build_openai_tools_payload(tools) + payload["tool_choice"] = "auto" + + if on_delta is None: + completion = client.chat.completions.create(**payload) + return _parse_openai_completion(completion) + + payload = {**payload, "stream": True, "stream_options": {"include_usage": True}} + stream = client.chat.completions.create(**payload) + return _consume_openai_stream(stream, on_delta) + + def stream_chat( + self, + system: str, + messages: list[dict[str, Any]], + on_delta: Callable[[str], None], + max_tokens: int = 1024, + ) -> str: + client = self._get_client() + stream = client.chat.completions.create( + model=self.model, + messages=[{"role": "system", "content": system}, *_to_openai_wire_messages(messages)], + stream=True, + **self._token_params(max_tokens), + ) + full = "" + for chunk in stream: + delta = chunk.choices[0].delta.content if chunk.choices else None + if delta: + full += delta + on_delta(delta) + return full + + def describe_image(self, image_bytes: bytes, mime_type: str) -> str: + client = self._get_client() + encoded = base64.b64encode(image_bytes).decode("utf-8") + completion = client.chat.completions.create( + model=self.model, + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image for a Slack conversation."}, + {"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{encoded}"}}, + ], + } + ], + ) + return completion.choices[0].message.content or "" + + def generate_image(self, prompt: str) -> bytes: + client = self._get_client() + response = client.images.generate(**self._image_generate_kwargs(prompt)) + return base64.b64decode(response.data[0].b64_json) diff --git a/src/llms/xai.py b/src/llms/xai.py new file mode 100644 index 0000000..23a7e2a --- /dev/null +++ b/src/llms/xai.py @@ -0,0 +1,33 @@ +"""XAIProvider — Grok chat + grok-imagine at https://api.x.ai/v1.""" +from __future__ import annotations + +from typing import Any + +from src.llms.openai_wire import _OpenAICompatProvider + + +class XAIProvider(_OpenAICompatProvider): + """xAI (Grok) — OpenAI-wire compatible, different base URL and image params. + + Models: + text: grok-4-1-fast-reasoning, grok-4.20-0309-reasoning, ... + image: grok-imagine-image, grok-imagine-image-pro + + Differences from OpenAI that matter here: + - `images.generate` rejects `size` (uses `aspect_ratio`/`resolution`). + We omit `size` and request `response_format=b64_json` so we can + decode bytes locally, matching the rest of the pipeline. + - All current grok chat models accept `max_tokens` + `temperature` + the classic way — no `max_completion_tokens` split. + """ + + BASE_URL = "https://api.x.ai/v1" + API_KEY_ENV_VAR = "XAI_API_KEY" + + def _image_generate_kwargs(self, prompt: str) -> dict[str, Any]: + return { + "model": self.image_model, + "prompt": prompt, + "n": 1, + "response_format": "b64_json", + } diff --git a/src/logging_utils.py b/src/logging_utils.py new file mode 100644 index 0000000..0833b90 --- /dev/null +++ b/src/logging_utils.py @@ -0,0 +1,62 @@ +import json +import logging +import os +import sys +from contextvars import ContextVar +from typing import Any + +_request_id: ContextVar[str] = ContextVar("request_id", default="-") + + +def set_request_id(value: str) -> None: + _request_id.set(value) + + +def get_request_id() -> str: + return _request_id.get() + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, Any] = { + "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"), + "level": record.levelname, + "logger": record.name, + "request_id": get_request_id(), + "message": record.getMessage(), + } + extra = getattr(record, "extra_fields", None) + if isinstance(extra, dict): + payload.update(extra) + if record.exc_info: + payload["exc"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=False, default=str) + + +_configured = False + + +def _configure_root() -> None: + global _configured + if _configured: + return + level_name = os.getenv("LOG_LEVEL", "INFO").upper() + level = getattr(logging, level_name, logging.INFO) + handler = logging.StreamHandler(stream=sys.stdout) + handler.setFormatter(JsonFormatter()) + root = logging.getLogger() + root.handlers = [handler] + root.setLevel(level) + _configured = True + + +def get_logger(name: str) -> logging.LoggerAdapter: + _configure_root() + return logging.LoggerAdapter(logging.getLogger(name), extra={}) + + +def log_event(logger: logging.Logger | logging.LoggerAdapter, event: str, **fields: Any) -> None: + # LoggerAdapter.process() in Python 3.12 overwrites extra= with self.extra, + # so go straight to the underlying logger when present. + base = logger.logger if isinstance(logger, logging.LoggerAdapter) else logger + base.info(event, extra={"extra_fields": fields}) diff --git a/src/slack_helpers.py b/src/slack_helpers.py new file mode 100644 index 0000000..5d858a4 --- /dev/null +++ b/src/slack_helpers.py @@ -0,0 +1,579 @@ +"""Slack-facing helpers: message splitting, status indicator, user name cache, allowlist.""" +from __future__ import annotations + +import logging +import re +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from typing import Any, Iterable + +from slack_sdk.errors import SlackApiError + +logger = logging.getLogger(__name__) + + +CODE_FENCE = "```" +PARAGRAPH_SEP = "\n\n" +SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+") + + +def _slack_error_code(exc: SlackApiError) -> str: + """Extract the `error` field from a SlackApiError's response payload. + + SlackApiError.response is normally a SlackResponse mapping; access via + `.get("error")` works on both dict-like and SlackResponse objects. + Falls back to empty string if anything is unexpected. + """ + response = getattr(exc, "response", None) + if response is None: + return "" + try: + return response.get("error", "") or "" + except (AttributeError, TypeError): + return "" + + +class MessageFormatter: + """Split a long message into Slack-safe chunks. + + Strategy (greedy, paragraph-first): + 1. Cut at the last \\n\\n that fits inside max_len. This keeps + sentences whole and avoids the "mid-word" splits that happen + with a hard slice. + 2. If that cut lands inside a ``` code block, push the whole + block to the next chunk by re-cutting at the \\n\\n right + before the block opens. + 3. If the block itself is larger than max_len, cut at the last + \\n\\n inside the block; if the block uses only single \\n + between lines (the common case), cut at the last \\n instead. + Close the current chunk with \\n``` and reopen the next chunk + with ```\\n. + 4. When no \\n\\n is available within max_len at all, fall back + to a sentence boundary (.!? + whitespace), then to a single + \\n, then hard-slice. + """ + + @staticmethod + def split_message(text: str, max_len: int = 2000) -> list[str]: + if not text: + return [""] + if len(text) <= max_len: + return [text] + + chunks: list[str] = [] + remaining = text + fence_suffix = "\n" + CODE_FENCE + fence_prefix = CODE_FENCE + "\n" + + while len(remaining) > max_len: + # 1. Greedy paragraph-boundary cut within max_len. + cut = remaining.rfind(PARAGRAPH_SEP, 0, max_len) + if cut > 0: + first = remaining[:cut] + tail_start = cut + len(PARAGRAPH_SEP) + else: + first, tail_start = MessageFormatter._fallback_cut(remaining, max_len) + + # 2. Code-fence balancing: an odd ``` count means the cut + # landed inside a code block. + if first.count(CODE_FENCE) % 2 == 1: + last_fence = first.rfind(CODE_FENCE) + # Try pushing the whole block to the next chunk by + # cutting at the \n\n right before the block opens. + block_start_cut = first.rfind(PARAGRAPH_SEP, 0, last_fence) + if block_start_cut > 0: + first = remaining[:block_start_cut] + tail_start = block_start_cut + len(PARAGRAPH_SEP) + else: + # Block won't fit anywhere whole. Cut inside the block, + # leaving room for the closing fence so the chunk still + # respects max_len. The cut must land after the opening + # fence and its newline so we always make progress. + min_cut = last_fence + len(CODE_FENCE) + 1 + inner_budget = max_len - len(fence_suffix) + inner_cut = remaining.rfind(PARAGRAPH_SEP, min_cut, inner_budget) + if inner_cut > 0: + first = remaining[:inner_cut] + tail_start = inner_cut + len(PARAGRAPH_SEP) + else: + # Code blocks usually have only single \n between + # lines, not \n\n. Cut at a line boundary so a token + # doesn't get sliced. + line_cut = remaining.rfind("\n", min_cut, inner_budget) + if line_cut > 0: + first = remaining[:line_cut] + tail_start = line_cut + 1 + else: + first = remaining[:inner_budget] + tail_start = inner_budget + chunks.append(first + fence_suffix) + remaining = fence_prefix + remaining[tail_start:] + continue + + chunks.append(first) + remaining = remaining[tail_start:] + + if remaining: + chunks.append(remaining) + + return chunks + + @staticmethod + def _fallback_cut(text: str, max_len: int) -> tuple[str, int]: + """Choose a cut when no \\n\\n boundary exists within max_len. + + Tries (in order): sentence boundary (.!? + whitespace), single + \\n, then hard slice. The single-\\n fallback handles content + like bullet lists or code blocks that only use one newline + between lines. + """ + last_match = None + for match in SENTENCE_SPLIT_RE.finditer(text): + if match.start() >= max_len: + break + last_match = match + if last_match is not None: + return text[: last_match.start()], last_match.end() + + line_cut = text.rfind("\n", 0, max_len) + if line_cut > 0: + return text[:line_cut], line_cut + 1 + + return text[:max_len], max_len + + +def set_thread_status(client: Any, channel: str, thread_ts: str, status: str) -> None: + """Set (or clear) the assistant thread's transient status indicator. + + Renders as a typing-like "... is thinking" line in AI-enabled workspaces. + Pass an empty string to clear it. Swallows API errors when the feature + is not enabled on the workspace (tier: assistant.threads). + """ + try: + client.assistant_threads_setStatus(channel_id=channel, thread_ts=thread_ts, status=status) + except (SlackApiError, AttributeError, TypeError) as exc: + logger.debug("assistant_threads_setStatus failed: %s", exc) + + +# --------------------------------------------------------------------------- # +# Streaming message +# --------------------------------------------------------------------------- # + + +class StreamingMessage: + """Stream LLM output into a single Slack message. + + Preferred path uses Slack's native streaming API (chat.startStream / + appendStream / stopStream, available in AI-enabled workspaces). If those + calls fail (unsupported, missing scope, etc.) we fall back to a regular + chat.postMessage + repeated chat.update pattern. + + Appends are throttled by `min_interval` to stay within Slack rate limits + (chat.appendStream is Tier 4 = 100+/min; chat.update is Tier 3 = 50+/min). + """ + + NATIVE_METHOD = "chat.startStream" + APPEND_METHOD = "chat.appendStream" + STOP_METHOD = "chat.stopStream" + # After this many consecutive chat_update failures on the fallback path, + # finalize the current ts and open a fresh chat_postMessage. Covers the + # case where the current ts is unreachable (deleted, rate-limited on a + # specific msg, etc.) without waiting for the buffer to hit max_len. + MAX_CONSECUTIVE_UPDATE_FAILURES = 3 + + def __init__( + self, + client: Any, + channel: str, + thread_ts: str, + placeholder: str = ":robot_face:", + min_interval: float = 0.6, + max_len: int = 2000, + enable_native: bool = False, + ) -> None: + self.client = client + self.channel = channel + self.thread_ts = thread_ts + self.placeholder = placeholder + self.min_interval = min_interval + # Soft cap for a single Slack message in fallback streaming mode; + # when the rolling buffer approaches this size we finalize the + # current ts and roll to a fresh chat_postMessage. + self.max_len = max_len + # Native Slack streaming (chat.startStream/appendStream/stopStream) + # renders an extra "searching..." status UI beside our message on + # AI-enabled workspaces, which looks like two replies to the user. + # Default off — stream into a plain chat.postMessage + chat.update + # loop so there's exactly one reply ts throughout the session. + self.enable_native = enable_native + self.ts: str | None = None + self._buffer = "" + self._last_flush = 0.0 + self._native = False # True once chat.startStream succeeds + self._stopped = False + self._consecutive_update_failures = 0 + # Concatenation of every prefix already sealed into earlier ts'es by + # the size-overflow roll-finalize path. stop() uses this to skip the + # part of final_text that is already on screen, otherwise the latest + # ts gets overwritten with a chunk whose content overlaps the rolled + # message above it ("first two messages are nearly identical"). + self._finalized_text = "" + + # -- start ---------------------------------------------------------- # + + def start(self) -> None: + """Initialize the streaming message. + + Starts with a plain chat.postMessage so the rest of the lifecycle + is a single ts under our control. If `enable_native` is set we + also try the Slack native streaming API, but it's off by default + because on AI-enabled workspaces it renders an extra "searching" + status UI alongside our reply that looks like a second message. + """ + if self.enable_native: + try: + res = self.client.api_call( + self.NATIVE_METHOD, + params={ + "channel": self.channel, + "thread_ts": self.thread_ts, + "markdown_text": self.placeholder, + }, + ) + if res.get("ok"): + self.ts = res.get("ts") + self._native = True + return + logger.debug("%s returned not-ok: %s", self.NATIVE_METHOD, res.get("error")) + except (SlackApiError, AttributeError, TypeError, KeyError) as exc: + logger.debug("%s failed, falling back to postMessage: %s", self.NATIVE_METHOD, exc) + + # Default path: regular message we'll keep editing with chat.update. + res = self.client.chat_postMessage(channel=self.channel, thread_ts=self.thread_ts, text=self.placeholder) + self.ts = res.get("ts") if isinstance(res, dict) else res["ts"] + + # -- append --------------------------------------------------------- # + + def append(self, delta: str) -> None: + """Accumulate `delta` and flush to Slack if the throttle interval passed.""" + if not delta or self._stopped or not self.ts: + return + self._buffer += delta + now = time.monotonic() + if now - self._last_flush < self.min_interval: + return + self._flush() + self._last_flush = now + + def _flush(self) -> None: + if not self._buffer or not self.ts: + return + text = self._buffer + if self._native: + try: + self.client.api_call( + self.APPEND_METHOD, + params={"channel": self.channel, "ts": self.ts, "markdown_text": text}, + ) + self._buffer = "" + return + except (SlackApiError, AttributeError, TypeError) as exc: + logger.debug("%s failed, downgrading to chat.update: %s", self.APPEND_METHOD, exc) + self._native = False + + # Fallback: chat.update with the full accumulated text plus cursor. + # When the buffer approaches the per-message limit we finalize this + # message and roll into a fresh chat_postMessage so nothing gets lost + # behind a msg_too_long error on the next update. + display = text + " " + self.placeholder + if len(display) >= self.max_len: + # Cut at the last \n\n or \n so we don't seal mid-token + # (e.g. inside a JS template literal like `${body` or a + # half-streamed identifier). The buffer between the cut + # point and the seal is carried into the next placeholder. + cut = text.rfind(PARAGRAPH_SEP) + sep_len = len(PARAGRAPH_SEP) + if cut <= 0: + cut = text.rfind("\n") + sep_len = 1 + if cut > 0: + sealed_body = text[:cut] + rest = text[cut + sep_len:] + finalized_chunk = text[:cut + sep_len] + else: + # No newline anywhere — fall back to sealing the whole + # buffer (single-line content has no better option). + sealed_body = text + rest = "" + finalized_chunk = text + + # If the sealed body leaves an unclosed code block, close + # it here and reopen on the next placeholder so each rolled + # message renders as a balanced block in Slack. + sealed_text = sealed_body + carry = "" + if sealed_body.count(CODE_FENCE) % 2 == 1: + sealed_text = sealed_body + "\n" + CODE_FENCE + carry = CODE_FENCE + "\n" + + sealed = False + try: + self.client.chat_update(channel=self.channel, ts=self.ts, text=sealed_text) + sealed = True + except SlackApiError as exc: + logger.warning("chat_update (roll-finalize) failed: %s", exc) + if sealed: + # Track the raw (un-fenced) prefix so stop()'s + # final_text slice still matches the LLM output. + self._finalized_text += finalized_chunk + self._roll_to_new_message() + if sealed: + self._buffer = carry + rest + return + try: + self.client.chat_update(channel=self.channel, ts=self.ts, text=display) + self._consecutive_update_failures = 0 + except SlackApiError as exc: + # msg_too_long is an explicit "this payload exceeds chat.update's + # rendered limit" signal — Slack's mrkdwn → section-block coercion + # caps a single block at ~3000 chars and fails well before the + # documented 4000-char text limit on multibyte/markdown content. + # Retrying the same buffer against a fresh ts via _roll_to_new_message + # just produces the same failure on the new placeholder, leaving a + # trail of empty :loading: messages in the thread. Spill the buffer + # via chat.postMessage (40k-char limit, no rolling-update history) + # and drop the current placeholder so the next delta starts fresh. + if _slack_error_code(exc) == "msg_too_long": + self._spill_buffer_via_post_message(text) + return + self._consecutive_update_failures += 1 + logger.warning( + "chat_update during stream failed (%d consecutive): %s", + self._consecutive_update_failures, + exc, + ) + # If updates keep failing on this ts (deleted, message-level rate + # limit, permission change), roll to a fresh message instead of + # burning cycles on the same broken ts until the buffer hits + # max_len. The accumulated buffer rides along into the new ts. + if self._consecutive_update_failures >= self.MAX_CONSECUTIVE_UPDATE_FAILURES: + self._consecutive_update_failures = 0 + self._roll_to_new_message(preserve_buffer=True) + + def _spill_buffer_via_post_message(self, text: str) -> None: + """Recover from chat.update msg_too_long by posting the buffered text + as fresh thread messages and dropping the current placeholder ts. + + chat.postMessage's 40k-char limit is much more permissive than + chat.update's effective ~3k-char limit on multibyte/markdown content, + so MessageFormatter.split_message at self.max_len reliably fits. + Deleting the (still :loading:) placeholder keeps the thread free of + the empty placeholder + spilled-answer "double output" pattern. + Setting `self.ts = None` lets the next delta lazy-start a fresh + placeholder via the same deferred path used at first delta. + """ + chunks = MessageFormatter.split_message(text, max_len=self.max_len) + for chunk in chunks: + try: + self.client.chat_postMessage( + channel=self.channel, thread_ts=self.thread_ts, text=chunk, + ) + except SlackApiError as exc: + logger.warning("spill chat_postMessage failed: %s", exc) + if self.ts: + try: + self.client.chat_delete(channel=self.channel, ts=self.ts) + except SlackApiError as exc: + logger.debug("placeholder chat_delete after spill failed: %s", exc) + self._buffer = "" + self._consecutive_update_failures = 0 + self.ts = None + + def _roll_to_new_message(self, preserve_buffer: bool = False) -> None: + """Open a fresh placeholder message and reset the buffer. Used when + the fallback rolling update would overflow the per-message limit, + or when repeated chat_update failures make the current ts unusable. + + When `preserve_buffer` is True, the accumulated delta text is kept so + the next flush re-sends it against the new ts. This matters for the + consecutive-failure path: the deltas never reached Slack on the old + ts, so we can't just drop them. The size-overflow path passes + `preserve_buffer=False` because the old ts already received the full + buffer content via the `text=text` finalize update. + """ + try: + res = self.client.chat_postMessage( + channel=self.channel, + thread_ts=self.thread_ts, + text=self.placeholder, + ) + self.ts = res.get("ts") if isinstance(res, dict) else res["ts"] + if not preserve_buffer: + self._buffer = "" + except SlackApiError as exc: + logger.warning("roll-to-new-message failed: %s", exc) + + # -- stop ----------------------------------------------------------- # + + def stop(self, final_text: str) -> None: + """Finalize the message with `final_text`. Safe to call once. + + If `final_text` exceeds the per-message limit we split it with the + MessageFormatter, put the first chunk into the current ts, and post + the remaining chunks as additional thread messages. This avoids the + msg_too_long failures we saw when a long answer was written back + through a single chat.update. + """ + if self._stopped or not self.ts: + return + self._stopped = True + + # If streaming rolled to a new ts via the size-overflow path, the + # earlier ts'es already display the prefix portion of the answer. + # Strip that prefix here so the latest ts gets only the suffix + # instead of overwriting itself with content already shown above + # (the "first two messages nearly identical" duplication). + if self._finalized_text and final_text.startswith(self._finalized_text): + final_text = final_text[len(self._finalized_text):] + # If the rolled prefix left us inside an unclosed code + # block, the next placeholder was carrying ```\n. Reapply + # that prefix so chat_update doesn't render the suffix as + # plain text outside any fence. + if self._finalized_text.count(CODE_FENCE) % 2 == 1: + final_text = CODE_FENCE + "\n" + final_text + + if self._native: + # Native streaming: stopStream accepts up to 12k chars, but be + # conservative and split to self.max_len anyway to keep UX + # consistent with the fallback path. + chunks = MessageFormatter.split_message(final_text, max_len=self.max_len) + try: + self.client.api_call( + self.STOP_METHOD, + params={"channel": self.channel, "ts": self.ts, "markdown_text": chunks[0]}, + ) + for extra in chunks[1:]: + try: + self.client.chat_postMessage( + channel=self.channel, thread_ts=self.thread_ts, text=extra, + ) + except SlackApiError as exc: + logger.warning("follow-up postMessage failed: %s", exc) + return + except (SlackApiError, AttributeError, TypeError) as exc: + logger.debug("%s failed, finalizing with chat.update: %s", self.STOP_METHOD, exc) + self._native = False + + # Fallback finalizer: split and roll. + chunks = MessageFormatter.split_message(final_text, max_len=self.max_len) + first = chunks[0] + try: + self.client.chat_update(channel=self.channel, ts=self.ts, text=first) + except SlackApiError as exc: + logger.warning("final chat_update failed (len=%d): %s", len(first), exc) + # Drop the still-:loading: placeholder before posting the answer + # via chat.postMessage; otherwise the user sees the placeholder + # and the spilled answer side-by-side ("double output"). + try: + self.client.chat_delete(channel=self.channel, ts=self.ts) + except SlackApiError as exc2: + logger.debug("placeholder chat_delete after final failure: %s", exc2) + # Fallback to postMessage so at least the text lands somewhere. + try: + self.client.chat_postMessage( + channel=self.channel, thread_ts=self.thread_ts, text=first, + ) + except SlackApiError as exc2: + logger.warning("final postMessage also failed: %s", exc2) + for extra in chunks[1:]: + try: + self.client.chat_postMessage( + channel=self.channel, thread_ts=self.thread_ts, text=extra, + ) + except SlackApiError as exc: + logger.warning("follow-up postMessage failed: %s", exc) + + +@dataclass +class UserNameCache: + """Module-level cache keyed by user_id. Survives warm starts. + + Thread-safe: `warm()` resolves cache misses in parallel threads, so + cache writes go through `_lock`. Reads are lock-free (a `dict.get` + on an existing key is GIL-atomic in CPython; a worst-case race just + causes a redundant `users_info` call, never a corrupt cache).""" + + _cache: dict[str, str] + _lock: threading.Lock = field(default_factory=threading.Lock) + + @classmethod + def _default(cls) -> "UserNameCache": + return cls(_cache={}) + + def get(self, client: Any, user_id: str) -> str: + if not user_id: + return "" + cached = self._cache.get(user_id) + if cached is not None: + return cached + try: + info = client.users_info(user=user_id) + profile = (info.get("user") or {}).get("profile") or {} + name = ( + profile.get("display_name") + or profile.get("real_name") + or (info.get("user") or {}).get("real_name") + or user_id + ) + except SlackApiError as exc: + logger.debug("users_info failed for %s: %s", user_id, exc) + name = user_id + with self._lock: + self._cache[user_id] = name + return name + + def warm(self, client: Any, user_ids: Iterable[str]) -> None: + """Pre-resolve display names for the given user IDs in parallel. + + Used by callers that know they'll need many user names before the + rendering loop starts (e.g. `fetch_thread_history`). Without this, + `get()` runs serially inside the loop and 50 cache misses become + 50 sequential `users_info` calls, easily blowing the tool timeout.""" + misses = list({uid for uid in user_ids if uid and uid not in self._cache}) + if not misses: + return + with ThreadPoolExecutor(max_workers=min(len(misses), 8)) as pool: + list(pool.map(lambda uid: self.get(client, uid), misses)) + + +user_name_cache = UserNameCache._default() + + +def channel_allowed(channel: str, allowed_ids: list[str]) -> bool: + """Return True if no allowlist configured or channel is listed.""" + if not allowed_ids: + return True + return channel in allowed_ids + + +def sanitize_error(exc: BaseException) -> str: + """User-facing error text. Strips internal paths/tokens.""" + msg = str(exc) or exc.__class__.__name__ + # Redact tokens. Order matters: more specific patterns (sk-ant, sk-proj) + # run before the generic sk- pattern so the labels stay accurate. + msg = re.sub(r"xox[abprs]-[A-Za-z0-9-]+", "[redacted-slack-token]", msg) + msg = re.sub(r"sk-ant-[A-Za-z0-9\-_]{10,}", "[redacted-anthropic-key]", msg) + msg = re.sub(r"sk-[A-Za-z0-9\-_]{10,}", "[redacted-openai-key]", msg) + msg = re.sub(r"xai-[A-Za-z0-9\-_]{10,}", "[redacted-xai-key]", msg) + msg = re.sub(r"tvly-[A-Za-z0-9\-_]{10,}", "[redacted-tavily-key]", msg) + # AWS access keys: AKIA (long-term) / ASIA (temporary session). + msg = re.sub(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b", "[redacted-aws-key]", msg) + # Truncate stack-like paths. + msg = re.sub(r"(/[\w./-]+\.py)", "[path]", msg) + if len(msg) > 300: + msg = msg[:297] + "..." + return msg + + diff --git a/src/tools/__init__.py b/src/tools/__init__.py new file mode 100644 index 0000000..fe0f37e --- /dev/null +++ b/src/tools/__init__.py @@ -0,0 +1,38 @@ +"""Tool package. + +Public API: the registry plumbing plus the shared default_registry. +Tool functions themselves register via side-effect imports of the +sibling submodules below — importing this package is enough for the +agent to see every built-in tool. +""" +from src.tools.registry import ( + ToolContext, + ToolDef, + ToolExecutor, + ToolRegistry, + default_registry, + tool, +) + +# Side-effect imports: each submodule uses @tool(default_registry, ...) on +# one or more functions. Importing them registers those tools. +# NOTE: 'time' below is src/tools/time.py (the get_current_time tool), +# NOT stdlib time. Do not do `from src.tools import time` expecting +# stdlib — that resolves to our submodule. stdlib time is still safely +# imported as `import time` inside registry.py / slack.py. +from . import ( # noqa: F401 (imported for side effects) + image, + search, + slack, + time, + web, +) + +__all__ = [ + "ToolContext", + "ToolDef", + "ToolExecutor", + "ToolRegistry", + "default_registry", + "tool", +] diff --git a/src/tools/image.py b/src/tools/image.py new file mode 100644 index 0000000..d740cb0 --- /dev/null +++ b/src/tools/image.py @@ -0,0 +1,29 @@ +"""Image generation tool — calls the configured LLM provider and uploads +to the current Slack thread.""" +from __future__ import annotations + +from src.tools.registry import ToolContext, default_registry, tool + + +@tool( + default_registry, + name="generate_image", + description="Generate an image from a prompt and upload it to the Slack thread. Returns the permalink.", + parameters={ + "type": "object", + "properties": {"prompt": {"type": "string"}}, + "required": ["prompt"], + }, + timeout=240.0, # gpt-image-2 / titan / stability can take 60–180s; Lambda caps at 300s, leaves ~60s for compose + upload +) +def generate_image(ctx: ToolContext, prompt: str) -> dict[str, str]: + image_bytes = ctx.llm.generate_image(prompt) + upload = ctx.slack_client.files_upload_v2( + channel=ctx.channel, + thread_ts=ctx.thread_ts, + title="Generated image", + filename="generated.png", + file=image_bytes, + ) + file_info = upload.get("file", {}) + return {"permalink": file_info.get("permalink", ""), "title": file_info.get("title", "generated.png")} diff --git a/src/tools/registry.py b/src/tools/registry.py new file mode 100644 index 0000000..5f3e763 --- /dev/null +++ b/src/tools/registry.py @@ -0,0 +1,140 @@ +"""Tool registry + executor. Tool functions live in sibling submodules +(slack.py, search.py, web.py, image.py, time.py) and register themselves +via the @tool decorator on import.""" +from __future__ import annotations + +import logging +import time +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout +from dataclasses import dataclass, field +from typing import Any, Callable + +from src.config import Settings +from src.llms import LLMProvider, ToolCall + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Registry +# --------------------------------------------------------------------------- # + + +@dataclass +class ToolDef: + name: str + description: str + parameters: dict[str, Any] # JSON Schema + fn: Callable[..., Any] + timeout: float | None = None # None -> use executor default + + +@dataclass +class ToolRegistry: + _tools: dict[str, ToolDef] = field(default_factory=dict) + + def register(self, td: ToolDef) -> None: + self._tools[td.name] = td + + def names(self) -> list[str]: + return list(self._tools.keys()) + + def get(self, name: str) -> ToolDef | None: + return self._tools.get(name) + + def specs(self) -> list[dict[str, Any]]: + return [ + {"name": t.name, "description": t.description, "parameters": t.parameters} + for t in self._tools.values() + ] + + +def tool( + registry: ToolRegistry, + name: str, + description: str, + parameters: dict[str, Any], + timeout: float | None = None, +): + def decorator(fn: Callable[..., Any]): + registry.register( + ToolDef(name=name, description=description, parameters=parameters, fn=fn, timeout=timeout) + ) + return fn + + return decorator + + +# --------------------------------------------------------------------------- # +# Context +# --------------------------------------------------------------------------- # + + +@dataclass +class ToolContext: + slack_client: Any + channel: str + thread_ts: str + event: dict[str, Any] + settings: Settings + llm: LLMProvider + + +# --------------------------------------------------------------------------- # +# Executor +# --------------------------------------------------------------------------- # + + +class ToolExecutor: + def __init__(self, context: ToolContext, registry: ToolRegistry, timeout: float = 20.0): + self.context = context + self.registry = registry + self.timeout = timeout + self._pool = ThreadPoolExecutor(max_workers=2) + self._closed = False + + def execute(self, call: ToolCall) -> dict[str, Any]: + td = self.registry.get(call.name) + started = time.monotonic() + if td is None: + return {"ok": False, "error": f"unknown tool: {call.name}"} + effective_timeout = td.timeout if td.timeout is not None else self.timeout + try: + future = self._pool.submit(td.fn, self.context, **(call.arguments or {})) + result = future.result(timeout=effective_timeout) + return {"ok": True, "result": result, "duration_ms": int((time.monotonic() - started) * 1000)} + except FuturesTimeout: + logger.warning("tool %s timed out after %.1fs", call.name, effective_timeout) + return {"ok": False, "error": f"tool '{call.name}' timed out after {effective_timeout}s"} + except Exception as exc: # noqa: BLE001 + # Broad catch on purpose: provider SDKs raise their own APIError + # hierarchies (openai.APIError, anthropic.APIError, httpx.HTTPError) + # that were missing from the previous allowlist — and when they + # escaped the executor the whole agent loop aborted with a generic + # error instead of handing the failure back to the LLM to recover. + # The agent already treats {"ok": False, ...} as a recoverable tool + # result, so swallowing here is correct. + logger.exception("tool %s failed", call.name) + return {"ok": False, "error": f"{exc.__class__.__name__}: {exc}"} + + def close(self) -> None: + """Release the worker pool. + + Called by the owning agent at end-of-request. Safe to call twice. + Must be invoked in Lambda warm-start environments — otherwise every + request spawns a fresh ThreadPoolExecutor whose non-daemon workers + stay in the process-wide registry until interpreter exit. + """ + if self._closed: + return + self._closed = True + # wait=False so a timed-out tool's worker doesn't pin the Lambda + # invocation. The stray thread will be cleaned up on GC. + self._pool.shutdown(wait=False) + + +# --------------------------------------------------------------------------- # +# Built-in tools +# --------------------------------------------------------------------------- # + +default_registry = ToolRegistry() diff --git a/src/tools/search.py b/src/tools/search.py new file mode 100644 index 0000000..4ccf489 --- /dev/null +++ b/src/tools/search.py @@ -0,0 +1,67 @@ +"""Web search tool (DuckDuckGo Instant Answer + optional Tavily).""" +from __future__ import annotations + +import json +import logging +import urllib.parse +import urllib.request + +from src.tools.registry import ToolContext, default_registry, tool + +logger = logging.getLogger(__name__) + +DUCKDUCKGO_HOST = "api.duckduckgo.com" +TAVILY_HOST = "api.tavily.com" + + +@tool( + default_registry, + name="search_web", + description="Search the public web for up-to-date information. Uses Tavily if TAVILY_API_KEY is set, otherwise DuckDuckGo Instant Answer.", + parameters={ + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 20, "default": 5}, + }, + "required": ["query"], + }, +) +def search_web(ctx: ToolContext, query: str, limit: int = 5) -> list[dict[str, str]]: + if ctx.settings.tavily_api_key: + return _tavily_search(ctx.settings.tavily_api_key, query, limit) + return _ddg_search(query, limit) + + +def _ddg_search(query: str, limit: int) -> list[dict[str, str]]: + params = urllib.parse.urlencode({"q": query, "format": "json", "no_redirect": 1, "no_html": 1}) + url = f"https://{DUCKDUCKGO_HOST}/?{params}" + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.hostname != DUCKDUCKGO_HOST: + raise ValueError("invalid web search URL") + with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 + payload = json.loads(response.read().decode("utf-8")) + results: list[dict[str, str]] = [] + if payload.get("AbstractURL"): + results.append({"title": payload.get("AbstractText", ""), "url": payload["AbstractURL"]}) + for item in payload.get("RelatedTopics", []): + if "Text" in item and "FirstURL" in item: + results.append({"title": item["Text"], "url": item["FirstURL"]}) + if len(results) >= limit: + break + return results[:limit] + + +def _tavily_search(api_key: str, query: str, limit: int) -> list[dict[str, str]]: + url = f"https://{TAVILY_HOST}/search" + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.hostname != TAVILY_HOST: + raise ValueError("invalid Tavily URL") + body = json.dumps({"api_key": api_key, "query": query, "max_results": limit}).encode() + req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=15) as response: # noqa: S310 + payload = json.loads(response.read().decode("utf-8")) + return [ + {"title": r.get("title", ""), "url": r.get("url", ""), "content": r.get("content", "")} + for r in payload.get("results", [])[:limit] + ] diff --git a/src/tools/slack.py b/src/tools/slack.py new file mode 100644 index 0000000..30745cd --- /dev/null +++ b/src/tools/slack.py @@ -0,0 +1,421 @@ +"""Slack-centric tools: read images/documents attached to the triggering +mention, fetch the current thread's history.""" +from __future__ import annotations + +import logging +import time +import urllib.error +import urllib.parse +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any, Callable + +from slack_sdk.errors import SlackApiError + +from src.slack_helpers import user_name_cache +from src.tools.registry import ToolContext, default_registry, tool + +logger = logging.getLogger(__name__) + +SLACK_FILE_HOSTS = {"files.slack.com", "files-edge.slack.com", "files-pri.slack.com"} +DOC_TEXT_PREFIX = "text/" +DOC_PDF_MIME = "application/pdf" + + +@tool( + default_registry, + name="read_attached_images", + description=( + "Read image files and return textual descriptions. By default reads " + "images attached to the current Slack mention. Pass `urls` to also " + "read images referenced from thread history (e.g. url_private_download " + "returned by fetch_thread_history)." + ), + parameters={ + "type": "object", + "properties": { + "limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 3}, + "urls": { + "type": "array", + "items": {"type": "string"}, + "description": "Additional Slack file URLs to describe (must be on files*.slack.com).", + }, + }, + "required": [], + }, + # Each image runs Slack download (15s) + LLM describe (multi-second) in + # parallel; the 60s ceiling is a generous safety net for limit=10. + timeout=60.0, +) +def read_attached_images( + ctx: ToolContext, + limit: int = 3, + urls: list[str] | None = None, +) -> list[dict[str, str]]: + token = ctx.settings.slack_bot_token + seen: set[str] = set() + candidates: list[tuple[str, str, str]] = [] # (url, mime_hint, name) + + # 1) Images from the current mention event + for file_info in (ctx.event.get("files") or [])[:limit]: + if len(candidates) >= limit: + break + mime = str(file_info.get("mimetype", "")) + if not mime.startswith("image/"): + continue + dl = file_info.get("url_private_download") or file_info.get("url_private") + if not dl or dl in seen: + continue + seen.add(dl) + candidates.append((dl, mime, file_info.get("name", "image"))) + + # 2) Extra URLs provided by the caller (typically from fetch_thread_history) + for extra in (urls or []): + if len(candidates) >= limit: + break + if extra in seen: + continue + seen.add(extra) + candidates.append((extra, "", _filename_from_url(extra))) + + # Pre-flight SSRF check: validate every URL we plan to fetch BEFORE we + # spin up worker threads, so an invalid host raises synchronously like + # the previous serial implementation did. + for url, _, _ in candidates: + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.hostname not in SLACK_FILE_HOSTS: + raise ValueError("invalid Slack file download URL") + + if not candidates: + return [] + + def _fetch(url: str, mime_hint: str, name: str) -> dict[str, str] | None: + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + with urllib.request.urlopen(req, timeout=15) as response: # noqa: S310 (host allowlisted) + data = response.read() + mime = mime_hint if mime_hint.startswith("image/") else _guess_image_mime(url) + if not mime.startswith("image/"): + return None + return {"name": name, "summary": ctx.llm.describe_image(data, mime)} + + results: list[dict[str, str] | None] = [None] * len(candidates) + with ThreadPoolExecutor(max_workers=min(len(candidates), 4)) as pool: + future_to_idx = {pool.submit(_fetch, *c): i for i, c in enumerate(candidates)} + for future in as_completed(future_to_idx): + results[future_to_idx[future]] = future.result() + + return [r for r in results if r is not None] + + +def _guess_image_mime(url: str) -> str: + path = urllib.parse.urlparse(url).path.lower() + for ext, mime in ( + (".png", "image/png"), + (".jpg", "image/jpeg"), + (".jpeg", "image/jpeg"), + (".gif", "image/gif"), + (".webp", "image/webp"), + (".bmp", "image/bmp"), + (".heic", "image/heic"), + ): + if path.endswith(ext): + return mime + return "image/png" # conservative default; describe_image will still attempt + + +def _filename_from_url(url: str) -> str: + path = urllib.parse.urlparse(url).path + name = path.rsplit("/", 1)[-1] if path else "image" + return name or "image" + + +def _fetch_slack_file(url: str, token: str, max_bytes: int) -> tuple[bytes, str]: + """Fetch a Slack file with size guard. Returns (body, mimetype_from_header). + + Raises: + ValueError: on disallowed host, oversize via Content-Length, or + oversize discovered while reading the body. + """ + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.hostname not in SLACK_FILE_HOSTS: + raise ValueError("invalid Slack file download URL") + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + with urllib.request.urlopen(req, timeout=15) as response: # noqa: S310 + content_length = response.headers.get("Content-Length") if response.headers else None + if content_length and content_length.isdigit() and int(content_length) > max_bytes: + raise ValueError(f"document exceeds MAX_DOC_BYTES={max_bytes}") + body = response.read(max_bytes + 1) + if len(body) > max_bytes: + raise ValueError(f"document exceeds MAX_DOC_BYTES={max_bytes}") + mime = (response.headers.get("Content-Type", "") or "").split(";", 1)[0].strip().lower() if response.headers else "" + return body, mime + + +def _parse_pdf( + data: bytes, + max_pages: int, + max_chars: int, +) -> tuple[str, int, bool]: + """Extract text from a PDF. Raises ValueError for recoverable issues so the + caller can emit a per-document error entry.""" + from io import BytesIO + + # Deferred import keeps pypdf out of cold-start for requests that never + # touch this tool. + from pypdf import PdfReader + from pypdf.errors import PdfReadError, DependencyError + + try: + reader = PdfReader(BytesIO(data)) + except PdfReadError as exc: + raise ValueError(f"PdfReadError: {exc}") from exc + if reader.is_encrypted: + raise ValueError("encrypted PDF not supported") + page_count = len(reader.pages) + if page_count > max_pages: + raise ValueError(f"document exceeds MAX_DOC_PAGES={max_pages}") + pieces: list[str] = [] + total = 0 + truncated = False + for page in reader.pages: + try: + piece = page.extract_text() or "" + except (PdfReadError, DependencyError) as exc: + raise ValueError(f"PdfReadError: {exc}") from exc + pieces.append(piece) + total += len(piece) + if total >= max_chars: + truncated = True + break + text = "\n".join(pieces) + if len(text) > max_chars: + text = text[:max_chars] + truncated = True + return text, page_count, truncated + + +def _parse_text(data: bytes, max_chars: int) -> tuple[str, bool]: + text = data.decode("utf-8", errors="replace") + truncated = len(text) > max_chars + if truncated: + text = text[:max_chars] + return text, truncated + + +@tool( + default_registry, + name="read_attached_document", + description=( + "Read PDF or text/* files attached to the current Slack mention " + "(and optionally extra URLs on files*.slack.com) and return the " + "extracted text. Images are skipped — use read_attached_images " + "for those. Returns one entry per document; if a document fails " + "(encrypted, oversize, corrupt) the entry carries an 'error' key." + ), + parameters={ + "type": "object", + "properties": { + "limit": {"type": "integer", "minimum": 1, "maximum": 5, "default": 2}, + "urls": { + "type": "array", + "items": {"type": "string"}, + "description": "Extra Slack file URLs (must be on files*.slack.com).", + }, + }, + "required": [], + }, + # Each document downloads from Slack (15s) and is parsed locally; + # parallelized below, but the 60s ceiling covers the worst case where + # max=5 documents all hit the download timeout. + timeout=60.0, +) +def read_attached_document( + ctx: ToolContext, + limit: int = 2, + urls: list[str] | None = None, +) -> list[dict[str, Any]]: + token = ctx.settings.slack_bot_token + max_bytes = ctx.settings.max_doc_bytes + max_chars = ctx.settings.max_doc_chars + seen: set[str] = set() + candidates: list[tuple[str, str, str]] = [] # (url, mime_hint, name) + + def _is_doc_mime(mime: str) -> bool: + mime = (mime or "").lower() + return mime == DOC_PDF_MIME or mime.startswith(DOC_TEXT_PREFIX) + + for file_info in (ctx.event.get("files") or [])[:limit]: + if len(candidates) >= limit: + break + mime = str(file_info.get("mimetype", "")) + if not _is_doc_mime(mime): + continue + dl = file_info.get("url_private_download") or file_info.get("url_private") + if not dl or dl in seen: + continue + seen.add(dl) + candidates.append((dl, mime, file_info.get("name", "document"))) + + for extra in (urls or []): + if len(candidates) >= limit: + break + if extra in seen: + continue + seen.add(extra) + candidates.append((extra, "", _filename_from_url(extra))) + + if not candidates: + return [] + + def _process_one(url: str, file_mime_hint: str, name: str) -> dict[str, Any] | None: + try: + body, header_mime = _fetch_slack_file(url, token, max_bytes) + except ValueError as exc: + return {"name": name, "error": str(exc)} + except urllib.error.HTTPError as exc: + return {"name": name, "error": f"HTTPError: {exc.code}"} + mime = (header_mime or file_mime_hint or "").lower() + if mime == DOC_PDF_MIME: + try: + text, pages, truncated = _parse_pdf( + body, ctx.settings.max_doc_pages, max_chars + ) + except ValueError as exc: + return {"name": name, "error": str(exc)} + return { + "name": name, + "mimetype": DOC_PDF_MIME, + "pages": pages, + "chars": len(text), + "truncated": truncated, + "text": text, + } + if mime.startswith(DOC_TEXT_PREFIX): + text, truncated = _parse_text(body, max_chars) + return { + "name": name, + "mimetype": mime, + "pages": 0, + "chars": len(text), + "truncated": truncated, + "text": text, + } + # non-doc mime: silently skip (images handled by read_attached_images) + return None + + results: list[dict[str, Any] | None] = [None] * len(candidates) + with ThreadPoolExecutor(max_workers=min(len(candidates), 4)) as pool: + future_to_idx = {pool.submit(_process_one, *c): i for i, c in enumerate(candidates)} + for future in as_completed(future_to_idx): + results[future_to_idx[future]] = future.result() + + return [r for r in results if r is not None] + + +@tool( + default_registry, + name="fetch_thread_history", + description=( + "Fetch recent messages from the current Slack thread for context. " + "Returns each message's user display name, text, file metadata " + "(for images include url_private_download so read_attached_images " + "can describe them), reactions with emoji names and reacting users, " + "and timestamp." + ), + parameters={ + "type": "object", + "properties": {"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20}}, + "required": [], + }, + # conversations_replies + up to ~limit users_info lookups (parallelized + # via UserNameCache.warm). 30s leaves headroom for retry backoff. + timeout=30.0, +) +def fetch_thread_history(ctx: ToolContext, limit: int = 20) -> list[dict[str, Any]]: + def _map(res: dict[str, Any]) -> list[dict[str, Any]]: + client = ctx.slack_client + messages = res.get("messages", []) + + # Resolve every author/reacter we'll need in parallel before the + # rendering loop. With a cold cache and limit=50 this would + # otherwise be 50+ serial users_info calls (the original timeout + # bug for read_attached_images, repeating itself here). + user_ids: set[str] = set() + for item in messages: + uid = item.get("user") or item.get("bot_id") + if uid: + user_ids.add(uid) + for r in item.get("reactions") or []: + for u in (r.get("users") or []): + if u: + user_ids.add(u) + user_name_cache.warm(client, user_ids) + + out: list[dict[str, Any]] = [] + for item in messages: + user_id = item.get("user") or item.get("bot_id") or "" + files = [] + for f in item.get("files") or []: + files.append( + { + "name": f.get("name", ""), + "mimetype": f.get("mimetype", ""), + "url_private_download": f.get("url_private_download", ""), + "permalink": f.get("permalink", ""), + "title": f.get("title", ""), + } + ) + reactions = [] + for r in item.get("reactions") or []: + reacting_users = [user_name_cache.get(client, u) for u in (r.get("users") or [])] + reactions.append( + { + "emoji": r.get("name", ""), + "count": r.get("count", 0), + "users": reacting_users, + } + ) + out.append( + { + "user": user_name_cache.get(client, user_id) if user_id else "", + "text": item.get("text", ""), + "ts": item.get("ts", ""), + "files": files, + "reactions": reactions, + } + ) + return out + + return _with_slack_retry( + lambda: ctx.slack_client.conversations_replies( + channel=ctx.channel, ts=ctx.thread_ts, limit=limit + ), + _map, + label="conversations_replies", + ) + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # + + +def _with_slack_retry(call: Callable[[], Any], map_result: Callable[[Any], Any], label: str, attempts: int = 3) -> Any: + delay = 1.0 + last: SlackApiError | None = None + for attempt in range(attempts): + try: + return map_result(call()) + except SlackApiError as exc: + error = (exc.response or {}).get("error") if hasattr(exc, "response") else None + if error == "ratelimited" and attempt < attempts - 1: + retry_after = int((exc.response.headers or {}).get("Retry-After", delay)) if hasattr(exc, "response") else delay + logger.warning("%s rate limited, sleeping %ds", label, retry_after) + time.sleep(retry_after) + delay *= 2 + last = exc + continue + raise + if last: + raise last + return [] diff --git a/src/tools/time.py b/src/tools/time.py new file mode 100644 index 0000000..a48de52 --- /dev/null +++ b/src/tools/time.py @@ -0,0 +1,46 @@ +"""Current-time tool for answering 'today', 'now', weekday questions.""" +from __future__ import annotations + +from typing import Any + +from src.tools.registry import ToolContext, default_registry, tool + + +@tool( + default_registry, + name="get_current_time", + description=( + "Return the current wall-clock time. Uses the server default " + "timezone (DEFAULT_TIMEZONE env) unless 'timezone' is provided. " + "Useful for 'today', 'now', 'this week', or weekday questions." + ), + parameters={ + "type": "object", + "properties": { + "timezone": { + "type": "string", + "description": ( + "Optional IANA timezone (e.g. 'Asia/Seoul', 'UTC', " + "'America/New_York'). Omit to use the server default." + ), + } + }, + "required": [], + }, +) +def get_current_time(ctx: ToolContext, timezone: str | None = None) -> dict[str, Any]: + from datetime import datetime + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + tz_name = timezone or ctx.settings.default_timezone + try: + tz = ZoneInfo(tz_name) + except ZoneInfoNotFoundError as exc: + raise ValueError(f"unknown timezone: {tz_name}") from exc + now = datetime.now(tz) + return { + "iso": now.isoformat(timespec="seconds"), + "timezone": tz_name, + "weekday": now.strftime("%A"), + "unix": int(now.timestamp()), + } diff --git a/src/tools/web.py b/src/tools/web.py new file mode 100644 index 0000000..a6c5c9b --- /dev/null +++ b/src/tools/web.py @@ -0,0 +1,396 @@ +"""Public-HTTPS web fetch (Jina Reader with raw-urllib fallback) + SSRF guard. + +All helpers live here so the SSRF pre-flight, the no-redirect handler, the +Jina/raw fetchers, and the HTML/markdown parsers can be audited together. +Test patches target this module's urlopen / build_opener / getaddrinfo names. +""" +from __future__ import annotations + +import ipaddress +import logging +import re +import socket +import urllib.error +import urllib.parse +import urllib.request +from html.parser import HTMLParser +from typing import Any + +from src.tools.registry import ToolContext, default_registry, tool + +logger = logging.getLogger(__name__) + + +_PUBLIC_WEB_UA = "lambda-gurumi-bot/1.0 (+https://github.com/nalbam/lambda-gurumi-bot)" +_WEB_FETCH_TIMEOUT = 12 # Jina Reader + direct raw GET; capped shorter than Slack internal fetch (15s) because web targets have no retry budget inside the tool. + + +def _validate_public_https_url(url: str) -> tuple[str, str]: + """Return (scheme, hostname) after asserting the URL is safe to fetch. + + Rules: + - scheme == 'https' + - hostname is present and is NOT an IP literal + - every address returned by getaddrinfo for the hostname is a public, + routable unicast address (not private / loopback / link-local / + reserved / multicast / unspecified). + """ + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https": + raise ValueError("fetch_webpage requires https") + host = parsed.hostname + if not host: + raise ValueError("URL missing hostname") + try: + ipaddress.ip_address(host) + except ValueError: + pass + else: + raise ValueError("IP literals not allowed") + try: + infos = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM) + except socket.gaierror as exc: + raise ValueError(f"DNS resolution failed: {exc}") from exc + if not infos: + raise ValueError("DNS resolution returned no addresses") + for info in infos: + addr = ipaddress.ip_address(info[4][0]) + if ( + addr.is_private + or addr.is_loopback + or addr.is_link_local + or addr.is_reserved + or addr.is_multicast + or addr.is_unspecified + or not addr.is_global + ): + raise ValueError("hostname resolves to non-public address") + return parsed.scheme, host + + +class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """urllib handler that refuses to follow redirects. + + Raw fetches must hit exactly the host whose DNS we pre-validated. A 3xx + pointing at a private host would silently defeat the SSRF guard. + """ + + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise urllib.error.HTTPError( + req.full_url, code, "redirects not allowed", headers, fp + ) + + +_JINA_LINK_RE = re.compile( + r"(? links out of raw HTML. + + Skips content inside script/style/noscript/template. Breaks paragraphs + on block-level end tags. Link hrefs are resolved against ``base_url``; + filtering (https-only, dedup, self-ref drop) is left to ``_filter_links``. + """ + + _SKIP_TAGS = {"script", "style", "noscript", "template"} + _BREAK_TAGS = {"p", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6", "br", "tr"} + + def __init__(self, base_url: str): + super().__init__(convert_charrefs=True) + self._base_url = base_url + self._skip_depth = 0 + self._text_chunks: list[str] = [] + self._title_chunks: list[str] = [] + self._in_title = False + self._current_link_url: str | None = None + self._current_link_text: list[str] = [] + self.links: list[tuple[str, str]] = [] + + def handle_starttag(self, tag, attrs): + if tag in self._SKIP_TAGS: + self._skip_depth += 1 + return + if tag == "title": + self._in_title = True + return + if tag == "a": + # Nested : the inner link overwrites the outer one. Matches + # how HTML5 parsers implicitly close an unclosed ; the outer + # link is lost but the inner (usually more specific) is kept. + href = dict(attrs).get("href") + if href: + self._current_link_url = urllib.parse.urljoin(self._base_url, href) + self._current_link_text = [] + + def handle_endtag(self, tag): + if tag in self._SKIP_TAGS: + if self._skip_depth > 0: + self._skip_depth -= 1 + return + if tag == "title": + self._in_title = False + return + if tag == "a" and self._current_link_url is not None: + text = " ".join("".join(self._current_link_text).split()) + self.links.append((text, self._current_link_url)) + self._current_link_url = None + self._current_link_text = [] + return + if tag in self._BREAK_TAGS: + self._text_chunks.append("\n") + + def handle_data(self, data): + if self._skip_depth > 0: + return + if self._in_title: + self._title_chunks.append(data) + return + if self._current_link_url is not None: + self._current_link_text.append(data) + self._text_chunks.append(data) + + def title(self) -> str: + return " ".join("".join(self._title_chunks).split()) + + def text(self) -> str: + joined = "".join(self._text_chunks) + lines = [" ".join(line.split()) for line in joined.split("\n")] + return "\n".join(line for line in lines if line) + + +def _filter_links( + raw: list[tuple[str, str]], base_url: str, limit: int +) -> list[dict[str, str]]: + if limit <= 0: + return [] + base_parsed = urllib.parse.urlparse(base_url) + base_key = base_parsed._replace( + scheme=base_parsed.scheme.lower(), + netloc=base_parsed.netloc.lower(), + fragment="", + ).geturl() + seen: set[str] = set() + out: list[dict[str, str]] = [] + for text, url in raw: + if not url.startswith("https://"): + continue + parsed = urllib.parse.urlparse(url) + if not parsed.netloc: + continue + # Normalize host case for comparison (scheme+host are case-insensitive) + normalized = parsed._replace( + scheme=parsed.scheme.lower(), + netloc=parsed.netloc.lower(), + fragment="", + ).geturl() + if normalized == base_key: + continue + if normalized in seen: + continue + seen.add(normalized) + out.append({"title": (text or url).strip(), "url": url}) + if len(out) >= limit: + break + return out + + +def _extract_markdown_links( + md: str, base_url: str, limit: int +) -> list[dict[str, str]]: + raw = [(m.group(1), m.group(2)) for m in _JINA_LINK_RE.finditer(md)] + return _filter_links(raw, base_url, limit) + + +def _read_body_capped(response, max_bytes: int) -> bytes: + content_length = response.headers.get("Content-Length") if response.headers else None + if content_length and content_length.isdigit() and int(content_length) > max_bytes: + raise ValueError(f"webpage exceeds MAX_WEB_BYTES={max_bytes}") + body = response.read(max_bytes + 1) + if len(body) > max_bytes: + raise ValueError(f"webpage exceeds MAX_WEB_BYTES={max_bytes}") + return body + + +def _jina_fetch(base: str, target_url: str, max_bytes: int) -> str: + """Call Jina Reader and return the markdown body as text. + + Size gate is identical to the raw path; oversize responses raise so the + caller can fall through to a direct fetch (which may be smaller). + """ + quoted = urllib.parse.quote(target_url, safe=":/?#[]@!$&'()*+,;=") + endpoint = f"{base.rstrip('/')}/{quoted}" + req = urllib.request.Request( + endpoint, + headers={ + "Accept": "text/markdown", + "User-Agent": _PUBLIC_WEB_UA, + "X-Return-Format": "markdown", + }, + ) + with urllib.request.urlopen(req, timeout=_WEB_FETCH_TIMEOUT) as response: # noqa: S310 (URL built from validated base + percent-encoded target) + body = _read_body_capped(response, max_bytes) + return body.decode("utf-8", errors="replace") + + +def _raw_fetch(url: str, max_bytes: int) -> str: + """Direct GET on the target URL with redirects disabled. + + Caller is expected to have passed ``url`` through + ``_validate_public_https_url`` so the connection target is public. + """ + opener = urllib.request.build_opener(_NoRedirectHandler()) + req = urllib.request.Request(url, headers={"User-Agent": _PUBLIC_WEB_UA}) + with opener.open(req, timeout=_WEB_FETCH_TIMEOUT) as response: # noqa: S310 (URL pre-validated by _validate_public_https_url; redirects disabled by _NoRedirectHandler) + body = _read_body_capped(response, max_bytes) + return body.decode("utf-8", errors="replace") + + +def _parse_jina_response(text: str) -> tuple[str, str]: + """Split the Jina Reader preamble ("Title:", "URL Source:", "Markdown + Content:") from the body. Returns (title, body).""" + if not text: + return "", "" + all_lines = text.split("\n") + title = "" + body_start = 0 + for i, line in enumerate(all_lines[:_JINA_HEADER_MAX_LINES]): + if line.startswith("Title: "): + title = line[len("Title: "):].strip() + body_start = max(body_start, i + 1) + elif line.startswith("URL Source: "): + body_start = max(body_start, i + 1) + elif line.startswith("Markdown Content:"): + inline = line[len("Markdown Content:"):].strip() + if inline: + # inline content on same line as the marker: keep it + body_lines = [inline] + all_lines[i + 1:] + return title, "\n".join(body_lines).lstrip("\n") + body_start = i + 1 + break + if body_start == 0: + return title, text + body = "\n".join(all_lines[body_start:]).lstrip("\n") + return title, body + + +@tool( + default_registry, + name="fetch_webpage", + description=( + "Fetch a public HTTPS web page and return clean text content plus a " + "list of outbound links. Use this for summarizing articles, landing " + "pages, or news indexes. For Slack-hosted files use " + "read_attached_document / read_attached_images instead." + ), + parameters={ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Absolute https URL of the page to fetch.", + }, + "max_chars": { + "type": "integer", + "minimum": 500, + "maximum": 20000, + "description": "Optional cap on returned content characters. Clamped by MAX_WEB_CHARS.", + }, + "max_links": { + "type": "integer", + "minimum": 0, + "maximum": 50, + "description": "Optional cap on returned link count. Clamped by MAX_WEB_LINKS.", + }, + }, + "required": ["url"], + }, + timeout=30.0, +) +def fetch_webpage( + ctx: ToolContext, + url: str, + max_chars: int | None = None, + max_links: int | None = None, +) -> dict[str, Any]: + _validate_public_https_url(url) + settings = ctx.settings + effective_chars = min( + max_chars if max_chars is not None else settings.max_web_chars, + settings.max_web_chars, + ) + effective_links = min( + max_links if max_links is not None else settings.max_web_links, + settings.max_web_links, + ) + + title = "" + content = "" + links: list[dict[str, str]] = [] + jina_ok = False + jina_err: str | None = None + + # HTTPError is a subclass of URLError; we list both to make the + # "HTTP status error" path visible as a first-class error type for + # readers. Order doesn't affect matching (URLError catches both). + try: + jina_text = _jina_fetch(settings.jina_reader_base, url, settings.max_web_bytes) + parsed_title, body = _parse_jina_response(jina_text) + if body.strip(): + title = parsed_title + content = body + links = _extract_markdown_links(body, url, effective_links) + jina_ok = True + else: + jina_err = "empty body" + except ( + ValueError, + urllib.error.HTTPError, + urllib.error.URLError, + socket.timeout, + UnicodeDecodeError, + ) as exc: + jina_err = f"{exc.__class__.__name__}: {exc}" + logger.debug("fetch_webpage jina fallback (url=%s): %s", url, jina_err) + + if not jina_ok: + # Belt-and-suspenders: re-validate before the raw fetch. _raw_fetch + # assumes its caller has passed the URL through + # _validate_public_https_url; re-checking here keeps that contract + # enforced even if future refactors mutate `url` in between. + _validate_public_https_url(url) + try: + html_text = _raw_fetch(url, settings.max_web_bytes) + except ( + ValueError, + urllib.error.HTTPError, + urllib.error.URLError, + socket.timeout, + ) as exc: + raise ValueError( + f"fetch_webpage failed: jina={jina_err}, raw={exc.__class__.__name__}: {exc}" + ) from exc + extractor = _HtmlTextExtractor(url) + extractor.feed(html_text) + title = extractor.title() or title + content = extractor.text() + links = _filter_links(extractor.links, url, effective_links) + + source = "jina" if jina_ok else "raw" + + truncated = False + if len(content) > effective_chars: + content = content[:effective_chars] + truncated = True + + return { + "url": url, + "title": title, + "content": content, + "links": links, + "chars": len(content), + "truncated": truncated, + "source": source, + } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/llms/__init__.py b/tests/llms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/llms/test_bedrock.py b/tests/llms/test_bedrock.py new file mode 100644 index 0000000..803d2ff --- /dev/null +++ b/tests/llms/test_bedrock.py @@ -0,0 +1,339 @@ +"""Tests for src.llms.bedrock.""" +from __future__ import annotations + +import base64 +import json +from unittest.mock import MagicMock, patch + +import pytest + +from src.llms.bedrock import BedrockProvider + + +def _bedrock_response(payload: dict): + body = MagicMock() + body.read.return_value = json.dumps(payload).encode() + return {"body": body} + + +def test_bedrock_inference_profile_routes_to_claude(): + """us.anthropic.claude-* inference profile IDs must still hit the Claude + path (tool_use, messages API), not the unknown-family fallback.""" + provider = BedrockProvider( + model="us.anthropic.claude-opus-4-6-v1", + image_model="amazon.nova-canvas-v1:0", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + { + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 2}, + } + ) + tools = [{"name": "search_web", "description": "", "parameters": {"type": "object"}}] + result = provider.chat(system="s", messages=[], tools=tools) + assert result.content == "hi" + # Claude body carries tools (not Nova's toolConfig) + body = provider._client.invoke_model.call_args.kwargs["body"] + parsed = json.loads(body) + assert "tools" in parsed # routed into _claude_chat, not fallback + assert parsed["tools"][0]["name"] == "search_web" + + +def test_bedrock_inference_profile_image_routing(): + """global./us. prefixed image model IDs still reach the Titan/Nova body.""" + provider = BedrockProvider( + model="us.anthropic.claude-opus-4-6-v1", + image_model="us.amazon.nova-canvas-v1:0", + region="us-east-1", + ) + body = provider._build_image_body("cat") + assert body["taskType"] == "TEXT_IMAGE" + + +def test_bedrock_claude_chat_text(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="amazon.titan-image-generator-v1", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + { + "content": [{"type": "text", "text": "안녕"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 5, "output_tokens": 7}, + } + ) + result = provider.chat(system="s", messages=[{"role": "user", "content": "hi"}]) + assert result.content == "안녕" + assert result.stop_reason == "end_turn" + assert result.token_usage == {"input": 5, "output": 7} + + +def test_bedrock_claude_chat_with_tool_use(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="amazon.titan-image-generator-v1", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + { + "content": [ + {"type": "text", "text": "I'll search."}, + {"type": "tool_use", "id": "tu_1", "name": "search_web", "input": {"query": "x"}}, + ], + "stop_reason": "tool_use", + "usage": {"input_tokens": 3, "output_tokens": 4}, + } + ) + tools = [{"name": "search_web", "description": "", "parameters": {"type": "object"}}] + result = provider.chat(system="s", messages=[], tools=tools) + assert result.stop_reason == "tool_use" + assert result.tool_calls[0].name == "search_web" + assert result.tool_calls[0].arguments == {"query": "x"} + + +def test_bedrock_nova_coerces_dict_tool_content_to_json(): + """If a caller hands us a dict as tool content, Nova's `{"text": ...}` + block must receive JSON, not Python's repr via str().""" + msgs = [ + {"role": "tool", "tool_call_id": "t1", "content": {"ok": True, "count": 2}}, + ] + translated = BedrockProvider._to_nova_messages(msgs) + text = translated[0]["content"][0]["toolResult"]["content"][0]["text"] + assert text == '{"ok": true, "count": 2}' + + +def test_bedrock_nova_preserves_string_tool_content(): + msgs = [ + {"role": "tool", "tool_call_id": "t1", "content": '{"already":"json"}'}, + ] + translated = BedrockProvider._to_nova_messages(msgs) + text = translated[0]["content"][0]["toolResult"]["content"][0]["text"] + assert text == '{"already":"json"}' + + +def test_bedrock_message_translation_tool_role(): + messages = [ + {"role": "user", "content": "ask"}, + {"role": "assistant", "content": "", "tool_calls": [{"id": "t1", "name": "foo", "arguments": {"a": 1}}]}, + {"role": "tool", "tool_call_id": "t1", "content": "{\"ok\":true}"}, + ] + translated = BedrockProvider._to_anthropic_messages(messages) + assert translated[0] == {"role": "user", "content": "ask"} + assert translated[1]["role"] == "assistant" + assert translated[1]["content"][0]["type"] == "tool_use" + assert translated[1]["content"][0]["name"] == "foo" + assert translated[2]["role"] == "user" + assert translated[2]["content"][0]["type"] == "tool_result" + + +def test_bedrock_nova_chat_text(): + provider = BedrockProvider(model="amazon.nova-pro-v1:0", image_model="amazon.nova-canvas-v1:0", region="us-east-1") + provider._client = MagicMock() + provider._client.converse.return_value = { + "output": {"message": {"content": [{"text": "hi"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 1, "outputTokens": 2}, + } + result = provider.chat(system="s", messages=[{"role": "user", "content": "hi"}]) + assert result.content == "hi" + assert result.stop_reason == "end_turn" + assert result.token_usage == {"input": 1, "output": 2} + + +def test_bedrock_nova_tool_use(): + provider = BedrockProvider(model="amazon.nova-pro-v1:0", image_model="amazon.nova-canvas-v1:0", region="us-east-1") + provider._client = MagicMock() + provider._client.converse.return_value = { + "output": { + "message": { + "content": [ + {"text": "let me search"}, + {"toolUse": {"toolUseId": "tu1", "name": "search_web", "input": {"query": "q"}}}, + ] + } + }, + "stopReason": "tool_use", + "usage": {"inputTokens": 2, "outputTokens": 3}, + } + result = provider.chat( + system="s", + messages=[], + tools=[{"name": "search_web", "description": "", "parameters": {"type": "object"}}], + ) + assert result.stop_reason == "tool_use" + assert result.tool_calls[0].name == "search_web" + + +def test_build_image_body_titan(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="amazon.titan-image-generator-v1", + region="us-east-1", + ) + body = provider._build_image_body("a cat") + assert body["taskType"] == "TEXT_IMAGE" + + +def test_build_image_body_stability(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="stability.stable-diffusion-xl-v1", + region="us-east-1", + ) + body = provider._build_image_body("a cat") + assert body["text_prompts"][0]["text"] == "a cat" + + +def test_build_image_body_unknown_raises(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="mystery.v1", + region="us-east-1", + ) + with pytest.raises(ValueError): + provider._build_image_body("x") + + +def test_bedrock_describe_image_returns_text(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="amazon.titan-image-generator-v1", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + {"content": [{"type": "text", "text": "a cat"}]} + ) + out = provider.describe_image(b"fake", "image/png") + assert out == "a cat" + + +def test_bedrock_nova_describe_image_uses_converse_not_invoke_model(): + """Nova text models can't accept Claude's Messages body — describe_image + must route to the Converse API with an `image` content block instead.""" + provider = BedrockProvider( + model="amazon.nova-pro-v1:0", + image_model="amazon.nova-canvas-v1:0", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.converse.return_value = { + "output": {"message": {"content": [{"text": "a nova cat"}]}}, + } + out = provider.describe_image(b"bytes", "image/png") + assert out == "a nova cat" + provider._client.converse.assert_called_once() + provider._client.invoke_model.assert_not_called() + messages = provider._client.converse.call_args.kwargs["messages"] + img_block = next(b for b in messages[0]["content"] if "image" in b) + assert img_block["image"]["format"] == "png" + assert img_block["image"]["source"] == {"bytes": b"bytes"} + + +def test_bedrock_nova_describe_image_maps_mime_to_nova_format(): + """image/jpeg must be sent as format='jpeg' (Nova only accepts a short + form: png/jpeg/gif/webp).""" + provider = BedrockProvider( + model="amazon.nova-lite-v1:0", + image_model="amazon.nova-canvas-v1:0", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.converse.return_value = { + "output": {"message": {"content": [{"text": "x"}]}}, + } + provider.describe_image(b"bytes", "image/jpeg") + messages = provider._client.converse.call_args.kwargs["messages"] + img_block = next(b for b in messages[0]["content"] if "image" in b) + assert img_block["image"]["format"] == "jpeg" + + +def test_bedrock_nova_describe_image_inference_profile_routes_to_converse(): + """`us.amazon.nova-*` inference-profile IDs must still hit the Nova path.""" + provider = BedrockProvider( + model="us.amazon.nova-pro-v1:0", + image_model="amazon.nova-canvas-v1:0", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.converse.return_value = { + "output": {"message": {"content": [{"text": "ok"}]}}, + } + provider.describe_image(b"x", "image/png") + provider._client.converse.assert_called_once() + provider._client.invoke_model.assert_not_called() + + +def test_bedrock_nova_describe_image_unknown_mime_falls_back_to_png(): + """Unsupported MIME types must not crash — fall back to 'png' so Nova + receives a valid format value.""" + provider = BedrockProvider( + model="amazon.nova-pro-v1:0", + image_model="amazon.nova-canvas-v1:0", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.converse.return_value = { + "output": {"message": {"content": [{"text": "x"}]}}, + } + provider.describe_image(b"bytes", "application/octet-stream") + messages = provider._client.converse.call_args.kwargs["messages"] + img_block = next(b for b in messages[0]["content"] if "image" in b) + assert img_block["image"]["format"] == "png" + + +def test_bedrock_generate_image_titan_returns_bytes(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="amazon.titan-image-generator-v1", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + {"images": [base64.b64encode(b"imgdata").decode()]} + ) + assert provider.generate_image("cat") == b"imgdata" + + +def test_bedrock_generate_image_stability_returns_bytes(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="stability.stable-diffusion-xl-v1", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + {"artifacts": [{"base64": base64.b64encode(b"xyz").decode()}]} + ) + assert provider.generate_image("cat") == b"xyz" + + +def test_bedrock_client_uses_explicit_timeout_and_retry_config(): + """Regression: default botocore read_timeout=60s + legacy retry=5 turns a + single 80-90s Sonnet generation into 5 silent re-invocations that exhaust + Lambda's 300s budget before any exception surfaces. The bedrock-runtime + client must be built with an explicit Config that aligns read_timeout with + the worker Lambda timeout and caps retries at 2 in standard mode.""" + provider = BedrockProvider( + model="us.anthropic.claude-sonnet-4-5-20250929-v1:0", + image_model="amazon.nova-canvas-v1:0", + region="us-east-1", + ) + with patch("src.llms.bedrock.boto3.client") as mock_boto3_client: + mock_boto3_client.return_value = MagicMock() + provider._get_client() + + mock_boto3_client.assert_called_once() + kwargs = mock_boto3_client.call_args.kwargs + assert kwargs["region_name"] == "us-east-1" + cfg = kwargs["config"] + assert cfg.connect_timeout == 10 + assert cfg.read_timeout == 290 + assert cfg.retries == {"max_attempts": 2, "mode": "standard"} + diff --git a/tests/llms/test_composite.py b/tests/llms/test_composite.py new file mode 100644 index 0000000..c3938c3 --- /dev/null +++ b/tests/llms/test_composite.py @@ -0,0 +1,17 @@ +"""Tests for src.llms.composite.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +from src.llms.composite import _CompositeProvider + + +def test_composite_provider_routes_image_to_image_llm(): + text = MagicMock() + image = MagicMock() + image.generate_image.return_value = b"img" + composite = _CompositeProvider(text=text, image=image) + composite.chat(system="s", messages=[]) + text.chat.assert_called_once() + composite.generate_image("x") + image.generate_image.assert_called_once_with("x") diff --git a/tests/llms/test_factory.py b/tests/llms/test_factory.py new file mode 100644 index 0000000..7d982a2 --- /dev/null +++ b/tests/llms/test_factory.py @@ -0,0 +1,35 @@ +"""Tests for src.llms.factory (get_llm).""" +from __future__ import annotations + +from src.llms import get_llm +from src.llms.composite import _CompositeProvider +from src.llms.openai import OpenAIProvider +from src.llms.xai import XAIProvider + + +def test_get_llm_builds_xai_provider(): + provider = get_llm( + provider="xai", + model="grok-4-1-fast-reasoning", + image_provider="xai", + image_model="grok-imagine-image", + region="us-east-1", + api_keys={"xai": "xai-secret"}, + ) + assert isinstance(provider, XAIProvider) + assert provider._api_key == "xai-secret" + + +def test_get_llm_composite_xai_text_openai_image(): + """Mixed-provider setups still work through _CompositeProvider.""" + provider = get_llm( + provider="xai", + model="grok-4-1-fast-reasoning", + image_provider="openai", + image_model="gpt-image-1", + region="us-east-1", + api_keys={"xai": "xai-key"}, + ) + assert isinstance(provider, _CompositeProvider) + assert isinstance(provider.text, XAIProvider) + assert isinstance(provider.image, OpenAIProvider) diff --git a/tests/llms/test_openai.py b/tests/llms/test_openai.py new file mode 100644 index 0000000..a5b95c5 --- /dev/null +++ b/tests/llms/test_openai.py @@ -0,0 +1,271 @@ +"""Tests for src.llms.openai.""" +from __future__ import annotations + +import base64 +import json +from unittest.mock import MagicMock + +from src.llms.openai import OpenAIProvider + + +def _openai_completion(content="", tool_calls=None, finish="stop"): + choice = MagicMock() + choice.finish_reason = finish + choice.message.content = content + choice.message.tool_calls = tool_calls or [] + completion = MagicMock() + completion.choices = [choice] + completion.usage.prompt_tokens = 10 + completion.usage.completion_tokens = 20 + return completion + + +def _openai_tool_call(call_id, name, args_obj): + tc = MagicMock() + tc.id = call_id + tc.function.name = name + tc.function.arguments = json.dumps(args_obj) + return tc + + +def _make_stream_chunk(*, content=None, tool_calls=None, finish=None, usage=None): + chunk = MagicMock() + if usage is not None: + chunk.usage = usage + else: + chunk.usage = None + if content is None and tool_calls is None and finish is None: + chunk.choices = [] + return chunk + choice = MagicMock() + choice.finish_reason = finish + choice.delta.content = content + choice.delta.tool_calls = tool_calls + chunk.choices = [choice] + return chunk + + +def _stream_tool_call(index, call_id=None, name=None, arguments=None): + tc = MagicMock() + tc.index = index + tc.id = call_id + fn = MagicMock() + fn.name = name + fn.arguments = arguments + tc.function = fn + return tc + + +def test_openai_chat_parses_text(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="hello") + result = provider.chat(system="s", messages=[{"role": "user", "content": "hi"}]) + assert result.content == "hello" + assert result.stop_reason == "end_turn" + assert result.tool_calls == [] + assert result.token_usage == {"input": 10, "output": 20} + + +def test_openai_legacy_model_uses_max_tokens_and_temperature(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="x") + provider.chat(system="s", messages=[]) + kwargs = provider._client.chat.completions.create.call_args.kwargs + assert "max_tokens" in kwargs + assert "temperature" in kwargs + assert "max_completion_tokens" not in kwargs + + +def test_openai_new_generation_uses_max_completion_tokens(): + provider = OpenAIProvider(model="gpt-5.4", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="x") + provider.chat(system="s", messages=[]) + kwargs = provider._client.chat.completions.create.call_args.kwargs + assert "max_completion_tokens" in kwargs + assert "max_tokens" not in kwargs + assert "temperature" not in kwargs + + +def test_openai_o1_model_uses_max_completion_tokens(): + provider = OpenAIProvider(model="o1-mini", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="x") + provider.chat(system="s", messages=[]) + kwargs = provider._client.chat.completions.create.call_args.kwargs + assert "max_completion_tokens" in kwargs + assert "temperature" not in kwargs + + +def test_openai_chat_streams_content_when_on_delta_given(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + chunks = [ + _make_stream_chunk(content="hello"), + _make_stream_chunk(content=" world"), + _make_stream_chunk(finish="stop"), + ] + provider._client.chat.completions.create.return_value = iter(chunks) + + received: list[str] = [] + result = provider.chat(system="s", messages=[], on_delta=received.append) + + assert received == ["hello", " world"] + assert result.content == "hello world" + assert result.stop_reason == "end_turn" + assert result.tool_calls == [] + # must have requested streaming + kwargs = provider._client.chat.completions.create.call_args.kwargs + assert kwargs.get("stream") is True + + +def test_openai_chat_stream_suppresses_content_after_tool_calls_begin(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + chunks = [ + # tool_call begins first (no content yet) + _make_stream_chunk(tool_calls=[_stream_tool_call(0, call_id="c1", name="search_web")]), + _make_stream_chunk(tool_calls=[_stream_tool_call(0, arguments='{"query":')]), + _make_stream_chunk(tool_calls=[_stream_tool_call(0, arguments='"x"}')]), + # Model sometimes also emits commentary content after initiating a tool_call. + _make_stream_chunk(content="I'll search."), + _make_stream_chunk(finish="tool_calls"), + ] + provider._client.chat.completions.create.return_value = iter(chunks) + + received: list[str] = [] + result = provider.chat( + system="s", + messages=[], + tools=[{"name": "search_web", "description": "", "parameters": {"type": "object"}}], + on_delta=received.append, + ) + + # Content after tool_calls started must NOT reach on_delta. + assert received == [] + assert result.tool_calls[0].name == "search_web" + assert result.tool_calls[0].arguments == {"query": "x"} + assert result.stop_reason == "tool_use" + + +def test_openai_chat_stream_accumulates_usage_from_last_chunk(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + usage = MagicMock() + usage.prompt_tokens = 42 + usage.completion_tokens = 7 + chunks = [ + _make_stream_chunk(content="hi"), + _make_stream_chunk(finish="stop", usage=usage), + ] + provider._client.chat.completions.create.return_value = iter(chunks) + + result = provider.chat(system="s", messages=[], on_delta=lambda _: None) + assert result.token_usage == {"input": 42, "output": 7} + + +def test_openai_translates_canonical_tool_calls(): + """Canonical assistant tool_calls must be serialized to OpenAI's wire format.""" + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="done") + + canonical = [ + {"role": "user", "content": "ask"}, + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "c1", "name": "search_web", "arguments": {"query": "q"}}], + }, + {"role": "tool", "tool_call_id": "c1", "content": "{\"ok\": true}"}, + ] + provider.chat(system="s", messages=canonical) + sent = provider._client.chat.completions.create.call_args.kwargs["messages"] + # system + 3 canonical = 4 + assert len(sent) == 4 + assistant = sent[2] + assert assistant["role"] == "assistant" + assert assistant["tool_calls"][0]["type"] == "function" + assert assistant["tool_calls"][0]["function"]["name"] == "search_web" + # arguments must be a JSON string, not a dict + assert isinstance(assistant["tool_calls"][0]["function"]["arguments"], str) + assert json.loads(assistant["tool_calls"][0]["function"]["arguments"]) == {"query": "q"} + + +def test_openai_chat_parses_tool_calls(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + tc = _openai_tool_call("call_1", "search_web", {"query": "aws"}) + provider._client.chat.completions.create.return_value = _openai_completion( + tool_calls=[tc], finish="tool_calls" + ) + result = provider.chat(system="s", messages=[], tools=[{"name": "search_web", "description": "", "parameters": {}}]) + assert result.stop_reason == "tool_use" + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].name == "search_web" + assert result.tool_calls[0].arguments == {"query": "aws"} + + +def test_openai_chat_handles_bad_tool_arguments(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + tc = MagicMock() + tc.id = "x" + tc.function.name = "search_web" + tc.function.arguments = "not json" + provider._client.chat.completions.create.return_value = _openai_completion( + tool_calls=[tc], finish="tool_calls" + ) + result = provider.chat(system="s", messages=[], tools=[{"name": "search_web", "description": "", "parameters": {}}]) + assert result.tool_calls[0].arguments == {} + + +def test_openai_stream_chat_invokes_callback(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + + def _chunk(text): + ch = MagicMock() + ch.choices[0].delta.content = text + return ch + + provider._client.chat.completions.create.return_value = iter([_chunk("he"), _chunk("llo")]) + seen = [] + result = provider.stream_chat(system="s", messages=[], on_delta=seen.append) + assert result == "hello" + assert seen == ["he", "llo"] + + +def test_openai_describe_image_uses_vision_format(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="it's a cat") + out = provider.describe_image(b"\x89PNG", "image/png") + assert out == "it's a cat" + args = provider._client.chat.completions.create.call_args.kwargs + assert args["messages"][0]["content"][1]["type"] == "image_url" + + +def test_openai_generate_image_decodes_b64(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + response = MagicMock() + response.data = [MagicMock(b64_json=base64.b64encode(b"hello").decode())] + provider._client.images.generate.return_value = response + assert provider.generate_image("cat") == b"hello" + kwargs = provider._client.images.generate.call_args.kwargs + # gpt-image-1 must NOT send response_format (API rejects it) + assert "response_format" not in kwargs + + +def test_openai_generate_image_dalle_sends_response_format(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="dall-e-3") + provider._client = MagicMock() + response = MagicMock() + response.data = [MagicMock(b64_json=base64.b64encode(b"ok").decode())] + provider._client.images.generate.return_value = response + provider.generate_image("cat") + kwargs = provider._client.images.generate.call_args.kwargs + assert kwargs["response_format"] == "b64_json" diff --git a/tests/llms/test_xai.py b/tests/llms/test_xai.py new file mode 100644 index 0000000..8716822 --- /dev/null +++ b/tests/llms/test_xai.py @@ -0,0 +1,110 @@ +"""Tests for src.llms.xai.""" +from __future__ import annotations + +import base64 +import json +from unittest.mock import MagicMock, patch + +from src.llms.xai import XAIProvider + + +def _openai_completion(content="", tool_calls=None, finish="stop"): + choice = MagicMock() + choice.finish_reason = finish + choice.message.content = content + choice.message.tool_calls = tool_calls or [] + completion = MagicMock() + completion.choices = [choice] + completion.usage.prompt_tokens = 10 + completion.usage.completion_tokens = 20 + return completion + + +def _openai_tool_call(call_id, name, args_obj): + tc = MagicMock() + tc.id = call_id + tc.function.name = name + tc.function.arguments = json.dumps(args_obj) + return tc + + +def test_xai_provider_uses_xai_base_url_and_api_key(): + """XAIProvider must instantiate OpenAI client with the xAI base URL and + the explicit api_key, so traffic goes to api.x.ai rather than OpenAI.""" + provider = XAIProvider( + model="grok-4-1-fast-reasoning", + image_model="grok-imagine-image", + api_key="xai-test", + ) + with patch("openai.OpenAI") as openai_ctor: + openai_ctor.return_value = MagicMock() + provider._get_client() + kwargs = openai_ctor.call_args.kwargs + assert kwargs.get("base_url") == "https://api.x.ai/v1" + assert kwargs.get("api_key") == "xai-test" + + +def test_xai_chat_parses_tool_calls(): + """Grok returns the same wire shape as OpenAI for tool calls; the + shared parser must turn them into ToolCall objects.""" + provider = XAIProvider(model="grok-4-1-fast-reasoning", image_model="grok-imagine-image", api_key="x") + provider._client = MagicMock() + tc = _openai_tool_call("call_g1", "search_web", {"query": "xai"}) + provider._client.chat.completions.create.return_value = _openai_completion( + tool_calls=[tc], finish="tool_calls" + ) + result = provider.chat( + system="s", + messages=[], + tools=[{"name": "search_web", "description": "", "parameters": {"type": "object"}}], + ) + assert result.stop_reason == "tool_use" + assert result.tool_calls[0].name == "search_web" + assert result.tool_calls[0].arguments == {"query": "xai"} + + +def test_xai_chat_uses_legacy_max_tokens_always(): + """All current grok chat models accept max_tokens + temperature; + XAIProvider must not switch to max_completion_tokens (OpenAI-only split).""" + provider = XAIProvider(model="grok-4.20-0309-reasoning", image_model="grok-imagine-image", api_key="x") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="hi") + provider.chat(system="s", messages=[]) + kwargs = provider._client.chat.completions.create.call_args.kwargs + assert "max_tokens" in kwargs + assert "temperature" in kwargs + assert "max_completion_tokens" not in kwargs + + +def test_xai_generate_image_skips_size_and_requests_b64(): + """xAI images.generate rejects `size` (uses aspect_ratio/resolution). + We must omit it and explicitly ask for b64_json so we can decode bytes + into files_upload_v2.""" + provider = XAIProvider(model="grok-4-1-fast-reasoning", image_model="grok-imagine-image", api_key="x") + provider._client = MagicMock() + response = MagicMock() + response.data = [MagicMock(b64_json=base64.b64encode(b"xai-bytes").decode())] + provider._client.images.generate.return_value = response + + assert provider.generate_image("a cat") == b"xai-bytes" + kwargs = provider._client.images.generate.call_args.kwargs + assert kwargs["model"] == "grok-imagine-image" + assert kwargs["prompt"] == "a cat" + assert kwargs["response_format"] == "b64_json" + assert "size" not in kwargs # xAI rejects this + + +def test_xai_stream_chat_emits_deltas(): + provider = XAIProvider(model="grok-4-1-fast-reasoning", image_model="grok-imagine-image", api_key="x") + provider._client = MagicMock() + + def _chunk(text): + ch = MagicMock() + ch.choices[0].delta.content = text + return ch + + provider._client.chat.completions.create.return_value = iter([_chunk("gr"), _chunk("ok")]) + seen: list[str] = [] + result = provider.stream_chat(system="s", messages=[], on_delta=seen.append) + assert result == "grok" + assert seen == ["gr", "ok"] diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000..a52e3fd --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,374 @@ +from unittest.mock import MagicMock + +from src.agent import SlackMentionAgent +from src.llms import LLMResult, ToolCall +from src.tools import ToolContext, ToolRegistry, tool + + +def _ctx(): + return ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event={}, + settings=MagicMock(), + llm=MagicMock(), + ) + + +class ScriptedLLM: + def __init__(self, results: list[LLMResult]): + self._results = list(results) + self.calls: list[dict] = [] + + def chat(self, system, messages, tools=None, max_tokens=1024, on_delta=None): + self.calls.append({"messages": list(messages), "tools": tools, "on_delta": on_delta}) + if not self._results: + result = LLMResult(content="(empty)", tool_calls=[], stop_reason="end_turn") + else: + result = self._results.pop(0) + if on_delta is not None and result.content and not result.tool_calls: + on_delta(result.content) + return result + + def stream_chat(self, system, messages, on_delta, max_tokens=1024): + result = self.chat(system, messages) + if on_delta and result.content: + on_delta(result.content) + return result.content + + def describe_image(self, b, m): + return "desc" + + def generate_image(self, p): + return b"img" + + +def _registry_with_search(): + reg = ToolRegistry() + + @tool(reg, name="search_web", description="", parameters={"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}) + def _search(ctx, query): + return [{"title": "AWS", "url": "https://aws.amazon.com"}] + + return reg + + +def test_agent_terminates_when_no_tool_calls(): + reg = _registry_with_search() + llm = ScriptedLLM([LLMResult(content="final", tool_calls=[], stop_reason="end_turn")]) + agent = SlackMentionAgent(llm=llm, context=_ctx(), registry=reg, max_steps=3) + result = agent.run("question") + assert result.text == "final" + assert result.steps == 1 + assert result.tool_calls_count == 0 + + +def test_agent_runs_tool_then_returns_text(): + reg = _registry_with_search() + llm = ScriptedLLM( + [ + LLMResult( + content="", + tool_calls=[ToolCall(id="c1", name="search_web", arguments={"query": "aws"})], + stop_reason="tool_use", + ), + LLMResult(content="결과 기반 답변", tool_calls=[], stop_reason="end_turn"), + ] + ) + agent = SlackMentionAgent(llm=llm, context=_ctx(), registry=reg, max_steps=3) + result = agent.run("질문") + assert result.text == "결과 기반 답변" + assert result.tool_calls_count == 1 + assert result.steps == 2 + + +def test_agent_duplicate_call_is_skipped(): + reg = _registry_with_search() + called = {"count": 0} + + reg = ToolRegistry() + + @tool(reg, name="search_web", description="", parameters={"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}) + def _search(ctx, query): + called["count"] += 1 + return [{"title": "X"}] + + # LLM calls the same tool twice with identical args, then finishes. + llm = ScriptedLLM( + [ + LLMResult( + content="", + tool_calls=[ToolCall(id="c1", name="search_web", arguments={"query": "same"})], + stop_reason="tool_use", + ), + LLMResult( + content="", + tool_calls=[ToolCall(id="c2", name="search_web", arguments={"query": "same"})], + stop_reason="tool_use", + ), + LLMResult(content="done", tool_calls=[], stop_reason="end_turn"), + ] + ) + agent = SlackMentionAgent(llm=llm, context=_ctx(), registry=reg, max_steps=5) + agent.run("q") + assert called["count"] == 1 # second call suppressed + + +def test_agent_captures_image_url(): + reg = ToolRegistry() + + @tool(reg, name="generate_image", description="", parameters={"type": "object", "properties": {"prompt": {"type": "string"}}, "required": ["prompt"]}) + def _gen(ctx, prompt): + return {"permalink": "https://slack/x"} + + llm = ScriptedLLM( + [ + LLMResult( + content="", + tool_calls=[ToolCall(id="c1", name="generate_image", arguments={"prompt": "cat"})], + stop_reason="tool_use", + ), + LLMResult(content="here is your image", tool_calls=[], stop_reason="end_turn"), + ] + ) + agent = SlackMentionAgent(llm=llm, context=_ctx(), registry=reg, max_steps=3) + result = agent.run("그려줘") + assert result.image_url == "https://slack/x" + + +def test_agent_forces_final_compose_at_max_steps(): + reg = _registry_with_search() + # Every step returns more tool calls — never ends. + def infinite(): + while True: + yield LLMResult( + content="", + tool_calls=[ToolCall(id="x", name="search_web", arguments={"query": "q"})], + stop_reason="tool_use", + ) + + class EndlessLLM: + def __init__(self): + self._gen = infinite() + self.end_called = False + + def chat(self, *a, **k): + return next(self._gen) + + def stream_chat(self, system, messages, on_delta, max_tokens=1024): + self.end_called = True + if on_delta: + on_delta("forced") + return "forced" + + def describe_image(self, *a, **k): + return "" + + def generate_image(self, *a, **k): + return b"" + + llm = EndlessLLM() + agent = SlackMentionAgent(llm=llm, context=_ctx(), registry=reg, max_steps=2, on_stream=lambda d: None) + result = agent.run("q") + assert result.text == "forced" + assert llm.end_called is True + assert result.steps == 2 + + +def test_agent_on_step_fires_for_tool_use_and_compose(): + reg = _registry_with_search() + events: list[tuple[int, str, dict]] = [] + llm = ScriptedLLM( + [ + LLMResult( + content="", + tool_calls=[ToolCall(id="c1", name="search_web", arguments={"query": "x"})], + stop_reason="tool_use", + ), + LLMResult(content="done", tool_calls=[], stop_reason="end_turn"), + ] + ) + agent = SlackMentionAgent( + llm=llm, + context=_ctx(), + registry=reg, + max_steps=3, + on_step=lambda step, phase, detail: events.append((step, phase, detail)), + ) + agent.run("q") + phases = [p for _, p, _ in events] + assert "tool_use" in phases + assert "tool_result" in phases + assert "compose" in phases + # compose should fire on the second hop (step=2) without max_steps_hit flag + compose_events = [e for e in events if e[1] == "compose"] + assert compose_events[0][2].get("max_steps_hit") is not True + + +def test_agent_streams_final_answer_when_on_stream_set(): + """When on_stream is set, chat(on_delta=...) streams content during the + terminal hop. The agent must not then re-call the LLM to re-stream.""" + reg = _registry_with_search() + delta_buffer: list[str] = [] + + class StreamingLLM(ScriptedLLM): + def chat(self, system, messages, tools=None, max_tokens=1024, on_delta=None): + self.calls.append({"on_delta": on_delta}) + # emit three deltas through on_delta, no tool_calls + if on_delta is not None: + for chunk in ["재미있", "는 답변", "입니다"]: + on_delta(chunk) + return LLMResult(content="재미있는 답변입니다", tool_calls=[], stop_reason="end_turn") + + llm = StreamingLLM([]) + agent = SlackMentionAgent( + llm=llm, + context=_ctx(), + registry=reg, + max_steps=3, + on_stream=delta_buffer.append, + ) + result = agent.run("q") + assert delta_buffer == ["재미있", "는 답변", "입니다"] + assert result.text == "재미있는 답변입니다" + # Exactly one chat call — no compose re-call. + assert len(llm.calls) == 1 + + +def test_agent_closes_owned_executor_after_run(): + """SlackMentionAgent must release the ThreadPoolExecutor it created so + Lambda warm containers don't accumulate idle workers across requests.""" + reg = _registry_with_search() + llm = ScriptedLLM([LLMResult(content="done", tool_calls=[], stop_reason="end_turn")]) + agent = SlackMentionAgent(llm=llm, context=_ctx(), registry=reg, max_steps=3) + agent.run("q") + assert agent.executor._closed is True + + +def test_agent_closes_owned_executor_on_exception(): + """Even when the LLM raises, the owned executor must still be closed.""" + reg = _registry_with_search() + + class BoomLLM: + def chat(self, *a, **k): + raise RuntimeError("boom") + + def stream_chat(self, *a, **k): # pragma: no cover + raise RuntimeError("boom") + + def describe_image(self, *a, **k): # pragma: no cover + return "" + + def generate_image(self, *a, **k): # pragma: no cover + return b"" + + agent = SlackMentionAgent(llm=BoomLLM(), context=_ctx(), registry=reg, max_steps=3) + import pytest + + with pytest.raises(RuntimeError): + agent.run("q") + assert agent.executor._closed is True + + +def test_agent_does_not_close_injected_executor(): + """An externally-supplied ToolExecutor is owned by the caller; the agent + must not shut it down.""" + from src.tools.registry import ToolExecutor + + reg = _registry_with_search() + ext_exec = ToolExecutor(_ctx(), reg) + llm = ScriptedLLM([LLMResult(content="done", tool_calls=[], stop_reason="end_turn")]) + agent = SlackMentionAgent( + llm=llm, context=_ctx(), registry=reg, max_steps=3, tool_executor=ext_exec + ) + agent.run("q") + assert ext_exec._closed is False + ext_exec.close() + + +def test_system_prompt_keeps_task_rules_even_when_system_message_set(): + """SYSTEM_MESSAGE must NOT replace the task rules — it is appended as + additional policy so the agent's planning / tool / attachment guidance + always stays in the prompt.""" + reg = _registry_with_search() + agent = SlackMentionAgent( + llm=MagicMock(), + context=_ctx(), + registry=reg, + max_steps=3, + system_message="Do not expose secrets.", + persona_message="자연스러운 한국어로 핵심부터 답한다.", + ) + prompt = agent._build_system_prompt() + # Layer 1: task + slack + attachment rules are always present. + assert "Plan work, call tools when needed" in prompt + assert "in parallel within a single turn" in prompt + assert "generate_image" in prompt + assert "fetch_thread_history" in prompt + # Layer 2 / 3 labels present and carry the operator-supplied text. + assert "Additional policy:" in prompt + assert "Do not expose secrets." in prompt + assert "Response style:" in prompt + assert "자연스러운 한국어로 핵심부터 답한다." in prompt + # Language re-emphasis remains last. + assert prompt.rstrip().endswith("Respond in language: ko.") + + +def test_system_prompt_includes_tier_s_guidance(): + """The three Tier-S guidances must stay in the prompt: + (H1) Slack mrkdwn rendering, (H2) speculative attachment lookup on + ambiguous references, (H3) tool-failure response policy.""" + reg = _registry_with_search() + agent = SlackMentionAgent( + llm=MagicMock(), context=_ctx(), registry=reg, max_steps=3 + ) + prompt = agent._build_system_prompt() + # H1: mrkdwn vs GitHub markdown guidance. + assert "mrkdwn" in prompt + assert "`*bold*`" in prompt + assert "`**bold**`" in prompt # the anti-pattern is called out + assert "" in prompt + # H2: speculative attachment call on ambiguous reference. + assert "speculatively" in prompt + assert "이 사진" in prompt + # H3: tool-failure policy. + assert "ok:false" in prompt + assert "do not retry blindly" in prompt + assert "do not fabricate" in prompt + + +def test_system_prompt_omits_empty_optional_layers(): + """When SYSTEM_MESSAGE / PERSONA_MESSAGE are unset, their labeled + blocks must not appear in the prompt.""" + reg = _registry_with_search() + agent = SlackMentionAgent( + llm=MagicMock(), context=_ctx(), registry=reg, max_steps=3 + ) + prompt = agent._build_system_prompt() + assert "Additional policy:" not in prompt + assert "Response style:" not in prompt + assert "Plan work" in prompt + + +def test_agent_aggregates_token_usage(): + reg = _registry_with_search() + llm = ScriptedLLM( + [ + LLMResult( + content="", + tool_calls=[ToolCall(id="c1", name="search_web", arguments={"query": "x"})], + stop_reason="tool_use", + token_usage={"input": 10, "output": 20}, + ), + LLMResult( + content="done", + tool_calls=[], + stop_reason="end_turn", + token_usage={"input": 5, "output": 7}, + ), + ] + ) + agent = SlackMentionAgent(llm=llm, context=_ctx(), registry=reg, max_steps=3) + result = agent.run("q") + assert result.token_usage == {"input": 15, "output": 27} diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..d2d7eb3 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,555 @@ +"""Tests for the receiver / worker split in app.lambda_handler. + +We only exercise the routing + enqueue logic here — `_process` itself is +covered transitively by tests/test_agent.py and friends. All external +dependencies (boto3, slack_sdk.WebClient, Bolt) are mocked so the tests +don't need real credentials. +""" +import json + +import pytest + + +@pytest.fixture +def app_module(): + """Import the app module fresh. + + `app.Settings.from_env()` reads env at import time but does not + validate Slack credentials, so this is safe without setting + SLACK_BOT_TOKEN / SLACK_SIGNING_SECRET. + """ + import app + + return app + + +# --------------------------------------------------------------------------- # +# lambda_handler routing +# --------------------------------------------------------------------------- # + + +def test_lambda_handler_routes_worker_flag_to_process_worker(app_module, monkeypatch): + """`event["_worker"] is True` must skip Slack / Bolt entirely and call the worker.""" + received = {} + + def fake_worker(payload): + received["payload"] = payload + + monkeypatch.setattr(app_module, "_process_worker", fake_worker) + + def boom_bolt(): + raise AssertionError("_get_bolt_app must not be called on the worker path") + + monkeypatch.setattr(app_module, "_get_bolt_app", boom_bolt) + + event = {"_worker": True, "slack_event": {"channel": "C1", "text": "hi"}, "is_dm": False} + result = app_module.lambda_handler(event, None) + + assert result == {"statusCode": 200, "body": ""} + assert received["payload"] is event + + +def test_lambda_handler_short_circuits_slack_retry_without_bolt(app_module, monkeypatch): + """Receiver path: an X-Slack-Retry-Num header means Slack is re-delivering. + We already dispatched the first try to a worker; swallow the retry.""" + + def boom_bolt(): + raise AssertionError("Bolt must not be invoked on a retried delivery") + + monkeypatch.setattr(app_module, "_get_bolt_app", boom_bolt) + + event = {"headers": {"X-Slack-Retry-Num": "1"}, "body": "..."} + assert app_module.lambda_handler(event, None) == {"statusCode": 200, "body": ""} + + +def test_lambda_handler_short_circuits_slack_retry_lowercase_header(app_module, monkeypatch): + """API Gateway may lowercase the header name. Our guard normalizes.""" + monkeypatch.setattr( + app_module, + "_get_bolt_app", + lambda: (_ for _ in ()).throw(AssertionError("should not hit Bolt")), + ) + event = {"headers": {"x-slack-retry-num": "3"}} + assert app_module.lambda_handler(event, None) == {"statusCode": 200, "body": ""} + + +def test_lambda_handler_delegates_normal_request_to_bolt(app_module, monkeypatch): + """A normal Slack HTTP event (no retry header, no _worker flag) must + reach the Bolt SlackRequestHandler.""" + calls = [] + + class FakeHandler: + def __init__(self, bolt_app): + calls.append(("init", bolt_app)) + + def handle(self, event, context): + calls.append(("handle", event, context)) + return {"statusCode": 200, "body": "bolt"} + + monkeypatch.setattr(app_module, "SlackRequestHandler", FakeHandler) + monkeypatch.setattr(app_module, "_get_bolt_app", lambda: "fake-bolt-app") + + event = {"headers": {"Content-Type": "application/json"}, "body": "{}"} + result = app_module.lambda_handler(event, "ctx") + + assert result == {"statusCode": 200, "body": "bolt"} + assert calls[0] == ("init", "fake-bolt-app") + assert calls[1] == ("handle", event, "ctx") + + +def test_lambda_handler_worker_flag_false_takes_receiver_path(app_module, monkeypatch): + """`_worker=False` should fall through to the receiver path, not be + treated as a worker marker.""" + + def boom_worker(payload): + raise AssertionError("_process_worker must not run when _worker is falsy") + + monkeypatch.setattr(app_module, "_process_worker", boom_worker) + + class FakeHandler: + def __init__(self, bolt_app): + pass + + def handle(self, event, context): + return {"statusCode": 202} + + monkeypatch.setattr(app_module, "SlackRequestHandler", FakeHandler) + monkeypatch.setattr(app_module, "_get_bolt_app", lambda: object()) + + assert app_module.lambda_handler({"_worker": False, "headers": {}}, None) == {"statusCode": 202} + + +# --------------------------------------------------------------------------- # +# _enqueue_worker — receiver → worker bridge +# --------------------------------------------------------------------------- # + + +def test_enqueue_worker_runs_inline_when_not_in_lambda(app_module, monkeypatch): + """Without AWS_LAMBDA_FUNCTION_NAME (local dev / tests), the + receiver must execute the worker path inline rather than trying to + issue a boto3.invoke that would fail against real AWS.""" + monkeypatch.delenv("AWS_LAMBDA_FUNCTION_NAME", raising=False) + + def boom_client(): + raise AssertionError("boto3 must not be touched off-Lambda") + + monkeypatch.setattr(app_module, "_get_lambda_client", boom_client) + + captured = [] + monkeypatch.setattr(app_module, "_process_worker", lambda payload: captured.append(payload)) + + event = {"channel": "C1", "text": "hello"} + app_module._enqueue_worker(event, is_dm=False) + + assert captured == [{"slack_event": event, "is_dm": False}] + + +def test_enqueue_worker_fires_async_invoke_in_lambda(app_module, monkeypatch): + """When AWS_LAMBDA_FUNCTION_NAME is set, issue an async Lambda invoke + with the correct payload shape — and NOT run the worker inline.""" + monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "gurumi-mention") + + invocations = [] + + class FakeLambdaClient: + def invoke(self, **kwargs): + invocations.append(kwargs) + return {"StatusCode": 202} + + monkeypatch.setattr(app_module, "_get_lambda_client", lambda: FakeLambdaClient()) + + def boom_inline(payload): + raise AssertionError("inline worker must not run when invoke succeeds") + + monkeypatch.setattr(app_module, "_process_worker", boom_inline) + + event = {"channel": "D1", "text": "안녕", "user": "U1"} + app_module._enqueue_worker(event, is_dm=True) + + assert len(invocations) == 1 + call = invocations[0] + assert call["FunctionName"] == "gurumi-mention" + assert call["InvocationType"] == "Event" + payload = json.loads(call["Payload"].decode("utf-8")) + assert payload == {"_worker": True, "slack_event": event, "is_dm": True} + + +def test_enqueue_worker_falls_back_to_inline_on_invoke_failure(app_module, monkeypatch): + """If boto3.invoke raises (IAM denied / throttle / network), fall back + to inline execution so the user's message isn't silently dropped.""" + monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "gurumi-mention") + + class BrokenClient: + def invoke(self, **_kwargs): + raise RuntimeError("network unreachable") + + monkeypatch.setattr(app_module, "_get_lambda_client", lambda: BrokenClient()) + + captured = [] + monkeypatch.setattr(app_module, "_process_worker", lambda payload: captured.append(payload)) + + event = {"channel": "C1", "text": "hi"} + app_module._enqueue_worker(event, is_dm=False) + + assert captured == [{"slack_event": event, "is_dm": False}] + + +def test_enqueue_worker_payload_preserves_non_ascii(app_module, monkeypatch): + """Korean / emoji in the Slack event must survive the JSON round-trip.""" + monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "gurumi-mention") + + invocations = [] + + class FakeClient: + def invoke(self, **kwargs): + invocations.append(kwargs) + + monkeypatch.setattr(app_module, "_get_lambda_client", lambda: FakeClient()) + monkeypatch.setattr(app_module, "_process_worker", lambda _p: None) + + event = {"text": "이미지 그려줘 🎨", "channel": "C1"} + app_module._enqueue_worker(event, is_dm=False) + + payload = json.loads(invocations[0]["Payload"].decode("utf-8")) + assert payload["slack_event"]["text"] == "이미지 그려줘 🎨" + + +# --------------------------------------------------------------------------- # +# _process_worker — the async Lambda entrypoint +# --------------------------------------------------------------------------- # + + +def test_process_worker_builds_webclient_from_bot_token(app_module, monkeypatch): + """Bolt's injected WebClient is gone by the time the worker invocation + runs — it lived in the receiver process. The worker must mint a fresh + one from the bot token on `settings`. + + `Settings` is a frozen dataclass, so we replace the whole module-level + `settings` with a dataclasses.replace() copy that carries the token + we want to observe. + """ + import dataclasses + + override = dataclasses.replace(app_module.settings, slack_bot_token="xoxb-test-token") + monkeypatch.setattr(app_module, "settings", override) + + created = [] + + class FakeWeb: + def __init__(self, token): + created.append(token) + self.token = token + + monkeypatch.setattr(app_module, "WebClient", FakeWeb) + + captured = {} + + def fake_process(event, client, say, is_dm): + captured["event"] = event + captured["client"] = client + captured["say"] = say + captured["is_dm"] = is_dm + + monkeypatch.setattr(app_module, "_process", fake_process) + + payload = {"slack_event": {"channel": "C1", "text": "hi"}, "is_dm": True} + app_module._process_worker(payload) + + assert created == ["xoxb-test-token"] + assert captured["event"] == {"channel": "C1", "text": "hi"} + assert captured["is_dm"] is True + assert isinstance(captured["client"], FakeWeb) + assert callable(captured["say"]) + + +def test_process_worker_say_callable_posts_to_event_channel(app_module, monkeypatch): + """The `say` closure passed to _process must post to the channel the + original Slack event came from — not some default — and must forward + thread_ts when provided.""" + posts = [] + + class FakeWeb: + def __init__(self, token): + pass + + def chat_postMessage(self, **kwargs): + posts.append(kwargs) + + monkeypatch.setattr(app_module, "WebClient", FakeWeb) + + captured_say = {} + + def fake_process(event, client, say, is_dm): + captured_say["fn"] = say + + monkeypatch.setattr(app_module, "_process", fake_process) + + payload = {"slack_event": {"channel": "C-origin", "text": "x"}, "is_dm": False} + app_module._process_worker(payload) + + say = captured_say["fn"] + say("hello world") + say("threaded reply", thread_ts="1700000000.000100") + + assert posts == [ + {"channel": "C-origin", "text": "hello world"}, + {"channel": "C-origin", "text": "threaded reply", "thread_ts": "1700000000.000100"}, + ] + + +def test_process_worker_tolerates_missing_fields(app_module, monkeypatch): + """Defensive: a malformed payload (no slack_event, no is_dm) must + still reach _process with sane defaults rather than crash.""" + + class FakeWeb: + def __init__(self, token): + pass + + monkeypatch.setattr(app_module, "WebClient", FakeWeb) + + seen = {} + + def fake_process(event, client, say, is_dm): + seen["event"] = event + seen["is_dm"] = is_dm + + monkeypatch.setattr(app_module, "_process", fake_process) + + app_module._process_worker({}) + + assert seen == {"event": {}, "is_dm": False} + + +# --------------------------------------------------------------------------- # +# Channel allowlist — block reply with first-channel substitution +# --------------------------------------------------------------------------- # + + +class _FakeDedup: + """Minimal DedupStore stand-in: reserve always succeeds, no throttle.""" + + def reserve(self, key, user="system"): + return True + + def count_user_active(self, user): + return 0 + + +def test_process_blocked_channel_substitutes_first_allowed_channel(app_module, monkeypatch): + """비허용 채널 응답의 `{}` 는 ALLOWED_CHANNEL_IDS 의 첫 번째 채널로 치환되며, + Slack 채널 멘션 형식(`<#ID>`)으로 감싸 클릭 가능한 링크로 렌더되어야 한다.""" + import dataclasses + + override = dataclasses.replace( + app_module.settings, + allowed_channel_ids=["C04PPA399CP", "C08A9550X"], + allowed_channel_message="구루미에게 질문은 {} 채널을 이용해 주세요~", + ) + monkeypatch.setattr(app_module, "settings", override) + monkeypatch.setattr(app_module, "_get_dedup", lambda: _FakeDedup()) + + posts = [] + + def fake_say(text, thread_ts=None): + posts.append({"text": text, "thread_ts": thread_ts}) + + event = { + "channel": "C-BLOCKED", + "ts": "1700000000.000100", + "text": "hi", + "user": "U1", + "client_msg_id": "msg-block-1", + } + app_module._process(event, client=object(), say=fake_say, is_dm=False) + + assert posts == [ + { + "text": "구루미에게 질문은 <#C04PPA399CP> 채널을 이용해 주세요~", + "thread_ts": "1700000000.000100", + } + ] + + +def test_process_blocked_channel_message_without_placeholder_unchanged(app_module, monkeypatch): + """`{}` 가 없는 메시지는 가공 없이 그대로 전송되어야 한다.""" + import dataclasses + + override = dataclasses.replace( + app_module.settings, + allowed_channel_ids=["C04PPA399CP"], + allowed_channel_message="허용되지 않은 채널입니다.", + ) + monkeypatch.setattr(app_module, "settings", override) + monkeypatch.setattr(app_module, "_get_dedup", lambda: _FakeDedup()) + + posts = [] + app_module._process( + { + "channel": "C-X", + "ts": "1.1", + "text": "hi", + "user": "U1", + "client_msg_id": "msg-block-2", + }, + client=object(), + say=lambda text, thread_ts=None: posts.append({"text": text, "thread_ts": thread_ts}), + is_dm=False, + ) + + assert posts == [{"text": "허용되지 않은 채널입니다.", "thread_ts": "1.1"}] + + +def test_process_blocked_channel_no_message_when_unset(app_module, monkeypatch): + """ALLOWED_CHANNEL_MESSAGE 가 비어 있으면 차단된 채널에서 아무 응답도 가지 않는다.""" + import dataclasses + + override = dataclasses.replace( + app_module.settings, + allowed_channel_ids=["C04PPA399CP"], + allowed_channel_message="", + ) + monkeypatch.setattr(app_module, "settings", override) + monkeypatch.setattr(app_module, "_get_dedup", lambda: _FakeDedup()) + + posts = [] + app_module._process( + { + "channel": "C-X", + "ts": "1.1", + "text": "hi", + "user": "U1", + "client_msg_id": "msg-block-3", + }, + client=object(), + say=lambda text, thread_ts=None: posts.append({"text": text, "thread_ts": thread_ts}), + is_dm=False, + ) + + assert posts == [] + + +# --------------------------------------------------------------------------- # +# User allowlist — block reply with first-user substitution (channel + DM) +# --------------------------------------------------------------------------- # + + +def test_process_blocked_user_substitutes_first_allowed_user(app_module, monkeypatch): + """비허용 유저 응답의 `{}` 는 ALLOWED_USER_IDS 의 첫 번째 유저를 + Slack 멘션 형식(`<@ID>`)으로 치환해야 한다. 채널 검사를 통과한 뒤에도 유저로 + 차단되는 케이스.""" + import dataclasses + + override = dataclasses.replace( + app_module.settings, + allowed_channel_ids=[], # 채널 검사 통과 + allowed_user_ids=["U-ADMIN", "U-OPS"], + allowed_user_message="이 봇은 {} 만 답변합니다.", + ) + monkeypatch.setattr(app_module, "settings", override) + monkeypatch.setattr(app_module, "_get_dedup", lambda: _FakeDedup()) + + posts = [] + app_module._process( + { + "channel": "C-OK", + "ts": "1.1", + "text": "hi", + "user": "U-RANDOM", + "client_msg_id": "msg-user-1", + }, + client=object(), + say=lambda text, thread_ts=None: posts.append({"text": text, "thread_ts": thread_ts}), + is_dm=False, + ) + + assert posts == [{"text": "이 봇은 <@U-ADMIN> 만 답변합니다.", "thread_ts": "1.1"}] + + +def test_process_blocked_user_applies_in_dm(app_module, monkeypatch): + """유저 화이트리스트는 DM 경로에도 적용되어야 한다 — 채널 화이트리스트와의 + 핵심 차이. is_dm=True 라도 비허용 유저는 차단 메시지를 받는다.""" + import dataclasses + + override = dataclasses.replace( + app_module.settings, + allowed_user_ids=["U-ADMIN"], + allowed_user_message="DM 도 차단합니다.", + ) + monkeypatch.setattr(app_module, "settings", override) + monkeypatch.setattr(app_module, "_get_dedup", lambda: _FakeDedup()) + + posts = [] + app_module._process( + { + "channel": "D-DM", + "ts": "1.1", + "text": "hi", + "user": "U-RANDOM", + "client_msg_id": "msg-user-2", + }, + client=object(), + say=lambda text, thread_ts=None: posts.append({"text": text, "thread_ts": thread_ts}), + is_dm=True, + ) + + assert posts == [{"text": "DM 도 차단합니다.", "thread_ts": "1.1"}] + + +def test_process_blocked_user_no_message_when_unset(app_module, monkeypatch): + """ALLOWED_USER_MESSAGE 가 비어 있으면 차단된 유저에게 응답이 가지 않는다.""" + import dataclasses + + override = dataclasses.replace( + app_module.settings, + allowed_user_ids=["U-ADMIN"], + allowed_user_message="", + ) + monkeypatch.setattr(app_module, "settings", override) + monkeypatch.setattr(app_module, "_get_dedup", lambda: _FakeDedup()) + + posts = [] + app_module._process( + { + "channel": "C-OK", + "ts": "1.1", + "text": "hi", + "user": "U-RANDOM", + "client_msg_id": "msg-user-3", + }, + client=object(), + say=lambda text, thread_ts=None: posts.append({"text": text, "thread_ts": thread_ts}), + is_dm=False, + ) + + assert posts == [] + + +def test_process_blocked_channel_short_circuits_before_user_check(app_module, monkeypatch): + """채널·유저 둘 다 차단인 경우 채널 메시지 한 번만 전송 — 유저 검사로 진행 안 됨.""" + import dataclasses + + override = dataclasses.replace( + app_module.settings, + allowed_channel_ids=["C-OK"], + allowed_channel_message="채널 차단", + allowed_user_ids=["U-ADMIN"], + allowed_user_message="유저 차단", + ) + monkeypatch.setattr(app_module, "settings", override) + monkeypatch.setattr(app_module, "_get_dedup", lambda: _FakeDedup()) + + posts = [] + app_module._process( + { + "channel": "C-BAD", + "ts": "1.1", + "text": "hi", + "user": "U-RANDOM", + "client_msg_id": "msg-both-1", + }, + client=object(), + say=lambda text, thread_ts=None: posts.append({"text": text, "thread_ts": thread_ts}), + is_dm=False, + ) + + assert posts == [{"text": "채널 차단", "thread_ts": "1.1"}] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..d59e7ba --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,213 @@ +import pytest + + +@pytest.fixture +def reload_config(): + """Build a fresh Settings from the current os.environ (no module reload).""" + + def _reload(): + from src.config import Settings + + return Settings.from_env() + + return _reload + + +def _clear_env(monkeypatch): + for key in [ + "SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", "LLM_PROVIDER", "LLM_MODEL", + "IMAGE_PROVIDER", "IMAGE_MODEL", "OPENAI_API_KEY", "RESPONSE_LANGUAGE", + "AGENT_MAX_STEPS", "DYNAMODB_TABLE_NAME", "AWS_REGION", "ALLOWED_CHANNEL_IDS", + "ALLOWED_CHANNEL_MESSAGE", "ALLOWED_USER_IDS", "ALLOWED_USER_MESSAGE", + "MAX_LEN_SLACK", "MAX_THROTTLE_COUNT", + "MAX_HISTORY_CHARS", "BOT_CURSOR", "SYSTEM_MESSAGE", "PERSONA_MESSAGE", "TAVILY_API_KEY", "XAI_API_KEY", "LOG_LEVEL", + "DEFAULT_TIMEZONE", "MAX_DOC_CHARS", "MAX_DOC_PAGES", "MAX_DOC_BYTES", + "MAX_WEB_CHARS", "MAX_WEB_BYTES", "MAX_WEB_LINKS", "JINA_READER_BASE", + ]: + monkeypatch.delenv(key, raising=False) + + +def test_defaults(monkeypatch, reload_config): + _clear_env(monkeypatch) + s = reload_config() + assert s.llm_provider == "openai" + assert s.llm_model == "gpt-4o-mini" + assert s.image_provider == "openai" + assert s.image_model == "gpt-image-1" + assert s.response_language == "ko" + assert s.agent_max_steps == 3 + assert s.max_len_slack == 2000 + assert s.allowed_channel_ids == [] + assert s.allowed_user_ids == [] + assert s.allowed_user_message == "허용된 유저만 응답합니다." + assert s.tavily_api_key is None + + +def test_invalid_enum_falls_back(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("RESPONSE_LANGUAGE", "jp") + monkeypatch.setenv("LLM_PROVIDER", "mystery") + s = reload_config() + assert s.response_language == "ko" + assert s.llm_provider == "openai" + + +def test_invalid_int_falls_back_to_default(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("AGENT_MAX_STEPS", "not-an-int") + s = reload_config() + assert s.agent_max_steps == 3 + + +def test_int_below_minimum_clamped(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("MAX_LEN_SLACK", "10") + s = reload_config() + assert s.max_len_slack == 500 + + +def test_list_env_splits_commas(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("ALLOWED_CHANNEL_IDS", "C1,C2, C3 ") + s = reload_config() + assert s.allowed_channel_ids == ["C1", "C2", "C3"] + + +def test_list_env_none_sentinel(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("ALLOWED_CHANNEL_IDS", "None") + s = reload_config() + assert s.allowed_channel_ids == [] + + +def test_allowed_user_ids_parsed_from_env(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("ALLOWED_USER_IDS", "U1,U2, U3 ") + monkeypatch.setenv("ALLOWED_USER_MESSAGE", "허용된 유저만 응답합니다.") + s = reload_config() + assert s.allowed_user_ids == ["U1", "U2", "U3"] + assert s.allowed_user_message == "허용된 유저만 응답합니다." + + +def test_require_slack_credentials_raises_when_missing(monkeypatch, reload_config): + _clear_env(monkeypatch) + s = reload_config() + with pytest.raises(RuntimeError): + s.require_slack_credentials() + + +def test_require_slack_credentials_ok(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-x") + monkeypatch.setenv("SLACK_SIGNING_SECRET", "secret") + s = reload_config() + s.require_slack_credentials() # no raise + + +def test_persona_and_system_messages_default_none(monkeypatch, reload_config): + _clear_env(monkeypatch) + s = reload_config() + assert s.system_message is None + assert s.persona_message is None + + +def test_persona_and_system_messages_from_env(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("SYSTEM_MESSAGE", "Do not expose secrets.") + monkeypatch.setenv("PERSONA_MESSAGE", "자연스러운 한국어로 핵심부터 답한다.") + s = reload_config() + assert s.system_message == "Do not expose secrets." + assert s.persona_message == "자연스러운 한국어로 핵심부터 답한다." + + +def test_xai_provider_is_a_valid_enum_value(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("LLM_PROVIDER", "xai") + monkeypatch.setenv("IMAGE_PROVIDER", "xai") + s = reload_config() + assert s.llm_provider == "xai" + assert s.image_provider == "xai" + + +def test_xai_api_key_default_none_and_override(monkeypatch, reload_config): + _clear_env(monkeypatch) + s = reload_config() + assert s.xai_api_key is None + + monkeypatch.setenv("XAI_API_KEY", "xai-abc") + s2 = reload_config() + assert s2.xai_api_key == "xai-abc" + + +def test_doc_limits_defaults(monkeypatch, reload_config): + _clear_env(monkeypatch) + s = reload_config() + assert s.default_timezone == "Asia/Seoul" + assert s.max_doc_chars == 20_000 + assert s.max_doc_pages == 50 + assert s.max_doc_bytes == 25 * 1024 * 1024 + + +def test_default_timezone_fallback_on_invalid_env(monkeypatch, reload_config, caplog): + _clear_env(monkeypatch) + monkeypatch.setenv("DEFAULT_TIMEZONE", "Narnia/Center") + with caplog.at_level("WARNING"): + s = reload_config() + assert s.default_timezone == "Asia/Seoul" + assert any("DEFAULT_TIMEZONE" in rec.message for rec in caplog.records) + + +def test_doc_limits_honor_env_and_clamp(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("MAX_DOC_CHARS", "5000") + monkeypatch.setenv("MAX_DOC_PAGES", "0") # below minimum → clamps to 1 + monkeypatch.setenv("MAX_DOC_BYTES", "100") # below minimum → clamps to 65536 + s = reload_config() + assert s.max_doc_chars == 5000 + assert s.max_doc_pages == 1 + assert s.max_doc_bytes == 65_536 + + +def test_default_timezone_custom_value(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("DEFAULT_TIMEZONE", "America/New_York") + s = reload_config() + assert s.default_timezone == "America/New_York" + + +def test_web_fetch_defaults(monkeypatch, reload_config): + _clear_env(monkeypatch) + s = reload_config() + assert s.max_web_chars == 8000 + assert s.max_web_bytes == 2 * 1024 * 1024 + assert s.max_web_links == 20 + assert s.jina_reader_base == "https://r.jina.ai" + + +def test_web_fetch_env_overrides(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("MAX_WEB_CHARS", "1000") + monkeypatch.setenv("MAX_WEB_BYTES", "131072") # 128KB + monkeypatch.setenv("MAX_WEB_LINKS", "5") + monkeypatch.setenv("JINA_READER_BASE", "https://custom.reader.example") + s = reload_config() + assert s.max_web_chars == 1000 + assert s.max_web_bytes == 131072 + assert s.max_web_links == 5 + assert s.jina_reader_base == "https://custom.reader.example" + + +def test_web_fetch_below_minimum_clamped(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("MAX_WEB_CHARS", "10") # below 500 floor + monkeypatch.setenv("MAX_WEB_BYTES", "1024") # below 64KB floor + s = reload_config() + assert s.max_web_chars == 500 + assert s.max_web_bytes == 64 * 1024 + + +def test_web_fetch_jina_reader_base_http_rejected(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("JINA_READER_BASE", "http://internal.proxy.example") + s = reload_config() + assert s.jina_reader_base == "https://r.jina.ai" # fallback diff --git a/tests/test_dedup.py b/tests/test_dedup.py new file mode 100644 index 0000000..d2238c4 --- /dev/null +++ b/tests/test_dedup.py @@ -0,0 +1,153 @@ +import time + +import boto3 +import pytest + +try: + from moto import mock_aws +except ImportError: # pragma: no cover + pytest.skip("moto not installed", allow_module_level=True) + +from src.dedup import ConversationStore, DedupStore + + +TABLE = "lambda-slack-bot-test" +REGION = "us-east-1" + + +def _create_table(): + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=TABLE, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "id", "AttributeType": "S"}, + {"AttributeName": "user", "AttributeType": "S"}, + {"AttributeName": "expire_at", "AttributeType": "N"}, + ], + GlobalSecondaryIndexes=[ + { + "IndexName": "user-index", + "KeySchema": [ + {"AttributeName": "user", "KeyType": "HASH"}, + {"AttributeName": "expire_at", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "KEYS_ONLY"}, + "ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + } + ], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + + +@mock_aws +def test_dedup_reserve_first_call_succeeds(): + _create_table() + store = DedupStore(table_name=TABLE, region=REGION) + assert store.reserve("abc") is True + + +@mock_aws +def test_dedup_reserve_second_call_returns_false(): + _create_table() + store = DedupStore(table_name=TABLE, region=REGION) + assert store.reserve("abc") is True + assert store.reserve("abc") is False + + +@mock_aws +def test_dedup_different_keys_independent(): + _create_table() + store = DedupStore(table_name=TABLE, region=REGION) + assert store.reserve("a") is True + assert store.reserve("b") is True + + +@mock_aws +def test_count_user_active_ignores_expired(): + _create_table() + store = DedupStore(table_name=TABLE, region=REGION) + store.reserve("fresh", user="U1", ttl_seconds=3600) + # Manually insert an expired record for the same user. + boto3.resource("dynamodb", region_name=REGION).Table(TABLE).put_item( + Item={"id": "dedup:old", "user": "U1", "expire_at": int(time.time()) - 10} + ) + assert store.count_user_active("U1") == 1 + + +@mock_aws +def test_count_user_active_unknown_user_zero(): + _create_table() + store = DedupStore(table_name=TABLE, region=REGION) + assert store.count_user_active("nobody") == 0 + + +@mock_aws +def test_conversation_put_and_get_roundtrip(): + _create_table() + convo = ConversationStore(table_name=TABLE, region=REGION) + msgs = [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}] + convo.put("T1", "U1", msgs) + assert convo.get("T1") == msgs + + +@mock_aws +def test_conversation_get_missing_returns_empty(): + _create_table() + convo = ConversationStore(table_name=TABLE, region=REGION) + assert convo.get("unseen") == [] + + +@mock_aws +def test_conversation_truncate_to_chars(): + _create_table() + convo = ConversationStore(table_name=TABLE, region=REGION) + msgs = [{"role": "user", "content": "x" * 1000} for _ in range(10)] + convo.put("T1", "U1", msgs, max_chars=3000) + stored = convo.get("T1") + import json + assert len(json.dumps(stored, ensure_ascii=False)) <= 3000 + assert len(stored) < len(msgs) + + +def test_conversation_truncate_helper_direct(): + msgs = [{"role": "user", "content": "x" * 500} for _ in range(5)] + trimmed = ConversationStore.truncate_to_chars(msgs, max_chars=1200) + import json + assert len(json.dumps(trimmed, ensure_ascii=False)) <= 1200 + assert len(trimmed) < len(msgs) + + +def test_conversation_truncate_keeps_newest_messages(): + """Truncation drops the oldest entries — the most recent turn must survive + as long as it fits.""" + msgs = [ + {"role": "user", "content": f"msg-{i}"} + for i in range(20) + ] + trimmed = ConversationStore.truncate_to_chars(msgs, max_chars=200) + assert trimmed, "should keep at least some messages" + # The newest message must be in the kept suffix. + assert trimmed[-1]["content"] == "msg-19" + + +def test_conversation_truncate_budget_matches_exact_dumps_length(): + """The fast cumulative-size algorithm must agree with the naive + json.dumps(kept) size, within a single byte.""" + import json + + msgs = [{"role": "user", "content": "a" * 17 + str(i)} for i in range(8)] + for budget in (50, 80, 120, 200, 300, 500, 1000, 5000): + trimmed = ConversationStore.truncate_to_chars(msgs, max_chars=budget) + assert len(json.dumps(trimmed, ensure_ascii=False)) <= budget, ( + f"budget={budget}, kept={len(trimmed)}, " + f"actual={len(json.dumps(trimmed, ensure_ascii=False))}" + ) + + +def test_conversation_truncate_single_large_msg_overflows_budget(): + """If every individual message exceeds the budget, return an empty list + rather than partial garbage.""" + msgs = [{"role": "user", "content": "x" * 1000}] + trimmed = ConversationStore.truncate_to_chars(msgs, max_chars=50) + assert trimmed == [] diff --git a/tests/test_logging_utils.py b/tests/test_logging_utils.py new file mode 100644 index 0000000..2aaf47d --- /dev/null +++ b/tests/test_logging_utils.py @@ -0,0 +1,56 @@ +import json +import logging +from io import StringIO + +from src.logging_utils import JsonFormatter, get_logger, log_event, set_request_id + + +def test_json_formatter_includes_request_id(): + set_request_id("req-abc") + rec = logging.LogRecord("x", logging.INFO, "f.py", 1, "hello", None, None) + formatted = JsonFormatter().format(rec) + payload = json.loads(formatted) + assert payload["request_id"] == "req-abc" + assert payload["message"] == "hello" + assert payload["level"] == "INFO" + + +def test_json_formatter_includes_extra_fields(): + rec = logging.LogRecord("x", logging.INFO, "f.py", 1, "evt", None, None) + rec.extra_fields = {"key": "value", "n": 3} + payload = json.loads(JsonFormatter().format(rec)) + assert payload["key"] == "value" + assert payload["n"] == 3 + + +def test_json_formatter_renders_exception(): + try: + raise ValueError("boom") + except ValueError: + import sys + + rec = logging.LogRecord("x", logging.ERROR, "f.py", 1, "err", None, sys.exc_info()) + payload = json.loads(JsonFormatter().format(rec)) + assert "boom" in payload["exc"] + + +def test_log_event_attaches_fields(): + """log_event should emit a LogRecord whose extra_fields match the kwargs.""" + captured: list[logging.LogRecord] = [] + + class _Capture(logging.Handler): + def emit(self, record): + captured.append(record) + + underlying = logging.getLogger("test.log_event") + underlying.addHandler(_Capture()) + underlying.setLevel(logging.INFO) + try: + adapter = logging.LoggerAdapter(underlying, extra={}) + log_event(adapter, "my.event", tool="x", count=5) + finally: + underlying.handlers = [h for h in underlying.handlers if not isinstance(h, _Capture)] + + assert captured, "no log record emitted" + extras = getattr(captured[0], "extra_fields", None) + assert extras == {"tool": "x", "count": 5} diff --git a/tests/test_slack_helpers.py b/tests/test_slack_helpers.py new file mode 100644 index 0000000..9a7889b --- /dev/null +++ b/tests/test_slack_helpers.py @@ -0,0 +1,784 @@ +from unittest.mock import MagicMock + +import pytest +from slack_sdk.errors import SlackApiError + +from src.slack_helpers import ( + MessageFormatter, + UserNameCache, + channel_allowed, + sanitize_error, +) + + +def test_split_short_message_is_single_chunk(): + chunks = MessageFormatter.split_message("hello", max_len=100) + assert chunks == ["hello"] + + +def test_split_by_paragraph(): + para1 = "A" * 1500 + para2 = "B" * 1500 + text = f"{para1}\n\n{para2}" + chunks = MessageFormatter.split_message(text, max_len=2000) + assert len(chunks) == 2 + assert all(len(c) <= 2000 for c in chunks) + + +def test_split_by_sentence_when_paragraph_too_long(): + sent = "Sentence with some length. " * 100 # long paragraph without \n\n + chunks = MessageFormatter.split_message(sent, max_len=300) + assert len(chunks) > 1 + assert all(len(c) <= 300 for c in chunks) + + +def test_split_keeps_small_code_blocks_intact(): + """Code blocks that fit within max_len should not be split.""" + body = "text before\n\n```\nprint('x')\n```\n\ntext after" + chunks = MessageFormatter.split_message(body, max_len=2000) + # Entire content fits; must be a single chunk. + assert len(chunks) == 1 + assert chunks[0].count("```") == 2 + + +def test_split_code_block_longer_than_max_len_still_respects_limit(): + """When a code block exceeds max_len, the splitter cuts inside the + block, closes the current chunk with ``` and reopens the next one + with ```. Every chunk must still fit max_len AND be self-balanced.""" + code = "```\n" + ("def x():\n return 1\n" * 100) + "```" + chunks = MessageFormatter.split_message(code, max_len=500) + assert all(len(c) <= 500 for c in chunks) + # Each chunk closes its own code block — no chunk leaks an unclosed + # fence into the user's thread view. + assert all(c.count("```") % 2 == 0 for c in chunks) + + +def test_split_empty_string(): + assert MessageFormatter.split_message("", max_len=100) == [""] + + +def test_split_cuts_at_paragraph_boundary_not_mid_word(): + """The greedy \\n\\n cut must keep words and sentences whole rather + than slicing in the middle of a paragraph.""" + para1 = "First paragraph. " * 10 # ~170 chars + para2 = "Second paragraph. " * 10 # ~180 chars + para3 = "Third paragraph. " * 10 # ~170 chars + text = f"{para1}\n\n{para2}\n\n{para3}" + chunks = MessageFormatter.split_message(text, max_len=200) + # Every chunk should end at a paragraph or sentence boundary, never + # in the middle of a word like "para" + "graph". + for chunk in chunks: + assert not chunk.endswith("para"), f"mid-word cut: {chunk!r}" + # Trailing chars should be one of: full word, period+space, or end-of-text + assert chunk[-1] in ".!? \n" or chunk == chunks[-1] + + +def test_split_pushes_code_block_to_next_chunk_when_possible(): + """If the cut would land inside a code block but a \\n\\n exists + right before the block starts, push the whole block to the next + chunk so the block stays intact.""" + text = ( + "intro paragraph\n\n" + "another intro paragraph\n\n" + "```\n" + "def example():\n" + " return 42\n" + "```\n\n" + "epilogue" + ) + # Force a cut that initially lands inside the code block. + chunks = MessageFormatter.split_message(text, max_len=60) + # The code block must appear whole in some chunk — never split + # across two chunks when it could have been pushed. + block = "```\ndef example():\n return 42\n```" + assert any(block in c for c in chunks), f"block was split: {chunks}" + # All chunks fit and are self-balanced. + assert all(len(c) <= 60 for c in chunks) + assert all(c.count("```") % 2 == 0 for c in chunks) + + +def test_split_inside_code_block_uses_inner_paragraph_break(): + """When the block itself is too large, cut at the last \\n\\n + inside the block and re-fence both sides.""" + inner_para1 = "a " * 50 # ~100 chars + inner_para2 = "b " * 50 # ~100 chars + inner_para3 = "c " * 50 # ~100 chars + text = f"```\n{inner_para1}\n\n{inner_para2}\n\n{inner_para3}\n```" + chunks = MessageFormatter.split_message(text, max_len=150) + # Length budget respected on every chunk. + assert all(len(c) <= 150 for c in chunks), [len(c) for c in chunks] + # Every chunk is self-balanced: it opens with ``` and closes with ``` + # so the user never sees an unclosed code block. + for chunk in chunks: + assert chunk.count("```") % 2 == 0, f"unbalanced fence: {chunk!r}" + assert chunk.startswith("```") + assert chunk.rstrip().endswith("```") + + +def test_split_inside_code_block_falls_back_to_single_newline(): + """Real code blocks rarely contain \\n\\n — they use single \\n + between lines. When the block exceeds max_len, the splitter must + cut at a line boundary (\\n) so identifiers and tokens stay whole + instead of getting hard-sliced mid-word.""" + code = "```\n" + "\n".join(f"def function_number_{i}(): return {i}" for i in range(20)) + "\n```" + chunks = MessageFormatter.split_message(code, max_len=150) + assert all(len(c) <= 150 for c in chunks), [len(c) for c in chunks] + # Every chunk balanced: both ``` are inside the chunk. + for chunk in chunks: + assert chunk.count("```") % 2 == 0, f"unbalanced fence: {chunk!r}" + assert chunk.startswith("```") + assert chunk.rstrip().endswith("```") + # No identifier got cut in half. Every "function_number_" that + # appears must be complete (each cut should have landed on \n, + # never inside the identifier). + import re + + full_text = "\n".join(chunks) + # Each occurrence of "function_number_" must be followed by a digit + # and then "(" — otherwise it was sliced. + bad = re.findall(r"function_number_(?!\d+\()[^\n]{0,5}", full_text) + assert not bad, f"identifier sliced: {bad}" + + +def test_split_falls_back_to_single_newline_for_lists(): + """Lists or other line-based content without \\n\\n or sentence + punctuation should still cut at \\n rather than hard-slicing.""" + text = "\n".join(f"- item number {i} with some descriptive text" for i in range(20)) + chunks = MessageFormatter.split_message(text, max_len=150) + assert all(len(c) <= 150 for c in chunks), [len(c) for c in chunks] + # Every chunk should start and end at a list-item boundary, not + # mid-word. We verify by ensuring no chunk ends with a partial + # "item number " pattern. + for chunk in chunks[:-1]: + # Trailing content must be a complete item line, not split. + last_line = chunk.rstrip("\n").rsplit("\n", 1)[-1] + assert last_line.startswith("- item number "), f"mid-word cut: {last_line!r}" + + +def test_user_name_cache_uses_display_name(): + cache = UserNameCache._default() + client = MagicMock() + client.users_info.return_value = {"user": {"profile": {"display_name": "Alice"}}} + assert cache.get(client, "U1") == "Alice" + # second call is cached + assert cache.get(client, "U1") == "Alice" + client.users_info.assert_called_once() + + +def test_user_name_cache_falls_back_to_user_id_on_error(): + cache = UserNameCache._default() + client = MagicMock() + client.users_info.side_effect = SlackApiError("fail", {}) + assert cache.get(client, "U2") == "U2" + + +def test_user_name_cache_warm_resolves_misses_in_parallel(): + """warm() must hit users_info once per uncached id and run them + concurrently — the original serial pattern blew the tool timeout + when many new users appeared in a thread.""" + import threading + import time as _time + + cache = UserNameCache._default() + client = MagicMock() + + in_flight = 0 + peak = 0 + lock = threading.Lock() + + def _slow_users_info(user): + nonlocal in_flight, peak + with lock: + in_flight += 1 + peak = max(peak, in_flight) + _time.sleep(0.2) + with lock: + in_flight -= 1 + return {"user": {"profile": {"display_name": f"name-{user}"}}} + + client.users_info.side_effect = _slow_users_info + + started = _time.monotonic() + cache.warm(client, ["U1", "U2", "U3", "U4"]) + elapsed = _time.monotonic() - started + + assert peak >= 2, f"expected concurrent users_info, peak={peak}" + assert elapsed < 0.6, f"expected parallel ~0.2s, took {elapsed:.2f}s" + assert cache.get(client, "U1") == "name-U1" + # warm populated the cache, so get() does not trigger a second API call + assert client.users_info.call_count == 4 + + +def test_user_name_cache_warm_skips_cached_and_empty_ids(): + cache = UserNameCache._default() + client = MagicMock() + client.users_info.return_value = {"user": {"profile": {"display_name": "Alice"}}} + cache.get(client, "U1") # prime cache + client.users_info.reset_mock() + + cache.warm(client, ["U1", "", None, "U1"]) # all already-cached or skippable + + client.users_info.assert_not_called() + + +def test_channel_allowed_no_allowlist(): + assert channel_allowed("C1", []) is True + + +def test_channel_allowed_allowlist_match(): + assert channel_allowed("C1", ["C1", "C2"]) is True + + +def test_channel_allowed_allowlist_miss(): + assert channel_allowed("C9", ["C1", "C2"]) is False + + +def test_sanitize_error_redacts_tokens(): + class FakeErr(Exception): + pass + + exc = FakeErr("failed with token xoxb-12345-67890 for /path/to/file.py boom") + out = sanitize_error(exc) + assert "xoxb-12345" not in out + assert "redacted-slack-token" in out + assert "[path]" in out + + +def test_sanitize_error_redacts_openai_key(): + exc = ValueError("Bad request using sk-proj-abcdefghij1234567890xyz") + out = sanitize_error(exc) + assert "sk-proj" not in out + + +def test_sanitize_error_truncates_long(): + exc = ValueError("x" * 1000) + out = sanitize_error(exc) + assert len(out) <= 300 + + +def test_sanitize_error_redacts_anthropic_key(): + exc = ValueError("failed: sk-ant-api03-abc123xyz456_-_deadbeef") + out = sanitize_error(exc) + assert "sk-ant-api03" not in out + assert "redacted-anthropic-key" in out + # Must not also match the generic openai key pattern (order matters). + assert "redacted-openai-key" not in out + + +def test_sanitize_error_redacts_xai_key(): + exc = ValueError("grok call failed with xai-abcdef1234567890xyz") + out = sanitize_error(exc) + assert "xai-abcdef" not in out + assert "redacted-xai-key" in out + + +def test_sanitize_error_redacts_tavily_key(): + exc = ValueError("tavily error: tvly-abcdefghij1234567890") + out = sanitize_error(exc) + assert "tvly-abcdef" not in out + assert "redacted-tavily-key" in out + + +def test_sanitize_error_redacts_aws_access_key(): + exc = ValueError("Boto3 failure for AKIAIOSFODNN7EXAMPLE in region") + out = sanitize_error(exc) + assert "AKIAIOSFODNN7EXAMPLE" not in out + assert "redacted-aws-key" in out + + +def test_sanitize_error_redacts_aws_session_key(): + exc = ValueError("temp creds ASIA123456789ABCDEF0 expired") + out = sanitize_error(exc) + assert "ASIA123456789ABCDEF0" not in out + assert "redacted-aws-key" in out + + +# --------------------------------------------------------------------------- # +# StreamingMessage +# --------------------------------------------------------------------------- # + +from src.slack_helpers import CODE_FENCE, StreamingMessage + + +def _slack_client_native_stream(): + """Client whose api_call responds to chat.startStream/appendStream/stopStream.""" + client = MagicMock() + + def api_call(method, params=None, **_): + if method == "chat.startStream": + return {"ok": True, "channel": params["channel"], "ts": "1234.5678"} + if method == "chat.appendStream": + return {"ok": True, "channel": params["channel"], "ts": params["ts"]} + if method == "chat.stopStream": + return {"ok": True, "channel": params["channel"], "ts": params["ts"]} + raise AssertionError(f"unexpected api_call: {method}") + + client.api_call.side_effect = api_call + return client + + +def test_streaming_message_native_start_uses_api_call(): + client = _slack_client_native_stream() + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1", placeholder=":robot:", enable_native=True) + sm.start() + assert sm.ts == "1234.5678" + assert sm._native is True + client.api_call.assert_called_once() + assert client.api_call.call_args.args[0] == "chat.startStream" + + +def test_streaming_message_fallback_when_native_fails(): + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.return_value = {"ok": True, "ts": "fallback-ts"} + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1") + sm.start() + assert sm.ts == "fallback-ts" + assert sm._native is False + client.chat_postMessage.assert_called_once() + + +def test_streaming_message_append_throttles(): + client = _slack_client_native_stream() + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1", min_interval=10.0, enable_native=True) + sm.start() + # First append should flush (last_flush=0 -> elapsed > interval) + sm.append("hello ") + # Second append within interval: should buffer, not flush + sm.append("world") + # Count of appendStream calls should be <= 1 within this tight window + append_calls = [c for c in client.api_call.call_args_list if c.args[0] == "chat.appendStream"] + assert len(append_calls) <= 1 + + +def test_streaming_message_stop_finalizes_native(): + client = _slack_client_native_stream() + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1", enable_native=True) + sm.start() + sm.stop("final answer") + stop_calls = [c for c in client.api_call.call_args_list if c.args[0] == "chat.stopStream"] + assert len(stop_calls) == 1 + assert stop_calls[0].kwargs["params"]["markdown_text"] == "final answer" + + +def test_streaming_message_stop_fallback_uses_chat_update(): + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.return_value = {"ok": True, "ts": "fallback-ts"} + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1") + sm.start() + sm.stop("done") + client.chat_update.assert_called_with(channel="C1", ts="fallback-ts", text="done") + + +def test_streaming_message_stop_is_idempotent(): + client = _slack_client_native_stream() + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1", enable_native=True) + sm.start() + sm.stop("a") + sm.stop("b") + stop_calls = [c for c in client.api_call.call_args_list if c.args[0] == "chat.stopStream"] + assert len(stop_calls) == 1 # only first stop fires + + +def test_streaming_message_append_noop_before_start(): + client = MagicMock() + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1") + sm.append("hi") # should not explode, ts is None + client.api_call.assert_not_called() + + +def test_streaming_message_fallback_rolls_when_buffer_exceeds_limit(): + """In fallback mode, when the rolling buffer approaches max_len we must + finalize the current ts and open a fresh chat_postMessage so nothing gets + lost behind msg_too_long.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + # start -> one placeholder, then every new postMessage returns a new ts + client.chat_postMessage.side_effect = [ + {"ok": True, "ts": "first"}, + {"ok": True, "ts": "second"}, + ] + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1", min_interval=0.0, max_len=50) + sm.start() + # Force one flush so we have state; buffer is small. + sm.append("hi") + # Feed enough content to exceed max_len on the next flush. + sm.append("x" * 60) + # After the roll, ts should have advanced and the buffer should be empty. + assert sm.ts == "second" + assert sm._buffer == "" + # chat_update was used to finalize the first message content before rolling. + assert client.chat_update.called + + +def test_streaming_message_rolls_after_consecutive_update_failures(): + """If chat_update keeps failing on the current ts (deleted, permission, + message-level rate limit), the streamer must roll to a fresh ts instead + of looping forever. The accumulated buffer must be preserved so nothing + is lost.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.side_effect = [ + {"ok": True, "ts": "first"}, + {"ok": True, "ts": "rolled"}, + ] + client.chat_update.side_effect = SlackApiError( + "no", {"error": "message_not_found"} + ) + sm = StreamingMessage( + client=client, channel="C1", thread_ts="ts1", min_interval=0.0, max_len=10_000 + ) + sm.start() + # Three consecutive append -> flush -> chat_update failures. The third one + # should trigger a roll. + sm.append("alpha") + sm.append("beta") + sm.append("gamma") + assert sm.ts == "rolled" + # Buffer preserved: the deltas never reached Slack on the old ts. + assert "alpha" in sm._buffer + assert "beta" in sm._buffer + assert "gamma" in sm._buffer + # Failure counter reset after roll so the new ts gets a clean window. + assert sm._consecutive_update_failures == 0 + + +def test_streaming_message_resets_failure_counter_on_success(): + """A successful chat_update must reset the consecutive-failure counter.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.return_value = {"ok": True, "ts": "ts-a"} + call_count = {"n": 0} + + def _flaky_update(**_): + call_count["n"] += 1 + if call_count["n"] == 1: + raise SlackApiError("transient", {"error": "ratelimited"}) + return {"ok": True} + + client.chat_update.side_effect = _flaky_update + sm = StreamingMessage( + client=client, channel="C1", thread_ts="ts1", min_interval=0.0, max_len=10_000 + ) + sm.start() + sm.append("one") # fails + sm.append("two") # succeeds, counter resets + assert sm._consecutive_update_failures == 0 + # Still on original ts (no roll) because we only saw 1 failure. + assert sm.ts == "ts-a" + + +def test_streaming_message_stop_splits_long_final(): + """A final_text longer than max_len must land in multiple messages.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + # Return a fresh ts for every postMessage so the test is not sensitive + # to how many follow-ups the splitter produces. + client.chat_postMessage.return_value = {"ok": True, "ts": "post-ts"} + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1", max_len=100) + sm.start() + final = "para one " * 30 + "\n\n" + "para two " * 30 + "\n\n" + "para three " * 30 + sm.stop(final) + # first chunk went to chat_update on the placeholder ts + assert client.chat_update.call_args.kwargs["ts"] == "post-ts" + # at least one follow-up post message was issued (beyond the initial placeholder) + follow_calls = [c for c in client.chat_postMessage.call_args_list if c.kwargs.get("thread_ts") == "ts1"] + assert len(follow_calls) >= 1 + + +def test_streaming_message_flush_msg_too_long_spills_via_postmessage_and_drops_placeholder(): + """Regression: chat.update's effective limit on multibyte/markdown content + is well under its documented 4000-char cap (mrkdwn → section block coerces + to ~3000). Retrying the same buffer against a fresh ts via roll-with- + preserve-buffer produces the same failure on every new placeholder, so + the user ends up seeing N empty :loading: messages plus the spilled + answer ("double output"). msg_too_long must spill via chat.postMessage + and chat.delete the placeholder.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.return_value = {"ok": True, "ts": "placeholder-ts"} + client.chat_update.side_effect = SlackApiError( + "too long", {"error": "msg_too_long"} + ) + sm = StreamingMessage( + client=client, channel="C1", thread_ts="ts1", min_interval=0.0, max_len=10_000 + ) + sm.start() + sm.append("a" * 1500) + + # Spill path: at least one chat.postMessage with the buffer body went out, + # the placeholder was deleted, and ts is reset so the next delta lazily + # opens a fresh placeholder via the deferred-start path. + spill_calls = [ + c + for c in client.chat_postMessage.call_args_list + if c.kwargs.get("thread_ts") == "ts1" and c.kwargs.get("text", "").startswith("a") + ] + assert len(spill_calls) >= 1 + client.chat_delete.assert_called_with(channel="C1", ts="placeholder-ts") + assert sm.ts is None + assert sm._buffer == "" + assert sm._consecutive_update_failures == 0 + + +def test_streaming_message_stop_msg_too_long_deletes_placeholder(): + """If the final chat.update fails (e.g. msg_too_long on a multi-paragraph + answer), the placeholder must be deleted before the fallback chat.postMessage + so the user doesn't see :loading: alongside the answer.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.return_value = {"ok": True, "ts": "placeholder-ts"} + client.chat_update.side_effect = SlackApiError( + "too long", {"error": "msg_too_long"} + ) + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1", max_len=10_000) + sm.start() + sm.stop("final answer") + + client.chat_delete.assert_called_with(channel="C1", ts="placeholder-ts") + # Fallback postMessage carries the answer; the start() call also posted a + # placeholder, so we filter on the actual answer body. + answer_posts = [ + c + for c in client.chat_postMessage.call_args_list + if c.kwargs.get("text") == "final answer" + ] + assert len(answer_posts) == 1 + + +def test_streaming_message_stop_skips_already_rolled_prefix(): + """Regression: when streaming rolled to a new ts via the size-overflow + path, stop(final_text) must skip the prefix already sealed into the + earlier ts. Otherwise the latest ts gets overwritten with content that + overlaps the rolled message above it, producing two near-identical + messages (prefix in ts1, prefix in ts2, then the suffix as ts3).""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.side_effect = [ + {"ok": True, "ts": "ts1"}, + {"ok": True, "ts": "ts2"}, + {"ok": True, "ts": "ts3"}, + {"ok": True, "ts": "ts4"}, + ] + client.chat_update.return_value = {"ok": True} + + sm = StreamingMessage( + client=client, channel="C1", thread_ts="thread-1", min_interval=0.0, max_len=50 + ) + sm.start() + + # Stream ~60 chars to trigger one roll-finalize: ts1 sealed, ts2 placeholder. + prefix = "A" * 60 + sm.append(prefix) + assert sm.ts == "ts2" + + # Stream the suffix into ts2. + suffix = "B" * 30 + final_text = sm._finalized_text + suffix + sm.append(suffix) + + # Final compose call: pass the FULL streamed content. stop() should + # recognize the rolled prefix and only send the suffix to ts2. + sm.stop(final_text) + + # ts1 was rolled-finalized via chat_update with the prefix. + assert any( + call.kwargs.get("ts") == "ts1" and call.kwargs.get("text") == prefix + for call in client.chat_update.call_args_list + ) + # ts2 was finalized via chat_update with ONLY the suffix — never the + # prefix-overlapping content that caused the duplication bug. + ts2_finalize_calls = [ + call for call in client.chat_update.call_args_list + if call.kwargs.get("ts") == "ts2" and not call.kwargs.get("text", "").endswith(":robot_face:") + ] + assert ts2_finalize_calls, "expected a final chat_update on ts2" + for call in ts2_finalize_calls: + text = call.kwargs.get("text", "") + assert prefix not in text, f"ts2 finalize must not contain the rolled prefix: {text!r}" + + +def test_streaming_message_roll_seals_unclosed_code_block_with_fence(): + """Regression: when the rolling buffer ends inside an open code + block, the seal must add a closing ``` so the rolled message + renders as a balanced block in Slack, and the next placeholder + must reopen the block with ``` so streaming continues inside the + same fence. Without this, ts1 leaks an unclosed ``` and the user + sees the code block run past the message boundary.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.side_effect = [ + {"ok": True, "ts": "ts1"}, + {"ok": True, "ts": "ts2"}, + ] + client.chat_update.return_value = {"ok": True} + + sm = StreamingMessage( + client=client, channel="C1", thread_ts="thread-1", min_interval=0.0, max_len=50 + ) + sm.start() + + # Stream content that opens but never closes a code block, large + # enough to trigger a roll on the next flush. + open_block = "```python\n" + ("x" * 50) + sm.append(open_block) + + # ts1 was sealed via chat_update with a closing fence appended. + seal_calls = [ + c for c in client.chat_update.call_args_list + if c.kwargs.get("ts") == "ts1" + ] + assert seal_calls, "expected chat_update on ts1 during roll" + last_text = seal_calls[-1].kwargs["text"] + assert last_text.count(CODE_FENCE) % 2 == 0, f"ts1 still unbalanced: {last_text!r}" + assert last_text.endswith(CODE_FENCE), f"ts1 missing closing fence: {last_text!r}" + + # ts2 took over and its buffer was pre-loaded with ```\n so the + # next delta continues inside the reopened code block. + assert sm.ts == "ts2" + assert sm._buffer.startswith(CODE_FENCE), f"ts2 buffer missing carry-over: {sm._buffer!r}" + # The carried-over content includes the rest of the buffer that + # came after the cut point — no token is dropped. + assert "x" * 50 in sm._buffer + + # _finalized_text tracks the raw prefix up to (and including) the + # cut point so stop()'s final_text slice still matches the LLM + # output. Here the cut lands on the only \n in open_block, so the + # finalized prefix is "```python\n". + assert sm._finalized_text == "```python\n" + + +def test_streaming_message_roll_no_carry_when_fence_already_balanced(): + """If the rolling buffer ends with the code block already closed, + no extra fence should be added on either side.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.side_effect = [ + {"ok": True, "ts": "ts1"}, + {"ok": True, "ts": "ts2"}, + ] + client.chat_update.return_value = {"ok": True} + + sm = StreamingMessage( + client=client, channel="C1", thread_ts="thread-1", min_interval=0.0, max_len=50 + ) + sm.start() + + closed_block = "```\nshort code\n```\n" + ("y" * 40) + sm.append(closed_block) + + seal_calls = [ + c for c in client.chat_update.call_args_list + if c.kwargs.get("ts") == "ts1" + ] + assert seal_calls + last_text = seal_calls[-1].kwargs["text"] + # Sealed text is the buffer prefix up to the cut point — no + # synthetic closing fence is appended because the prefix is + # already balanced. + assert last_text.count(CODE_FENCE) % 2 == 0 + assert "```" not in last_text[-3:] or last_text.endswith("```") # original closing fence kept + # No code-fence carry-over leaked into the new ts buffer because + # the sealed prefix was already balanced. + assert not sm._buffer.startswith(CODE_FENCE) + + +def test_streaming_message_roll_cuts_at_last_newline_not_mid_token(): + """Regression: the roll-finalize used to seal whatever raw bytes + happened to be in the buffer when it crossed max_len, splitting + JS template literals like `${body` mid-token. Cut at the last + \\n inside the buffer instead, and carry the post-cut bytes into + the next ts so the user sees a clean line boundary.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.side_effect = [ + {"ok": True, "ts": "ts1"}, + {"ok": True, "ts": "ts2"}, + ] + client.chat_update.return_value = {"ok": True} + + sm = StreamingMessage( + client=client, channel="C1", thread_ts="thread-1", min_interval=0.0, max_len=60 + ) + sm.start() + + # Stream content that crosses max_len with a mid-token tail. The + # buffer ends with `${body` — without the cut fix it would seal + # exactly there, breaking the template literal across two + # messages. + body = "first line of plain text\nconst base = `v0:${timestamp}:${body" + sm.append(body) + + seal_calls = [ + c for c in client.chat_update.call_args_list + if c.kwargs.get("ts") == "ts1" + ] + assert seal_calls, "expected chat_update on ts1 during roll" + sealed = seal_calls[-1].kwargs["text"] + + # ts1 must end at the \n boundary, not on the `${body` fragment. + assert "${body" not in sealed, f"mid-token cut: {sealed!r}" + assert sealed.endswith("text"), f"expected line cut, got: {sealed!r}" + + # The post-cut tail rides into ts2 so no streamed bytes are lost. + assert "${body" in sm._buffer + assert "const base" in sm._buffer + + +def test_streaming_message_stop_reapplies_fence_when_roll_left_open_block(): + """If the rolled prefix ended inside an unclosed code block, the + next ts was carrying ```\\n during streaming. stop() must reapply + that prefix on its chat_update so the latest ts doesn't render + the suffix as plain text outside the fence.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.return_value = {"ok": True, "ts": "ts1"} + client.chat_update.return_value = {"ok": True} + + sm = StreamingMessage( + client=client, channel="C1", thread_ts="thread-1", min_interval=0.0, max_len=10_000 + ) + sm.start() + + # Simulate what _flush would do on roll: track an unclosed-block + # prefix in _finalized_text and a fresh ts in self.ts. + sm._finalized_text = "```python\n" + sm.ts = "ts1" + + full_answer = "```python\nbody = run()\nresult = body\n```" + sm.stop(full_answer) + + final_calls = [ + c for c in client.chat_update.call_args_list + if c.kwargs.get("ts") == "ts1" + ] + assert final_calls, "expected final chat_update on ts1" + body = final_calls[-1].kwargs["text"] + # The latest ts must reopen the block so its content renders as + # code, not as plain text. + assert body.startswith(CODE_FENCE), f"missing fence reopen: {body!r}" + assert body.count(CODE_FENCE) % 2 == 0, f"unbalanced final ts: {body!r}" + + +def test_streaming_message_stop_unchanged_when_no_roll(): + """Without rolling, stop(final_text) should behave exactly as before: + one chat_update on the placeholder ts with the full text.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.return_value = {"ok": True, "ts": "only-ts"} + client.chat_update.return_value = {"ok": True} + + sm = StreamingMessage( + client=client, channel="C1", thread_ts="thread-1", min_interval=0.0, max_len=10_000 + ) + sm.start() + sm.stop("hello world") + + final_calls = [ + call for call in client.chat_update.call_args_list + if call.kwargs.get("text") == "hello world" + ] + assert len(final_calls) == 1 + + diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tools/_helpers.py b/tests/tools/_helpers.py new file mode 100644 index 0000000..465fdd4 --- /dev/null +++ b/tests/tools/_helpers.py @@ -0,0 +1,51 @@ +"""Shared fixtures for tests/tools/ — _settings, _ctx, _streamed_read.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +from src.config import Settings +from src.tools.registry import ToolContext + + +def _settings(**overrides) -> Settings: + base = { + "slack_bot_token": "xoxb-test", + "slack_signing_secret": "sig", + "llm_provider": "openai", + "llm_model": "gpt-4o-mini", + "image_provider": "openai", + "image_model": "gpt-image-1", + "agent_max_steps": 3, + "response_language": "ko", + "dynamodb_table_name": "t", + "aws_region": "us-east-1", + } + base.update(overrides) + return Settings(**base) + + +def _ctx(event=None, slack_client=None, llm=None): + return ToolContext( + slack_client=slack_client or MagicMock(), + channel="C1", + thread_ts="ts1", + event=event or {}, + settings=_settings(), + llm=llm or MagicMock(), + ) + + +def _streamed_read(body: bytes): + """Build a urlopen-mock read side_effect that serves `body` in chunks.""" + buf = {"pos": 0} + + def _chunked(n=-1): + if n == -1: + remaining = body[buf["pos"]:] + buf["pos"] = len(body) + return remaining + chunk = body[buf["pos"]:buf["pos"] + n] + buf["pos"] += len(chunk) + return chunk + + return _chunked diff --git a/tests/tools/test_image.py b/tests/tools/test_image.py new file mode 100644 index 0000000..9b02fff --- /dev/null +++ b/tests/tools/test_image.py @@ -0,0 +1,23 @@ +"""Tests for src.tools.image.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +from tests.tools._helpers import _ctx +from src.tools.image import generate_image + + +# --------------------------------------------------------------------------- # +# generate_image +# --------------------------------------------------------------------------- # + + +def test_generate_image_returns_permalink(): + llm = MagicMock() + llm.generate_image.return_value = b"imgbytes" + client = MagicMock() + client.files_upload_v2.return_value = {"file": {"permalink": "https://slack/abc", "title": "t"}} + ctx = _ctx(slack_client=client, llm=llm) + out = generate_image(ctx, prompt="cat") + assert out == {"permalink": "https://slack/abc", "title": "t"} + llm.generate_image.assert_called_once_with("cat") diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py new file mode 100644 index 0000000..a3a1a61 --- /dev/null +++ b/tests/tools/test_registry.py @@ -0,0 +1,183 @@ +"""Tests for src.tools.registry.""" +from __future__ import annotations + +from tests.tools._helpers import _ctx +from src.llms import ToolCall +from src.tools import default_registry +from src.tools.registry import ToolDef, ToolExecutor, ToolRegistry + + +# --------------------------------------------------------------------------- # +# Registry +# --------------------------------------------------------------------------- # + + +def test_default_registry_has_expected_tools(): + names = set(default_registry.names()) + assert { + "read_attached_images", + "fetch_thread_history", + "search_web", + "generate_image", + "get_current_time", + "read_attached_document", + }.issubset(names) + assert "search_slack_messages" not in names # removed — user-token only, tied to installer + + +def test_registry_specs_match_llm_shape(): + for spec in default_registry.specs(): + assert set(spec.keys()) == {"name", "description", "parameters"} + assert spec["parameters"]["type"] == "object" + + +# --------------------------------------------------------------------------- # +# Executor +# --------------------------------------------------------------------------- # + + +def test_executor_unknown_tool(): + registry = ToolRegistry() + executor = ToolExecutor(_ctx(), registry) + result = executor.execute(ToolCall(id="1", name="nope", arguments={})) + assert result["ok"] is False + assert "unknown tool" in result["error"] + + +def test_executor_timeout_guards_slow_tools(): + import time + + registry = ToolRegistry() + + def slow(ctx): + time.sleep(1.0) + + registry.register(ToolDef(name="slow", description="", parameters={"type": "object", "properties": {}}, fn=slow)) + executor = ToolExecutor(_ctx(), registry, timeout=0.1) + result = executor.execute(ToolCall(id="1", name="slow", arguments={})) + assert result["ok"] is False + assert "timed out" in result["error"] + + +def test_executor_wraps_boto_client_error(): + """Bedrock invoke failures (botocore ClientError) must be returned as + {ok: False, error: ...} so the LLM can plan around the failure instead + of the exception bubbling out of the agent loop.""" + from botocore.exceptions import ClientError + + registry = ToolRegistry() + + def failing_bedrock(ctx): + raise ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Legacy model"}}, + "InvokeModel", + ) + + registry.register( + ToolDef( + name="bedrock_thing", + description="", + parameters={"type": "object", "properties": {}}, + fn=failing_bedrock, + ) + ) + executor = ToolExecutor(_ctx(), registry) + result = executor.execute(ToolCall(id="1", name="bedrock_thing", arguments={})) + assert result["ok"] is False + assert "ResourceNotFoundException" in result["error"] or "Legacy" in result["error"] + + +def test_executor_captures_tool_error(): + registry = ToolRegistry() + + def boom(ctx): + raise ValueError("nope") + + registry.register(ToolDef(name="boom", description="", parameters={"type": "object", "properties": {}}, fn=boom)) + executor = ToolExecutor(_ctx(), registry) + result = executor.execute(ToolCall(id="1", name="boom", arguments={})) + assert result["ok"] is False + assert "nope" in result["error"] + + +def test_executor_per_tool_timeout_override(): + """A tool registered with its own timeout overrides the executor default.""" + import time + + registry = ToolRegistry() + + def moderately_slow(ctx): + time.sleep(0.3) + return "done" + + registry.register( + ToolDef( + name="slowish", + description="", + parameters={"type": "object", "properties": {}}, + fn=moderately_slow, + timeout=1.0, + ) + ) + # Default timeout short enough to kill a naïve tool; per-tool override lets + # this one finish. + executor = ToolExecutor(_ctx(), registry, timeout=0.1) + result = executor.execute(ToolCall(id="1", name="slowish", arguments={})) + assert result["ok"] is True + assert result["result"] == "done" + + +def test_generate_image_tool_has_extended_timeout(): + """Image generation is slow; its registered timeout must be > default.""" + td = default_registry.get("generate_image") + assert td is not None + assert td.timeout is not None + assert td.timeout >= 60.0 + + +def test_default_registry_now_includes_fetch_webpage(): + names = set(default_registry.names()) + assert "fetch_webpage" in names + + +def test_executor_wraps_arbitrary_provider_exception(): + """Tools that call provider SDKs can raise provider-specific errors + (openai.APIError, anthropic.APIError, httpx.HTTPError, etc.) which aren't + subclasses of ValueError/TypeError. The executor must still surface them + as {ok: False, ...} so the LLM can recover — not propagate and abort the + whole agent loop.""" + + class FakeProviderError(Exception): + """Stand-in for openai.APIError / anthropic.APIError etc. — not a + subclass of any stdlib exception category the old allowlist knew + about.""" + + registry = ToolRegistry() + + def provider_call(ctx): + raise FakeProviderError("rate limit: try again in 42s") + + registry.register( + ToolDef( + name="llm_thing", + description="", + parameters={"type": "object", "properties": {}}, + fn=provider_call, + ) + ) + executor = ToolExecutor(_ctx(), registry) + result = executor.execute(ToolCall(id="1", name="llm_thing", arguments={})) + assert result["ok"] is False + assert "FakeProviderError" in result["error"] + assert "rate limit" in result["error"] + + +def test_executor_close_is_idempotent(): + """close() is called by the owning agent at end-of-request — invoking it + twice (e.g. agent.run() running then the caller also calling close) must + not raise.""" + registry = ToolRegistry() + executor = ToolExecutor(_ctx(), registry) + executor.close() + executor.close() + assert executor._closed is True diff --git a/tests/tools/test_search.py b/tests/tools/test_search.py new file mode 100644 index 0000000..65f51eb --- /dev/null +++ b/tests/tools/test_search.py @@ -0,0 +1,44 @@ +"""Tests for src.tools.search.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +from tests.tools._helpers import _ctx, _settings +from src.tools.registry import ToolContext +from src.tools.search import search_web + + +# --------------------------------------------------------------------------- # +# search_web +# --------------------------------------------------------------------------- # + + +def test_search_web_ddg_parses_results(): + ctx = _ctx() + payload = { + "AbstractURL": "https://example.com/a", + "AbstractText": "abstract", + "RelatedTopics": [{"Text": "t1", "FirstURL": "https://example.com/1"}], + } + with patch("src.tools.search.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = json.dumps(payload).encode() + results = search_web(ctx, query="q", limit=5) + assert results[0]["url"] == "https://example.com/a" + assert results[1]["url"] == "https://example.com/1" + + +def test_search_web_uses_tavily_when_key_set(): + ctx = ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event={}, + settings=_settings(tavily_api_key="tvly-xyz"), + llm=MagicMock(), + ) + payload = {"results": [{"title": "t", "url": "https://x", "content": "c"}]} + with patch("src.tools.search.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = json.dumps(payload).encode() + out = search_web(ctx, query="q", limit=5) + assert out == [{"title": "t", "url": "https://x", "content": "c"}] diff --git a/tests/tools/test_slack.py b/tests/tools/test_slack.py new file mode 100644 index 0000000..68069db --- /dev/null +++ b/tests/tools/test_slack.py @@ -0,0 +1,682 @@ +"""Tests for src.tools.slack.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from tests.tools._helpers import _ctx, _settings +from src.tools.registry import ToolContext, ToolExecutor +from src.tools.slack import ( + fetch_thread_history, + read_attached_document, + read_attached_images, +) + + +# --------------------------------------------------------------------------- # +# read_attached_images SSRF guard +# --------------------------------------------------------------------------- # + + +def test_read_attached_images_rejects_non_slack_host(): + event = {"files": [{"mimetype": "image/png", "url_private_download": "https://evil.example.com/x.png"}]} + with pytest.raises(ValueError): + read_attached_images(_ctx(event=event), limit=1) + + +def test_read_attached_images_rejects_http_scheme(): + event = {"files": [{"mimetype": "image/png", "url_private_download": "http://files.slack.com/x.png"}]} + with pytest.raises(ValueError): + read_attached_images(_ctx(event=event), limit=1) + + +def test_read_attached_images_accepts_slack_host_variants(): + event = { + "files": [ + {"mimetype": "image/png", "url_private_download": "https://files-pri.slack.com/x.png", "name": "a"}, + ] + } + llm = MagicMock() + llm.describe_image.return_value = "a cat" + ctx = _ctx(event=event, llm=llm) + with patch("src.tools.slack.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = b"fake" + result = read_attached_images(ctx, limit=1) + assert result == [{"name": "a", "summary": "a cat"}] + + +def test_read_attached_images_skips_non_image_mimetypes(): + event = {"files": [{"mimetype": "application/pdf", "url_private_download": "https://files.slack.com/x.pdf"}]} + assert read_attached_images(_ctx(event=event), limit=1) == [] + + +# --------------------------------------------------------------------------- # +# fetch_thread_history +# --------------------------------------------------------------------------- # + + +def test_fetch_thread_history_resolves_user_files_and_reactions(): + """History should carry display names, file metadata, and reactions so the + LLM can answer things like "누가 좋아요 눌렀어?" or "아까 그 이미지 분석해줘".""" + from src.slack_helpers import user_name_cache + + # Reset the module-level cache so prior tests don't leak. + user_name_cache._cache.clear() + + client = MagicMock() + client.conversations_replies.return_value = { + "messages": [ + { + "user": "U1", + "text": "look at this", + "ts": "1713.1", + "files": [ + { + "name": "cat.png", + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/x/cat.png", + "permalink": "https://slack/p1", + "title": "cute", + } + ], + }, + { + "user": "U2", + "text": "nice!", + "ts": "1713.2", + "reactions": [ + {"name": "thumbsup", "count": 2, "users": ["U1", "U3"]}, + ], + }, + ] + } + + def _users_info(user): + return {"user": {"profile": {"display_name": f"name-{user}"}}} + + client.users_info.side_effect = _users_info + + out = fetch_thread_history(_ctx(slack_client=client), limit=5) + assert len(out) == 2 + first, second = out + assert first["user"] == "name-U1" + assert first["text"] == "look at this" + assert first["ts"] == "1713.1" + assert first["files"] == [ + { + "name": "cat.png", + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/x/cat.png", + "permalink": "https://slack/p1", + "title": "cute", + } + ] + assert first["reactions"] == [] + + assert second["user"] == "name-U2" + assert second["files"] == [] + assert second["reactions"] == [ + {"emoji": "thumbsup", "count": 2, "users": ["name-U1", "name-U3"]} + ] + + +def test_fetch_thread_history_resolves_users_concurrently(): + """fetch_thread_history must prefetch user names in parallel via + UserNameCache.warm. Without this, a thread of N unique users + R + reacters becomes N+R serial users_info calls and blows the tool + timeout on cold caches.""" + import threading + import time as _time + + from src.slack_helpers import user_name_cache + + user_name_cache._cache.clear() + + in_flight = 0 + peak = 0 + lock = threading.Lock() + + def _slow_users_info(user): + nonlocal in_flight, peak + with lock: + in_flight += 1 + peak = max(peak, in_flight) + _time.sleep(0.2) + with lock: + in_flight -= 1 + return {"user": {"profile": {"display_name": f"name-{user}"}}} + + client = MagicMock() + client.users_info.side_effect = _slow_users_info + client.conversations_replies.return_value = { + "messages": [ + {"user": f"U{i}", "text": "hi", "ts": f"100.{i}"} + for i in range(4) + ] + } + + started = _time.monotonic() + out = fetch_thread_history(_ctx(slack_client=client), limit=10) + elapsed = _time.monotonic() - started + + assert len(out) == 4 + assert peak >= 2, f"expected concurrent users_info, peak={peak}" + assert elapsed < 0.6, f"expected parallel ~0.2s, took {elapsed:.2f}s" + assert {item["user"] for item in out} == {f"name-U{i}" for i in range(4)} + + +def test_read_attached_images_accepts_extra_urls(): + """Images referenced from fetch_thread_history (url_private_download) must + be loadable via read_attached_images(urls=[...]).""" + ctx = _ctx() + ctx.llm.describe_image.return_value = "a cat history" + with patch("src.tools.slack.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = b"fake-bytes" + out = read_attached_images( + ctx, + limit=5, + urls=["https://files.slack.com/x/cat.png"], + ) + assert out == [{"name": "cat.png", "summary": "a cat history"}] + + +def test_read_attached_images_urls_reject_non_slack_host(): + ctx = _ctx() + with pytest.raises(ValueError): + read_attached_images(ctx, urls=["https://evil.example.com/cat.png"]) + + +def test_read_attached_images_runs_describes_in_parallel(): + """The describe step is the slow one (LLM call). Three images that each + take 0.3s to describe should finish in well under the serial 0.9s if the + pool is actually parallel. Guards against accidentally re-serializing the + fetch loop (the original bug).""" + import threading + import time as _time + + event = { + "files": [ + { + "mimetype": "image/png", + "url_private_download": f"https://files.slack.com/img{i}.png", + "name": f"img{i}.png", + } + for i in range(3) + ] + } + ctx = _ctx(event=event) + + in_flight = 0 + peak = 0 + lock = threading.Lock() + + def _slow_describe(_data, _mime): + nonlocal in_flight, peak + with lock: + in_flight += 1 + peak = max(peak, in_flight) + _time.sleep(0.3) + with lock: + in_flight -= 1 + return "described" + + ctx.llm.describe_image.side_effect = _slow_describe + + with patch("src.tools.slack.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = b"x" + started = _time.monotonic() + out = read_attached_images(ctx, limit=3) + elapsed = _time.monotonic() - started + + assert len(out) == 3 + assert peak >= 2, f"expected concurrent describes, peak={peak}" + assert elapsed < 0.7, f"expected parallel ~0.3s, took {elapsed:.2f}s" + + +def test_read_attached_images_preserves_order(): + """Output order must match candidate order (event files first, then urls), + independent of which describe call finishes first.""" + import threading + import time as _time + + event = { + "files": [ + { + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/event.png", + "name": "event.png", + } + ] + } + ctx = _ctx(event=event) + + delays = {b"event": 0.2, b"extra": 0.0} + barrier = threading.Event() + + def _describe(data, _mime): + # Force the extra image to finish first so we can verify ordering + # comes from the candidate index, not completion order. + _time.sleep(delays.get(data, 0.0)) + if data == b"extra": + barrier.set() + return f"sum-{data.decode()}" + + ctx.llm.describe_image.side_effect = _describe + + def _open(req, timeout=15): # noqa: ARG001 + url = req.full_url if hasattr(req, "full_url") else req + body = b"event" if "event.png" in url else b"extra" + cm = MagicMock() + cm.__enter__.return_value.read.return_value = body + return cm + + with patch("src.tools.slack.urllib.request.urlopen", side_effect=_open): + out = read_attached_images( + ctx, + limit=3, + urls=["https://files.slack.com/extra.png"], + ) + + assert [item["name"] for item in out] == ["event.png", "extra.png"] + + +def test_read_attached_images_respects_total_limit_across_event_and_urls(): + event = { + "files": [ + { + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/e1.png", + "name": "e1.png", + } + ] + } + ctx = _ctx(event=event) + ctx.llm.describe_image.return_value = "desc" + with patch("src.tools.slack.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = b"x" + out = read_attached_images( + ctx, + limit=2, + urls=[ + "https://files.slack.com/u1.png", + "https://files.slack.com/u2.png", # should be skipped (limit=2) + ], + ) + assert len(out) == 2 + assert {item["name"] for item in out} == {"e1.png", "u1.png"} + + +# --------------------------------------------------------------------------- # +# read_attached_document +# --------------------------------------------------------------------------- # + + +def test_read_attached_document_runs_in_parallel(): + """Multiple text documents should download concurrently. The serial + implementation took ~3 * download_time; parallel should finish in + roughly download_time. Guards against re-serializing the loop.""" + import threading + import time as _time + + event = { + "files": [ + { + "mimetype": "text/plain", + "url_private_download": f"https://files.slack.com/d{i}.txt", + "name": f"d{i}.txt", + } + for i in range(3) + ] + } + ctx = _ctx(event=event) + + in_flight = 0 + peak = 0 + lock = threading.Lock() + + def _slow_open(_req, timeout=15): # noqa: ARG001 + nonlocal in_flight, peak + with lock: + in_flight += 1 + peak = max(peak, in_flight) + _time.sleep(0.3) + with lock: + in_flight -= 1 + cm = MagicMock() + cm.__enter__.return_value.read.return_value = b"hello" + cm.__enter__.return_value.headers = {"Content-Length": "5", "Content-Type": "text/plain"} + return cm + + with patch("src.tools.slack.urllib.request.urlopen", side_effect=_slow_open): + started = _time.monotonic() + out = read_attached_document(ctx, limit=3) + elapsed = _time.monotonic() - started + + assert len(out) == 3 + assert peak >= 2, f"expected concurrent fetches, peak={peak}" + assert elapsed < 0.7, f"expected parallel ~0.3s, took {elapsed:.2f}s" + + +def test_read_attached_document_preserves_order_under_parallel_completion(): + """Output order must follow candidate order, not completion order.""" + import time as _time + + event = { + "files": [ + { + "mimetype": "text/plain", + "url_private_download": "https://files.slack.com/slow.txt", + "name": "slow.txt", + }, + { + "mimetype": "text/plain", + "url_private_download": "https://files.slack.com/fast.txt", + "name": "fast.txt", + }, + ] + } + ctx = _ctx(event=event) + + def _open(req, timeout=15): # noqa: ARG001 + url = req.full_url if hasattr(req, "full_url") else req + delay = 0.2 if "slow.txt" in url else 0.0 + _time.sleep(delay) + cm = MagicMock() + cm.__enter__.return_value.read.return_value = b"x" + cm.__enter__.return_value.headers = {"Content-Length": "1", "Content-Type": "text/plain"} + return cm + + with patch("src.tools.slack.urllib.request.urlopen", side_effect=_open): + out = read_attached_document(ctx, limit=2) + + assert [item["name"] for item in out] == ["slow.txt", "fast.txt"] + + +def test_read_attached_document_text_file(): + event = { + "files": [ + { + "mimetype": "text/plain", + "url_private_download": "https://files.slack.com/notes.txt", + "name": "notes.txt", + } + ] + } + ctx = _ctx(event=event) + body = b"Hello\n world.\nLine 3." + with patch("src.tools.slack.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.read.return_value = body + resp.headers = {"Content-Length": str(len(body))} + out = read_attached_document(ctx, limit=1) + assert len(out) == 1 + entry = out[0] + assert entry["name"] == "notes.txt" + assert entry["mimetype"] == "text/plain" + assert entry["truncated"] is False + assert "Hello" in entry["text"] + assert entry["chars"] == len(entry["text"]) + assert entry["pages"] == 0 # text files report 0 pages + + +def _build_pdf_bytes(pages_text: list[str]) -> bytes: + """Build a minimal PDF (one page per string) using reportlab. Test-only.""" + from io import BytesIO + from reportlab.pdfgen.canvas import Canvas + from reportlab.lib.pagesizes import letter + + buf = BytesIO() + canvas = Canvas(buf, pagesize=letter) + for text in pages_text: + canvas.drawString(72, 720, text) + canvas.showPage() + canvas.save() + return buf.getvalue() + + +def _mock_pdf_response(opener, body: bytes, headers=None): + """Wire the urlopen mock to stream `body` in chunks through `_fetch_slack_file`.""" + resp = opener.return_value.__enter__.return_value + buf = {"pos": 0} + + def _chunked(n=-1): + if n == -1: + remaining = body[buf["pos"]:] + buf["pos"] = len(body) + return remaining + chunk = body[buf["pos"]:buf["pos"] + n] + buf["pos"] += len(chunk) + return chunk + + resp.read.side_effect = _chunked + resp.headers = dict(headers or {"Content-Length": str(len(body)), "Content-Type": "application/pdf"}) + + +def test_read_attached_document_pdf_happy_path(): + pdf = _build_pdf_bytes(["Hello PDF page one.", "Page two here."]) + event = { + "files": [ + { + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/report.pdf", + "name": "report.pdf", + } + ] + } + ctx = _ctx(event=event) + with patch("src.tools.slack.urllib.request.urlopen") as opener: + _mock_pdf_response(opener, pdf) + out = read_attached_document(ctx, limit=1) + assert len(out) == 1 + entry = out[0] + assert entry["name"] == "report.pdf" + assert entry["pages"] == 2 + assert entry["truncated"] is False + assert entry["chars"] > 0 + + +def test_read_attached_document_pdf_truncation(): + pdf = _build_pdf_bytes(["A" * 500]) + event = { + "files": [ + { + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/big.pdf", + "name": "big.pdf", + } + ] + } + ctx = _ctx( + event=event, + ) + ctx = ToolContext( + slack_client=ctx.slack_client, + channel=ctx.channel, + thread_ts=ctx.thread_ts, + event=ctx.event, + settings=_settings(max_doc_chars=50), + llm=ctx.llm, + ) + with patch("src.tools.slack.urllib.request.urlopen") as opener: + _mock_pdf_response(opener, pdf) + out = read_attached_document(ctx, limit=1) + assert out[0]["truncated"] is True + assert out[0]["chars"] == 50 + + +def test_read_attached_document_page_cap(): + pdf = _build_pdf_bytes(["p1", "p2", "p3"]) + event = { + "files": [ + { + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/pages.pdf", + "name": "pages.pdf", + } + ] + } + ctx = ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event=event, + settings=_settings(max_doc_pages=2), + llm=MagicMock(), + ) + with patch("src.tools.slack.urllib.request.urlopen") as opener: + _mock_pdf_response(opener, pdf) + out = read_attached_document(ctx, limit=1) + assert "error" in out[0] + assert "MAX_DOC_PAGES" in out[0]["error"] + + +def test_read_attached_document_size_cap_via_content_length(): + event = { + "files": [ + { + "mimetype": "text/plain", + "url_private_download": "https://files.slack.com/huge.txt", + "name": "huge.txt", + } + ] + } + ctx = ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event=event, + settings=_settings(max_doc_bytes=100), # tiny cap + llm=MagicMock(), + ) + with patch("src.tools.slack.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": "200"} # > cap + resp.read.return_value = b"x" * 10 # should never be read past cap + out = read_attached_document(ctx, limit=1) + assert "error" in out[0] + assert "MAX_DOC_BYTES" in out[0]["error"] + + +def test_read_attached_document_size_cap_via_streamed_read(): + event = { + "files": [ + { + "mimetype": "text/plain", + "url_private_download": "https://files.slack.com/nohead.txt", + "name": "nohead.txt", + } + ] + } + ctx = ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event=event, + settings=_settings(max_doc_bytes=100), + llm=MagicMock(), + ) + body = b"y" * 200 + with patch("src.tools.slack.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {} # no Content-Length + buf = {"pos": 0} + + def _chunked(n=-1): + if n == -1: + remaining = body[buf["pos"]:] + buf["pos"] = len(body) + return remaining + chunk = body[buf["pos"]:buf["pos"] + n] + buf["pos"] += len(chunk) + return chunk + + resp.read.side_effect = _chunked + out = read_attached_document(ctx, limit=1) + assert "error" in out[0] + assert "MAX_DOC_BYTES" in out[0]["error"] + + +def test_read_attached_document_rejects_non_slack_host(): + ctx = _ctx() + out = read_attached_document( + ctx, urls=["https://evil.example.com/foo.pdf"], limit=1 + ) + assert len(out) == 1 + assert "error" in out[0] + assert "invalid" in out[0]["error"].lower() + + +def test_read_attached_document_skips_encrypted_pdf(): + from io import BytesIO + from pypdf import PdfWriter + + writer = PdfWriter() + writer.add_blank_page(width=612, height=792) + # NOTE: pypdf>=4.0 uses keyword-only user_password. If requirements.txt's + # upper pin is ever relaxed past 6.0, verify this signature still holds. + writer.encrypt(user_password="secret") + buf = BytesIO() + writer.write(buf) + encrypted_pdf = buf.getvalue() + + event = { + "files": [ + { + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/enc.pdf", + "name": "enc.pdf", + } + ] + } + ctx = _ctx(event=event) + with patch("src.tools.slack.urllib.request.urlopen") as opener: + _mock_pdf_response(opener, encrypted_pdf) + out = read_attached_document(ctx, limit=1) + assert "error" in out[0] + assert "encrypted" in out[0]["error"] + + +def test_read_attached_document_skips_image_mime(): + event = { + "files": [ + { + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/a.png", + "name": "a.png", + } + ] + } + ctx = _ctx(event=event) + # urlopen should NOT be called — image MIMEs are filtered before fetch + with patch("src.tools.slack.urllib.request.urlopen") as opener: + out = read_attached_document(ctx, limit=1) + opener.assert_not_called() + assert out == [] + + +def test_read_attached_document_http_error_returns_per_item(): + import urllib.error + + event = { + "files": [ + { + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/missing.pdf", + "name": "missing.pdf", + } + ] + } + ctx = _ctx(event=event) + with patch("src.tools.slack.urllib.request.urlopen") as opener: + opener.side_effect = urllib.error.HTTPError( + url="https://files.slack.com/missing.pdf", + code=404, + msg="Not Found", + hdrs=None, + fp=None, + ) + out = read_attached_document(ctx, limit=1) + assert len(out) == 1 + assert "error" in out[0] + assert "404" in out[0]["error"] diff --git a/tests/tools/test_time.py b/tests/tools/test_time.py new file mode 100644 index 0000000..fc6b315 --- /dev/null +++ b/tests/tools/test_time.py @@ -0,0 +1,44 @@ +"""Tests for src.tools.time.""" +from __future__ import annotations + +from tests.tools._helpers import _ctx +from src.llms import ToolCall +from src.tools.registry import ToolExecutor +from src.tools import default_registry +from src.tools.time import get_current_time + + +# --------------------------------------------------------------------------- # +# get_current_time +# --------------------------------------------------------------------------- # + + +def test_get_current_time_uses_default_timezone(): + ctx = _ctx() # _settings() default_timezone defaults to Asia/Seoul + out = get_current_time(ctx) + assert out["timezone"] == "Asia/Seoul" + assert out["iso"].endswith("+09:00") + # Weekday is a full English day name (Monday..Sunday) + assert out["weekday"] in { + "Monday", "Tuesday", "Wednesday", "Thursday", + "Friday", "Saturday", "Sunday", + } + assert isinstance(out["unix"], int) + + +def test_get_current_time_respects_custom_timezone(): + ctx = _ctx() + out = get_current_time(ctx, timezone="UTC") + assert out["timezone"] == "UTC" + assert out["iso"].endswith("+00:00") + + +def test_get_current_time_invalid_tz_via_executor(): + """Invalid timezone should surface as {ok: False, error: ...} via the + executor so the LLM can recover.""" + executor = ToolExecutor(_ctx(), default_registry) + result = executor.execute( + ToolCall(id="t1", name="get_current_time", arguments={"timezone": "Narnia/Center"}) + ) + assert result["ok"] is False + assert "unknown timezone" in result["error"] diff --git a/tests/tools/test_web.py b/tests/tools/test_web.py new file mode 100644 index 0000000..6b67593 --- /dev/null +++ b/tests/tools/test_web.py @@ -0,0 +1,488 @@ +"""Tests for src.tools.web.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from tests.tools._helpers import _ctx, _settings, _streamed_read +from src.config import Settings +from src.tools.registry import ToolContext, ToolExecutor +from src.tools.web import ( + _HtmlTextExtractor, + _NoRedirectHandler, + _extract_markdown_links, + _filter_links, + _jina_fetch, + _parse_jina_response, + _raw_fetch, + _validate_public_https_url, + fetch_webpage, +) + + +def _public_dns(monkeypatch): + """Route all src.tools.web.socket.getaddrinfo lookups to a public IP.""" + + def _public(host, port, family=0, type=0, *args, **kwargs): + return [(None, None, None, "", ("93.184.216.34", port))] + + monkeypatch.setattr("src.tools.web.socket.getaddrinfo", _public) + + +# --------------------------------------------------------------------------- # +# fetch_webpage — SSRF guard +# --------------------------------------------------------------------------- # + + +def test_validate_public_https_url_rejects_http_scheme(): + with pytest.raises(ValueError, match="https"): + _validate_public_https_url("http://example.com/") + + +def test_validate_public_https_url_rejects_ip_literal_v4(): + with pytest.raises(ValueError, match="IP literals"): + _validate_public_https_url("https://127.0.0.1/") + + +def test_validate_public_https_url_rejects_ip_literal_v6(): + with pytest.raises(ValueError, match="IP literals"): + _validate_public_https_url("https://[::1]/") + + +def test_validate_public_https_url_rejects_private_dns(monkeypatch): + def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): + # Simulate DNS pointing at RFC1918 space. + return [(None, None, None, "", ("10.0.0.1", port))] + + monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) + with pytest.raises(ValueError, match="non-public"): + _validate_public_https_url("https://internal.corp.example/") + + +def test_validate_public_https_url_rejects_metadata_host(monkeypatch): + def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): + return [(None, None, None, "", ("169.254.169.254", port))] + + monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) + with pytest.raises(ValueError, match="non-public"): + _validate_public_https_url("https://cloud.metadata.example/") + + +def test_validate_public_https_url_accepts_public_host(monkeypatch): + def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): + return [(None, None, None, "", ("93.184.216.34", port))] # example.com + + monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) + scheme, host = _validate_public_https_url("https://example.com/path") + assert scheme == "https" + assert host == "example.com" + + +def test_validate_public_https_url_dns_failure(monkeypatch): + import socket as _socket + + def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): + raise _socket.gaierror("nodename nor servname provided") + + monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) + with pytest.raises(ValueError, match="DNS resolution failed"): + _validate_public_https_url("https://nonexistent.invalid.example/") + + +def test_no_redirect_handler_raises_on_302(): + import urllib.error + import urllib.request + + handler = _NoRedirectHandler() + req = urllib.request.Request("https://example.com/") + with pytest.raises(urllib.error.HTTPError, match="redirects not allowed"): + handler.redirect_request(req, None, 302, "Found", {}, "https://evil.example/") + + +# --------------------------------------------------------------------------- # +# fetch_webpage — HTML parser + Jina response parser +# --------------------------------------------------------------------------- # + + +def test_html_text_extractor_basic(): + html = ( + " Hello " + "" + "" + "" + "

Heading

" + "

Para one.

" + "

Para two.

" + "
Link A" + "Rel" + "Frag" + "Mail" + "" + ) + x = _HtmlTextExtractor("https://base.example/page") + x.feed(html) + assert x.title() == "Hello" + text = x.text() + assert "Heading" in text + assert "Para one." in text + assert "Para two." in text + assert "alert" not in text + assert ".a{}" not in text + assert ("Link A", "https://a.example/1") in x.links + # Relative links resolved against base + assert ("Rel", "https://base.example/relative") in x.links + # mailto / fragment retained raw — filtering happens in _filter_links + assert any(url.startswith("mailto:") for _, url in x.links) + + +def test_filter_links_drops_non_https_and_dedups(): + raw = [ + ("A", "https://a.example/1"), + ("A dup", "https://a.example/1#top"), # dedups by fragment-stripped url + ("B", "https://b.example/"), + ("Self", "https://base.example/page"), # self-ref dropped + ("Mail", "mailto:x@y"), + ("JS", "javascript:void(0)"), + ("HTTP", "http://insecure.example/"), + ] + out = _filter_links(raw, base_url="https://base.example/page", limit=10) + urls = [item["url"] for item in out] + assert urls == ["https://a.example/1", "https://b.example/"] + assert out[0]["title"] == "A" + + +def test_filter_links_respects_limit(): + raw = [(f"T{i}", f"https://x.example/{i}") for i in range(5)] + out = _filter_links(raw, base_url="https://base.example/", limit=3) + assert len(out) == 3 + assert [item["url"] for item in out] == [ + "https://x.example/0", + "https://x.example/1", + "https://x.example/2", + ] + + +def test_extract_markdown_links_parses_inline_markdown(): + md = ( + "Title\n\nSome prose with [Google](https://google.com/about) " + "and [Self](https://base.example/page) and [Same](https://google.com/about?ref=x).\n" + "[Another](https://example.org/)" + ) + out = _extract_markdown_links(md, base_url="https://base.example/page", limit=10) + urls = [item["url"] for item in out] + assert "https://google.com/about" in urls + assert "https://example.org/" in urls + assert "https://base.example/page" not in urls # self-ref dropped + + +def test_parse_jina_response_strips_header(): + payload = ( + "Title: Example Page\n" + "URL Source: https://example.com/\n" + "Markdown Content:\n" + "# Heading\n\nBody text.\n" + ) + title, body = _parse_jina_response(payload) + assert title == "Example Page" + assert body.startswith("# Heading") + assert "URL Source" not in body + + +def test_parse_jina_response_no_header(): + payload = "just raw markdown\n\nwith no prefix" + title, body = _parse_jina_response(payload) + assert title == "" + assert body == payload + + +def test_html_text_extractor_empty(): + x = _HtmlTextExtractor("https://base.example/") + x.feed("") + assert x.title() == "" + assert x.text() == "" + assert x.links == [] + + +def test_filter_links_host_case_normalization(): + out = _filter_links( + [ + ("A", "https://Example.COM/path"), + ("B", "https://example.com/path"), # dup by host case + ("Self", "https://BASE.EXAMPLE/page"), # self-ref by host case + ], + base_url="https://base.example/page", + limit=10, + ) + urls = [item["url"] for item in out] + assert urls == ["https://Example.COM/path"] # first-seen wins, case preserved in output + + +def test_extract_markdown_links_skips_images(): + md = ( + "![logo](https://img.example/logo.png) " + "see [Docs](https://docs.example/) for more" + ) + out = _extract_markdown_links(md, base_url="https://base.example/", limit=10) + urls = [item["url"] for item in out] + assert "https://img.example/logo.png" not in urls + assert "https://docs.example/" in urls + + +def test_extract_markdown_links_preserves_paren_in_url(): + md = "see [Wiki](https://en.wikipedia.org/wiki/Foo_(bar)) for context" + out = _extract_markdown_links(md, base_url="https://base.example/", limit=10) + urls = [item["url"] for item in out] + assert "https://en.wikipedia.org/wiki/Foo_(bar)" in urls + + +def test_parse_jina_response_inline_markdown_content(): + payload = "Title: T\nURL Source: https://example.com/\nMarkdown Content: # Heading\n\nBody here." + title, body = _parse_jina_response(payload) + assert title == "T" + assert body.startswith("# Heading") + assert "Body here." in body + + +# --------------------------------------------------------------------------- # +# fetch_webpage — fetch helpers +# --------------------------------------------------------------------------- # + + +def test_jina_fetch_returns_body_under_cap(): + payload = b"Title: x\nURL Source: https://example.com/\n\nBody here." + with patch("src.tools.web.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": str(len(payload)), "Content-Type": "text/markdown"} + resp.read.side_effect = _streamed_read(payload) + text = _jina_fetch("https://r.jina.ai", "https://example.com/", max_bytes=1024) + assert "Body here." in text + + +def test_jina_fetch_content_length_over_cap(): + with patch("src.tools.web.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": "999999"} + resp.read.side_effect = _streamed_read(b"x" * 10) + with pytest.raises(ValueError, match="MAX_WEB_BYTES"): + _jina_fetch("https://r.jina.ai", "https://example.com/", max_bytes=1024) + + +def test_jina_fetch_streamed_over_cap(): + body = b"x" * 2000 + with patch("src.tools.web.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {} # no Content-Length + resp.read.side_effect = _streamed_read(body) + with pytest.raises(ValueError, match="MAX_WEB_BYTES"): + _jina_fetch("https://r.jina.ai", "https://example.com/", max_bytes=1024) + + +def test_raw_fetch_returns_body_under_cap(monkeypatch): + body = b"Hihello" + + # _raw_fetch builds its own opener (with _NoRedirectHandler) and calls opener.open. + # Patch urllib.request.build_opener so we intercept that call. + fake_opener = MagicMock() + cm = fake_opener.open.return_value.__enter__.return_value + cm.headers = {"Content-Length": str(len(body)), "Content-Type": "text/html"} + cm.read.side_effect = _streamed_read(body) + monkeypatch.setattr("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) + + html = _raw_fetch("https://example.com/", max_bytes=1024) + assert "hello" in html + + +def test_raw_fetch_streamed_over_cap(monkeypatch): + body = b"a" * 4096 + fake_opener = MagicMock() + cm = fake_opener.open.return_value.__enter__.return_value + cm.headers = {} + cm.read.side_effect = _streamed_read(body) + monkeypatch.setattr("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) + with pytest.raises(ValueError, match="MAX_WEB_BYTES"): + _raw_fetch("https://example.com/", max_bytes=1024) + + +# --------------------------------------------------------------------------- # +# fetch_webpage — end-to-end via tool function +# --------------------------------------------------------------------------- # + + +def test_fetch_webpage_rejects_http_via_tool(monkeypatch): + ctx = _ctx() + with pytest.raises(ValueError, match="https"): + fetch_webpage(ctx, url="http://example.com/") + + +def test_fetch_webpage_jina_happy_path(monkeypatch): + _public_dns(monkeypatch) + jina_body = ( + b"Title: Example Page\n" + b"URL Source: https://example.com/\n" + b"Markdown Content:\n" + b"# Hello\n\nSee [Docs](https://docs.example.com/) and [Blog](https://blog.example.com/).\n" + ) + ctx = _ctx() + with patch("src.tools.web.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": str(len(jina_body))} + resp.read.side_effect = _streamed_read(jina_body) + out = fetch_webpage(ctx, url="https://example.com/") + assert out["source"] == "jina" + assert out["title"] == "Example Page" + assert "Hello" in out["content"] + urls = [link["url"] for link in out["links"]] + assert "https://docs.example.com/" in urls + assert "https://blog.example.com/" in urls + assert out["truncated"] is False + assert out["chars"] == len(out["content"]) + + +def test_fetch_webpage_falls_back_to_raw_on_jina_5xx(monkeypatch): + import urllib.error + + _public_dns(monkeypatch) + html_body = ( + b"Raw Title" + b"

Raw body.

" + b"Docs" + ) + + def jina_fail(*args, **kwargs): + raise urllib.error.HTTPError( + "https://r.jina.ai/https://example.com/", 500, "boom", {}, None + ) + + fake_opener = MagicMock() + cm = fake_opener.open.return_value.__enter__.return_value + cm.headers = {"Content-Length": str(len(html_body))} + cm.read.side_effect = _streamed_read(html_body) + + monkeypatch.setattr("src.tools.web.urllib.request.urlopen", jina_fail) + monkeypatch.setattr("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) + + ctx = _ctx() + out = fetch_webpage(ctx, url="https://example.com/") + assert out["source"] == "raw" + assert out["title"] == "Raw Title" + assert "Raw body." in out["content"] + assert any(link["url"] == "https://docs.example.com/" for link in out["links"]) + + +def test_fetch_webpage_jina_body_over_cap_falls_back_to_raw(monkeypatch): + """Jina oversize → fall through to raw fetch instead of raising.""" + _public_dns(monkeypatch) + settings = Settings( + slack_bot_token="xoxb-test", + slack_signing_secret="sig", + llm_provider="openai", + llm_model="gpt-4o-mini", + image_provider="openai", + image_model="gpt-image-1", + agent_max_steps=3, + response_language="ko", + dynamodb_table_name="t", + aws_region="us-east-1", + max_web_bytes=128, + max_web_chars=8000, + max_web_links=20, + jina_reader_base="https://r.jina.ai", + ) + ctx = ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event={}, + settings=settings, + llm=MagicMock(), + ) + + huge_jina = b"x" * 4096 + raw_html = b"

raw small body

" + + fake_opener = MagicMock() + cm = fake_opener.open.return_value.__enter__.return_value + cm.headers = {"Content-Length": str(len(raw_html))} + cm.read.side_effect = _streamed_read(raw_html) + + with patch("src.tools.web.urllib.request.urlopen") as jina_open: + jresp = jina_open.return_value.__enter__.return_value + jresp.headers = {} # no Content-Length → streamed-read path + jresp.read.side_effect = _streamed_read(huge_jina) + monkeypatch_build = patch("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) + with monkeypatch_build: + out = fetch_webpage(ctx, url="https://example.com/") + assert out["source"] == "raw" + assert "raw small body" in out["content"] + + +def test_fetch_webpage_max_chars_truncates(monkeypatch): + _public_dns(monkeypatch) + long_body = b"Title: T\nURL Source: https://example.com/\nMarkdown Content:\n" + (b"A" * 500) + ctx = _ctx() + with patch("src.tools.web.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": str(len(long_body))} + resp.read.side_effect = _streamed_read(long_body) + out = fetch_webpage(ctx, url="https://example.com/", max_chars=200) + assert out["truncated"] is True + assert out["chars"] == 200 + assert len(out["content"]) == 200 + + +def test_fetch_webpage_both_paths_fail_raises(monkeypatch): + import urllib.error + + _public_dns(monkeypatch) + + def jina_500(*args, **kwargs): + raise urllib.error.HTTPError( + "https://r.jina.ai/...", 500, "jina boom", {}, None + ) + + def raw_503(*args, **kwargs): + raise urllib.error.HTTPError( + "https://example.com/", 503, "raw boom", {}, None + ) + + fake_opener = MagicMock() + fake_opener.open.side_effect = raw_503 + + monkeypatch.setattr("src.tools.web.urllib.request.urlopen", jina_500) + monkeypatch.setattr( + "src.tools.web.urllib.request.build_opener", lambda *_: fake_opener + ) + + ctx = _ctx() + with pytest.raises(ValueError, match=r"jina=.*raw="): + fetch_webpage(ctx, url="https://example.com/") + + +def test_fetch_webpage_max_links_dedup(monkeypatch): + _public_dns(monkeypatch) + link_section = ( + "[a](https://a.example/)" + "[b](https://b.example/)" + "[c](https://c.example/)" + "[d](https://d.example/)" + "[dup-a](https://a.example/)" + "[e](https://e.example/)" + "[dup-b](https://b.example/)" + "[f](https://f.example/)" + ) + payload = ( + "Title: T\nURL Source: https://example.com/\nMarkdown Content:\n" + link_section + ).encode() + ctx = _ctx() + with patch("src.tools.web.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": str(len(payload))} + resp.read.side_effect = _streamed_read(payload) + out = fetch_webpage(ctx, url="https://example.com/", max_links=3) + urls = [link["url"] for link in out["links"]] + assert urls == [ + "https://a.example/", + "https://b.example/", + "https://c.example/", + ]