forked from claude-did-this/claude-hub
Compare commits
8 Commits
remove-n8n
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f939b0e2a0 | ||
|
|
cee3cd29f6 | ||
|
|
bac1583b46 | ||
|
|
e095826e02 | ||
|
|
426ac442e2 | ||
|
|
25b90a5d7c | ||
|
|
a45b039777 | ||
|
|
0169f338b0 |
31
.env.example
31
.env.example
@@ -2,6 +2,27 @@
|
||||
NODE_ENV=development
|
||||
PORT=3002
|
||||
|
||||
# ============================
|
||||
# SECRETS CONFIGURATION
|
||||
# ============================
|
||||
# The application supports two methods for providing secrets:
|
||||
#
|
||||
# 1. Environment Variables (shown below) - Convenient for development
|
||||
# 2. Secret Files - More secure for production
|
||||
#
|
||||
# If both are provided, SECRET FILES TAKE PRIORITY over environment variables.
|
||||
#
|
||||
# For file-based secrets, the app looks for files at:
|
||||
# - /run/secrets/github_token (or path in GITHUB_TOKEN_FILE)
|
||||
# - /run/secrets/anthropic_api_key (or path in ANTHROPIC_API_KEY_FILE)
|
||||
# - /run/secrets/webhook_secret (or path in GITHUB_WEBHOOK_SECRET_FILE)
|
||||
#
|
||||
# To use file-based secrets in development:
|
||||
# 1. Create a secrets directory: mkdir secrets
|
||||
# 2. Add secret files: echo "your-secret" > secrets/github_token.txt
|
||||
# 3. Mount in docker-compose or use GITHUB_TOKEN_FILE=/path/to/secret
|
||||
# ============================
|
||||
|
||||
# GitHub Webhook Settings
|
||||
GITHUB_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
GITHUB_TOKEN=ghp_your_github_token_here
|
||||
@@ -22,6 +43,10 @@ DEFAULT_BRANCH=main
|
||||
# Claude API Settings
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
|
||||
# Claude Hub Directory
|
||||
# Directory where Claude Hub stores configuration, authentication, and database files (default: ~/.claude-hub)
|
||||
CLAUDE_HUB_DIR=/home/user/.claude-hub
|
||||
|
||||
# Container Settings
|
||||
CLAUDE_USE_CONTAINERS=1
|
||||
CLAUDE_CONTAINER_IMAGE=claudecode:latest
|
||||
@@ -40,12 +65,6 @@ ANTHROPIC_MODEL=us.anthropic.claude-3-7-sonnet-20250219-v1:0
|
||||
# USE_AWS_PROFILE=true
|
||||
# AWS_PROFILE=claude-webhook
|
||||
|
||||
# Discord Chatbot Configuration
|
||||
DISCORD_BOT_TOKEN=your_discord_bot_token
|
||||
DISCORD_PUBLIC_KEY=your_discord_public_key
|
||||
DISCORD_APPLICATION_ID=your_discord_application_id
|
||||
DISCORD_AUTHORIZED_USERS=user1,user2,admin
|
||||
DISCORD_BOT_MENTION=claude
|
||||
|
||||
# Container Capabilities (optional)
|
||||
CLAUDE_CONTAINER_CAP_NET_RAW=true
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -77,11 +77,12 @@ config
|
||||
auth.json
|
||||
service-account.json
|
||||
|
||||
# Claude Hub Directory
|
||||
.claude-hub/
|
||||
|
||||
# Docker secrets
|
||||
secrets/
|
||||
|
||||
# Benchmark results
|
||||
benchmark_results_*.json
|
||||
|
||||
# Temporary and backup files
|
||||
*.backup
|
||||
|
||||
@@ -9,10 +9,6 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${HOME}/.aws:/root/.aws:ro
|
||||
- ${HOME}/.claude:/home/claudeuser/.claude
|
||||
secrets:
|
||||
- github_token
|
||||
- anthropic_api_key
|
||||
- webhook_secret
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3002
|
||||
@@ -29,28 +25,14 @@ services:
|
||||
- PR_REVIEW_DEBOUNCE_MS=${PR_REVIEW_DEBOUNCE_MS:-5000}
|
||||
- PR_REVIEW_MAX_WAIT_MS=${PR_REVIEW_MAX_WAIT_MS:-1800000}
|
||||
- PR_REVIEW_CONDITIONAL_TIMEOUT_MS=${PR_REVIEW_CONDITIONAL_TIMEOUT_MS:-300000}
|
||||
# Point to secret files instead of env vars
|
||||
- GITHUB_TOKEN_FILE=/run/secrets/github_token
|
||||
- ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic_api_key
|
||||
- GITHUB_WEBHOOK_SECRET_FILE=/run/secrets/webhook_secret
|
||||
# Secrets from environment variables
|
||||
- GITHUB_TOKEN=${GITHUB_TOKEN}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3002/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- n8n_default
|
||||
|
||||
secrets:
|
||||
github_token:
|
||||
file: ./secrets/github_token.txt
|
||||
anthropic_api_key:
|
||||
file: ./secrets/anthropic_api_key.txt
|
||||
webhook_secret:
|
||||
file: ./secrets/webhook_secret.txt
|
||||
|
||||
networks:
|
||||
n8n_default:
|
||||
external: true
|
||||
start_period: 10s
|
||||
@@ -1,121 +0,0 @@
|
||||
# Discord Chatbot Provider Setup
|
||||
|
||||
## Overview
|
||||
|
||||
This implementation provides a comprehensive chatbot provider system that integrates Claude with Discord using slash commands. The system requires repository and branch parameters to function properly.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **ChatbotProvider.js**: Abstract base class for all chatbot providers
|
||||
- **DiscordProvider.js**: Discord-specific implementation with Ed25519 signature verification
|
||||
- **ProviderFactory.js**: Dependency injection singleton for managing providers
|
||||
- **chatbotController.js**: Generic webhook handler working with any provider
|
||||
- **chatbot.js**: Express routes with rate limiting
|
||||
|
||||
## Required Environment Variables
|
||||
|
||||
```bash
|
||||
DISCORD_BOT_TOKEN=your_discord_bot_token
|
||||
DISCORD_PUBLIC_KEY=your_discord_public_key
|
||||
DISCORD_APPLICATION_ID=your_discord_application_id
|
||||
DISCORD_AUTHORIZED_USERS=user1,user2,admin
|
||||
DISCORD_BOT_MENTION=claude
|
||||
```
|
||||
|
||||
## Discord Slash Command Configuration
|
||||
|
||||
In the Discord Developer Portal, create a slash command with these parameters:
|
||||
|
||||
- **Command Name**: `claude`
|
||||
- **Description**: `Ask Claude to help with repository tasks`
|
||||
- **Parameters**:
|
||||
- `repo` (required, string): Repository in format "owner/name"
|
||||
- `branch` (optional, string): Git branch name (defaults to "main")
|
||||
- `command` (required, string): Command for Claude to execute
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `POST /api/webhooks/chatbot/discord` - Discord webhook handler (rate limited: 100 req/15min per IP)
|
||||
- `GET /api/webhooks/chatbot/stats` - Provider statistics and status
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```
|
||||
/claude repo:owner/myrepo command:help me fix this bug
|
||||
/claude repo:owner/myrepo branch:feature command:review this code
|
||||
/claude repo:owner/myrepo command:add error handling to this function
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
- Ed25519 webhook signature verification
|
||||
- User authorization checking
|
||||
- Repository parameter validation
|
||||
- Rate limiting (100 requests per 15 minutes per IP)
|
||||
- Container isolation for Claude execution
|
||||
- Input sanitization and validation
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Set up environment variables in `.env`:
|
||||
```bash
|
||||
DISCORD_BOT_TOKEN=your_token
|
||||
DISCORD_PUBLIC_KEY=your_public_key
|
||||
DISCORD_APPLICATION_ID=your_app_id
|
||||
DISCORD_AUTHORIZED_USERS=user1,user2
|
||||
```
|
||||
|
||||
3. Configure Discord slash command in Developer Portal
|
||||
|
||||
4. Start the server:
|
||||
```bash
|
||||
npm start
|
||||
# or for development
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all unit tests
|
||||
npm run test:unit
|
||||
|
||||
# Run specific provider tests
|
||||
npm test -- test/unit/providers/DiscordProvider.test.js
|
||||
|
||||
# Run controller tests
|
||||
npm test -- test/unit/controllers/chatbotController.test.js
|
||||
```
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
1. **Repository Parameter Validation**: Commands require a `repo` parameter in "owner/name" format
|
||||
2. **Branch Support**: Optional `branch` parameter (defaults to "main")
|
||||
3. **Error Handling**: Comprehensive error messages with reference IDs
|
||||
4. **Rate Limiting**: Protection against abuse with express-rate-limit
|
||||
5. **Message Splitting**: Automatic splitting for Discord's 2000 character limit
|
||||
6. **Comprehensive Testing**: 35+ unit tests covering all scenarios
|
||||
|
||||
## Workflow
|
||||
|
||||
1. User executes Discord slash command: `/claude repo:owner/myrepo command:fix this issue`
|
||||
2. Discord sends webhook to `/api/webhooks/chatbot/discord`
|
||||
3. System verifies signature and parses payload
|
||||
4. Repository parameter is validated (required)
|
||||
5. Branch parameter is extracted (defaults to "main")
|
||||
6. User authorization is checked
|
||||
7. Command is processed by Claude with repository context
|
||||
8. Response is sent back to Discord (automatically split if needed)
|
||||
|
||||
## Extension Points
|
||||
|
||||
The architecture supports easy addition of new platforms:
|
||||
- Implement new provider class extending ChatbotProvider
|
||||
- Add environment configuration in ProviderFactory
|
||||
- Register provider and add route handler
|
||||
- System automatically handles authentication, validation, and Claude integration
|
||||
102
docs/SCRIPTS.md
102
docs/SCRIPTS.md
@@ -9,25 +9,20 @@ This document provides an overview of the scripts in this repository, organized
|
||||
| `scripts/setup/setup.sh` | Main setup script for the project | `./scripts/setup/setup.sh` |
|
||||
| `scripts/setup/setup-precommit.sh` | Sets up pre-commit hooks | `./scripts/setup/setup-precommit.sh` |
|
||||
| `scripts/setup/setup-claude-auth.sh` | Sets up Claude authentication | `./scripts/setup/setup-claude-auth.sh` |
|
||||
| `scripts/setup/setup-new-repo.sh` | Sets up a new clean repository | `./scripts/setup/setup-new-repo.sh` |
|
||||
| `scripts/setup/create-new-repo.sh` | Creates a new repository | `./scripts/setup/create-new-repo.sh` |
|
||||
| `scripts/setup/setup-secure-credentials.sh` | Sets up secure credentials | `./scripts/setup/setup-secure-credentials.sh` |
|
||||
|
||||
## Build Scripts
|
||||
|
||||
| Script | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `scripts/build/build-claude-container.sh` | Builds the Claude container | `./scripts/build/build-claude-container.sh` |
|
||||
| `scripts/build/build-claudecode.sh` | Builds the Claude Code runner Docker image | `./scripts/build/build-claudecode.sh` |
|
||||
| `scripts/build/update-production-image.sh` | Updates the production Docker image | `./scripts/build/update-production-image.sh` |
|
||||
| `scripts/build/build.sh` | Builds the Docker images | `./scripts/build/build.sh` |
|
||||
|
||||
## AWS Configuration and Credentials
|
||||
|
||||
| Script | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `scripts/aws/create-aws-profile.sh` | Creates AWS profiles programmatically | `./scripts/aws/create-aws-profile.sh <profile-name> <access-key-id> <secret-access-key> [region] [output-format]` |
|
||||
| `scripts/aws/migrate-aws-credentials.sh` | Migrates AWS credentials to profiles | `./scripts/aws/migrate-aws-credentials.sh` |
|
||||
| `scripts/aws/setup-aws-profiles.sh` | Sets up AWS profiles | `./scripts/aws/setup-aws-profiles.sh` |
|
||||
| `scripts/aws/update-aws-creds.sh` | Updates AWS credentials | `./scripts/aws/update-aws-creds.sh` |
|
||||
|
||||
## Runtime and Execution
|
||||
|
||||
@@ -45,58 +40,48 @@ This document provides an overview of the scripts in this repository, organized
|
||||
|--------|-------------|-------|
|
||||
| `scripts/security/init-firewall.sh` | Initializes firewall for containers | `./scripts/security/init-firewall.sh` |
|
||||
| `scripts/security/accept-permissions.sh` | Handles permission acceptance | `./scripts/security/accept-permissions.sh` |
|
||||
| `scripts/security/fix-credential-references.sh` | Fixes credential references | `./scripts/security/fix-credential-references.sh` |
|
||||
| `scripts/security/credential-audit.sh` | Audits code for credential leaks | `./scripts/security/credential-audit.sh` |
|
||||
|
||||
## Utility Scripts
|
||||
|
||||
| Script | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `scripts/utils/ensure-test-dirs.sh` | Ensures test directories exist | `./scripts/utils/ensure-test-dirs.sh` |
|
||||
| `scripts/utils/prepare-clean-repo.sh` | Prepares a clean repository | `./scripts/utils/prepare-clean-repo.sh` |
|
||||
| `scripts/utils/volume-test.sh` | Tests volume mounting | `./scripts/utils/volume-test.sh` |
|
||||
| `scripts/utils/setup-repository-labels.js` | Sets up GitHub repository labels | `node scripts/utils/setup-repository-labels.js owner/repo` |
|
||||
|
||||
## Testing Scripts
|
||||
## Testing
|
||||
|
||||
### Integration Tests
|
||||
All shell-based test scripts have been migrated to JavaScript E2E tests using Jest. Use the following npm commands:
|
||||
|
||||
| Script | Description | Usage |
|
||||
### JavaScript Test Files
|
||||
|
||||
**Note**: Shell-based test scripts have been migrated to JavaScript E2E tests using Jest. The following test files provide comprehensive testing:
|
||||
|
||||
| Test File | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `test/integration/test-full-flow.sh` | Tests the full workflow | `./test/integration/test-full-flow.sh` |
|
||||
| `test/integration/test-claudecode-docker.sh` | Tests Claude Code Docker setup | `./test/integration/test-claudecode-docker.sh` |
|
||||
| `test/e2e/scenarios/container-execution.test.js` | Tests container functionality | `npm run test:e2e` |
|
||||
| `test/e2e/scenarios/claude-integration.test.js` | Tests Claude integration | `npm run test:e2e` |
|
||||
| `test/e2e/scenarios/docker-execution.test.js` | Tests Docker execution | `npm run test:e2e` |
|
||||
| `test/e2e/scenarios/security-firewall.test.js` | Tests security and firewall | `npm run test:e2e` |
|
||||
|
||||
### AWS Tests
|
||||
### Running Tests
|
||||
|
||||
| Script | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `test/aws/test-aws-profile.sh` | Tests AWS profile configuration | `./test/aws/test-aws-profile.sh` |
|
||||
| `test/aws/test-aws-mount.sh` | Tests AWS mount functionality | `./test/aws/test-aws-mount.sh` |
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
### Container Tests
|
||||
# Run unit tests
|
||||
npm run test:unit
|
||||
|
||||
| Script | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `test/container/test-basic-container.sh` | Tests basic container functionality | `./test/container/test-basic-container.sh` |
|
||||
| `test/container/test-container-cleanup.sh` | Tests container cleanup | `./test/container/test-container-cleanup.sh` |
|
||||
| `test/container/test-container-privileged.sh` | Tests container privileged mode | `./test/container/test-container-privileged.sh` |
|
||||
# Run E2E tests
|
||||
npm run test:e2e
|
||||
|
||||
### Claude Tests
|
||||
# Run tests with coverage
|
||||
npm run test:coverage
|
||||
|
||||
| Script | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `test/claude/test-claude-direct.sh` | Tests direct Claude integration | `./test/claude/test-claude-direct.sh` |
|
||||
| `test/claude/test-claude-no-firewall.sh` | Tests Claude without firewall | `./test/claude/test-claude-no-firewall.sh` |
|
||||
| `test/claude/test-claude-installation.sh` | Tests Claude installation | `./test/claude/test-claude-installation.sh` |
|
||||
| `test/claude/test-claude-version.sh` | Tests Claude version | `./test/claude/test-claude-version.sh` |
|
||||
| `test/claude/test-claude-response.sh` | Tests Claude response | `./test/claude/test-claude-response.sh` |
|
||||
| `test/claude/test-direct-claude.sh` | Tests direct Claude access | `./test/claude/test-direct-claude.sh` |
|
||||
|
||||
### Security Tests
|
||||
|
||||
| Script | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `test/security/test-firewall.sh` | Tests firewall configuration | `./test/security/test-firewall.sh` |
|
||||
| `test/security/test-with-auth.sh` | Tests with authentication | `./test/security/test-with-auth.sh` |
|
||||
| `test/security/test-github-token.sh` | Tests GitHub token | `./test/security/test-github-token.sh` |
|
||||
# Run tests in watch mode
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
## Common Workflows
|
||||
|
||||
@@ -109,6 +94,9 @@ This document provides an overview of the scripts in this repository, organized
|
||||
# Set up Claude authentication
|
||||
./scripts/setup/setup-claude-auth.sh
|
||||
|
||||
# Set up secure credentials
|
||||
./scripts/setup/setup-secure-credentials.sh
|
||||
|
||||
# Create AWS profile
|
||||
./scripts/aws/create-aws-profile.sh claude-webhook YOUR_ACCESS_KEY YOUR_SECRET_KEY
|
||||
```
|
||||
@@ -116,8 +104,8 @@ This document provides an overview of the scripts in this repository, organized
|
||||
### Building and Running
|
||||
|
||||
```bash
|
||||
# Build Claude Code container
|
||||
./scripts/build/build-claudecode.sh
|
||||
# Build Docker images
|
||||
./scripts/build/build.sh
|
||||
|
||||
# Start the API server
|
||||
./scripts/runtime/start-api.sh
|
||||
@@ -129,22 +117,18 @@ docker compose up -d
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run integration tests
|
||||
./test/integration/test-full-flow.sh
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run AWS tests
|
||||
./test/aws/test-aws-profile.sh
|
||||
# Run E2E tests specifically
|
||||
npm run test:e2e
|
||||
|
||||
# Run Claude tests
|
||||
./test/claude/test-claude-direct.sh
|
||||
# Run unit tests specifically
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
## Backward Compatibility
|
||||
## Notes
|
||||
|
||||
For backward compatibility, wrapper scripts are provided in the root directory for the most commonly used scripts:
|
||||
|
||||
- `setup-claude-auth.sh` -> `scripts/setup/setup-claude-auth.sh`
|
||||
- `build-claudecode.sh` -> `scripts/build/build-claudecode.sh`
|
||||
- `start-api.sh` -> `scripts/runtime/start-api.sh`
|
||||
|
||||
These wrappers simply forward all arguments to the actual scripts in their new locations.
|
||||
- All shell-based test scripts have been migrated to JavaScript E2E tests for better maintainability and consistency.
|
||||
- The project uses npm scripts for most common operations. See `package.json` for available scripts.
|
||||
- Docker Compose is the recommended way to run the service in production.
|
||||
@@ -1,220 +0,0 @@
|
||||
# Chatbot Providers Documentation
|
||||
|
||||
This document describes the chatbot provider system that enables Claude to work with Discord using dependency injection and configuration-based selection. The system is designed with an extensible architecture that can support future platforms.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The chatbot provider system uses a flexible architecture with:
|
||||
|
||||
- **Base Provider Interface**: Common contract for all chatbot providers (`ChatbotProvider.js`)
|
||||
- **Provider Implementations**: Platform-specific implementations (currently Discord only)
|
||||
- **Provider Factory**: Dependency injection container for managing providers (`ProviderFactory.js`)
|
||||
- **Generic Controller**: Unified webhook handling logic (`chatbotController.js`)
|
||||
- **Route Integration**: Clean API endpoints for each provider
|
||||
|
||||
## Available Providers
|
||||
|
||||
### Discord Provider
|
||||
**Status**: ✅ Implemented
|
||||
**Endpoint**: `POST /api/webhooks/chatbot/discord`
|
||||
|
||||
Features:
|
||||
- Ed25519 signature verification
|
||||
- Slash command support
|
||||
- Interactive component handling
|
||||
- Message splitting for 2000 character limit
|
||||
- Follow-up message support
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Discord
|
||||
```bash
|
||||
DISCORD_BOT_TOKEN=your_discord_bot_token
|
||||
DISCORD_PUBLIC_KEY=your_discord_public_key
|
||||
DISCORD_APPLICATION_ID=your_discord_application_id
|
||||
DISCORD_AUTHORIZED_USERS=user1,user2,admin
|
||||
DISCORD_BOT_MENTION=claude
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Webhook Endpoints
|
||||
|
||||
- `POST /api/webhooks/chatbot/discord` - Discord webhook handler
|
||||
|
||||
### Management Endpoints
|
||||
|
||||
- `GET /api/webhooks/chatbot/stats` - Provider statistics and status
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Discord Setup
|
||||
|
||||
1. **Create Discord Application**
|
||||
- Go to https://discord.com/developers/applications
|
||||
- Create a new application
|
||||
- Copy Application ID, Bot Token, and Public Key
|
||||
|
||||
2. **Configure Webhook**
|
||||
- Set webhook URL to `https://your-domain.com/api/webhooks/chatbot/discord`
|
||||
- Configure slash commands in Discord Developer Portal
|
||||
|
||||
3. **Environment Setup**
|
||||
```bash
|
||||
DISCORD_BOT_TOKEN=your_bot_token
|
||||
DISCORD_PUBLIC_KEY=your_public_key
|
||||
DISCORD_APPLICATION_ID=your_app_id
|
||||
DISCORD_AUTHORIZED_USERS=user1,user2
|
||||
```
|
||||
|
||||
4. **Configure Discord Slash Command**
|
||||
Create a slash command in Discord Developer Portal with these parameters:
|
||||
- **Command Name**: `claude`
|
||||
- **Description**: `Ask Claude to help with repository tasks`
|
||||
- **Parameters**:
|
||||
- `repo` (required): Repository in format "owner/name"
|
||||
- `branch` (optional): Git branch name (defaults to "main")
|
||||
- `command` (required): Command for Claude to execute
|
||||
|
||||
5. **Test the Bot**
|
||||
- Use slash commands: `/claude repo:owner/myrepo command:help me fix this bug`
|
||||
- Optional branch: `/claude repo:owner/myrepo branch:feature command:review this code`
|
||||
- Bot responds directly in Discord channel
|
||||
|
||||
### Adding a New Provider
|
||||
|
||||
To add a new chatbot provider in the future:
|
||||
|
||||
1. **Create Provider Class**
|
||||
```javascript
|
||||
// src/providers/NewProvider.js
|
||||
const ChatbotProvider = require('./ChatbotProvider');
|
||||
|
||||
class NewProvider extends ChatbotProvider {
|
||||
async initialize() {
|
||||
// Provider-specific initialization
|
||||
}
|
||||
|
||||
verifyWebhookSignature(req) {
|
||||
// Platform-specific signature verification
|
||||
}
|
||||
|
||||
parseWebhookPayload(payload) {
|
||||
// Parse platform-specific payload
|
||||
}
|
||||
|
||||
// Implement all required methods...
|
||||
}
|
||||
|
||||
module.exports = NewProvider;
|
||||
```
|
||||
|
||||
2. **Register Provider**
|
||||
```javascript
|
||||
// src/providers/ProviderFactory.js
|
||||
const NewProvider = require('./NewProvider');
|
||||
|
||||
// In constructor:
|
||||
this.registerProvider('newprovider', NewProvider);
|
||||
```
|
||||
|
||||
3. **Add Route Handler**
|
||||
```javascript
|
||||
// src/controllers/chatbotController.js
|
||||
async function handleNewProviderWebhook(req, res) {
|
||||
return await handleChatbotWebhook(req, res, 'newprovider');
|
||||
}
|
||||
```
|
||||
|
||||
4. **Add Environment Config**
|
||||
```javascript
|
||||
// In ProviderFactory.js getEnvironmentConfig():
|
||||
case 'newprovider':
|
||||
config.apiKey = process.env.NEWPROVIDER_API_KEY;
|
||||
config.secret = process.env.NEWPROVIDER_SECRET;
|
||||
// Add other config...
|
||||
break;
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
### Webhook Verification
|
||||
The Discord provider implements Ed25519 signature verification for secure webhook authentication.
|
||||
|
||||
### User Authorization
|
||||
- Configurable authorized user lists for Discord
|
||||
- Discord-specific user ID validation
|
||||
- Graceful handling of unauthorized access attempts
|
||||
|
||||
### Container Security
|
||||
- Isolated execution environment for Claude commands
|
||||
- Resource limits and capability restrictions
|
||||
- Secure credential management
|
||||
|
||||
## Provider Factory
|
||||
|
||||
The `ProviderFactory` manages provider instances using dependency injection:
|
||||
|
||||
```javascript
|
||||
const providerFactory = require('./providers/ProviderFactory');
|
||||
|
||||
// Create provider from environment
|
||||
const discord = await providerFactory.createFromEnvironment('discord');
|
||||
|
||||
// Get existing provider
|
||||
const provider = providerFactory.getProvider('discord');
|
||||
|
||||
// Get statistics
|
||||
const stats = providerFactory.getStats();
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The system provides comprehensive error handling:
|
||||
|
||||
- **Provider Initialization Errors**: Graceful fallback and logging
|
||||
- **Webhook Verification Failures**: Clear error responses
|
||||
- **Command Processing Errors**: User-friendly error messages with reference IDs
|
||||
- **Network/API Errors**: Automatic retry logic where appropriate
|
||||
|
||||
## Monitoring and Debugging
|
||||
|
||||
### Logging
|
||||
The Discord provider uses structured logging with:
|
||||
- Provider name identification
|
||||
- Request/response tracking
|
||||
- Error correlation IDs
|
||||
- Performance metrics
|
||||
|
||||
### Statistics Endpoint
|
||||
The `/api/webhooks/chatbot/stats` endpoint provides:
|
||||
- Provider registration status
|
||||
- Initialization health
|
||||
- Basic configuration info (non-sensitive)
|
||||
|
||||
### Health Checks
|
||||
The provider can be health-checked to ensure proper operation.
|
||||
|
||||
## Extensible Architecture
|
||||
|
||||
While only Discord is currently implemented, the system is designed to easily support additional platforms:
|
||||
|
||||
- **Modular Design**: Each provider is self-contained with common interfaces
|
||||
- **Dependency Injection**: Clean separation between provider logic and application code
|
||||
- **Configuration-Driven**: Environment-based provider selection and configuration
|
||||
- **Unified Webhook Handling**: Common controller logic with platform-specific implementations
|
||||
- **Standardized Security**: Consistent signature verification and authorization patterns
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
The extensible architecture enables future enhancements such as:
|
||||
|
||||
- **Additional Platforms**: Easy integration of new chat platforms
|
||||
- **Message Threading**: Support for threaded conversations
|
||||
- **Rich Media**: File attachments and embeds
|
||||
- **Interactive Components**: Buttons, dropdowns, forms
|
||||
- **Multi-provider Commands**: Cross-platform functionality
|
||||
- **Provider Plugins**: Dynamic provider loading
|
||||
- **Advanced Authorization**: Role-based access control
|
||||
@@ -15,7 +15,6 @@
|
||||
"test": "jest --testPathPattern='test/(unit|integration).*\\.test\\.(js|ts)$'",
|
||||
"test:unit": "jest --testMatch='**/test/unit/**/*.test.{js,ts}'",
|
||||
"test:integration": "jest --testMatch='**/test/integration/**/*.test.{js,ts}'",
|
||||
"test:chatbot": "jest --testMatch='**/test/unit/providers/**/*.test.{js,ts}' --testMatch='**/test/unit/controllers/chatbotController.test.{js,ts}'",
|
||||
"test:e2e": "jest --testMatch='**/test/e2e/**/*.test.{js,ts}'",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:watch": "jest --watch",
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Docker Hub publishing script for Claude GitHub Webhook
|
||||
# Usage: ./publish-docker.sh YOUR_DOCKERHUB_USERNAME [VERSION]
|
||||
|
||||
DOCKERHUB_USERNAME=${1:-intelligenceassist}
|
||||
VERSION=${2:-latest}
|
||||
|
||||
# Default to intelligenceassist organization
|
||||
|
||||
IMAGE_NAME="claude-github-webhook"
|
||||
FULL_IMAGE_NAME="$DOCKERHUB_USERNAME/$IMAGE_NAME"
|
||||
|
||||
echo "Building Docker image..."
|
||||
docker build -t $IMAGE_NAME:latest .
|
||||
|
||||
echo "Tagging image as $FULL_IMAGE_NAME:$VERSION..."
|
||||
docker tag $IMAGE_NAME:latest $FULL_IMAGE_NAME:$VERSION
|
||||
|
||||
if [ "$VERSION" != "latest" ]; then
|
||||
echo "Also tagging as $FULL_IMAGE_NAME:latest..."
|
||||
docker tag $IMAGE_NAME:latest $FULL_IMAGE_NAME:latest
|
||||
fi
|
||||
|
||||
echo "Logging in to Docker Hub..."
|
||||
docker login
|
||||
|
||||
echo "Pushing to Docker Hub..."
|
||||
docker push $FULL_IMAGE_NAME:$VERSION
|
||||
|
||||
if [ "$VERSION" != "latest" ]; then
|
||||
docker push $FULL_IMAGE_NAME:latest
|
||||
fi
|
||||
|
||||
echo "Successfully published to Docker Hub!"
|
||||
echo "Users can now pull with: docker pull $FULL_IMAGE_NAME:$VERSION"
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Run claudecode container interactively for testing and debugging
|
||||
docker run -it --rm \
|
||||
-v $(pwd):/workspace \
|
||||
-v ~/.aws:/root/.aws:ro \
|
||||
-v ~/.claude:/root/.claude \
|
||||
-w /workspace \
|
||||
--entrypoint /bin/bash \
|
||||
claudecode:latest
|
||||
@@ -1,263 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Script to clean up redundant scripts after reorganization
|
||||
echo "Starting script cleanup..."
|
||||
|
||||
# Create a backup directory for redundant scripts
|
||||
BACKUP_DIR="./scripts/archived"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
echo "Created backup directory: $BACKUP_DIR"
|
||||
|
||||
# Function to archive a script instead of deleting it
|
||||
archive_script() {
|
||||
local script=$1
|
||||
if [ -f "$script" ]; then
|
||||
echo "Archiving $script to $BACKUP_DIR"
|
||||
git mv "$script" "$BACKUP_DIR/$(basename $script)"
|
||||
else
|
||||
echo "Warning: $script not found, skipping"
|
||||
fi
|
||||
}
|
||||
|
||||
# Archive redundant test scripts
|
||||
echo "Archiving redundant test scripts..."
|
||||
archive_script "test/claude/test-direct-claude.sh" # Duplicate of test-claude-direct.sh
|
||||
archive_script "test/claude/test-claude-version.sh" # Can be merged with test-claude-installation.sh
|
||||
|
||||
# Archive obsolete AWS credential scripts
|
||||
echo "Archiving obsolete AWS credential scripts..."
|
||||
archive_script "scripts/aws/update-aws-creds.sh" # Obsolete, replaced by profile-based auth
|
||||
|
||||
# Archive temporary/one-time setup scripts
|
||||
echo "Moving one-time setup scripts to archived directory..."
|
||||
mkdir -p "$BACKUP_DIR/one-time"
|
||||
git mv "scripts/utils/prepare-clean-repo.sh" "$BACKUP_DIR/one-time/"
|
||||
git mv "scripts/utils/fix-credential-references.sh" "$BACKUP_DIR/one-time/"
|
||||
|
||||
# Archive redundant container test scripts that can be consolidated
|
||||
echo "Archiving redundant container test scripts..."
|
||||
archive_script "test/container/test-container-privileged.sh" # Can be merged with test-basic-container.sh
|
||||
|
||||
# Archive our temporary reorganization scripts
|
||||
echo "Archiving temporary reorganization scripts..."
|
||||
git mv "reorganize-scripts.sh" "$BACKUP_DIR/one-time/"
|
||||
git mv "script-organization.md" "$BACKUP_DIR/one-time/"
|
||||
|
||||
# After archiving, create a consolidated container test script
|
||||
echo "Creating consolidated container test script..."
|
||||
cat > test/container/test-container.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Consolidated container test script
|
||||
# Usage: ./test-container.sh [basic|privileged|cleanup]
|
||||
|
||||
set -e
|
||||
|
||||
TEST_TYPE=${1:-basic}
|
||||
|
||||
case "$TEST_TYPE" in
|
||||
basic)
|
||||
echo "Running basic container test..."
|
||||
# Basic container test logic from test-basic-container.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="echo 'Basic container test'" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
privileged)
|
||||
echo "Running privileged container test..."
|
||||
# Privileged container test logic from test-container-privileged.sh
|
||||
docker run --rm -it \
|
||||
--privileged \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="echo 'Privileged container test'" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
cleanup)
|
||||
echo "Running container cleanup test..."
|
||||
# Container cleanup test logic from test-container-cleanup.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="echo 'Container cleanup test'" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown test type: $TEST_TYPE"
|
||||
echo "Usage: ./test-container.sh [basic|privileged|cleanup]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Test complete!"
|
||||
EOF
|
||||
chmod +x test/container/test-container.sh
|
||||
|
||||
# Create a consolidated Claude test script
|
||||
echo "Creating consolidated Claude test script..."
|
||||
cat > test/claude/test-claude.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Consolidated Claude test script
|
||||
# Usage: ./test-claude.sh [direct|installation|no-firewall|response]
|
||||
|
||||
set -e
|
||||
|
||||
TEST_TYPE=${1:-direct}
|
||||
|
||||
case "$TEST_TYPE" in
|
||||
direct)
|
||||
echo "Testing direct Claude integration..."
|
||||
# Direct Claude test logic from test-claude-direct.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="echo 'Direct Claude test'" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
-e ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-test-key}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
installation)
|
||||
echo "Testing Claude installation..."
|
||||
# Installation test logic from test-claude-installation.sh and test-claude-version.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="claude-cli --version && claude --version" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
no-firewall)
|
||||
echo "Testing Claude without firewall..."
|
||||
# Test logic from test-claude-no-firewall.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="echo 'Claude without firewall test'" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
-e DISABLE_FIREWALL=true \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
response)
|
||||
echo "Testing Claude response..."
|
||||
# Test logic from test-claude-response.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="claude \"Tell me a joke\"" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
-e ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-test-key}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown test type: $TEST_TYPE"
|
||||
echo "Usage: ./test-claude.sh [direct|installation|no-firewall|response]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Test complete!"
|
||||
EOF
|
||||
chmod +x test/claude/test-claude.sh
|
||||
|
||||
# Create a consolidated build script
|
||||
echo "Creating consolidated build script..."
|
||||
cat > scripts/build/build.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Consolidated build script
|
||||
# Usage: ./build.sh [claude|claudecode|production]
|
||||
|
||||
set -e
|
||||
|
||||
BUILD_TYPE=${1:-claudecode}
|
||||
|
||||
case "$BUILD_TYPE" in
|
||||
claude)
|
||||
echo "Building Claude container..."
|
||||
docker build -f Dockerfile.claude -t claude-container:latest .
|
||||
;;
|
||||
|
||||
claudecode)
|
||||
echo "Building Claude Code runner Docker image..."
|
||||
docker build -f Dockerfile.claudecode -t claude-code-runner:latest .
|
||||
;;
|
||||
|
||||
production)
|
||||
if [ ! -d "./claude-config" ]; then
|
||||
echo "Error: claude-config directory not found."
|
||||
echo "Please run ./scripts/setup/setup-claude-auth.sh first and copy the config."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building production image with pre-authenticated config..."
|
||||
cp Dockerfile.claudecode Dockerfile.claudecode.backup
|
||||
# Production build logic from update-production-image.sh
|
||||
# ... (truncated for brevity)
|
||||
docker build -f Dockerfile.claudecode -t claude-code-runner:production .
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown build type: $BUILD_TYPE"
|
||||
echo "Usage: ./build.sh [claude|claudecode|production]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Build complete!"
|
||||
EOF
|
||||
chmod +x scripts/build/build.sh
|
||||
|
||||
# Update documentation to reflect the changes
|
||||
echo "Updating documentation..."
|
||||
sed -i 's|test-direct-claude.sh|test-claude.sh direct|g' SCRIPTS.md
|
||||
sed -i 's|test-claude-direct.sh|test-claude.sh direct|g' SCRIPTS.md
|
||||
sed -i 's|test-claude-version.sh|test-claude.sh installation|g' SCRIPTS.md
|
||||
sed -i 's|test-claude-installation.sh|test-claude.sh installation|g' SCRIPTS.md
|
||||
sed -i 's|test-claude-no-firewall.sh|test-claude.sh no-firewall|g' SCRIPTS.md
|
||||
sed -i 's|test-claude-response.sh|test-claude.sh response|g' SCRIPTS.md
|
||||
|
||||
sed -i 's|test-basic-container.sh|test-container.sh basic|g' SCRIPTS.md
|
||||
sed -i 's|test-container-privileged.sh|test-container.sh privileged|g' SCRIPTS.md
|
||||
sed -i 's|test-container-cleanup.sh|test-container.sh cleanup|g' SCRIPTS.md
|
||||
|
||||
sed -i 's|build-claude-container.sh|build.sh claude|g' SCRIPTS.md
|
||||
sed -i 's|build-claudecode.sh|build.sh claudecode|g' SCRIPTS.md
|
||||
sed -i 's|update-production-image.sh|build.sh production|g' SCRIPTS.md
|
||||
|
||||
# Create a final wrapper script for backward compatibility
|
||||
cat > build-claudecode.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Wrapper script for backward compatibility
|
||||
echo "This script is now located at scripts/build/build.sh"
|
||||
exec scripts/build/build.sh claudecode "$@"
|
||||
EOF
|
||||
chmod +x build-claudecode.sh
|
||||
|
||||
# After all operations are complete, clean up this script too
|
||||
echo "Script cleanup complete!"
|
||||
echo
|
||||
echo "Note: This script (cleanup-scripts.sh) has completed its job and can now be removed."
|
||||
echo "After verifying the changes, you can remove it with:"
|
||||
echo "rm cleanup-scripts.sh"
|
||||
echo
|
||||
echo "To commit these changes, run:"
|
||||
echo "git add ."
|
||||
echo "git commit -m \"Clean up redundant scripts and consolidate functionality\""
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/bin/bash
|
||||
# This script prepares a clean repository without sensitive files
|
||||
|
||||
# Set directories
|
||||
CURRENT_REPO="/home/jonflatt/n8n/claude-repo"
|
||||
CLEAN_REPO="/tmp/clean-repo"
|
||||
|
||||
# Create clean repo directory if it doesn't exist
|
||||
mkdir -p "$CLEAN_REPO"
|
||||
|
||||
# Files and patterns to exclude
|
||||
EXCLUDES=(
|
||||
".git"
|
||||
".env"
|
||||
".env.backup"
|
||||
"node_modules"
|
||||
"coverage"
|
||||
"\\"
|
||||
)
|
||||
|
||||
# Build rsync exclude arguments
|
||||
EXCLUDE_ARGS=""
|
||||
for pattern in "${EXCLUDES[@]}"; do
|
||||
EXCLUDE_ARGS="$EXCLUDE_ARGS --exclude='$pattern'"
|
||||
done
|
||||
|
||||
# Sync files to clean repo
|
||||
echo "Copying files to clean repository..."
|
||||
eval "rsync -av $EXCLUDE_ARGS $CURRENT_REPO/ $CLEAN_REPO/"
|
||||
|
||||
# Create a new .gitignore if it doesn't exist
|
||||
if [ ! -f "$CLEAN_REPO/.gitignore" ]; then
|
||||
echo "Creating .gitignore..."
|
||||
cat > "$CLEAN_REPO/.gitignore" << EOF
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.backup
|
||||
|
||||
# Coverage reports
|
||||
coverage/
|
||||
|
||||
# Temp directory
|
||||
tmp/
|
||||
|
||||
# Test results
|
||||
test-results/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
/response.txt
|
||||
"\\"
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Clean repository prepared at $CLEAN_REPO"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Create a new GitHub repository"
|
||||
echo "2. Initialize the clean repository with git:"
|
||||
echo " cd $CLEAN_REPO"
|
||||
echo " git init"
|
||||
echo " git add ."
|
||||
echo " git commit -m \"Initial commit\""
|
||||
echo "3. Set the remote origin and push:"
|
||||
echo " git remote add origin <new-repository-url>"
|
||||
echo " git push -u origin main"
|
||||
echo ""
|
||||
echo "Important: Make sure to review the files once more before committing to ensure no sensitive data is included."
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Script to reorganize the script files according to the proposed structure
|
||||
echo "Starting script reorganization..."
|
||||
|
||||
# Create directory structure
|
||||
echo "Creating directory structure..."
|
||||
mkdir -p scripts/setup
|
||||
mkdir -p scripts/build
|
||||
mkdir -p scripts/aws
|
||||
mkdir -p scripts/runtime
|
||||
mkdir -p scripts/security
|
||||
mkdir -p scripts/utils
|
||||
|
||||
mkdir -p test/integration
|
||||
mkdir -p test/aws
|
||||
mkdir -p test/container
|
||||
mkdir -p test/claude
|
||||
mkdir -p test/security
|
||||
mkdir -p test/utils
|
||||
|
||||
# Move setup scripts
|
||||
echo "Moving setup scripts..."
|
||||
git mv scripts/setup.sh scripts/setup/
|
||||
git mv scripts/setup-precommit.sh scripts/setup/
|
||||
git mv setup-claude-auth.sh scripts/setup/
|
||||
git mv setup-new-repo.sh scripts/setup/
|
||||
git mv create-new-repo.sh scripts/setup/
|
||||
|
||||
# Move build scripts
|
||||
echo "Moving build scripts..."
|
||||
git mv build-claude-container.sh scripts/build/
|
||||
git mv build-claudecode.sh scripts/build/
|
||||
git mv update-production-image.sh scripts/build/
|
||||
|
||||
# Move AWS scripts
|
||||
echo "Moving AWS scripts..."
|
||||
git mv scripts/create-aws-profile.sh scripts/aws/
|
||||
git mv scripts/migrate-aws-credentials.sh scripts/aws/
|
||||
git mv scripts/setup-aws-profiles.sh scripts/aws/
|
||||
git mv update-aws-creds.sh scripts/aws/
|
||||
|
||||
# Move runtime scripts
|
||||
echo "Moving runtime scripts..."
|
||||
git mv start-api.sh scripts/runtime/
|
||||
git mv entrypoint.sh scripts/runtime/
|
||||
git mv claudecode-entrypoint.sh scripts/runtime/
|
||||
git mv startup.sh scripts/runtime/
|
||||
git mv claude-wrapper.sh scripts/runtime/
|
||||
|
||||
# Move security scripts
|
||||
echo "Moving security scripts..."
|
||||
git mv init-firewall.sh scripts/security/
|
||||
git mv accept-permissions.sh scripts/security/
|
||||
git mv fix-credential-references.sh scripts/security/
|
||||
|
||||
# Move utility scripts
|
||||
echo "Moving utility scripts..."
|
||||
git mv scripts/ensure-test-dirs.sh scripts/utils/
|
||||
git mv prepare-clean-repo.sh scripts/utils/
|
||||
git mv volume-test.sh scripts/utils/
|
||||
|
||||
# Move test scripts
|
||||
echo "Moving test scripts..."
|
||||
git mv test/test-full-flow.sh test/integration/
|
||||
git mv test/test-claudecode-docker.sh test/integration/
|
||||
|
||||
git mv test/test-aws-profile.sh test/aws/
|
||||
git mv test/test-aws-mount.sh test/aws/
|
||||
|
||||
git mv test/test-basic-container.sh test/container/
|
||||
git mv test/test-container-cleanup.sh test/container/
|
||||
git mv test/test-container-privileged.sh test/container/
|
||||
|
||||
git mv test/test-claude-direct.sh test/claude/
|
||||
git mv test/test-claude-no-firewall.sh test/claude/
|
||||
git mv test/test-claude-installation.sh test/claude/
|
||||
git mv test/test-claude-version.sh test/claude/
|
||||
git mv test/test-claude-response.sh test/claude/
|
||||
git mv test/test-direct-claude.sh test/claude/
|
||||
|
||||
git mv test/test-firewall.sh test/security/
|
||||
git mv test/test-with-auth.sh test/security/
|
||||
git mv test/test-github-token.sh test/security/
|
||||
|
||||
# Create wrapper scripts for backward compatibility
|
||||
echo "Creating wrapper scripts for backward compatibility..."
|
||||
|
||||
cat > setup-claude-auth.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Wrapper script for backward compatibility
|
||||
echo "This script is now located at scripts/setup/setup-claude-auth.sh"
|
||||
exec scripts/setup/setup-claude-auth.sh "$@"
|
||||
EOF
|
||||
chmod +x setup-claude-auth.sh
|
||||
|
||||
cat > build-claudecode.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Wrapper script for backward compatibility
|
||||
echo "This script is now located at scripts/build/build-claudecode.sh"
|
||||
exec scripts/build/build-claudecode.sh "$@"
|
||||
EOF
|
||||
chmod +x build-claudecode.sh
|
||||
|
||||
cat > start-api.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Wrapper script for backward compatibility
|
||||
echo "This script is now located at scripts/runtime/start-api.sh"
|
||||
exec scripts/runtime/start-api.sh "$@"
|
||||
EOF
|
||||
chmod +x start-api.sh
|
||||
|
||||
# Update docker-compose.yml file if it references specific script paths
|
||||
echo "Checking for docker-compose.yml updates..."
|
||||
if [ -f docker-compose.yml ]; then
|
||||
sed -i 's#./claudecode-entrypoint.sh#./scripts/runtime/claudecode-entrypoint.sh#g' docker-compose.yml
|
||||
sed -i 's#./entrypoint.sh#./scripts/runtime/entrypoint.sh#g' docker-compose.yml
|
||||
fi
|
||||
|
||||
# Update Dockerfile.claudecode if it references specific script paths
|
||||
echo "Checking for Dockerfile.claudecode updates..."
|
||||
if [ -f Dockerfile.claudecode ]; then
|
||||
sed -i 's#COPY init-firewall.sh#COPY scripts/security/init-firewall.sh#g' Dockerfile.claudecode
|
||||
sed -i 's#COPY claudecode-entrypoint.sh#COPY scripts/runtime/claudecode-entrypoint.sh#g' Dockerfile.claudecode
|
||||
fi
|
||||
|
||||
echo "Script reorganization complete!"
|
||||
echo
|
||||
echo "Please review the changes and test that all scripts still work properly."
|
||||
echo "You may need to update additional references in other files or scripts."
|
||||
echo
|
||||
echo "To commit these changes, run:"
|
||||
echo "git add ."
|
||||
echo "git commit -m \"Reorganize scripts into a more structured directory layout\""
|
||||
@@ -1,128 +0,0 @@
|
||||
# Script Organization Proposal
|
||||
|
||||
## Categories of Scripts
|
||||
|
||||
### 1. Setup and Installation
|
||||
- `scripts/setup.sh` - Main setup script for the project
|
||||
- `scripts/setup-precommit.sh` - Sets up pre-commit hooks
|
||||
- `setup-claude-auth.sh` - Sets up Claude authentication
|
||||
- `setup-new-repo.sh` - Sets up a new clean repository
|
||||
- `create-new-repo.sh` - Creates a new repository
|
||||
|
||||
### 2. Build Scripts
|
||||
- `build-claude-container.sh` - Builds the Claude container
|
||||
- `build-claudecode.sh` - Builds the Claude Code runner Docker image
|
||||
- `update-production-image.sh` - Updates the production Docker image
|
||||
|
||||
### 3. AWS Configuration and Credentials
|
||||
- `scripts/create-aws-profile.sh` - Creates AWS profiles programmatically
|
||||
- `scripts/migrate-aws-credentials.sh` - Migrates AWS credentials
|
||||
- `scripts/setup-aws-profiles.sh` - Sets up AWS profiles
|
||||
- `update-aws-creds.sh` - Updates AWS credentials
|
||||
|
||||
### 4. Runtime and Execution
|
||||
- `start-api.sh` - Starts the API server
|
||||
- `entrypoint.sh` - Container entrypoint script
|
||||
- `claudecode-entrypoint.sh` - Claude Code container entrypoint
|
||||
- `startup.sh` - Startup script
|
||||
- `claude-wrapper.sh` - Wrapper for Claude CLI
|
||||
|
||||
### 5. Network and Security
|
||||
- `init-firewall.sh` - Initializes firewall for containers
|
||||
- `accept-permissions.sh` - Handles permission acceptance
|
||||
- `fix-credential-references.sh` - Fixes credential references
|
||||
|
||||
### 6. Testing
|
||||
- `test/test-full-flow.sh` - Tests the full workflow
|
||||
- `test/test-claudecode-docker.sh` - Tests Claude Code Docker setup
|
||||
- `test/test-github-token.sh` - Tests GitHub token
|
||||
- `test/test-aws-profile.sh` - Tests AWS profile
|
||||
- `test/test-basic-container.sh` - Tests basic container functionality
|
||||
- `test/test-claude-direct.sh` - Tests direct Claude integration
|
||||
- `test/test-firewall.sh` - Tests firewall configuration
|
||||
- `test/test-direct-claude.sh` - Tests direct Claude access
|
||||
- `test/test-claude-no-firewall.sh` - Tests Claude without firewall
|
||||
- `test/test-claude-installation.sh` - Tests Claude installation
|
||||
- `test/test-aws-mount.sh` - Tests AWS mount functionality
|
||||
- `test/test-claude-version.sh` - Tests Claude version
|
||||
- `test/test-container-cleanup.sh` - Tests container cleanup
|
||||
- `test/test-claude-response.sh` - Tests Claude response
|
||||
- `test/test-container-privileged.sh` - Tests container privileged mode
|
||||
- `test/test-with-auth.sh` - Tests with authentication
|
||||
|
||||
### 7. Utility Scripts
|
||||
- `scripts/ensure-test-dirs.sh` - Ensures test directories exist
|
||||
- `prepare-clean-repo.sh` - Prepares a clean repository
|
||||
- `volume-test.sh` - Tests volume mounting
|
||||
|
||||
## Proposed Directory Structure
|
||||
|
||||
```
|
||||
/claude-repo
|
||||
├── scripts/
|
||||
│ ├── setup/
|
||||
│ │ ├── setup.sh
|
||||
│ │ ├── setup-precommit.sh
|
||||
│ │ ├── setup-claude-auth.sh
|
||||
│ │ ├── setup-new-repo.sh
|
||||
│ │ └── create-new-repo.sh
|
||||
│ ├── build/
|
||||
│ │ ├── build-claude-container.sh
|
||||
│ │ ├── build-claudecode.sh
|
||||
│ │ └── update-production-image.sh
|
||||
│ ├── aws/
|
||||
│ │ ├── create-aws-profile.sh
|
||||
│ │ ├── migrate-aws-credentials.sh
|
||||
│ │ ├── setup-aws-profiles.sh
|
||||
│ │ └── update-aws-creds.sh
|
||||
│ ├── runtime/
|
||||
│ │ ├── start-api.sh
|
||||
│ │ ├── entrypoint.sh
|
||||
│ │ ├── claudecode-entrypoint.sh
|
||||
│ │ ├── startup.sh
|
||||
│ │ └── claude-wrapper.sh
|
||||
│ ├── security/
|
||||
│ │ ├── init-firewall.sh
|
||||
│ │ ├── accept-permissions.sh
|
||||
│ │ └── fix-credential-references.sh
|
||||
│ └── utils/
|
||||
│ ├── ensure-test-dirs.sh
|
||||
│ ├── prepare-clean-repo.sh
|
||||
│ └── volume-test.sh
|
||||
├── test/
|
||||
│ ├── integration/
|
||||
│ │ ├── test-full-flow.sh
|
||||
│ │ ├── test-claudecode-docker.sh
|
||||
│ │ └── ...
|
||||
│ ├── aws/
|
||||
│ │ ├── test-aws-profile.sh
|
||||
│ │ ├── test-aws-mount.sh
|
||||
│ │ └── ...
|
||||
│ ├── container/
|
||||
│ │ ├── test-basic-container.sh
|
||||
│ │ ├── test-container-cleanup.sh
|
||||
│ │ ├── test-container-privileged.sh
|
||||
│ │ └── ...
|
||||
│ ├── claude/
|
||||
│ │ ├── test-claude-direct.sh
|
||||
│ │ ├── test-claude-no-firewall.sh
|
||||
│ │ ├── test-claude-installation.sh
|
||||
│ │ ├── test-claude-version.sh
|
||||
│ │ ├── test-claude-response.sh
|
||||
│ │ └── ...
|
||||
│ ├── security/
|
||||
│ │ ├── test-firewall.sh
|
||||
│ │ ├── test-with-auth.sh
|
||||
│ │ └── test-github-token.sh
|
||||
│ └── utils/
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Create the new directory structure
|
||||
2. Move scripts to their appropriate categories
|
||||
3. Update references in scripts to point to new locations
|
||||
4. Update documentation to reflect new organization
|
||||
5. Create wrapper scripts if needed to maintain backward compatibility
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
echo "Testing if Claude executable runs..."
|
||||
|
||||
docker run --rm \
|
||||
--entrypoint /bin/bash \
|
||||
claude-code-runner:latest \
|
||||
-c "cd /workspace && /usr/local/share/npm-global/bin/claude --version 2>&1 || echo 'Exit code: $?'"
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/bash
|
||||
echo "Testing Claude directly without entrypoint..."
|
||||
|
||||
docker run --rm \
|
||||
--privileged \
|
||||
-v $HOME/.aws:/home/node/.aws:ro \
|
||||
--entrypoint /bin/bash \
|
||||
claude-code-runner:latest \
|
||||
-c "cd /workspace && export HOME=/home/node && export PATH=/usr/local/share/npm-global/bin:\$PATH && export AWS_PROFILE=claude-webhook && export AWS_REGION=us-east-2 && export AWS_CONFIG_FILE=/home/node/.aws/config && export AWS_SHARED_CREDENTIALS_FILE=/home/node/.aws/credentials && export CLAUDE_CODE_USE_BEDROCK=1 && export ANTHROPIC_MODEL=us.anthropic.claude-3-7-sonnet-20250219-v1:0 && /usr/local/bin/init-firewall.sh && claude --print 'Hello world' 2>&1"
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Update AWS credentials in the environment
|
||||
export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-dummy-access-key}"
|
||||
export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-dummy-secret-key}"
|
||||
|
||||
# Create or update .env file with the new credentials
|
||||
if [ -f .env ]; then
|
||||
# Update existing .env file
|
||||
sed -i "s/^AWS_ACCESS_KEY_ID=.*/AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID/" .env
|
||||
sed -i "s/^AWS_SECRET_ACCESS_KEY=.*/AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY/" .env
|
||||
else
|
||||
# Create new .env file from example
|
||||
cp .env.example .env
|
||||
sed -i "s/^AWS_ACCESS_KEY_ID=.*/AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID/" .env
|
||||
sed -i "s/^AWS_SECRET_ACCESS_KEY=.*/AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY/" .env
|
||||
fi
|
||||
|
||||
echo "AWS credentials updated successfully."
|
||||
echo "AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:0:3}...${AWS_SECRET_ACCESS_KEY:(-3)}"
|
||||
|
||||
# Export the credentials for current session
|
||||
export AWS_ACCESS_KEY_ID
|
||||
export AWS_SECRET_ACCESS_KEY
|
||||
echo "Credentials exported to current shell environment."
|
||||
@@ -1,119 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Migration script to transition from static AWS credentials to best practices
|
||||
|
||||
echo "AWS Credential Migration Script"
|
||||
echo "=============================="
|
||||
echo
|
||||
|
||||
# Function to check if running on EC2
|
||||
check_ec2() {
|
||||
if curl -s -m 1 http://169.254.169.254/latest/meta-data/ > /dev/null 2>&1; then
|
||||
echo "✅ Running on EC2 instance"
|
||||
return 0
|
||||
else
|
||||
echo "❌ Not running on EC2 instance"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check if running in ECS
|
||||
check_ecs() {
|
||||
if [ -n "${AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}" ]; then
|
||||
echo "✅ Running in ECS with task role"
|
||||
return 0
|
||||
else
|
||||
echo "❌ Not running in ECS"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check for static credentials
|
||||
check_static_credentials() {
|
||||
if [ -n "${AWS_ACCESS_KEY_ID}" ] && [ -n "${AWS_SECRET_ACCESS_KEY}" ]; then
|
||||
echo "⚠️ Found static AWS credentials in environment"
|
||||
return 0
|
||||
else
|
||||
echo "✅ No static credentials in environment"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to update .env file
|
||||
update_env_file() {
|
||||
if [ -f .env ]; then
|
||||
echo "Updating .env file..."
|
||||
|
||||
# Comment out static credentials
|
||||
sed -i 's/^AWS_ACCESS_KEY_ID=/#AWS_ACCESS_KEY_ID=/' .env
|
||||
sed -i 's/^AWS_SECRET_ACCESS_KEY=/#AWS_SECRET_ACCESS_KEY=/' .env
|
||||
|
||||
# Add migration notes
|
||||
echo "" >> .env
|
||||
echo "# AWS Credentials migrated to use IAM roles/instance profiles" >> .env
|
||||
echo "# See docs/aws-authentication-best-practices.md for details" >> .env
|
||||
echo "" >> .env
|
||||
|
||||
echo "✅ Updated .env file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main migration process
|
||||
echo "1. Checking current environment..."
|
||||
echo
|
||||
|
||||
if check_ec2; then
|
||||
echo " Recommendation: Use IAM instance profile"
|
||||
echo " The application will automatically use instance metadata"
|
||||
elif check_ecs; then
|
||||
echo " Recommendation: Use ECS task role"
|
||||
echo " The application will automatically use task credentials"
|
||||
else
|
||||
echo " Recommendation: Use temporary credentials with STS AssumeRole"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "2. Checking for static credentials..."
|
||||
echo
|
||||
|
||||
if check_static_credentials; then
|
||||
echo " ⚠️ WARNING: Static credentials should be replaced with temporary credentials"
|
||||
echo
|
||||
read -p " Do you want to disable static credentials? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
update_env_file
|
||||
echo
|
||||
echo " To use temporary credentials, configure:"
|
||||
echo " - AWS_ROLE_ARN: The IAM role to assume"
|
||||
echo " - Or use AWS CLI profiles with assume role"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "3. Testing new credential provider..."
|
||||
echo
|
||||
|
||||
# Test the credential provider
|
||||
node test/test-aws-credential-provider.js
|
||||
|
||||
echo
|
||||
echo "Migration complete!"
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo "1. Review docs/aws-authentication-best-practices.md"
|
||||
echo "2. Update your deployment configuration"
|
||||
echo "3. Test the application with new credential provider"
|
||||
echo "4. Remove update-aws-creds.sh script (no longer needed)"
|
||||
echo
|
||||
|
||||
# Check if update-aws-creds.sh exists and suggest removal
|
||||
if [ -f update-aws-creds.sh ]; then
|
||||
echo "⚠️ Found update-aws-creds.sh - this script is no longer needed"
|
||||
read -p "Do you want to remove it? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
rm update-aws-creds.sh
|
||||
echo "✅ Removed update-aws-creds.sh"
|
||||
fi
|
||||
fi
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build the Claude Code container
|
||||
echo "Building Claude Code container..."
|
||||
docker build -t claudecode:latest -f Dockerfile.claude .
|
||||
|
||||
echo "Container built successfully. You can run it with:"
|
||||
echo "docker run --rm claudecode:latest \"claude --help\""
|
||||
|
||||
# Enable container mode in the .env file if it's not already set
|
||||
if ! grep -q "CLAUDE_USE_CONTAINERS=1" .env 2>/dev/null; then
|
||||
echo ""
|
||||
echo "Enabling container mode in .env file..."
|
||||
echo "CLAUDE_USE_CONTAINERS=1" >> .env
|
||||
echo "CLAUDE_CONTAINER_IMAGE=claudecode:latest" >> .env
|
||||
echo "Container mode enabled in .env file"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done! You can now use the Claude API with container mode."
|
||||
echo "To test it, run:"
|
||||
echo "node test-claude-api.js owner/repo container \"Your command here\""
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Build the Claude Code runner Docker image
|
||||
|
||||
echo "Building Claude Code runner Docker image..."
|
||||
docker build -f Dockerfile.claudecode -t claudecode:latest .
|
||||
|
||||
# Also tag it with the old name for backward compatibility
|
||||
docker tag claudecode:latest claude-code-runner:latest
|
||||
|
||||
echo "Build complete!"
|
||||
echo "Image tagged as:"
|
||||
echo " - claudecode:latest (primary)"
|
||||
echo " - claude-code-runner:latest (backward compatibility)"
|
||||
@@ -1,106 +0,0 @@
|
||||
#!/bin/bash
|
||||
if [ ! -d "./claude-config" ]; then
|
||||
echo "Error: claude-config directory not found."
|
||||
echo "Please run ./setup-claude-auth.sh first and copy the config."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updating Dockerfile.claudecode to include pre-authenticated config..."
|
||||
|
||||
# Create a backup of the original Dockerfile
|
||||
cp Dockerfile.claudecode Dockerfile.claudecode.backup
|
||||
|
||||
# Update the Dockerfile to copy the claude config
|
||||
cat > Dockerfile.claudecode.tmp << 'EOF'
|
||||
FROM node:20
|
||||
|
||||
# Install dependencies
|
||||
RUN apt update && apt install -y less \
|
||||
git \
|
||||
procps \
|
||||
sudo \
|
||||
fzf \
|
||||
zsh \
|
||||
man-db \
|
||||
unzip \
|
||||
gnupg2 \
|
||||
gh \
|
||||
iptables \
|
||||
ipset \
|
||||
iproute2 \
|
||||
dnsutils \
|
||||
aggregate \
|
||||
jq
|
||||
|
||||
# Set up npm global directory
|
||||
RUN mkdir -p /usr/local/share/npm-global && \
|
||||
chown -R node:node /usr/local/share
|
||||
|
||||
# Configure zsh and command history
|
||||
ENV USERNAME=node
|
||||
RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
|
||||
&& mkdir /commandhistory \
|
||||
&& touch /commandhistory/.bash_history \
|
||||
&& chown -R $USERNAME /commandhistory
|
||||
|
||||
# Create workspace and config directories
|
||||
RUN mkdir -p /workspace /home/node/.claude && \
|
||||
chown -R node:node /workspace /home/node/.claude
|
||||
|
||||
# Switch to node user temporarily for npm install
|
||||
USER node
|
||||
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
|
||||
ENV PATH=$PATH:/usr/local/share/npm-global/bin
|
||||
|
||||
# Install Claude Code
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Switch back to root
|
||||
USER root
|
||||
|
||||
# Copy the pre-authenticated Claude config
|
||||
COPY claude-config /root/.claude
|
||||
|
||||
# Copy the rest of the setup
|
||||
WORKDIR /workspace
|
||||
|
||||
# Install delta and zsh
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
wget "https://github.com/dandavison/delta/releases/download/0.18.2/git-delta_0.18.2_${ARCH}.deb" && \
|
||||
sudo dpkg -i "git-delta_0.18.2_${ARCH}.deb" && \
|
||||
rm "git-delta_0.18.2_${ARCH}.deb"
|
||||
|
||||
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v1.2.0/zsh-in-docker.sh)" -- \
|
||||
-p git \
|
||||
-p fzf \
|
||||
-a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
|
||||
-a "source /usr/share/doc/fzf/examples/completion.zsh" \
|
||||
-a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
|
||||
-x
|
||||
|
||||
# Copy firewall and entrypoint scripts
|
||||
COPY init-firewall.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/init-firewall.sh && \
|
||||
echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall && \
|
||||
chmod 0440 /etc/sudoers.d/node-firewall
|
||||
|
||||
COPY claudecode-entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Set the default shell to bash
|
||||
ENV SHELL /bin/zsh
|
||||
ENV DEVCONTAINER=true
|
||||
|
||||
# Run as root to allow permission management
|
||||
USER root
|
||||
|
||||
# Use the custom entrypoint
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
EOF
|
||||
|
||||
mv Dockerfile.claudecode.tmp Dockerfile.claudecode
|
||||
|
||||
echo "Building new production image..."
|
||||
docker build -f Dockerfile.claudecode -t claude-code-runner:latest .
|
||||
|
||||
echo "Production image updated successfully!"
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Script to fix potential credential references in the clean repository
|
||||
|
||||
CLEAN_REPO="/tmp/clean-repo"
|
||||
cd "$CLEAN_REPO" || exit 1
|
||||
|
||||
echo "Fixing potential credential references..."
|
||||
|
||||
# 1. Fix test files with example tokens
|
||||
echo "Updating test-credential-leak.js..."
|
||||
sed -i 's/ghp_verySecretGitHubToken123456789/github_token_example_1234567890/g' test-credential-leak.js
|
||||
|
||||
echo "Updating test-logger-redaction.js..."
|
||||
sed -i 's/ghp_verySecretGitHubToken123456789/github_token_example_1234567890/g' test/test-logger-redaction.js
|
||||
sed -i 's/ghp_nestedSecretToken/github_token_example_nested/g' test/test-logger-redaction.js
|
||||
sed -i 's/ghp_inCommand/github_token_example_command/g' test/test-logger-redaction.js
|
||||
sed -i 's/ghp_errorToken/github_token_example_error/g' test/test-logger-redaction.js
|
||||
sed -i 's/AKIAIOSFODNN7NESTED/EXAMPLE_NESTED_KEY_ID/g' test/test-logger-redaction.js
|
||||
|
||||
echo "Updating test-secrets.js..."
|
||||
sed -i 's/ghp_1234567890abcdefghijklmnopqrstuvwxy/github_token_example_1234567890/g' test/test-secrets.js
|
||||
|
||||
# 2. Fix references in documentation
|
||||
echo "Updating docs/container-setup.md..."
|
||||
sed -i 's/GITHUB_TOKEN=ghp_yourgithubtoken/GITHUB_TOKEN=your_github_token/g' docs/container-setup.md
|
||||
|
||||
echo "Updating docs/complete-workflow.md..."
|
||||
sed -i 's/`ghp_xxxxx`/`your_github_token`/g' docs/complete-workflow.md
|
||||
sed -i 's/`AKIA...`/`your_access_key_id`/g' docs/complete-workflow.md
|
||||
|
||||
# 3. Update AWS profile references in scripts
|
||||
echo "Updating aws profile scripts..."
|
||||
sed -i 's/aws_secret_access_key/aws_secret_key/g' scripts/create-aws-profile.sh
|
||||
sed -i 's/aws_secret_access_key/aws_secret_key/g' scripts/setup-aws-profiles.sh
|
||||
|
||||
# 4. Make awsCredentialProvider test use clearly labeled example values
|
||||
echo "Updating unit test files..."
|
||||
sed -i 's/aws_secret_access_key = default-secret-key/aws_secret_key = example-default-secret-key/g' test/unit/utils/awsCredentialProvider.test.js
|
||||
sed -i 's/aws_secret_access_key = test-secret-key/aws_secret_key = example-test-secret-key/g' test/unit/utils/awsCredentialProvider.test.js
|
||||
|
||||
echo "Updates completed. Running check again..."
|
||||
|
||||
# Check if any sensitive patterns remain (excluding clearly labeled examples)
|
||||
SENSITIVE_FILES=$(grep -r "ghp_\|AKIA\|aws_secret_access_key" --include="*.js" --include="*.sh" --include="*.json" --include="*.md" . | grep -v "EXAMPLE\|example\|REDACTED\|dummy\|\${\|ENV\|process.env\|context.env\|mock\|pattern" || echo "No sensitive data found")
|
||||
|
||||
if [ -n "$SENSITIVE_FILES" ] && [ "$SENSITIVE_FILES" != "No sensitive data found" ]; then
|
||||
echo "⚠️ Some potential sensitive patterns remain:"
|
||||
echo "$SENSITIVE_FILES"
|
||||
echo "Please review manually."
|
||||
else
|
||||
echo "✅ No sensitive patterns found. The repository is ready!"
|
||||
fi
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Script to prepare, clean, and set up a new repository
|
||||
|
||||
CURRENT_REPO="/home/jonflatt/n8n/claude-repo"
|
||||
CLEAN_REPO="/tmp/clean-repo"
|
||||
|
||||
echo "=== STEP 1: Preparing clean repository ==="
|
||||
# Run the prepare script
|
||||
bash "$CURRENT_REPO/prepare-clean-repo.sh"
|
||||
|
||||
echo ""
|
||||
echo "=== STEP 2: Fixing credential references ==="
|
||||
# Fix credential references
|
||||
bash "$CURRENT_REPO/fix-credential-references.sh"
|
||||
|
||||
echo ""
|
||||
echo "=== STEP 3: Setting up git repository ==="
|
||||
# Change to the clean repository
|
||||
cd "$CLEAN_REPO" || exit 1
|
||||
|
||||
# Initialize git repository
|
||||
git init
|
||||
|
||||
# Add all files
|
||||
git add .
|
||||
|
||||
# Check if there are any files to commit
|
||||
if ! git diff --cached --quiet; then
|
||||
# Create initial commit
|
||||
git commit -m "Initial commit - Clean repository"
|
||||
|
||||
echo ""
|
||||
echo "=== Repository ready! ==="
|
||||
echo "The clean repository has been created at: $CLEAN_REPO"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Create a new GitHub repository at https://github.com/new"
|
||||
echo "2. Connect this repository to GitHub:"
|
||||
echo " cd $CLEAN_REPO"
|
||||
echo " git remote add origin <your-new-repository-url>"
|
||||
echo " git branch -M main"
|
||||
echo " git push -u origin main"
|
||||
else
|
||||
echo "No files to commit. Something went wrong with the file preparation."
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Setup cron job for Claude CLI database backups
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKUP_SCRIPT="${SCRIPT_DIR}/../utils/backup-claude-db.sh"
|
||||
|
||||
# First ensure backup directories exist with proper permissions
|
||||
echo "Ensuring backup directories exist..."
|
||||
if [ ! -d "/backup/claude-cli" ]; then
|
||||
echo "Creating backup directories (requires sudo)..."
|
||||
sudo mkdir -p /backup/claude-cli/daily /backup/claude-cli/weekly
|
||||
sudo chown -R $USER:$USER /backup/claude-cli
|
||||
fi
|
||||
|
||||
# Ensure backup script exists and is executable
|
||||
if [ ! -f "${BACKUP_SCRIPT}" ]; then
|
||||
echo "Error: Backup script not found at ${BACKUP_SCRIPT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Make sure backup script is executable
|
||||
chmod +x "${BACKUP_SCRIPT}"
|
||||
|
||||
# Add cron job (daily at 2 AM)
|
||||
CRON_JOB="0 2 * * * ${BACKUP_SCRIPT} >> /var/log/claude-backup.log 2>&1"
|
||||
|
||||
# Check if cron job already exists
|
||||
if crontab -l 2>/dev/null | grep -q "backup-claude-db.sh"; then
|
||||
echo "Claude backup cron job already exists"
|
||||
else
|
||||
# Add the cron job
|
||||
(crontab -l 2>/dev/null; echo "${CRON_JOB}") | crontab -
|
||||
echo "Claude backup cron job added: ${CRON_JOB}"
|
||||
fi
|
||||
|
||||
# Create log file with proper permissions
|
||||
sudo touch /var/log/claude-backup.log
|
||||
sudo chown $USER:$USER /var/log/claude-backup.log
|
||||
|
||||
echo "Setup complete. Backups will run daily at 2 AM."
|
||||
echo "Logs will be written to /var/log/claude-backup.log"
|
||||
@@ -1,91 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup GitHub Actions self-hosted runner for claude-github-webhook
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
RUNNER_DIR="/home/jonflatt/github-actions-runner"
|
||||
RUNNER_VERSION="2.324.0"
|
||||
REPO_URL="https://github.com/intelligence-assist/claude-github-webhook"
|
||||
RUNNER_NAME="claude-webhook-runner"
|
||||
RUNNER_LABELS="self-hosted,linux,x64,claude-webhook"
|
||||
|
||||
echo "🚀 Setting up GitHub Actions self-hosted runner..."
|
||||
|
||||
# Create runner directory
|
||||
mkdir -p "$RUNNER_DIR"
|
||||
cd "$RUNNER_DIR"
|
||||
|
||||
# Download runner if not exists
|
||||
if [ ! -f "actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz" ]; then
|
||||
echo "📦 Downloading runner v${RUNNER_VERSION}..."
|
||||
curl -o "actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz" -L \
|
||||
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"
|
||||
fi
|
||||
|
||||
# Extract runner
|
||||
echo "📂 Extracting runner..."
|
||||
tar xzf "./actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"
|
||||
|
||||
# Install dependencies if needed
|
||||
echo "🔧 Installing dependencies..."
|
||||
sudo ./bin/installdependencies.sh || true
|
||||
|
||||
echo ""
|
||||
echo "⚠️ IMPORTANT: You need to get a runner registration token from GitHub!"
|
||||
echo ""
|
||||
echo "1. Go to: https://github.com/intelligence-assist/claude-github-webhook/settings/actions/runners/new"
|
||||
echo "2. Copy the registration token"
|
||||
echo "3. Run the configuration command below with your token:"
|
||||
echo ""
|
||||
echo "cd $RUNNER_DIR"
|
||||
echo "./config.sh --url $REPO_URL --token YOUR_TOKEN_HERE --name $RUNNER_NAME --labels $RUNNER_LABELS --unattended --replace"
|
||||
echo ""
|
||||
echo "4. After configuration, install as a service:"
|
||||
echo "sudo ./svc.sh install"
|
||||
echo "sudo ./svc.sh start"
|
||||
echo ""
|
||||
echo "5. Check status:"
|
||||
echo "sudo ./svc.sh status"
|
||||
echo ""
|
||||
|
||||
# Create systemd service file for the runner
|
||||
cat > "$RUNNER_DIR/actions.runner.service" << 'EOF'
|
||||
[Unit]
|
||||
Description=GitHub Actions Runner (claude-webhook-runner)
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=jonflatt
|
||||
WorkingDirectory=/home/jonflatt/github-actions-runner
|
||||
ExecStart=/home/jonflatt/github-actions-runner/run.sh
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
KillMode=process
|
||||
KillSignal=SIGTERM
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=github-runner
|
||||
|
||||
# Security settings
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/home/jonflatt/github-actions-runner
|
||||
ReadWritePaths=/home/jonflatt/n8n/claude-repo
|
||||
ReadWritePaths=/var/run/docker.sock
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo "📄 Systemd service file created at: $RUNNER_DIR/actions.runner.service"
|
||||
echo ""
|
||||
echo "Alternative: Use systemd directly instead of ./svc.sh:"
|
||||
echo "sudo cp $RUNNER_DIR/actions.runner.service /etc/systemd/system/github-runner-claude.service"
|
||||
echo "sudo systemctl daemon-reload"
|
||||
echo "sudo systemctl enable github-runner-claude"
|
||||
echo "sudo systemctl start github-runner-claude"
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Script to set up the new clean repository
|
||||
|
||||
CLEAN_REPO="/tmp/clean-repo"
|
||||
|
||||
# Change to the clean repository
|
||||
cd "$CLEAN_REPO" || exit 1
|
||||
echo "Changed to directory: $(pwd)"
|
||||
|
||||
# Initialize git repository
|
||||
echo "Initializing git repository..."
|
||||
git init
|
||||
|
||||
# Configure git if needed (optional)
|
||||
# git config user.name "Your Name"
|
||||
# git config user.email "your.email@example.com"
|
||||
|
||||
# Add all files
|
||||
echo "Adding files to git..."
|
||||
git add .
|
||||
|
||||
# First checking for any remaining sensitive data
|
||||
echo "Checking for potential sensitive data..."
|
||||
SENSITIVE_FILES=$(grep -r "ghp_\|AKIA\|aws_secret\|github_token" --include="*.js" --include="*.sh" --include="*.json" --include="*.md" . | grep -v "EXAMPLE\|REDACTED\|dummy\|\${\|ENV\|process.env\|context.env\|mock" || echo "No sensitive data found")
|
||||
|
||||
if [ -n "$SENSITIVE_FILES" ]; then
|
||||
echo "⚠️ Potential sensitive data found:"
|
||||
echo "$SENSITIVE_FILES"
|
||||
echo ""
|
||||
echo "Please review the above files and remove any real credentials before continuing."
|
||||
echo "After fixing, run this script again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Commit the code
|
||||
echo "Creating initial commit..."
|
||||
git commit -m "Initial commit - Clean repository" || exit 1
|
||||
|
||||
echo ""
|
||||
echo "✅ Repository setup complete!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Create a new GitHub repository at https://github.com/new"
|
||||
echo "2. Connect and push this repository with:"
|
||||
echo " git remote add origin <your-new-repository-url>"
|
||||
echo " git branch -M main"
|
||||
echo " git push -u origin main"
|
||||
echo ""
|
||||
echo "Important: The repository is ready at $CLEAN_REPO"
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Backup Claude CLI database to prevent corruption
|
||||
|
||||
# Use SUDO_USER if running with sudo, otherwise use current user
|
||||
ACTUAL_USER="${SUDO_USER:-$USER}"
|
||||
ACTUAL_HOME=$(eval echo ~$ACTUAL_USER)
|
||||
|
||||
CLAUDE_DIR="${ACTUAL_HOME}/.claude"
|
||||
DB_FILE="${CLAUDE_DIR}/__store.db"
|
||||
BACKUP_ROOT="/backup/claude-cli"
|
||||
BACKUP_DIR="${BACKUP_ROOT}/daily"
|
||||
WEEKLY_DIR="${BACKUP_ROOT}/weekly"
|
||||
|
||||
# Create backup directories if they don't exist (may need sudo)
|
||||
if [ ! -d "${BACKUP_ROOT}" ]; then
|
||||
if [ -w "/backup" ]; then
|
||||
mkdir -p "${BACKUP_DIR}" "${WEEKLY_DIR}"
|
||||
else
|
||||
echo "Error: Cannot create backup directories in /backup"
|
||||
echo "Please run: sudo mkdir -p ${BACKUP_DIR} ${WEEKLY_DIR}"
|
||||
echo "Then run: sudo chown -R $USER:$USER ${BACKUP_ROOT}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
mkdir -p "${BACKUP_DIR}" "${WEEKLY_DIR}"
|
||||
fi
|
||||
|
||||
# Generate timestamp for backup
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
DAY_OF_WEEK=$(date +%u) # 1=Monday, 6=Saturday
|
||||
DATE_ONLY=$(date +%Y%m%d)
|
||||
|
||||
# Create backup if database exists
|
||||
if [ -f "${DB_FILE}" ]; then
|
||||
echo "Backing up Claude database..."
|
||||
|
||||
# Daily backup
|
||||
DAILY_BACKUP="${BACKUP_DIR}/store_${TIMESTAMP}.db"
|
||||
cp "${DB_FILE}" "${DAILY_BACKUP}"
|
||||
echo "Daily backup created: ${DAILY_BACKUP}"
|
||||
|
||||
# Weekly backup on Saturdays
|
||||
if [ "${DAY_OF_WEEK}" -eq "6" ]; then
|
||||
WEEKLY_BACKUP="${WEEKLY_DIR}/store_saturday_${DATE_ONLY}.db"
|
||||
cp "${DB_FILE}" "${WEEKLY_BACKUP}"
|
||||
echo "Weekly Saturday backup created: ${WEEKLY_BACKUP}"
|
||||
fi
|
||||
|
||||
# Clean up old daily backups (keep last 7 days)
|
||||
find "${BACKUP_DIR}" -name "store_*.db" -type f -mtime +7 -delete
|
||||
|
||||
# Clean up old weekly backups (keep last 52 weeks)
|
||||
find "${WEEKLY_DIR}" -name "store_saturday_*.db" -type f -mtime +364 -delete
|
||||
|
||||
else
|
||||
echo "No Claude database found at ${DB_FILE}"
|
||||
fi
|
||||
@@ -1,91 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Benchmark script for measuring spin-up times
|
||||
set -e
|
||||
|
||||
BENCHMARK_RUNS=${1:-3}
|
||||
COMPOSE_FILE=${2:-docker-compose.yml}
|
||||
|
||||
echo "Benchmarking startup time with $COMPOSE_FILE (${BENCHMARK_RUNS} runs)"
|
||||
echo "=============================================="
|
||||
|
||||
TOTAL_TIME=0
|
||||
RESULTS=()
|
||||
|
||||
for i in $(seq 1 $BENCHMARK_RUNS); do
|
||||
echo "Run $i/$BENCHMARK_RUNS:"
|
||||
|
||||
# Ensure clean state
|
||||
docker compose -f $COMPOSE_FILE down >/dev/null 2>&1 || true
|
||||
docker system prune -f >/dev/null 2>&1 || true
|
||||
|
||||
# Start timing
|
||||
START_TIME=$(date +%s%3N)
|
||||
|
||||
# Start service
|
||||
docker compose -f $COMPOSE_FILE up -d >/dev/null 2>&1
|
||||
|
||||
# Wait for health check to pass
|
||||
echo -n " Waiting for service to be ready."
|
||||
while true; do
|
||||
if curl -s -f http://localhost:8082/health >/dev/null 2>&1; then
|
||||
READY_TIME=$(date +%s%3N)
|
||||
break
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
ELAPSED=$((READY_TIME - START_TIME))
|
||||
TOTAL_TIME=$((TOTAL_TIME + ELAPSED))
|
||||
RESULTS+=($ELAPSED)
|
||||
|
||||
echo " Ready! (${ELAPSED}ms)"
|
||||
|
||||
# Get detailed startup metrics
|
||||
METRICS=$(curl -s http://localhost:8082/health | jq -r '.startup.totalElapsed // "N/A"')
|
||||
echo " App startup time: ${METRICS}ms"
|
||||
|
||||
# Clean up
|
||||
docker compose -f $COMPOSE_FILE down >/dev/null 2>&1
|
||||
|
||||
# Brief pause between runs
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Results Summary:"
|
||||
echo "=============================================="
|
||||
|
||||
AVERAGE=$((TOTAL_TIME / BENCHMARK_RUNS))
|
||||
echo "Average startup time: ${AVERAGE}ms"
|
||||
|
||||
# Calculate min/max
|
||||
MIN=${RESULTS[0]}
|
||||
MAX=${RESULTS[0]}
|
||||
for time in "${RESULTS[@]}"; do
|
||||
[ $time -lt $MIN ] && MIN=$time
|
||||
[ $time -gt $MAX ] && MAX=$time
|
||||
done
|
||||
|
||||
echo "Fastest: ${MIN}ms"
|
||||
echo "Slowest: ${MAX}ms"
|
||||
echo "Individual results: ${RESULTS[*]}"
|
||||
|
||||
# Save results to file
|
||||
TIMESTAMP=$(date '+%Y%m%d_%H%M%S')
|
||||
RESULTS_FILE="benchmark_results_${TIMESTAMP}.json"
|
||||
|
||||
cat > $RESULTS_FILE << EOF
|
||||
{
|
||||
"timestamp": "$(date -Iseconds)",
|
||||
"compose_file": "$COMPOSE_FILE",
|
||||
"runs": $BENCHMARK_RUNS,
|
||||
"results_ms": [$(IFS=,; echo "${RESULTS[*]}")],
|
||||
"average_ms": $AVERAGE,
|
||||
"min_ms": $MIN,
|
||||
"max_ms": $MAX
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Results saved to: $RESULTS_FILE"
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test container with a volume mount for output
|
||||
OUTPUT_DIR="/tmp/claude-output"
|
||||
OUTPUT_FILE="$OUTPUT_DIR/output.txt"
|
||||
|
||||
echo "Docker Container Volume Test"
|
||||
echo "=========================="
|
||||
|
||||
# Ensure output directory exists and is empty
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
rm -f "$OUTPUT_FILE"
|
||||
|
||||
# Run container with volume mount for output
|
||||
docker run --rm \
|
||||
-v "$OUTPUT_DIR:/output" \
|
||||
claudecode:latest \
|
||||
bash -c "echo 'Hello from container' > /output/output.txt && echo 'Command executed successfully.'"
|
||||
|
||||
# Check if output file was created
|
||||
echo
|
||||
echo "Checking for output file: $OUTPUT_FILE"
|
||||
if [ -f "$OUTPUT_FILE" ]; then
|
||||
echo "Output file created. Contents:"
|
||||
cat "$OUTPUT_FILE"
|
||||
else
|
||||
echo "No output file was created."
|
||||
fi
|
||||
@@ -1,387 +0,0 @@
|
||||
const claudeService = require('../services/claudeService');
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const { sanitizeBotMentions } = require('../utils/sanitize');
|
||||
const providerFactory = require('../providers/ProviderFactory');
|
||||
|
||||
const logger = createLogger('chatbotController');
|
||||
|
||||
/**
|
||||
* Generic chatbot webhook handler that works with any provider
|
||||
* Uses dependency injection to handle different chatbot platforms
|
||||
*/
|
||||
async function handleChatbotWebhook(req, res, providerName) {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
logger.info(
|
||||
{
|
||||
provider: providerName,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
headers: {
|
||||
'user-agent': req.headers['user-agent'],
|
||||
'content-type': req.headers['content-type']
|
||||
}
|
||||
},
|
||||
`Received ${providerName} webhook`
|
||||
);
|
||||
|
||||
// Get or create provider
|
||||
let provider;
|
||||
try {
|
||||
provider = providerFactory.getProvider(providerName);
|
||||
if (!provider) {
|
||||
provider = await providerFactory.createFromEnvironment(providerName);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
provider: providerName
|
||||
},
|
||||
'Failed to initialize chatbot provider'
|
||||
);
|
||||
return res.status(500).json({
|
||||
error: 'Provider initialization failed',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Verify webhook signature
|
||||
try {
|
||||
const isValidSignature = provider.verifyWebhookSignature(req);
|
||||
if (!isValidSignature) {
|
||||
logger.warn(
|
||||
{
|
||||
provider: providerName,
|
||||
headers: Object.keys(req.headers)
|
||||
},
|
||||
'Invalid webhook signature'
|
||||
);
|
||||
return res.status(401).json({
|
||||
error: 'Invalid webhook signature'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
err: error,
|
||||
provider: providerName
|
||||
},
|
||||
'Webhook signature verification failed'
|
||||
);
|
||||
return res.status(401).json({
|
||||
error: 'Signature verification failed',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Parse webhook payload
|
||||
let messageContext;
|
||||
try {
|
||||
messageContext = provider.parseWebhookPayload(req.body);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
provider: providerName,
|
||||
messageType: messageContext.type,
|
||||
userId: messageContext.userId,
|
||||
channelId: messageContext.channelId
|
||||
},
|
||||
'Parsed webhook payload'
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
provider: providerName,
|
||||
bodyKeys: req.body ? Object.keys(req.body) : []
|
||||
},
|
||||
'Failed to parse webhook payload'
|
||||
);
|
||||
return res.status(400).json({
|
||||
error: 'Invalid payload format',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Handle special responses (like Discord PING)
|
||||
if (messageContext.shouldRespond && messageContext.responseData) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
logger.info(
|
||||
{
|
||||
provider: providerName,
|
||||
responseType: messageContext.type,
|
||||
responseTime: `${responseTime}ms`
|
||||
},
|
||||
'Sending immediate response'
|
||||
);
|
||||
return res.json(messageContext.responseData);
|
||||
}
|
||||
|
||||
// Skip processing if no command detected
|
||||
if (messageContext.type === 'unknown' || !messageContext.content) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
logger.info(
|
||||
{
|
||||
provider: providerName,
|
||||
messageType: messageContext.type,
|
||||
responseTime: `${responseTime}ms`
|
||||
},
|
||||
'No command detected, skipping processing'
|
||||
);
|
||||
return res.status(200).json({
|
||||
message: 'Webhook received but no command detected'
|
||||
});
|
||||
}
|
||||
|
||||
// Extract bot command
|
||||
const commandInfo = provider.extractBotCommand(messageContext.content);
|
||||
if (!commandInfo) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
logger.info(
|
||||
{
|
||||
provider: providerName,
|
||||
content: messageContext.content,
|
||||
responseTime: `${responseTime}ms`
|
||||
},
|
||||
'No bot mention found in message'
|
||||
);
|
||||
return res.status(200).json({
|
||||
message: 'Webhook received but no bot mention found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check user authorization
|
||||
const userId = provider.getUserId(messageContext);
|
||||
if (!provider.isUserAuthorized(userId)) {
|
||||
logger.info(
|
||||
{
|
||||
provider: providerName,
|
||||
userId: userId,
|
||||
username: messageContext.username
|
||||
},
|
||||
'Unauthorized user attempted to use bot'
|
||||
);
|
||||
|
||||
try {
|
||||
const errorMessage = sanitizeBotMentions(
|
||||
'❌ Sorry, only authorized users can trigger Claude commands.'
|
||||
);
|
||||
await provider.sendResponse(messageContext, errorMessage);
|
||||
} catch (responseError) {
|
||||
logger.error(
|
||||
{
|
||||
err: responseError,
|
||||
provider: providerName
|
||||
},
|
||||
'Failed to send unauthorized user message'
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Unauthorized user - command ignored',
|
||||
context: {
|
||||
provider: providerName,
|
||||
userId: userId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
provider: providerName,
|
||||
userId: userId,
|
||||
username: messageContext.username,
|
||||
command: commandInfo.command.substring(0, 100)
|
||||
},
|
||||
'Processing authorized command'
|
||||
);
|
||||
|
||||
try {
|
||||
// Extract repository and branch from message context (for Discord slash commands)
|
||||
const repoFullName = messageContext.repo || null;
|
||||
const branchName = messageContext.branch || 'main';
|
||||
|
||||
// Validate required repository parameter
|
||||
if (!repoFullName) {
|
||||
const errorMessage = sanitizeBotMentions(
|
||||
'❌ **Repository Required**: Please specify a repository using the `repo` parameter.\n\n' +
|
||||
'**Example:** `/claude repo:owner/repository command:fix this issue`'
|
||||
);
|
||||
await provider.sendResponse(messageContext, errorMessage);
|
||||
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Repository parameter is required',
|
||||
context: {
|
||||
provider: providerName,
|
||||
userId: userId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process command with Claude
|
||||
const claudeResponse = await claudeService.processCommand({
|
||||
repoFullName: repoFullName,
|
||||
issueNumber: null,
|
||||
command: commandInfo.command,
|
||||
isPullRequest: false,
|
||||
branchName: branchName,
|
||||
chatbotContext: {
|
||||
provider: providerName,
|
||||
userId: userId,
|
||||
username: messageContext.username,
|
||||
channelId: messageContext.channelId,
|
||||
guildId: messageContext.guildId,
|
||||
repo: repoFullName,
|
||||
branch: branchName
|
||||
}
|
||||
});
|
||||
|
||||
// Send response back to the platform
|
||||
await provider.sendResponse(messageContext, claudeResponse);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
logger.info(
|
||||
{
|
||||
provider: providerName,
|
||||
userId: userId,
|
||||
responseLength: claudeResponse ? claudeResponse.length : 0,
|
||||
responseTime: `${responseTime}ms`
|
||||
},
|
||||
'Command processed and response sent successfully'
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Command processed successfully',
|
||||
context: {
|
||||
provider: providerName,
|
||||
userId: userId,
|
||||
responseLength: claudeResponse ? claudeResponse.length : 0
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
provider: providerName,
|
||||
userId: userId,
|
||||
command: commandInfo.command.substring(0, 100)
|
||||
},
|
||||
'Error processing chatbot command'
|
||||
);
|
||||
|
||||
// Generate error reference for tracking
|
||||
const timestamp = new Date().toISOString();
|
||||
const errorId = `err-${Math.random().toString(36).substring(2, 10)}`;
|
||||
|
||||
logger.error(
|
||||
{
|
||||
errorId,
|
||||
timestamp,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
provider: providerName,
|
||||
userId: userId,
|
||||
command: commandInfo.command
|
||||
},
|
||||
'Error processing chatbot command (with reference ID)'
|
||||
);
|
||||
|
||||
// Try to send error message to user
|
||||
try {
|
||||
const errorMessage = provider.formatErrorMessage(error, errorId);
|
||||
await provider.sendResponse(messageContext, errorMessage);
|
||||
} catch (responseError) {
|
||||
logger.error(
|
||||
{
|
||||
err: responseError,
|
||||
provider: providerName
|
||||
},
|
||||
'Failed to send error message to user'
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to process command',
|
||||
errorReference: errorId,
|
||||
timestamp: timestamp,
|
||||
context: {
|
||||
provider: providerName,
|
||||
userId: userId
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const errorId = `err-${Math.random().toString(36).substring(2, 10)}`;
|
||||
|
||||
logger.error(
|
||||
{
|
||||
errorId,
|
||||
timestamp,
|
||||
err: {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
},
|
||||
provider: providerName
|
||||
},
|
||||
'Unexpected error in chatbot webhook handler'
|
||||
);
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
errorReference: errorId,
|
||||
timestamp: timestamp,
|
||||
provider: providerName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord-specific webhook handler
|
||||
*/
|
||||
async function handleDiscordWebhook(req, res) {
|
||||
return await handleChatbotWebhook(req, res, 'discord');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider status and statistics
|
||||
*/
|
||||
async function getProviderStats(req, res) {
|
||||
try {
|
||||
const stats = providerFactory.getStats();
|
||||
const providerDetails = {};
|
||||
|
||||
// Get detailed info for each initialized provider
|
||||
for (const [name, provider] of providerFactory.getAllProviders()) {
|
||||
providerDetails[name] = {
|
||||
name: provider.getProviderName(),
|
||||
initialized: true,
|
||||
botMention: provider.getBotMention()
|
||||
};
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats: stats,
|
||||
providers: providerDetails,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Failed to get provider stats');
|
||||
res.status(500).json({
|
||||
error: 'Failed to get provider statistics',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleChatbotWebhook,
|
||||
handleDiscordWebhook,
|
||||
getProviderStats
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* Base interface for all chatbot providers
|
||||
* Defines the contract that all chatbot providers must implement
|
||||
*/
|
||||
class ChatbotProvider {
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the provider with necessary credentials and setup
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async initialize() {
|
||||
throw new Error('initialize() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify incoming webhook signature for security
|
||||
* @param {Object} req - Express request object
|
||||
* @returns {boolean} - True if signature is valid
|
||||
*/
|
||||
verifyWebhookSignature(_req) {
|
||||
throw new Error('verifyWebhookSignature() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse incoming webhook payload to extract message and context
|
||||
* @param {Object} payload - Raw webhook payload
|
||||
* @returns {Object} - Standardized message object
|
||||
*/
|
||||
parseWebhookPayload(_payload) {
|
||||
throw new Error('parseWebhookPayload() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if message mentions the bot and extract command
|
||||
* @param {string} message - Message content
|
||||
* @returns {Object|null} - Command object or null if no mention
|
||||
*/
|
||||
extractBotCommand(_message) {
|
||||
throw new Error('extractBotCommand() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send response back to the chat platform
|
||||
* @param {Object} context - Message context (channel, user, etc.)
|
||||
* @param {string} response - Response text
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async sendResponse(_context, _response) {
|
||||
throw new Error('sendResponse() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-specific user ID for authorization
|
||||
* @param {Object} context - Message context
|
||||
* @returns {string} - User identifier
|
||||
*/
|
||||
getUserId(_context) {
|
||||
throw new Error('getUserId() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error message for the platform
|
||||
* @param {Error} error - Error object
|
||||
* @param {string} errorId - Error reference ID
|
||||
* @returns {string} - Formatted error message
|
||||
*/
|
||||
formatErrorMessage(error, errorId) {
|
||||
const timestamp = new Date().toISOString();
|
||||
return `❌ An error occurred while processing your command. (Reference: ${errorId}, Time: ${timestamp})\n\nPlease check with an administrator to review the logs for more details.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authorized to use the bot
|
||||
* @param {string} userId - Platform-specific user ID
|
||||
* @returns {boolean} - True if authorized
|
||||
*/
|
||||
isUserAuthorized(userId) {
|
||||
if (!userId) return false;
|
||||
|
||||
const authorizedUsers = this.config.authorizedUsers ||
|
||||
process.env.AUTHORIZED_USERS?.split(',').map(u => u.trim()) || [
|
||||
process.env.DEFAULT_AUTHORIZED_USER || 'admin'
|
||||
];
|
||||
|
||||
return authorizedUsers.includes(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider name for logging and identification
|
||||
* @returns {string} - Provider name
|
||||
*/
|
||||
getProviderName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bot mention pattern for this provider
|
||||
* @returns {string} - Bot username/mention pattern
|
||||
*/
|
||||
getBotMention() {
|
||||
return this.config.botMention || process.env.BOT_USERNAME || '@ClaudeBot';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ChatbotProvider;
|
||||
@@ -1,357 +0,0 @@
|
||||
const { verify } = require('crypto');
|
||||
const axios = require('axios');
|
||||
const ChatbotProvider = require('./ChatbotProvider');
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const secureCredentials = require('../utils/secureCredentials');
|
||||
|
||||
const logger = createLogger('DiscordProvider');
|
||||
|
||||
/**
|
||||
* Discord chatbot provider implementation
|
||||
* Handles Discord webhook interactions and message sending
|
||||
*/
|
||||
class DiscordProvider extends ChatbotProvider {
|
||||
constructor(config = {}) {
|
||||
super(config);
|
||||
this.botToken = null;
|
||||
this.publicKey = null;
|
||||
this.applicationId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Discord provider with credentials
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
this.botToken = secureCredentials.get('DISCORD_BOT_TOKEN') || process.env.DISCORD_BOT_TOKEN;
|
||||
this.publicKey =
|
||||
secureCredentials.get('DISCORD_PUBLIC_KEY') || process.env.DISCORD_PUBLIC_KEY;
|
||||
this.applicationId =
|
||||
secureCredentials.get('DISCORD_APPLICATION_ID') || process.env.DISCORD_APPLICATION_ID;
|
||||
|
||||
if (!this.botToken || !this.publicKey) {
|
||||
throw new Error('Discord bot token and public key are required');
|
||||
}
|
||||
|
||||
logger.info('Discord provider initialized successfully');
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Failed to initialize Discord provider');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Discord webhook signature using Ed25519
|
||||
*/
|
||||
verifyWebhookSignature(req) {
|
||||
try {
|
||||
const signature = req.headers['x-signature-ed25519'];
|
||||
const timestamp = req.headers['x-signature-timestamp'];
|
||||
|
||||
if (!signature || !timestamp) {
|
||||
logger.warn('Missing Discord signature headers');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip verification in test mode
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
logger.warn('Skipping Discord signature verification (test mode)');
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = req.rawBody || JSON.stringify(req.body);
|
||||
const message = timestamp + body;
|
||||
|
||||
try {
|
||||
const isValid = verify(
|
||||
'ed25519',
|
||||
Buffer.from(message),
|
||||
Buffer.from(this.publicKey, 'hex'),
|
||||
Buffer.from(signature, 'hex')
|
||||
);
|
||||
|
||||
logger.debug({ isValid }, 'Discord signature verification completed');
|
||||
return isValid;
|
||||
} catch (cryptoError) {
|
||||
logger.warn(
|
||||
{ err: cryptoError },
|
||||
'Discord signature verification failed due to crypto error'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Error verifying Discord webhook signature');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Discord webhook payload
|
||||
*/
|
||||
parseWebhookPayload(payload) {
|
||||
try {
|
||||
// Handle Discord interaction types
|
||||
switch (payload.type) {
|
||||
case 1: // PING
|
||||
return {
|
||||
type: 'ping',
|
||||
shouldRespond: true,
|
||||
responseData: { type: 1 } // PONG
|
||||
};
|
||||
|
||||
case 2: {
|
||||
// APPLICATION_COMMAND
|
||||
const repoInfo = this.extractRepoAndBranch(payload.data);
|
||||
return {
|
||||
type: 'command',
|
||||
command: payload.data?.name,
|
||||
options: payload.data?.options || [],
|
||||
channelId: payload.channel_id,
|
||||
guildId: payload.guild_id,
|
||||
userId: payload.member?.user?.id || payload.user?.id,
|
||||
username: payload.member?.user?.username || payload.user?.username,
|
||||
content: this.buildCommandContent(payload.data),
|
||||
interactionToken: payload.token,
|
||||
interactionId: payload.id,
|
||||
repo: repoInfo.repo,
|
||||
branch: repoInfo.branch
|
||||
};
|
||||
}
|
||||
|
||||
case 3: // MESSAGE_COMPONENT
|
||||
return {
|
||||
type: 'component',
|
||||
customId: payload.data?.custom_id,
|
||||
channelId: payload.channel_id,
|
||||
guildId: payload.guild_id,
|
||||
userId: payload.member?.user?.id || payload.user?.id,
|
||||
username: payload.member?.user?.username || payload.user?.username,
|
||||
interactionToken: payload.token,
|
||||
interactionId: payload.id
|
||||
};
|
||||
|
||||
default:
|
||||
logger.warn({ type: payload.type }, 'Unknown Discord interaction type');
|
||||
return {
|
||||
type: 'unknown',
|
||||
shouldRespond: false
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Error parsing Discord webhook payload');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build command content from Discord slash command data
|
||||
*/
|
||||
buildCommandContent(commandData) {
|
||||
if (!commandData || !commandData.name) return '';
|
||||
|
||||
let content = commandData.name;
|
||||
if (commandData.options && commandData.options.length > 0) {
|
||||
const args = commandData.options.map(option => `${option.name}:${option.value}`).join(' ');
|
||||
content += ` ${args}`;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract repository and branch information from Discord slash command options
|
||||
*/
|
||||
extractRepoAndBranch(commandData) {
|
||||
if (!commandData || !commandData.options) {
|
||||
return { repo: null, branch: null };
|
||||
}
|
||||
|
||||
const repoOption = commandData.options.find(opt => opt.name === 'repo');
|
||||
const branchOption = commandData.options.find(opt => opt.name === 'branch');
|
||||
|
||||
// Only default to 'main' if we have a repo but no branch
|
||||
const repo = repoOption ? repoOption.value : null;
|
||||
const branch = branchOption ? branchOption.value : repo ? 'main' : null;
|
||||
|
||||
return { repo, branch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bot command from Discord message
|
||||
*/
|
||||
extractBotCommand(content) {
|
||||
if (!content) return null;
|
||||
|
||||
// For Discord, commands are slash commands or direct mentions
|
||||
// Since this is already a command interaction, return the content
|
||||
return {
|
||||
command: content,
|
||||
originalMessage: content
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send response back to Discord
|
||||
*/
|
||||
async sendResponse(context, response) {
|
||||
try {
|
||||
if (context.type === 'ping') {
|
||||
// For ping, response is handled by the webhook endpoint directly
|
||||
return;
|
||||
}
|
||||
|
||||
// Send follow-up message for slash commands
|
||||
if (context.interactionToken && context.interactionId) {
|
||||
await this.sendFollowUpMessage(context.interactionToken, response);
|
||||
} else if (context.channelId) {
|
||||
await this.sendChannelMessage(context.channelId, response);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
channelId: context.channelId,
|
||||
userId: context.userId,
|
||||
responseLength: response.length
|
||||
},
|
||||
'Discord response sent successfully'
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
context: {
|
||||
channelId: context.channelId,
|
||||
userId: context.userId
|
||||
}
|
||||
},
|
||||
'Failed to send Discord response'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send follow-up message for Discord interactions
|
||||
*/
|
||||
async sendFollowUpMessage(interactionToken, content) {
|
||||
const url = `https://discord.com/api/v10/webhooks/${this.applicationId}/${interactionToken}`;
|
||||
|
||||
// Split long messages to respect Discord's 2000 character limit
|
||||
const messages = this.splitLongMessage(content, 2000);
|
||||
|
||||
for (const message of messages) {
|
||||
await axios.post(
|
||||
url,
|
||||
{
|
||||
content: message,
|
||||
flags: 0 // Make message visible to everyone
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bot ${this.botToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to Discord channel
|
||||
*/
|
||||
async sendChannelMessage(channelId, content) {
|
||||
const url = `https://discord.com/api/v10/channels/${channelId}/messages`;
|
||||
|
||||
// Split long messages to respect Discord's 2000 character limit
|
||||
const messages = this.splitLongMessage(content, 2000);
|
||||
|
||||
for (const message of messages) {
|
||||
await axios.post(
|
||||
url,
|
||||
{
|
||||
content: message
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bot ${this.botToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split long messages into chunks that fit Discord's character limit
|
||||
*/
|
||||
splitLongMessage(content, maxLength = 2000) {
|
||||
if (content.length <= maxLength) {
|
||||
return [content];
|
||||
}
|
||||
|
||||
const messages = [];
|
||||
let currentMessage = '';
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (currentMessage.length + line.length + 1 <= maxLength) {
|
||||
currentMessage += (currentMessage ? '\n' : '') + line;
|
||||
} else {
|
||||
if (currentMessage) {
|
||||
messages.push(currentMessage);
|
||||
currentMessage = line;
|
||||
} else {
|
||||
// Single line is too long, split it
|
||||
const chunks = this.splitLongLine(line, maxLength);
|
||||
messages.push(...chunks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentMessage) {
|
||||
messages.push(currentMessage);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a single long line into chunks
|
||||
*/
|
||||
splitLongLine(line, maxLength) {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < line.length; i += maxLength) {
|
||||
chunks.push(line.substring(i, i + maxLength));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Discord user ID for authorization
|
||||
*/
|
||||
getUserId(context) {
|
||||
return context.userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error message for Discord
|
||||
*/
|
||||
formatErrorMessage(error, errorId) {
|
||||
const timestamp = new Date().toISOString();
|
||||
return (
|
||||
'🚫 **Error Processing Command**\n\n' +
|
||||
`**Reference ID:** \`${errorId}\`\n` +
|
||||
`**Time:** ${timestamp}\n\n` +
|
||||
'Please contact an administrator with the reference ID above.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Discord-specific bot mention pattern
|
||||
*/
|
||||
getBotMention() {
|
||||
// Discord uses <@bot_id> format, but for slash commands we don't need mentions
|
||||
return this.config.botMention || 'claude';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DiscordProvider;
|
||||
@@ -1,246 +0,0 @@
|
||||
const DiscordProvider = require('./DiscordProvider');
|
||||
const { createLogger } = require('../utils/logger');
|
||||
|
||||
const logger = createLogger('ProviderFactory');
|
||||
|
||||
/**
|
||||
* Provider factory for chatbot providers using dependency injection
|
||||
* Manages the creation and configuration of different chatbot providers
|
||||
*/
|
||||
class ProviderFactory {
|
||||
constructor() {
|
||||
this.providers = new Map();
|
||||
this.providerClasses = new Map();
|
||||
this.defaultConfig = {};
|
||||
|
||||
// Register built-in providers
|
||||
this.registerProvider('discord', DiscordProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new provider class
|
||||
* @param {string} name - Provider name
|
||||
* @param {class} ProviderClass - Provider class constructor
|
||||
*/
|
||||
registerProvider(name, ProviderClass) {
|
||||
this.providerClasses.set(name.toLowerCase(), ProviderClass);
|
||||
logger.info({ provider: name }, 'Registered chatbot provider');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and initialize a provider instance
|
||||
* @param {string} name - Provider name
|
||||
* @param {Object} config - Provider configuration
|
||||
* @returns {Promise<ChatbotProvider>} - Initialized provider instance
|
||||
*/
|
||||
async createProvider(name, config = {}) {
|
||||
const providerName = name.toLowerCase();
|
||||
|
||||
// Check if provider is already created
|
||||
if (this.providers.has(providerName)) {
|
||||
return this.providers.get(providerName);
|
||||
}
|
||||
|
||||
// Get provider class
|
||||
const ProviderClass = this.providerClasses.get(providerName);
|
||||
if (!ProviderClass) {
|
||||
const availableProviders = Array.from(this.providerClasses.keys());
|
||||
throw new Error(
|
||||
`Unknown provider: ${name}. Available providers: ${availableProviders.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Merge with default config
|
||||
const finalConfig = { ...this.defaultConfig, ...config };
|
||||
|
||||
// Create and initialize provider
|
||||
const provider = new ProviderClass(finalConfig);
|
||||
await provider.initialize();
|
||||
|
||||
// Cache the provider
|
||||
this.providers.set(providerName, provider);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
provider: name,
|
||||
config: Object.keys(finalConfig)
|
||||
},
|
||||
'Created and initialized chatbot provider'
|
||||
);
|
||||
|
||||
return provider;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
provider: name
|
||||
},
|
||||
'Failed to create provider'
|
||||
);
|
||||
throw new Error(`Failed to create ${name} provider: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an existing provider instance
|
||||
* @param {string} name - Provider name
|
||||
* @returns {ChatbotProvider|null} - Provider instance or null if not found
|
||||
*/
|
||||
getProvider(name) {
|
||||
return this.providers.get(name.toLowerCase()) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all initialized provider instances
|
||||
* @returns {Map<string, ChatbotProvider>} - Map of provider name to instance
|
||||
*/
|
||||
getAllProviders() {
|
||||
return new Map(this.providers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available provider names
|
||||
* @returns {string[]} - Array of available provider names
|
||||
*/
|
||||
getAvailableProviders() {
|
||||
return Array.from(this.providerClasses.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default configuration for all providers
|
||||
* @param {Object} config - Default configuration
|
||||
*/
|
||||
setDefaultConfig(config) {
|
||||
this.defaultConfig = { ...config };
|
||||
logger.info({ configKeys: Object.keys(config) }, 'Set default provider configuration');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration for a specific provider
|
||||
* @param {string} name - Provider name
|
||||
* @param {Object} config - Updated configuration
|
||||
* @returns {Promise<ChatbotProvider>} - Updated provider instance
|
||||
*/
|
||||
async updateProviderConfig(name, config) {
|
||||
const providerName = name.toLowerCase();
|
||||
|
||||
// Remove existing provider to force recreation with new config
|
||||
if (this.providers.has(providerName)) {
|
||||
this.providers.delete(providerName);
|
||||
logger.info({ provider: name }, 'Removed existing provider for reconfiguration');
|
||||
}
|
||||
|
||||
// Create new provider with updated config
|
||||
return await this.createProvider(name, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create provider from environment configuration
|
||||
* @param {string} name - Provider name
|
||||
* @returns {Promise<ChatbotProvider>} - Configured provider instance
|
||||
*/
|
||||
async createFromEnvironment(name) {
|
||||
const providerName = name.toLowerCase();
|
||||
const config = this.getEnvironmentConfig(providerName);
|
||||
|
||||
return await this.createProvider(name, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider configuration from environment variables
|
||||
* @param {string} providerName - Provider name
|
||||
* @returns {Object} - Configuration object
|
||||
*/
|
||||
getEnvironmentConfig(providerName) {
|
||||
const config = {};
|
||||
|
||||
// Provider-specific environment variables
|
||||
switch (providerName) {
|
||||
case 'discord':
|
||||
config.botToken = process.env.DISCORD_BOT_TOKEN;
|
||||
config.publicKey = process.env.DISCORD_PUBLIC_KEY;
|
||||
config.applicationId = process.env.DISCORD_APPLICATION_ID;
|
||||
config.authorizedUsers = process.env.DISCORD_AUTHORIZED_USERS?.split(',').map(u =>
|
||||
u.trim()
|
||||
);
|
||||
config.botMention = process.env.DISCORD_BOT_MENTION;
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported provider: ${providerName}. Only 'discord' is currently supported.`
|
||||
);
|
||||
}
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(config).forEach(key => {
|
||||
if (config[key] === undefined) {
|
||||
delete config[key];
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple providers from configuration
|
||||
* @param {Object} providersConfig - Configuration for multiple providers
|
||||
* @returns {Promise<Map<string, ChatbotProvider>>} - Map of initialized providers
|
||||
*/
|
||||
async createMultipleProviders(providersConfig) {
|
||||
const results = new Map();
|
||||
const errors = [];
|
||||
|
||||
for (const [name, config] of Object.entries(providersConfig)) {
|
||||
try {
|
||||
const provider = await this.createProvider(name, config);
|
||||
results.set(name, provider);
|
||||
} catch (error) {
|
||||
errors.push({ provider: name, error: error.message });
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
provider: name
|
||||
},
|
||||
'Failed to create provider in batch'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
logger.warn({ errors, successCount: results.size }, 'Some providers failed to initialize');
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all providers
|
||||
*/
|
||||
async cleanup() {
|
||||
logger.info({ providerCount: this.providers.size }, 'Cleaning up chatbot providers');
|
||||
|
||||
this.providers.clear();
|
||||
logger.info('All providers cleaned up');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider statistics
|
||||
* @returns {Object} - Provider statistics
|
||||
*/
|
||||
getStats() {
|
||||
const stats = {
|
||||
totalRegistered: this.providerClasses.size,
|
||||
totalInitialized: this.providers.size,
|
||||
availableProviders: this.getAvailableProviders(),
|
||||
initializedProviders: Array.from(this.providers.keys())
|
||||
};
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const factory = new ProviderFactory();
|
||||
|
||||
module.exports = factory;
|
||||
@@ -1,30 +0,0 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const chatbotController = require('../controllers/chatbotController');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Rate limiting for chatbot webhooks
|
||||
// Allow 100 requests per 15 minutes per IP to prevent abuse
|
||||
// while allowing legitimate webhook traffic
|
||||
const chatbotLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // limit each IP to 100 requests per windowMs
|
||||
message: {
|
||||
error: 'Too many chatbot requests from this IP, please try again later.'
|
||||
},
|
||||
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
||||
skip: _req => {
|
||||
// Skip rate limiting in test environment
|
||||
return process.env.NODE_ENV === 'test';
|
||||
}
|
||||
});
|
||||
|
||||
// Discord webhook endpoint
|
||||
router.post('/discord', chatbotLimiter, chatbotController.handleDiscordWebhook);
|
||||
|
||||
// Provider statistics endpoint
|
||||
router.get('/stats', chatbotController.getProviderStats);
|
||||
|
||||
module.exports = router;
|
||||
@@ -9,7 +9,6 @@ This directory contains the test framework for the Claude Webhook service. The t
|
||||
/unit # Unit tests for individual components
|
||||
/controllers # Tests for controllers
|
||||
/services # Tests for services
|
||||
/providers # Tests for chatbot providers
|
||||
/security # Security-focused tests
|
||||
/utils # Tests for utility functions
|
||||
/integration # Integration tests between components
|
||||
@@ -35,9 +34,6 @@ npm test
|
||||
# Run only unit tests
|
||||
npm run test:unit
|
||||
|
||||
# Run only chatbot provider tests
|
||||
npm run test:chatbot
|
||||
|
||||
# Run only integration tests
|
||||
npm run test:integration
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Consolidated Claude test script
|
||||
# Usage: ./test-claude.sh [direct|installation|no-firewall|response]
|
||||
|
||||
set -e
|
||||
|
||||
TEST_TYPE=${1:-direct}
|
||||
|
||||
case "$TEST_TYPE" in
|
||||
direct)
|
||||
echo "Testing direct Claude integration..."
|
||||
# Direct Claude test logic from test-claude-direct.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="echo 'Direct Claude test'" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
-e ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-test-key}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
installation)
|
||||
echo "Testing Claude installation..."
|
||||
# Installation test logic from test-claude-installation.sh and test-claude-version.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="claude-cli --version && claude --version" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
no-firewall)
|
||||
echo "Testing Claude without firewall..."
|
||||
# Test logic from test-claude-no-firewall.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="echo 'Claude without firewall test'" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
-e DISABLE_FIREWALL=true \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
response)
|
||||
echo "Testing Claude response..."
|
||||
# Test logic from test-claude-response.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="claude \"Tell me a joke\"" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
-e ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-test-key}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown test type: $TEST_TYPE"
|
||||
echo "Usage: ./test-claude.sh [direct|installation|no-firewall|response]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Test complete!"
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Consolidated container test script
|
||||
# Usage: ./test-container.sh [basic|privileged|cleanup]
|
||||
|
||||
set -e
|
||||
|
||||
TEST_TYPE=${1:-basic}
|
||||
|
||||
case "$TEST_TYPE" in
|
||||
basic)
|
||||
echo "Running basic container test..."
|
||||
# Basic container test logic from test-basic-container.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="echo 'Basic container test'" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
privileged)
|
||||
echo "Running privileged container test..."
|
||||
# Privileged container test logic from test-container-privileged.sh
|
||||
docker run --rm -it \
|
||||
--privileged \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="echo 'Privileged container test'" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
cleanup)
|
||||
echo "Running container cleanup test..."
|
||||
# Container cleanup test logic from test-container-cleanup.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="echo 'Container cleanup test'" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown test type: $TEST_TYPE"
|
||||
echo "Usage: ./test-container.sh [basic|privileged|cleanup]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Test complete!"
|
||||
@@ -1,261 +0,0 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const bodyParser = require('body-parser');
|
||||
const chatbotRoutes = require('../../../src/routes/chatbot');
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../../src/controllers/chatbotController', () => ({
|
||||
handleDiscordWebhook: jest.fn(),
|
||||
getProviderStats: jest.fn()
|
||||
}));
|
||||
|
||||
const chatbotController = require('../../../src/controllers/chatbotController');
|
||||
|
||||
describe('Chatbot Integration Tests', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
|
||||
// Middleware to capture raw body for signature verification
|
||||
app.use(
|
||||
bodyParser.json({
|
||||
verify: (req, res, buf) => {
|
||||
req.rawBody = buf;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Mount chatbot routes
|
||||
app.use('/api/webhooks/chatbot', chatbotRoutes);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Discord webhook endpoint', () => {
|
||||
it('should route to Discord webhook handler', async () => {
|
||||
chatbotController.handleDiscordWebhook.mockImplementation((req, res) => {
|
||||
res.status(200).json({ success: true });
|
||||
});
|
||||
|
||||
const discordPayload = {
|
||||
type: 1 // PING
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/webhooks/chatbot/discord')
|
||||
.send(discordPayload)
|
||||
.expect(200);
|
||||
|
||||
expect(chatbotController.handleDiscordWebhook).toHaveBeenCalledTimes(1);
|
||||
expect(response.body).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should handle Discord slash command webhook', async () => {
|
||||
chatbotController.handleDiscordWebhook.mockImplementation((req, res) => {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Command processed successfully',
|
||||
context: {
|
||||
provider: 'discord',
|
||||
userId: 'user123'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const slashCommandPayload = {
|
||||
type: 2, // APPLICATION_COMMAND
|
||||
data: {
|
||||
name: 'claude',
|
||||
options: [
|
||||
{
|
||||
name: 'command',
|
||||
value: 'help me with this code'
|
||||
}
|
||||
]
|
||||
},
|
||||
channel_id: '123456789',
|
||||
member: {
|
||||
user: {
|
||||
id: 'user123',
|
||||
username: 'testuser'
|
||||
}
|
||||
},
|
||||
token: 'interaction_token',
|
||||
id: 'interaction_id'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/webhooks/chatbot/discord')
|
||||
.set('x-signature-ed25519', 'mock_signature')
|
||||
.set('x-signature-timestamp', '1234567890')
|
||||
.send(slashCommandPayload)
|
||||
.expect(200);
|
||||
|
||||
expect(chatbotController.handleDiscordWebhook).toHaveBeenCalledTimes(1);
|
||||
expect(response.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle Discord component interaction webhook', async () => {
|
||||
chatbotController.handleDiscordWebhook.mockImplementation((req, res) => {
|
||||
res.status(200).json({ success: true });
|
||||
});
|
||||
|
||||
const componentPayload = {
|
||||
type: 3, // MESSAGE_COMPONENT
|
||||
data: {
|
||||
custom_id: 'help_button'
|
||||
},
|
||||
channel_id: '123456789',
|
||||
user: {
|
||||
id: 'user123',
|
||||
username: 'testuser'
|
||||
},
|
||||
token: 'interaction_token',
|
||||
id: 'interaction_id'
|
||||
};
|
||||
|
||||
await request(app).post('/api/webhooks/chatbot/discord').send(componentPayload).expect(200);
|
||||
|
||||
expect(chatbotController.handleDiscordWebhook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should pass raw body for signature verification', async () => {
|
||||
chatbotController.handleDiscordWebhook.mockImplementation((req, res) => {
|
||||
// Verify that req.rawBody is available
|
||||
expect(req.rawBody).toBeInstanceOf(Buffer);
|
||||
res.status(200).json({ success: true });
|
||||
});
|
||||
|
||||
await request(app).post('/api/webhooks/chatbot/discord').send({ type: 1 });
|
||||
|
||||
expect(chatbotController.handleDiscordWebhook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Provider stats endpoint', () => {
|
||||
it('should return provider statistics', async () => {
|
||||
chatbotController.getProviderStats.mockImplementation((req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
stats: {
|
||||
totalRegistered: 1,
|
||||
totalInitialized: 1,
|
||||
availableProviders: ['discord'],
|
||||
initializedProviders: ['discord']
|
||||
},
|
||||
providers: {
|
||||
discord: {
|
||||
name: 'DiscordProvider',
|
||||
initialized: true,
|
||||
botMention: '@claude'
|
||||
}
|
||||
},
|
||||
timestamp: '2024-01-01T00:00:00.000Z'
|
||||
});
|
||||
});
|
||||
|
||||
const response = await request(app).get('/api/webhooks/chatbot/stats').expect(200);
|
||||
|
||||
expect(chatbotController.getProviderStats).toHaveBeenCalledTimes(1);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.stats).toBeDefined();
|
||||
expect(response.body.providers).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle stats endpoint errors', async () => {
|
||||
chatbotController.getProviderStats.mockImplementation((req, res) => {
|
||||
res.status(500).json({
|
||||
error: 'Failed to get provider statistics',
|
||||
message: 'Stats service unavailable'
|
||||
});
|
||||
});
|
||||
|
||||
const response = await request(app).get('/api/webhooks/chatbot/stats').expect(500);
|
||||
|
||||
expect(response.body.error).toBe('Failed to get provider statistics');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle Discord webhook controller errors', async () => {
|
||||
chatbotController.handleDiscordWebhook.mockImplementation((req, res) => {
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
errorReference: 'err-12345',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
provider: 'discord'
|
||||
});
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/webhooks/chatbot/discord')
|
||||
.send({ type: 1 })
|
||||
.expect(500);
|
||||
|
||||
expect(response.body.error).toBe('Internal server error');
|
||||
expect(response.body.errorReference).toBeDefined();
|
||||
expect(response.body.provider).toBe('discord');
|
||||
});
|
||||
|
||||
it('should handle invalid JSON payloads', async () => {
|
||||
// This test ensures that malformed JSON is handled by Express
|
||||
const response = await request(app)
|
||||
.post('/api/webhooks/chatbot/discord')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('invalid json{')
|
||||
.expect(400);
|
||||
|
||||
// Express returns different error formats for malformed JSON
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should handle missing Content-Type', async () => {
|
||||
chatbotController.handleDiscordWebhook.mockImplementation((req, res) => {
|
||||
res.status(200).json({ success: true });
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post('/api/webhooks/chatbot/discord')
|
||||
.send('plain text payload')
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request validation', () => {
|
||||
it('should accept valid Discord webhook requests', async () => {
|
||||
chatbotController.handleDiscordWebhook.mockImplementation((req, res) => {
|
||||
expect(req.body).toEqual({ type: 1 });
|
||||
expect(req.headers['content-type']).toContain('application/json');
|
||||
res.status(200).json({ type: 1 });
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post('/api/webhooks/chatbot/discord')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({ type: 1 })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should handle large payloads gracefully', async () => {
|
||||
chatbotController.handleDiscordWebhook.mockImplementation((req, res) => {
|
||||
res.status(200).json({ success: true });
|
||||
});
|
||||
|
||||
const largePayload = {
|
||||
type: 2,
|
||||
data: {
|
||||
name: 'claude',
|
||||
options: [
|
||||
{
|
||||
name: 'command',
|
||||
value: 'A'.repeat(2000) // Large command
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
await request(app).post('/api/webhooks/chatbot/discord').send(largePayload).expect(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,385 +0,0 @@
|
||||
// Mock dependencies
|
||||
jest.mock('../../../src/services/claudeService');
|
||||
jest.mock('../../../src/providers/ProviderFactory');
|
||||
jest.mock('../../../src/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn()
|
||||
})
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/utils/secureCredentials', () => ({
|
||||
get: jest.fn(),
|
||||
loadCredentials: jest.fn()
|
||||
}));
|
||||
|
||||
// Set required environment variables for claudeService
|
||||
process.env.BOT_USERNAME = 'testbot';
|
||||
process.env.DEFAULT_AUTHORIZED_USER = 'testuser';
|
||||
|
||||
const chatbotController = require('../../../src/controllers/chatbotController');
|
||||
const claudeService = require('../../../src/services/claudeService');
|
||||
const providerFactory = require('../../../src/providers/ProviderFactory');
|
||||
jest.mock('../../../src/utils/sanitize', () => ({
|
||||
sanitizeBotMentions: jest.fn(msg => msg)
|
||||
}));
|
||||
|
||||
describe('chatbotController', () => {
|
||||
let req, res, mockProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
method: 'POST',
|
||||
path: '/api/webhooks/chatbot/discord',
|
||||
headers: {
|
||||
'user-agent': 'Discord-Webhooks/1.0',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: {}
|
||||
};
|
||||
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis()
|
||||
};
|
||||
|
||||
mockProvider = {
|
||||
verifyWebhookSignature: jest.fn().mockReturnValue(true),
|
||||
parseWebhookPayload: jest.fn(),
|
||||
extractBotCommand: jest.fn(),
|
||||
sendResponse: jest.fn().mockResolvedValue(),
|
||||
getUserId: jest.fn(),
|
||||
isUserAuthorized: jest.fn().mockReturnValue(true),
|
||||
formatErrorMessage: jest
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
'🚫 **Error Processing Command**\n\n**Reference ID:** `test-error-id`\n**Time:** 2023-01-01T00:00:00.000Z\n\nPlease contact an administrator with the reference ID above.'
|
||||
),
|
||||
getProviderName: jest.fn().mockReturnValue('DiscordProvider'),
|
||||
getBotMention: jest.fn().mockReturnValue('@claude')
|
||||
};
|
||||
|
||||
providerFactory.getProvider.mockReturnValue(mockProvider);
|
||||
providerFactory.createFromEnvironment.mockResolvedValue(mockProvider);
|
||||
providerFactory.getStats.mockReturnValue({
|
||||
totalRegistered: 1,
|
||||
totalInitialized: 1,
|
||||
availableProviders: ['discord'],
|
||||
initializedProviders: ['discord']
|
||||
});
|
||||
providerFactory.getAllProviders.mockReturnValue(new Map([['discord', mockProvider]]));
|
||||
|
||||
claudeService.processCommand.mockResolvedValue('Claude response');
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('handleChatbotWebhook', () => {
|
||||
it('should handle successful webhook with valid signature', async () => {
|
||||
mockProvider.parseWebhookPayload.mockReturnValue({
|
||||
type: 'command',
|
||||
content: 'help me',
|
||||
userId: 'user123',
|
||||
username: 'testuser',
|
||||
channelId: 'channel123',
|
||||
repo: 'owner/test-repo',
|
||||
branch: 'main'
|
||||
});
|
||||
mockProvider.extractBotCommand.mockReturnValue({
|
||||
command: 'help me',
|
||||
originalMessage: 'help me'
|
||||
});
|
||||
mockProvider.getUserId.mockReturnValue('user123');
|
||||
|
||||
await chatbotController.handleChatbotWebhook(req, res, 'discord');
|
||||
|
||||
expect(mockProvider.verifyWebhookSignature).toHaveBeenCalledWith(req);
|
||||
expect(mockProvider.parseWebhookPayload).toHaveBeenCalledWith(req.body);
|
||||
expect(claudeService.processCommand).toHaveBeenCalledWith({
|
||||
repoFullName: 'owner/test-repo',
|
||||
issueNumber: null,
|
||||
command: 'help me',
|
||||
isPullRequest: false,
|
||||
branchName: 'main',
|
||||
chatbotContext: {
|
||||
provider: 'discord',
|
||||
userId: 'user123',
|
||||
username: 'testuser',
|
||||
channelId: 'channel123',
|
||||
guildId: undefined,
|
||||
repo: 'owner/test-repo',
|
||||
branch: 'main'
|
||||
}
|
||||
});
|
||||
expect(mockProvider.sendResponse).toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
message: 'Command processed successfully'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 401 for invalid webhook signature', async () => {
|
||||
mockProvider.verifyWebhookSignature.mockReturnValue(false);
|
||||
|
||||
await chatbotController.handleChatbotWebhook(req, res, 'discord');
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid webhook signature'
|
||||
});
|
||||
expect(claudeService.processCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle signature verification errors', async () => {
|
||||
mockProvider.verifyWebhookSignature.mockImplementation(() => {
|
||||
throw new Error('Signature verification failed');
|
||||
});
|
||||
|
||||
await chatbotController.handleChatbotWebhook(req, res, 'discord');
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Signature verification failed',
|
||||
message: 'Signature verification failed'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle immediate responses like Discord PING', async () => {
|
||||
mockProvider.parseWebhookPayload.mockReturnValue({
|
||||
type: 'ping',
|
||||
shouldRespond: true,
|
||||
responseData: { type: 1 }
|
||||
});
|
||||
|
||||
await chatbotController.handleChatbotWebhook(req, res, 'discord');
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({ type: 1 });
|
||||
expect(claudeService.processCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip processing for unknown message types', async () => {
|
||||
mockProvider.parseWebhookPayload.mockReturnValue({
|
||||
type: 'unknown',
|
||||
shouldRespond: false
|
||||
});
|
||||
|
||||
await chatbotController.handleChatbotWebhook(req, res, 'discord');
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: 'Webhook received but no command detected'
|
||||
});
|
||||
expect(claudeService.processCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip processing when no bot command is found', async () => {
|
||||
mockProvider.parseWebhookPayload.mockReturnValue({
|
||||
type: 'command',
|
||||
content: 'hello world',
|
||||
userId: 'user123'
|
||||
});
|
||||
mockProvider.extractBotCommand.mockReturnValue(null);
|
||||
|
||||
await chatbotController.handleChatbotWebhook(req, res, 'discord');
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: 'Webhook received but no bot mention found'
|
||||
});
|
||||
expect(claudeService.processCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle unauthorized users', async () => {
|
||||
mockProvider.parseWebhookPayload.mockReturnValue({
|
||||
type: 'command',
|
||||
content: 'help me',
|
||||
userId: 'unauthorized_user',
|
||||
username: 'baduser'
|
||||
});
|
||||
mockProvider.extractBotCommand.mockReturnValue({
|
||||
command: 'help me'
|
||||
});
|
||||
mockProvider.getUserId.mockReturnValue('unauthorized_user');
|
||||
mockProvider.isUserAuthorized.mockReturnValue(false);
|
||||
|
||||
await chatbotController.handleChatbotWebhook(req, res, 'discord');
|
||||
|
||||
expect(mockProvider.sendResponse).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'❌ Sorry, only authorized users can trigger Claude commands.'
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: 'Unauthorized user - command ignored',
|
||||
context: {
|
||||
provider: 'discord',
|
||||
userId: 'unauthorized_user'
|
||||
}
|
||||
});
|
||||
expect(claudeService.processCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle missing repository parameter', async () => {
|
||||
mockProvider.parseWebhookPayload.mockReturnValue({
|
||||
type: 'command',
|
||||
content: 'help me',
|
||||
userId: 'user123',
|
||||
username: 'testuser',
|
||||
repo: null, // No repo provided
|
||||
branch: null
|
||||
});
|
||||
mockProvider.extractBotCommand.mockReturnValue({
|
||||
command: 'help me'
|
||||
});
|
||||
mockProvider.getUserId.mockReturnValue('user123');
|
||||
|
||||
await chatbotController.handleChatbotWebhook(req, res, 'discord');
|
||||
|
||||
expect(mockProvider.sendResponse).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('Repository Required')
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: 'Repository parameter is required'
|
||||
})
|
||||
);
|
||||
expect(claudeService.processCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Claude service errors gracefully', async () => {
|
||||
mockProvider.parseWebhookPayload.mockReturnValue({
|
||||
type: 'command',
|
||||
content: 'help me',
|
||||
userId: 'user123',
|
||||
username: 'testuser',
|
||||
repo: 'owner/test-repo',
|
||||
branch: 'main'
|
||||
});
|
||||
mockProvider.extractBotCommand.mockReturnValue({
|
||||
command: 'help me'
|
||||
});
|
||||
mockProvider.getUserId.mockReturnValue('user123');
|
||||
|
||||
claudeService.processCommand.mockRejectedValue(new Error('Claude service error'));
|
||||
|
||||
await chatbotController.handleChatbotWebhook(req, res, 'discord');
|
||||
|
||||
expect(mockProvider.sendResponse).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('🚫 **Error Processing Command**')
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: 'Failed to process command'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle provider initialization failure', async () => {
|
||||
providerFactory.getProvider.mockReturnValue(null);
|
||||
providerFactory.createFromEnvironment.mockRejectedValue(new Error('Provider init failed'));
|
||||
|
||||
await chatbotController.handleChatbotWebhook(req, res, 'discord');
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Provider initialization failed',
|
||||
message: 'Provider init failed'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle payload parsing errors', async () => {
|
||||
mockProvider.parseWebhookPayload.mockImplementation(() => {
|
||||
throw new Error('Invalid payload');
|
||||
});
|
||||
|
||||
await chatbotController.handleChatbotWebhook(req, res, 'discord');
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid payload format',
|
||||
message: 'Invalid payload'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unexpected errors', async () => {
|
||||
providerFactory.getProvider.mockImplementation(() => {
|
||||
throw new Error('Unexpected error');
|
||||
});
|
||||
|
||||
await chatbotController.handleChatbotWebhook(req, res, 'discord');
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Provider initialization failed',
|
||||
message: 'Unexpected error'
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDiscordWebhook', () => {
|
||||
it('should call handleChatbotWebhook with discord provider', async () => {
|
||||
// Mock a simple provider response to avoid validation
|
||||
mockProvider.parseWebhookPayload.mockReturnValue({
|
||||
type: 'ping',
|
||||
shouldRespond: true,
|
||||
responseData: { type: 1 }
|
||||
});
|
||||
|
||||
await chatbotController.handleDiscordWebhook(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({ type: 1 });
|
||||
expect(res.status).not.toHaveBeenCalledWith(400); // Should not trigger repo validation
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderStats', () => {
|
||||
it('should return provider statistics successfully', async () => {
|
||||
await chatbotController.getProviderStats(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
stats: {
|
||||
totalRegistered: 1,
|
||||
totalInitialized: 1,
|
||||
availableProviders: ['discord'],
|
||||
initializedProviders: ['discord']
|
||||
},
|
||||
providers: {
|
||||
discord: {
|
||||
name: 'DiscordProvider',
|
||||
initialized: true,
|
||||
botMention: '@claude'
|
||||
}
|
||||
},
|
||||
timestamp: expect.any(String)
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors when getting stats', async () => {
|
||||
providerFactory.getStats.mockImplementation(() => {
|
||||
throw new Error('Stats error');
|
||||
});
|
||||
|
||||
await chatbotController.getProviderStats(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Failed to get provider statistics',
|
||||
message: 'Stats error'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,8 +47,8 @@ jest.mock('child_process', () => ({
|
||||
describe('Express Application', () => {
|
||||
let app: express.Application;
|
||||
const originalEnv = process.env;
|
||||
const mockLogger = (require('../../src/utils/logger') as any).createLogger();
|
||||
const mockStartupMetrics = new (require('../../src/utils/startup-metrics') as any).StartupMetrics();
|
||||
const mockLogger = (require('../../src/utils/logger')).createLogger();
|
||||
const mockStartupMetrics = new (require('../../src/utils/startup-metrics')).StartupMetrics();
|
||||
|
||||
// Mock express listen to prevent actual server start
|
||||
const mockListen = jest.fn((port: number, callback?: () => void) => {
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
const ChatbotProvider = require('../../../src/providers/ChatbotProvider');
|
||||
|
||||
describe('ChatbotProvider', () => {
|
||||
let provider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new ChatbotProvider({
|
||||
botMention: '@testbot',
|
||||
authorizedUsers: ['user1', 'user2']
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with default config', () => {
|
||||
const defaultProvider = new ChatbotProvider();
|
||||
expect(defaultProvider.config).toEqual({});
|
||||
expect(defaultProvider.name).toBe('ChatbotProvider');
|
||||
});
|
||||
|
||||
it('should initialize with provided config', () => {
|
||||
expect(provider.config.botMention).toBe('@testbot');
|
||||
expect(provider.config.authorizedUsers).toEqual(['user1', 'user2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('abstract methods', () => {
|
||||
it('should throw error for initialize()', async () => {
|
||||
await expect(provider.initialize()).rejects.toThrow(
|
||||
'initialize() must be implemented by subclass'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for verifyWebhookSignature()', () => {
|
||||
expect(() => provider.verifyWebhookSignature({})).toThrow(
|
||||
'verifyWebhookSignature() must be implemented by subclass'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for parseWebhookPayload()', () => {
|
||||
expect(() => provider.parseWebhookPayload({})).toThrow(
|
||||
'parseWebhookPayload() must be implemented by subclass'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for extractBotCommand()', () => {
|
||||
expect(() => provider.extractBotCommand('')).toThrow(
|
||||
'extractBotCommand() must be implemented by subclass'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for sendResponse()', async () => {
|
||||
await expect(provider.sendResponse({}, '')).rejects.toThrow(
|
||||
'sendResponse() must be implemented by subclass'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for getUserId()', () => {
|
||||
expect(() => provider.getUserId({})).toThrow('getUserId() must be implemented by subclass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatErrorMessage()', () => {
|
||||
it('should format error message with reference ID and timestamp', () => {
|
||||
const error = new Error('Test error');
|
||||
const errorId = 'test-123';
|
||||
|
||||
const message = provider.formatErrorMessage(error, errorId);
|
||||
|
||||
expect(message).toContain('❌ An error occurred');
|
||||
expect(message).toContain('Reference: test-123');
|
||||
expect(message).toContain('Please check with an administrator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUserAuthorized()', () => {
|
||||
it('should return false for null/undefined userId', () => {
|
||||
expect(provider.isUserAuthorized(null)).toBe(false);
|
||||
expect(provider.isUserAuthorized(undefined)).toBe(false);
|
||||
expect(provider.isUserAuthorized('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for authorized users from config', () => {
|
||||
expect(provider.isUserAuthorized('user1')).toBe(true);
|
||||
expect(provider.isUserAuthorized('user2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unauthorized users', () => {
|
||||
expect(provider.isUserAuthorized('unauthorized')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use environment variables when no config provided', () => {
|
||||
const originalEnv = process.env.AUTHORIZED_USERS;
|
||||
process.env.AUTHORIZED_USERS = 'envuser1,envuser2';
|
||||
|
||||
const envProvider = new ChatbotProvider();
|
||||
|
||||
expect(envProvider.isUserAuthorized('envuser1')).toBe(true);
|
||||
expect(envProvider.isUserAuthorized('envuser2')).toBe(true);
|
||||
expect(envProvider.isUserAuthorized('unauthorized')).toBe(false);
|
||||
|
||||
process.env.AUTHORIZED_USERS = originalEnv;
|
||||
});
|
||||
|
||||
it('should use default authorized user when no config or env provided', () => {
|
||||
const originalUsers = process.env.AUTHORIZED_USERS;
|
||||
const originalDefault = process.env.DEFAULT_AUTHORIZED_USER;
|
||||
|
||||
delete process.env.AUTHORIZED_USERS;
|
||||
process.env.DEFAULT_AUTHORIZED_USER = 'defaultuser';
|
||||
|
||||
const defaultProvider = new ChatbotProvider();
|
||||
|
||||
expect(defaultProvider.isUserAuthorized('defaultuser')).toBe(true);
|
||||
expect(defaultProvider.isUserAuthorized('other')).toBe(false);
|
||||
|
||||
process.env.AUTHORIZED_USERS = originalUsers;
|
||||
process.env.DEFAULT_AUTHORIZED_USER = originalDefault;
|
||||
});
|
||||
|
||||
it('should fallback to admin when no config provided', () => {
|
||||
const originalUsers = process.env.AUTHORIZED_USERS;
|
||||
const originalDefault = process.env.DEFAULT_AUTHORIZED_USER;
|
||||
|
||||
delete process.env.AUTHORIZED_USERS;
|
||||
delete process.env.DEFAULT_AUTHORIZED_USER;
|
||||
|
||||
const fallbackProvider = new ChatbotProvider();
|
||||
|
||||
expect(fallbackProvider.isUserAuthorized('admin')).toBe(true);
|
||||
expect(fallbackProvider.isUserAuthorized('other')).toBe(false);
|
||||
|
||||
process.env.AUTHORIZED_USERS = originalUsers;
|
||||
process.env.DEFAULT_AUTHORIZED_USER = originalDefault;
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderName()', () => {
|
||||
it('should return the class name', () => {
|
||||
expect(provider.getProviderName()).toBe('ChatbotProvider');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBotMention()', () => {
|
||||
it('should return bot mention from config', () => {
|
||||
expect(provider.getBotMention()).toBe('@testbot');
|
||||
});
|
||||
|
||||
it('should return bot mention from environment variable', () => {
|
||||
const originalEnv = process.env.BOT_USERNAME;
|
||||
process.env.BOT_USERNAME = '@envbot';
|
||||
|
||||
const envProvider = new ChatbotProvider();
|
||||
|
||||
expect(envProvider.getBotMention()).toBe('@envbot');
|
||||
|
||||
process.env.BOT_USERNAME = originalEnv;
|
||||
});
|
||||
|
||||
it('should return default bot mention when no config provided', () => {
|
||||
const originalEnv = process.env.BOT_USERNAME;
|
||||
delete process.env.BOT_USERNAME;
|
||||
|
||||
const defaultProvider = new ChatbotProvider();
|
||||
|
||||
expect(defaultProvider.getBotMention()).toBe('@ClaudeBot');
|
||||
|
||||
process.env.BOT_USERNAME = originalEnv;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Test concrete implementation to verify inheritance works correctly
|
||||
class TestChatbotProvider extends ChatbotProvider {
|
||||
async initialize() {
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
verifyWebhookSignature(req) {
|
||||
return req.valid === true;
|
||||
}
|
||||
|
||||
parseWebhookPayload(payload) {
|
||||
return { type: 'test', content: payload.message };
|
||||
}
|
||||
|
||||
extractBotCommand(message) {
|
||||
if (message.includes('@testbot')) {
|
||||
return { command: message.replace('@testbot', '').trim() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async sendResponse(context, response) {
|
||||
context.lastResponse = response;
|
||||
}
|
||||
|
||||
getUserId(context) {
|
||||
return context.userId;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ChatbotProvider inheritance', () => {
|
||||
let testProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
testProvider = new TestChatbotProvider({ botMention: '@testbot' });
|
||||
});
|
||||
|
||||
it('should allow concrete implementation to override abstract methods', async () => {
|
||||
await testProvider.initialize();
|
||||
expect(testProvider.initialized).toBe(true);
|
||||
|
||||
expect(testProvider.verifyWebhookSignature({ valid: true })).toBe(true);
|
||||
expect(testProvider.verifyWebhookSignature({ valid: false })).toBe(false);
|
||||
|
||||
const parsed = testProvider.parseWebhookPayload({ message: 'hello' });
|
||||
expect(parsed.type).toBe('test');
|
||||
expect(parsed.content).toBe('hello');
|
||||
|
||||
const command = testProvider.extractBotCommand('@testbot help me');
|
||||
expect(command.command).toBe('help me');
|
||||
|
||||
const context = { userId: '123' };
|
||||
await testProvider.sendResponse(context, 'test response');
|
||||
expect(context.lastResponse).toBe('test response');
|
||||
|
||||
expect(testProvider.getUserId({ userId: '456' })).toBe('456');
|
||||
});
|
||||
|
||||
it('should inherit base class utility methods', () => {
|
||||
expect(testProvider.getProviderName()).toBe('TestChatbotProvider');
|
||||
expect(testProvider.getBotMention()).toBe('@testbot');
|
||||
expect(testProvider.isUserAuthorized).toBeDefined();
|
||||
expect(testProvider.formatErrorMessage).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,491 +0,0 @@
|
||||
const DiscordProvider = require('../../../src/providers/DiscordProvider');
|
||||
const axios = require('axios');
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('axios');
|
||||
jest.mock('../../../src/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn()
|
||||
})
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/utils/secureCredentials', () => ({
|
||||
get: jest.fn()
|
||||
}));
|
||||
|
||||
const mockSecureCredentials = require('../../../src/utils/secureCredentials');
|
||||
|
||||
describe('DiscordProvider', () => {
|
||||
let provider;
|
||||
let originalEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
// Mock credentials
|
||||
mockSecureCredentials.get.mockImplementation(key => {
|
||||
const mockCreds = {
|
||||
DISCORD_BOT_TOKEN: 'mock_bot_token',
|
||||
DISCORD_PUBLIC_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
DISCORD_APPLICATION_ID: '123456789012345678'
|
||||
};
|
||||
return mockCreds[key];
|
||||
});
|
||||
|
||||
provider = new DiscordProvider({
|
||||
authorizedUsers: ['user1', 'user2']
|
||||
});
|
||||
|
||||
// Reset axios mock
|
||||
axios.post.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize successfully with valid credentials', async () => {
|
||||
await expect(provider.initialize()).resolves.toBeUndefined();
|
||||
expect(provider.botToken).toBe('mock_bot_token');
|
||||
expect(provider.publicKey).toBe(
|
||||
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||
);
|
||||
expect(provider.applicationId).toBe('123456789012345678');
|
||||
});
|
||||
|
||||
it('should use environment variables when secure credentials not available', async () => {
|
||||
mockSecureCredentials.get.mockReturnValue(null);
|
||||
process.env.DISCORD_BOT_TOKEN = 'env_bot_token';
|
||||
process.env.DISCORD_PUBLIC_KEY = 'env_public_key';
|
||||
process.env.DISCORD_APPLICATION_ID = 'env_app_id';
|
||||
|
||||
await provider.initialize();
|
||||
|
||||
expect(provider.botToken).toBe('env_bot_token');
|
||||
expect(provider.publicKey).toBe('env_public_key');
|
||||
expect(provider.applicationId).toBe('env_app_id');
|
||||
});
|
||||
|
||||
it('should throw error when required credentials are missing', async () => {
|
||||
mockSecureCredentials.get.mockReturnValue(null);
|
||||
delete process.env.DISCORD_BOT_TOKEN;
|
||||
delete process.env.DISCORD_PUBLIC_KEY;
|
||||
|
||||
await expect(provider.initialize()).rejects.toThrow(
|
||||
'Discord bot token and public key are required'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyWebhookSignature', () => {
|
||||
beforeEach(async () => {
|
||||
await provider.initialize();
|
||||
});
|
||||
|
||||
it('should return false when signature headers are missing', () => {
|
||||
const req = { headers: {} };
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when only timestamp is present', () => {
|
||||
const req = {
|
||||
headers: { 'x-signature-timestamp': '1234567890' }
|
||||
};
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when only signature is present', () => {
|
||||
const req = {
|
||||
headers: { 'x-signature-ed25519': 'some_signature' }
|
||||
};
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true in test mode', () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519': 'invalid_signature',
|
||||
'x-signature-timestamp': '1234567890'
|
||||
}
|
||||
};
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle crypto verification errors gracefully', () => {
|
||||
// Temporarily override NODE_ENV to ensure signature verification runs
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519': 'invalid_signature_format',
|
||||
'x-signature-timestamp': '1234567890'
|
||||
},
|
||||
rawBody: Buffer.from('test body'),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
// This should not throw, but return false due to invalid signature
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
|
||||
// Restore original NODE_ENV
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWebhookPayload', () => {
|
||||
it('should parse PING interaction', () => {
|
||||
const payload = { type: 1 };
|
||||
const result = provider.parseWebhookPayload(payload);
|
||||
|
||||
expect(result.type).toBe('ping');
|
||||
expect(result.shouldRespond).toBe(true);
|
||||
expect(result.responseData).toEqual({ type: 1 });
|
||||
});
|
||||
|
||||
it('should parse APPLICATION_COMMAND interaction', () => {
|
||||
const payload = {
|
||||
type: 2,
|
||||
data: {
|
||||
name: 'help',
|
||||
options: [{ name: 'topic', value: 'discord' }]
|
||||
},
|
||||
channel_id: '123456789',
|
||||
guild_id: '987654321',
|
||||
member: {
|
||||
user: {
|
||||
id: 'user123',
|
||||
username: 'testuser'
|
||||
}
|
||||
},
|
||||
token: 'interaction_token',
|
||||
id: 'interaction_id'
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(payload);
|
||||
|
||||
expect(result.type).toBe('command');
|
||||
expect(result.command).toBe('help');
|
||||
expect(result.options).toHaveLength(1);
|
||||
expect(result.channelId).toBe('123456789');
|
||||
expect(result.guildId).toBe('987654321');
|
||||
expect(result.userId).toBe('user123');
|
||||
expect(result.username).toBe('testuser');
|
||||
expect(result.content).toBe('help topic:discord');
|
||||
expect(result.interactionToken).toBe('interaction_token');
|
||||
expect(result.interactionId).toBe('interaction_id');
|
||||
expect(result.repo).toBe(null);
|
||||
expect(result.branch).toBe(null);
|
||||
});
|
||||
|
||||
it('should parse APPLICATION_COMMAND with repo and branch parameters', () => {
|
||||
const payload = {
|
||||
type: 2,
|
||||
data: {
|
||||
name: 'claude',
|
||||
options: [
|
||||
{ name: 'repo', value: 'owner/myrepo' },
|
||||
{ name: 'branch', value: 'feature-branch' },
|
||||
{ name: 'command', value: 'fix this bug' }
|
||||
]
|
||||
},
|
||||
channel_id: '123456789',
|
||||
guild_id: '987654321',
|
||||
member: {
|
||||
user: {
|
||||
id: 'user123',
|
||||
username: 'testuser'
|
||||
}
|
||||
},
|
||||
token: 'interaction_token',
|
||||
id: 'interaction_id'
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(payload);
|
||||
|
||||
expect(result.type).toBe('command');
|
||||
expect(result.command).toBe('claude');
|
||||
expect(result.options).toHaveLength(3);
|
||||
expect(result.repo).toBe('owner/myrepo');
|
||||
expect(result.branch).toBe('feature-branch');
|
||||
expect(result.content).toBe(
|
||||
'claude repo:owner/myrepo branch:feature-branch command:fix this bug'
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse APPLICATION_COMMAND with repo but no branch (defaults to main)', () => {
|
||||
const payload = {
|
||||
type: 2,
|
||||
data: {
|
||||
name: 'claude',
|
||||
options: [
|
||||
{ name: 'repo', value: 'owner/myrepo' },
|
||||
{ name: 'command', value: 'review this code' }
|
||||
]
|
||||
},
|
||||
channel_id: '123456789',
|
||||
guild_id: '987654321',
|
||||
member: {
|
||||
user: {
|
||||
id: 'user123',
|
||||
username: 'testuser'
|
||||
}
|
||||
},
|
||||
token: 'interaction_token',
|
||||
id: 'interaction_id'
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(payload);
|
||||
|
||||
expect(result.type).toBe('command');
|
||||
expect(result.repo).toBe('owner/myrepo');
|
||||
expect(result.branch).toBe('main'); // Default value
|
||||
expect(result.content).toBe('claude repo:owner/myrepo command:review this code');
|
||||
});
|
||||
|
||||
it('should parse MESSAGE_COMPONENT interaction', () => {
|
||||
const payload = {
|
||||
type: 3,
|
||||
data: {
|
||||
custom_id: 'button_click'
|
||||
},
|
||||
channel_id: '123456789',
|
||||
user: {
|
||||
id: 'user123',
|
||||
username: 'testuser'
|
||||
},
|
||||
token: 'interaction_token',
|
||||
id: 'interaction_id'
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(payload);
|
||||
|
||||
expect(result.type).toBe('component');
|
||||
expect(result.customId).toBe('button_click');
|
||||
expect(result.userId).toBe('user123');
|
||||
expect(result.username).toBe('testuser');
|
||||
});
|
||||
|
||||
it('should handle unknown interaction types', () => {
|
||||
const payload = { type: 999 };
|
||||
const result = provider.parseWebhookPayload(payload);
|
||||
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.shouldRespond).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle payload parsing errors', () => {
|
||||
expect(() => provider.parseWebhookPayload(null)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCommandContent', () => {
|
||||
it('should build command content with name only', () => {
|
||||
const commandData = { name: 'help' };
|
||||
const result = provider.buildCommandContent(commandData);
|
||||
expect(result).toBe('help');
|
||||
});
|
||||
|
||||
it('should build command content with options', () => {
|
||||
const commandData = {
|
||||
name: 'help',
|
||||
options: [
|
||||
{ name: 'topic', value: 'discord' },
|
||||
{ name: 'format', value: 'detailed' }
|
||||
]
|
||||
};
|
||||
const result = provider.buildCommandContent(commandData);
|
||||
expect(result).toBe('help topic:discord format:detailed');
|
||||
});
|
||||
|
||||
it('should handle empty command data', () => {
|
||||
expect(provider.buildCommandContent(null)).toBe('');
|
||||
expect(provider.buildCommandContent(undefined)).toBe('');
|
||||
expect(provider.buildCommandContent({})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBotCommand', () => {
|
||||
it('should extract command from content', () => {
|
||||
const result = provider.extractBotCommand('help me with discord');
|
||||
expect(result.command).toBe('help me with discord');
|
||||
expect(result.originalMessage).toBe('help me with discord');
|
||||
});
|
||||
|
||||
it('should return null for empty content', () => {
|
||||
expect(provider.extractBotCommand('')).toBeNull();
|
||||
expect(provider.extractBotCommand(null)).toBeNull();
|
||||
expect(provider.extractBotCommand(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRepoAndBranch', () => {
|
||||
it('should extract repo and branch from command options', () => {
|
||||
const commandData = {
|
||||
name: 'claude',
|
||||
options: [
|
||||
{ name: 'repo', value: 'owner/myrepo' },
|
||||
{ name: 'branch', value: 'feature-branch' },
|
||||
{ name: 'command', value: 'fix this' }
|
||||
]
|
||||
};
|
||||
|
||||
const result = provider.extractRepoAndBranch(commandData);
|
||||
expect(result.repo).toBe('owner/myrepo');
|
||||
expect(result.branch).toBe('feature-branch');
|
||||
});
|
||||
|
||||
it('should default branch to main when not provided', () => {
|
||||
const commandData = {
|
||||
name: 'claude',
|
||||
options: [
|
||||
{ name: 'repo', value: 'owner/myrepo' },
|
||||
{ name: 'command', value: 'fix this' }
|
||||
]
|
||||
};
|
||||
|
||||
const result = provider.extractRepoAndBranch(commandData);
|
||||
expect(result.repo).toBe('owner/myrepo');
|
||||
expect(result.branch).toBe('main');
|
||||
});
|
||||
|
||||
it('should return null values when no repo option provided', () => {
|
||||
const commandData = { name: 'claude' };
|
||||
const result = provider.extractRepoAndBranch(commandData);
|
||||
expect(result.repo).toBe(null);
|
||||
expect(result.branch).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle empty or null command data', () => {
|
||||
expect(provider.extractRepoAndBranch(null)).toEqual({ repo: null, branch: null });
|
||||
expect(provider.extractRepoAndBranch({})).toEqual({ repo: null, branch: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendResponse', () => {
|
||||
beforeEach(async () => {
|
||||
await provider.initialize();
|
||||
axios.post.mockResolvedValue({ data: { id: 'message_id' } });
|
||||
});
|
||||
|
||||
it('should skip response for ping interactions', async () => {
|
||||
const context = { type: 'ping' };
|
||||
await provider.sendResponse(context, 'test response');
|
||||
expect(axios.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should send follow-up message for interactions with token', async () => {
|
||||
const context = {
|
||||
type: 'command',
|
||||
interactionToken: 'test_token',
|
||||
interactionId: 'test_id'
|
||||
};
|
||||
|
||||
await provider.sendResponse(context, 'test response');
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
`https://discord.com/api/v10/webhooks/${provider.applicationId}/test_token`,
|
||||
{ content: 'test response', flags: 0 },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bot ${provider.botToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should send channel message when no interaction token', async () => {
|
||||
const context = {
|
||||
type: 'command',
|
||||
channelId: '123456789'
|
||||
};
|
||||
|
||||
await provider.sendResponse(context, 'test response');
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'https://discord.com/api/v10/channels/123456789/messages',
|
||||
{ content: 'test response' },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bot ${provider.botToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle axios errors', async () => {
|
||||
axios.post.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const context = {
|
||||
type: 'command',
|
||||
channelId: '123456789'
|
||||
};
|
||||
|
||||
await expect(provider.sendResponse(context, 'test response')).rejects.toThrow(
|
||||
'Network error'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitLongMessage', () => {
|
||||
it('should return single message when under limit', () => {
|
||||
const result = provider.splitLongMessage('short message', 2000);
|
||||
expect(result).toEqual(['short message']);
|
||||
});
|
||||
|
||||
it('should split long messages by lines', () => {
|
||||
const longMessage = 'line1\n'.repeat(50) + 'final line';
|
||||
const result = provider.splitLongMessage(longMessage, 100);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
expect(result.every(msg => msg.length <= 100)).toBe(true);
|
||||
});
|
||||
|
||||
it('should split very long single lines', () => {
|
||||
const longLine = 'a'.repeat(3000);
|
||||
const result = provider.splitLongMessage(longLine, 2000);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].length).toBe(2000);
|
||||
expect(result[1].length).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserId', () => {
|
||||
it('should return userId from context', () => {
|
||||
const context = { userId: 'user123' };
|
||||
expect(provider.getUserId(context)).toBe('user123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatErrorMessage', () => {
|
||||
it('should format Discord-specific error message', () => {
|
||||
const error = new Error('Test error');
|
||||
const errorId = 'test-123';
|
||||
|
||||
const message = provider.formatErrorMessage(error, errorId);
|
||||
|
||||
expect(message).toContain('🚫 **Error Processing Command**');
|
||||
expect(message).toContain('**Reference ID:** `test-123`');
|
||||
expect(message).toContain('Please contact an administrator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBotMention', () => {
|
||||
it('should return Discord-specific bot mention', () => {
|
||||
const provider = new DiscordProvider({ botMention: 'custombot' });
|
||||
expect(provider.getBotMention()).toBe('custombot');
|
||||
});
|
||||
|
||||
it('should return default bot mention', () => {
|
||||
const provider = new DiscordProvider();
|
||||
expect(provider.getBotMention()).toBe('claude');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,324 +0,0 @@
|
||||
// Mock dependencies
|
||||
jest.mock('../../../src/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn()
|
||||
})
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/utils/secureCredentials', () => ({
|
||||
get: jest.fn(),
|
||||
loadCredentials: jest.fn()
|
||||
}));
|
||||
|
||||
const _ProviderFactory = require('../../../src/providers/ProviderFactory');
|
||||
const DiscordProvider = require('../../../src/providers/DiscordProvider');
|
||||
const ChatbotProvider = require('../../../src/providers/ChatbotProvider');
|
||||
|
||||
// Mock DiscordProvider to avoid initialization issues in tests
|
||||
jest.mock('../../../src/providers/DiscordProvider', () => {
|
||||
const mockImplementation = jest.fn().mockImplementation(config => {
|
||||
const instance = {
|
||||
initialize: jest.fn().mockResolvedValue(),
|
||||
config,
|
||||
getProviderName: jest.fn().mockReturnValue('DiscordProvider')
|
||||
};
|
||||
Object.setPrototypeOf(instance, mockImplementation.prototype);
|
||||
return instance;
|
||||
});
|
||||
return mockImplementation;
|
||||
});
|
||||
|
||||
describe('ProviderFactory', () => {
|
||||
let factory;
|
||||
let originalEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
// Clear the factory singleton and create fresh instance for each test
|
||||
jest.resetModules();
|
||||
const ProviderFactoryClass = require('../../../src/providers/ProviderFactory').constructor;
|
||||
factory = new ProviderFactoryClass();
|
||||
|
||||
// Mock DiscordProvider
|
||||
DiscordProvider.mockImplementation(() => ({
|
||||
initialize: jest.fn().mockResolvedValue(),
|
||||
getProviderName: jest.fn().mockReturnValue('DiscordProvider'),
|
||||
getBotMention: jest.fn().mockReturnValue('@claude')
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with discord provider registered', () => {
|
||||
expect(factory.getAvailableProviders()).toContain('discord');
|
||||
});
|
||||
|
||||
it('should start with empty providers map', () => {
|
||||
expect(factory.getAllProviders().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerProvider', () => {
|
||||
class TestProvider extends ChatbotProvider {
|
||||
async initialize() {}
|
||||
verifyWebhookSignature() {
|
||||
return true;
|
||||
}
|
||||
parseWebhookPayload() {
|
||||
return {};
|
||||
}
|
||||
extractBotCommand() {
|
||||
return null;
|
||||
}
|
||||
async sendResponse() {}
|
||||
getUserId() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
it('should register new provider', () => {
|
||||
factory.registerProvider('test', TestProvider);
|
||||
expect(factory.getAvailableProviders()).toContain('test');
|
||||
});
|
||||
|
||||
it('should handle case-insensitive provider names', () => {
|
||||
factory.registerProvider('TEST', TestProvider);
|
||||
expect(factory.getAvailableProviders()).toContain('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('createProvider', () => {
|
||||
it('should create and cache discord provider', async () => {
|
||||
const provider = await factory.createProvider('discord');
|
||||
expect(provider).toBeInstanceOf(DiscordProvider);
|
||||
expect(DiscordProvider).toHaveBeenCalledWith({});
|
||||
|
||||
// Should return cached instance on second call
|
||||
const provider2 = await factory.createProvider('discord');
|
||||
expect(provider2).toBe(provider);
|
||||
expect(DiscordProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should create provider with custom config', async () => {
|
||||
const config = { botMention: '@custombot', authorizedUsers: ['user1'] };
|
||||
await factory.createProvider('discord', config);
|
||||
|
||||
expect(DiscordProvider).toHaveBeenCalledWith(config);
|
||||
});
|
||||
|
||||
it('should merge with default config', async () => {
|
||||
factory.setDefaultConfig({ globalSetting: true });
|
||||
const config = { botMention: '@custombot' };
|
||||
|
||||
await factory.createProvider('discord', config);
|
||||
|
||||
expect(DiscordProvider).toHaveBeenCalledWith({
|
||||
globalSetting: true,
|
||||
botMention: '@custombot'
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for unknown provider', async () => {
|
||||
await expect(factory.createProvider('unknown')).rejects.toThrow(
|
||||
'Unknown provider: unknown. Available providers: discord'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle provider initialization errors', async () => {
|
||||
DiscordProvider.mockImplementation(() => {
|
||||
throw new Error('Initialization failed');
|
||||
});
|
||||
|
||||
await expect(factory.createProvider('discord')).rejects.toThrow(
|
||||
'Failed to create discord provider: Initialization failed'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProvider', () => {
|
||||
it('should return existing provider', async () => {
|
||||
const provider = await factory.createProvider('discord');
|
||||
expect(factory.getProvider('discord')).toBe(provider);
|
||||
});
|
||||
|
||||
it('should return null for non-existent provider', () => {
|
||||
expect(factory.getProvider('nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('should be case-insensitive', async () => {
|
||||
const provider = await factory.createProvider('discord');
|
||||
expect(factory.getProvider('DISCORD')).toBe(provider);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDefaultConfig', () => {
|
||||
it('should set default configuration', () => {
|
||||
const config = { globalSetting: true, defaultUser: 'admin' };
|
||||
factory.setDefaultConfig(config);
|
||||
expect(factory.defaultConfig).toEqual(config);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('updateProviderConfig', () => {
|
||||
it('should recreate provider with new config', async () => {
|
||||
// Create initial provider
|
||||
await factory.createProvider('discord', { botMention: '@oldbot' });
|
||||
expect(DiscordProvider).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Update config
|
||||
await factory.updateProviderConfig('discord', { botMention: '@newbot' });
|
||||
expect(DiscordProvider).toHaveBeenCalledTimes(2);
|
||||
expect(DiscordProvider).toHaveBeenLastCalledWith({ botMention: '@newbot' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnvironmentConfig', () => {
|
||||
it('should extract Discord config from environment', () => {
|
||||
process.env.DISCORD_BOT_TOKEN = 'test_token';
|
||||
process.env.DISCORD_PUBLIC_KEY = 'test_key';
|
||||
process.env.DISCORD_APPLICATION_ID = 'test_id';
|
||||
process.env.DISCORD_AUTHORIZED_USERS = 'user1,user2,user3';
|
||||
process.env.DISCORD_BOT_MENTION = '@discordbot';
|
||||
|
||||
const config = factory.getEnvironmentConfig('discord');
|
||||
|
||||
expect(config).toEqual({
|
||||
botToken: 'test_token',
|
||||
publicKey: 'test_key',
|
||||
applicationId: 'test_id',
|
||||
authorizedUsers: ['user1', 'user2', 'user3'],
|
||||
botMention: '@discordbot'
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove undefined values from config', () => {
|
||||
// Only set some env vars
|
||||
process.env.DISCORD_BOT_TOKEN = 'test_token';
|
||||
// Don't set DISCORD_PUBLIC_KEY
|
||||
|
||||
const config = factory.getEnvironmentConfig('discord');
|
||||
|
||||
expect(config).toEqual({
|
||||
botToken: 'test_token'
|
||||
});
|
||||
expect(Object.prototype.hasOwnProperty.call(config, 'publicKey')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('createFromEnvironment', () => {
|
||||
it('should create provider using environment config', async () => {
|
||||
process.env.DISCORD_BOT_TOKEN = 'env_token';
|
||||
process.env.DISCORD_AUTHORIZED_USERS = 'envuser1,envuser2';
|
||||
|
||||
await factory.createFromEnvironment('discord');
|
||||
|
||||
expect(DiscordProvider).toHaveBeenCalledWith({
|
||||
botToken: 'env_token',
|
||||
authorizedUsers: ['envuser1', 'envuser2']
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMultipleProviders', () => {
|
||||
class MockTestProvider extends ChatbotProvider {
|
||||
async initialize() {}
|
||||
verifyWebhookSignature() {
|
||||
return true;
|
||||
}
|
||||
parseWebhookPayload() {
|
||||
return {};
|
||||
}
|
||||
extractBotCommand() {
|
||||
return null;
|
||||
}
|
||||
async sendResponse() {}
|
||||
getUserId() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
factory.registerProvider('test', MockTestProvider);
|
||||
});
|
||||
|
||||
it('should create multiple providers successfully', async () => {
|
||||
const config = {
|
||||
discord: { botMention: '@discord' },
|
||||
test: { botMention: '@test' }
|
||||
};
|
||||
|
||||
const results = await factory.createMultipleProviders(config);
|
||||
|
||||
expect(results.size).toBe(2);
|
||||
expect(results.has('discord')).toBe(true);
|
||||
expect(results.has('test')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle partial failures gracefully', async () => {
|
||||
const config = {
|
||||
discord: { botMention: '@discord' },
|
||||
unknown: { botMention: '@unknown' }
|
||||
};
|
||||
|
||||
const results = await factory.createMultipleProviders(config);
|
||||
|
||||
expect(results.size).toBe(1);
|
||||
expect(results.has('discord')).toBe(true);
|
||||
expect(results.has('unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clear all providers', async () => {
|
||||
await factory.createProvider('discord');
|
||||
expect(factory.getAllProviders().size).toBe(1);
|
||||
|
||||
await factory.cleanup();
|
||||
expect(factory.getAllProviders().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStats', () => {
|
||||
it('should return provider statistics', async () => {
|
||||
await factory.createProvider('discord');
|
||||
|
||||
const stats = factory.getStats();
|
||||
|
||||
expect(stats).toEqual({
|
||||
totalRegistered: 1,
|
||||
totalInitialized: 1,
|
||||
availableProviders: ['discord'],
|
||||
initializedProviders: ['discord']
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct stats when no providers initialized', () => {
|
||||
const stats = factory.getStats();
|
||||
|
||||
expect(stats).toEqual({
|
||||
totalRegistered: 1, // discord is registered by default
|
||||
totalInitialized: 0,
|
||||
availableProviders: ['discord'],
|
||||
initializedProviders: []
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('singleton behavior', () => {
|
||||
it('should be a singleton when imported normally', () => {
|
||||
// This tests the actual exported singleton
|
||||
const factory1 = require('../../../src/providers/ProviderFactory');
|
||||
const factory2 = require('../../../src/providers/ProviderFactory');
|
||||
|
||||
expect(factory1).toBe(factory2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,508 +0,0 @@
|
||||
const DiscordProvider = require('../../../src/providers/DiscordProvider');
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../../src/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn()
|
||||
})
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/utils/secureCredentials', () => ({
|
||||
get: jest.fn().mockReturnValue('mock_value')
|
||||
}));
|
||||
|
||||
describe('Discord Payload Processing Tests', () => {
|
||||
let provider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new DiscordProvider();
|
||||
});
|
||||
|
||||
describe('Real Discord Payload Examples', () => {
|
||||
it('should parse Discord PING interaction correctly', () => {
|
||||
const pingPayload = {
|
||||
id: '123456789012345678',
|
||||
type: 1,
|
||||
version: 1
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(pingPayload);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'ping',
|
||||
shouldRespond: true,
|
||||
responseData: { type: 1 }
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse Discord slash command without options', () => {
|
||||
const slashCommandPayload = {
|
||||
id: '123456789012345678',
|
||||
application_id: '987654321098765432',
|
||||
type: 2,
|
||||
data: {
|
||||
id: '456789012345678901',
|
||||
name: 'claude',
|
||||
type: 1,
|
||||
resolved: {},
|
||||
options: []
|
||||
},
|
||||
guild_id: '111111111111111111',
|
||||
channel_id: '222222222222222222',
|
||||
member: {
|
||||
user: {
|
||||
id: '333333333333333333',
|
||||
username: 'testuser',
|
||||
discriminator: '1234',
|
||||
avatar: 'avatar_hash'
|
||||
},
|
||||
roles: ['444444444444444444'],
|
||||
permissions: '2147483647'
|
||||
},
|
||||
token: 'unique_interaction_token',
|
||||
version: 1
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(slashCommandPayload);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'command',
|
||||
command: 'claude',
|
||||
options: [],
|
||||
channelId: '222222222222222222',
|
||||
guildId: '111111111111111111',
|
||||
userId: '333333333333333333',
|
||||
username: 'testuser',
|
||||
content: 'claude',
|
||||
interactionToken: 'unique_interaction_token',
|
||||
interactionId: '123456789012345678',
|
||||
repo: null,
|
||||
branch: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse Discord slash command with string option', () => {
|
||||
const slashCommandWithOptionsPayload = {
|
||||
id: '123456789012345678',
|
||||
application_id: '987654321098765432',
|
||||
type: 2,
|
||||
data: {
|
||||
id: '456789012345678901',
|
||||
name: 'claude',
|
||||
type: 1,
|
||||
options: [
|
||||
{
|
||||
name: 'prompt',
|
||||
type: 3,
|
||||
value: 'Help me debug this Python function'
|
||||
}
|
||||
]
|
||||
},
|
||||
guild_id: '111111111111111111',
|
||||
channel_id: '222222222222222222',
|
||||
member: {
|
||||
user: {
|
||||
id: '333333333333333333',
|
||||
username: 'developer',
|
||||
discriminator: '5678'
|
||||
}
|
||||
},
|
||||
token: 'another_interaction_token',
|
||||
version: 1
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(slashCommandWithOptionsPayload);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'command',
|
||||
command: 'claude',
|
||||
options: [
|
||||
{
|
||||
name: 'prompt',
|
||||
type: 3,
|
||||
value: 'Help me debug this Python function'
|
||||
}
|
||||
],
|
||||
channelId: '222222222222222222',
|
||||
guildId: '111111111111111111',
|
||||
userId: '333333333333333333',
|
||||
username: 'developer',
|
||||
content: 'claude prompt:Help me debug this Python function',
|
||||
interactionToken: 'another_interaction_token',
|
||||
interactionId: '123456789012345678',
|
||||
repo: null,
|
||||
branch: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse Discord slash command with multiple options', () => {
|
||||
const multiOptionPayload = {
|
||||
id: '123456789012345678',
|
||||
type: 2,
|
||||
data: {
|
||||
name: 'claude',
|
||||
options: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 3,
|
||||
value: 'review'
|
||||
},
|
||||
{
|
||||
name: 'file',
|
||||
type: 3,
|
||||
value: 'src/main.js'
|
||||
},
|
||||
{
|
||||
name: 'verbose',
|
||||
type: 5,
|
||||
value: true
|
||||
}
|
||||
]
|
||||
},
|
||||
channel_id: '222222222222222222',
|
||||
member: {
|
||||
user: {
|
||||
id: '333333333333333333',
|
||||
username: 'reviewer'
|
||||
}
|
||||
},
|
||||
token: 'multi_option_token'
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(multiOptionPayload);
|
||||
|
||||
expect(result.content).toBe('claude action:review file:src/main.js verbose:true');
|
||||
expect(result.options).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should parse Discord button interaction', () => {
|
||||
const buttonInteractionPayload = {
|
||||
id: '123456789012345678',
|
||||
application_id: '987654321098765432',
|
||||
type: 3,
|
||||
data: {
|
||||
component_type: 2,
|
||||
custom_id: 'help_button_click'
|
||||
},
|
||||
guild_id: '111111111111111111',
|
||||
channel_id: '222222222222222222',
|
||||
member: {
|
||||
user: {
|
||||
id: '333333333333333333',
|
||||
username: 'buttonclicker'
|
||||
}
|
||||
},
|
||||
message: {
|
||||
id: '555555555555555555',
|
||||
content: 'Original message content'
|
||||
},
|
||||
token: 'button_interaction_token',
|
||||
version: 1
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(buttonInteractionPayload);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'component',
|
||||
customId: 'help_button_click',
|
||||
channelId: '222222222222222222',
|
||||
guildId: '111111111111111111',
|
||||
userId: '333333333333333333',
|
||||
username: 'buttonclicker',
|
||||
interactionToken: 'button_interaction_token',
|
||||
interactionId: '123456789012345678'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse Discord select menu interaction', () => {
|
||||
const selectMenuPayload = {
|
||||
id: '123456789012345678',
|
||||
type: 3,
|
||||
data: {
|
||||
component_type: 3,
|
||||
custom_id: 'language_select',
|
||||
values: ['javascript', 'python']
|
||||
},
|
||||
channel_id: '222222222222222222',
|
||||
user: {
|
||||
id: '333333333333333333',
|
||||
username: 'selector'
|
||||
},
|
||||
token: 'select_interaction_token'
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(selectMenuPayload);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'component',
|
||||
customId: 'language_select',
|
||||
channelId: '222222222222222222',
|
||||
guildId: undefined,
|
||||
userId: '333333333333333333',
|
||||
username: 'selector',
|
||||
interactionToken: 'select_interaction_token',
|
||||
interactionId: '123456789012345678'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Discord DM (no guild_id)', () => {
|
||||
const dmPayload = {
|
||||
id: '123456789012345678',
|
||||
type: 2,
|
||||
data: {
|
||||
name: 'claude',
|
||||
options: [
|
||||
{
|
||||
name: 'question',
|
||||
value: 'How do I use async/await in JavaScript?'
|
||||
}
|
||||
]
|
||||
},
|
||||
channel_id: '222222222222222222',
|
||||
user: {
|
||||
id: '333333333333333333',
|
||||
username: 'dmuser'
|
||||
},
|
||||
token: 'dm_interaction_token'
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(dmPayload);
|
||||
|
||||
expect(result.guildId).toBeUndefined();
|
||||
expect(result.userId).toBe('333333333333333333');
|
||||
expect(result.username).toBe('dmuser');
|
||||
expect(result.type).toBe('command');
|
||||
});
|
||||
|
||||
it('should handle payload with missing optional fields', () => {
|
||||
const minimalPayload = {
|
||||
id: '123456789012345678',
|
||||
type: 2,
|
||||
data: {
|
||||
name: 'claude'
|
||||
},
|
||||
channel_id: '222222222222222222',
|
||||
user: {
|
||||
id: '333333333333333333',
|
||||
username: 'minimaluser'
|
||||
},
|
||||
token: 'minimal_token'
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(minimalPayload);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'command',
|
||||
command: 'claude',
|
||||
options: [],
|
||||
channelId: '222222222222222222',
|
||||
guildId: undefined,
|
||||
userId: '333333333333333333',
|
||||
username: 'minimaluser',
|
||||
content: 'claude',
|
||||
interactionToken: 'minimal_token',
|
||||
interactionId: '123456789012345678',
|
||||
repo: null,
|
||||
branch: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
it('should handle payload with null data gracefully', () => {
|
||||
const nullDataPayload = {
|
||||
id: '123456789012345678',
|
||||
type: 2,
|
||||
data: null,
|
||||
channel_id: '222222222222222222',
|
||||
user: {
|
||||
id: '333333333333333333',
|
||||
username: 'nulluser'
|
||||
},
|
||||
token: 'null_token'
|
||||
};
|
||||
|
||||
expect(() => provider.parseWebhookPayload(nullDataPayload)).not.toThrow();
|
||||
const result = provider.parseWebhookPayload(nullDataPayload);
|
||||
expect(result.content).toBe('');
|
||||
});
|
||||
|
||||
it('should handle payload with missing user information', () => {
|
||||
const noUserPayload = {
|
||||
id: '123456789012345678',
|
||||
type: 2,
|
||||
data: {
|
||||
name: 'claude'
|
||||
},
|
||||
channel_id: '222222222222222222',
|
||||
token: 'no_user_token'
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(noUserPayload);
|
||||
expect(result.userId).toBeUndefined();
|
||||
expect(result.username).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle unknown interaction type gracefully', () => {
|
||||
const unknownTypePayload = {
|
||||
id: '123456789012345678',
|
||||
type: 999, // Unknown type
|
||||
data: {
|
||||
name: 'claude'
|
||||
},
|
||||
channel_id: '222222222222222222',
|
||||
user: {
|
||||
id: '333333333333333333',
|
||||
username: 'unknownuser'
|
||||
},
|
||||
token: 'unknown_token'
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(unknownTypePayload);
|
||||
expect(result).toEqual({
|
||||
type: 'unknown',
|
||||
shouldRespond: false
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle very large option values', () => {
|
||||
const largeValuePayload = {
|
||||
id: '123456789012345678',
|
||||
type: 2,
|
||||
data: {
|
||||
name: 'claude',
|
||||
options: [
|
||||
{
|
||||
name: 'code',
|
||||
value: 'x'.repeat(4000) // Very large value
|
||||
}
|
||||
]
|
||||
},
|
||||
channel_id: '222222222222222222',
|
||||
user: {
|
||||
id: '333333333333333333',
|
||||
username: 'largeuser'
|
||||
},
|
||||
token: 'large_token'
|
||||
};
|
||||
|
||||
expect(() => provider.parseWebhookPayload(largeValuePayload)).not.toThrow();
|
||||
const result = provider.parseWebhookPayload(largeValuePayload);
|
||||
expect(result.content).toContain('claude code:');
|
||||
expect(result.content.length).toBeGreaterThan(4000);
|
||||
});
|
||||
|
||||
it('should handle special characters in usernames', () => {
|
||||
const specialCharsPayload = {
|
||||
id: '123456789012345678',
|
||||
type: 2,
|
||||
data: {
|
||||
name: 'claude'
|
||||
},
|
||||
channel_id: '222222222222222222',
|
||||
user: {
|
||||
id: '333333333333333333',
|
||||
username: 'user-with_special.chars123'
|
||||
},
|
||||
token: 'special_token'
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(specialCharsPayload);
|
||||
expect(result.username).toBe('user-with_special.chars123');
|
||||
});
|
||||
|
||||
it('should handle unicode characters in option values', () => {
|
||||
const unicodePayload = {
|
||||
id: '123456789012345678',
|
||||
type: 2,
|
||||
data: {
|
||||
name: 'claude',
|
||||
options: [
|
||||
{
|
||||
name: 'message',
|
||||
value: 'Hello 世界! 🚀 How are you?'
|
||||
}
|
||||
]
|
||||
},
|
||||
channel_id: '222222222222222222',
|
||||
user: {
|
||||
id: '333333333333333333',
|
||||
username: 'unicodeuser'
|
||||
},
|
||||
token: 'unicode_token'
|
||||
};
|
||||
|
||||
const result = provider.parseWebhookPayload(unicodePayload);
|
||||
expect(result.content).toBe('claude message:Hello 世界! 🚀 How are you?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCommandContent function', () => {
|
||||
it('should handle complex nested options structure', () => {
|
||||
const complexCommandData = {
|
||||
name: 'claude',
|
||||
options: [
|
||||
{
|
||||
name: 'subcommand',
|
||||
type: 1,
|
||||
options: [
|
||||
{
|
||||
name: 'param1',
|
||||
value: 'value1'
|
||||
},
|
||||
{
|
||||
name: 'param2',
|
||||
value: 'value2'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Note: Current implementation flattens all options
|
||||
const result = provider.buildCommandContent(complexCommandData);
|
||||
expect(result).toContain('claude');
|
||||
});
|
||||
|
||||
it('should handle boolean option values', () => {
|
||||
const booleanCommandData = {
|
||||
name: 'claude',
|
||||
options: [
|
||||
{
|
||||
name: 'verbose',
|
||||
value: true
|
||||
},
|
||||
{
|
||||
name: 'silent',
|
||||
value: false
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = provider.buildCommandContent(booleanCommandData);
|
||||
expect(result).toBe('claude verbose:true silent:false');
|
||||
});
|
||||
|
||||
it('should handle numeric option values', () => {
|
||||
const numericCommandData = {
|
||||
name: 'claude',
|
||||
options: [
|
||||
{
|
||||
name: 'count',
|
||||
value: 42
|
||||
},
|
||||
{
|
||||
name: 'rate',
|
||||
value: 3.14
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = provider.buildCommandContent(numericCommandData);
|
||||
expect(result).toBe('claude count:42 rate:3.14');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// Mock the controller
|
||||
jest.mock('../../../src/controllers/chatbotController', () => ({
|
||||
handleChatbotWebhook: jest.fn((req, res) => {
|
||||
res.status(200).json({ success: true });
|
||||
}),
|
||||
handleDiscordWebhook: jest.fn((req, res) => {
|
||||
res.status(200).json({ provider: 'discord' });
|
||||
}),
|
||||
getProviderStats: jest.fn((req, res) => {
|
||||
res.status(200).json({ stats: {} });
|
||||
})
|
||||
}));
|
||||
|
||||
describe('Chatbot Routes', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Import the router fresh
|
||||
const chatbotRouter = require('../../../src/routes/chatbot');
|
||||
app.use('/webhooks', chatbotRouter);
|
||||
});
|
||||
|
||||
it('should handle Discord webhook', async () => {
|
||||
const response = await request(app).post('/webhooks/discord').send({ type: 1 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.provider).toBe('discord');
|
||||
});
|
||||
|
||||
it('should get provider stats', async () => {
|
||||
const response = await request(app).get('/webhooks/stats');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('stats');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable no-redeclare */
|
||||
|
||||
import request from 'supertest';
|
||||
import express from 'express';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable no-redeclare */
|
||||
|
||||
import request from 'supertest';
|
||||
import express from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -1,431 +0,0 @@
|
||||
const crypto = require('crypto');
|
||||
const DiscordProvider = require('../../../src/providers/DiscordProvider');
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../../src/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn()
|
||||
})
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/utils/secureCredentials', () => ({
|
||||
get: jest.fn()
|
||||
}));
|
||||
|
||||
const mockSecureCredentials = require('../../../src/utils/secureCredentials');
|
||||
|
||||
describe.skip('Signature Verification Security Tests', () => {
|
||||
let provider;
|
||||
const validPublicKey = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
const _validPrivateKey = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789';
|
||||
|
||||
// Helper function to run test with production NODE_ENV
|
||||
const withProductionEnv = testFn => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
try {
|
||||
return testFn();
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockSecureCredentials.get.mockImplementation(key => {
|
||||
const mockCreds = {
|
||||
DISCORD_BOT_TOKEN: 'mock_bot_token',
|
||||
DISCORD_PUBLIC_KEY: validPublicKey,
|
||||
DISCORD_APPLICATION_ID: '123456789012345678'
|
||||
};
|
||||
return mockCreds[key];
|
||||
});
|
||||
|
||||
provider = new DiscordProvider();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Discord Ed25519 Signature Verification', () => {
|
||||
beforeEach(async () => {
|
||||
await provider.initialize();
|
||||
});
|
||||
|
||||
it('should reject requests with missing signature headers', () => {
|
||||
const req = {
|
||||
headers: {},
|
||||
rawBody: Buffer.from('test body'),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject requests with only timestamp header', () => {
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-timestamp': '1234567890'
|
||||
},
|
||||
rawBody: Buffer.from('test body'),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject requests with only signature header', () => {
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519': 'some_signature'
|
||||
},
|
||||
rawBody: Buffer.from('test body'),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle invalid signature format gracefully', () => {
|
||||
withProductionEnv(() => {
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519': 'invalid_hex_signature',
|
||||
'x-signature-timestamp': '1234567890'
|
||||
},
|
||||
rawBody: Buffer.from('test body'),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
// Should not throw an error, but return false
|
||||
expect(() => provider.verifyWebhookSignature(req)).not.toThrow();
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invalid public key format gracefully', async () => {
|
||||
// Override with invalid key format
|
||||
mockSecureCredentials.get.mockImplementation(key => {
|
||||
if (key === 'DISCORD_PUBLIC_KEY') return 'invalid_key_format';
|
||||
return 'mock_value';
|
||||
});
|
||||
|
||||
const invalidProvider = new DiscordProvider();
|
||||
await invalidProvider.initialize();
|
||||
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519':
|
||||
'64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
'x-signature-timestamp': '1234567890'
|
||||
},
|
||||
rawBody: Buffer.from('test body'),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
expect(invalidProvider.verifyWebhookSignature(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('should bypass verification in test mode', () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519': 'completely_invalid_signature',
|
||||
'x-signature-timestamp': '1234567890'
|
||||
},
|
||||
rawBody: Buffer.from('test body'),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(true);
|
||||
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
it('should handle crypto verification errors without throwing', () => {
|
||||
// Mock crypto.verify to throw an error
|
||||
const originalVerify = crypto.verify;
|
||||
crypto.verify = jest.fn().mockImplementation(() => {
|
||||
throw new Error('Crypto verification failed');
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519':
|
||||
'64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
'x-signature-timestamp': '1234567890'
|
||||
},
|
||||
rawBody: Buffer.from('test body'),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
expect(() => provider.verifyWebhookSignature(req)).not.toThrow();
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
|
||||
// Restore original function
|
||||
crypto.verify = originalVerify;
|
||||
});
|
||||
|
||||
it('should construct verification message correctly', () => {
|
||||
const timestamp = '1234567890';
|
||||
const body = 'test body content';
|
||||
const expectedMessage = timestamp + body;
|
||||
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519':
|
||||
'64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
'x-signature-timestamp': timestamp
|
||||
},
|
||||
rawBody: Buffer.from(body),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
// Mock crypto.verify to capture the message parameter
|
||||
const originalVerify = crypto.verify;
|
||||
const mockVerify = jest.fn().mockReturnValue(false);
|
||||
crypto.verify = mockVerify;
|
||||
|
||||
provider.verifyWebhookSignature(req);
|
||||
|
||||
expect(mockVerify).toHaveBeenCalledWith(
|
||||
'ed25519',
|
||||
Buffer.from(expectedMessage),
|
||||
expect.any(Buffer), // public key buffer
|
||||
expect.any(Buffer) // signature buffer
|
||||
);
|
||||
|
||||
crypto.verify = originalVerify;
|
||||
});
|
||||
|
||||
it('should use rawBody when available', () => {
|
||||
const timestamp = '1234567890';
|
||||
const rawBodyContent = 'raw body content';
|
||||
const bodyContent = { parsed: 'json' };
|
||||
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519':
|
||||
'64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
'x-signature-timestamp': timestamp
|
||||
},
|
||||
rawBody: Buffer.from(rawBodyContent),
|
||||
body: bodyContent
|
||||
};
|
||||
|
||||
const originalVerify = crypto.verify;
|
||||
const mockVerify = jest.fn().mockReturnValue(false);
|
||||
crypto.verify = mockVerify;
|
||||
|
||||
provider.verifyWebhookSignature(req);
|
||||
|
||||
// Should use rawBody, not JSON.stringify(body)
|
||||
expect(mockVerify).toHaveBeenCalledWith(
|
||||
'ed25519',
|
||||
Buffer.from(timestamp + rawBodyContent),
|
||||
expect.any(Buffer),
|
||||
expect.any(Buffer)
|
||||
);
|
||||
|
||||
crypto.verify = originalVerify;
|
||||
});
|
||||
|
||||
it('should fallback to JSON.stringify when rawBody is unavailable', () => {
|
||||
const timestamp = '1234567890';
|
||||
const bodyContent = { test: 'data' };
|
||||
const expectedMessage = timestamp + JSON.stringify(bodyContent);
|
||||
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519':
|
||||
'64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
'x-signature-timestamp': timestamp
|
||||
},
|
||||
// No rawBody provided
|
||||
body: bodyContent
|
||||
};
|
||||
|
||||
const originalVerify = crypto.verify;
|
||||
const mockVerify = jest.fn().mockReturnValue(false);
|
||||
crypto.verify = mockVerify;
|
||||
|
||||
provider.verifyWebhookSignature(req);
|
||||
|
||||
expect(mockVerify).toHaveBeenCalledWith(
|
||||
'ed25519',
|
||||
Buffer.from(expectedMessage),
|
||||
expect.any(Buffer),
|
||||
expect.any(Buffer)
|
||||
);
|
||||
|
||||
crypto.verify = originalVerify;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security Edge Cases', () => {
|
||||
beforeEach(async () => {
|
||||
await provider.initialize();
|
||||
});
|
||||
|
||||
it('should handle empty signature gracefully', () => {
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519': '',
|
||||
'x-signature-timestamp': '1234567890'
|
||||
},
|
||||
rawBody: Buffer.from('test body'),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty timestamp gracefully', () => {
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519':
|
||||
'64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
'x-signature-timestamp': ''
|
||||
},
|
||||
rawBody: Buffer.from('test body'),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle signature with wrong length', () => {
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519': 'short_sig',
|
||||
'x-signature-timestamp': '1234567890'
|
||||
},
|
||||
rawBody: Buffer.from('test body'),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle very long signature without crashing', () => {
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519': 'a'.repeat(1000), // Very long signature
|
||||
'x-signature-timestamp': '1234567890'
|
||||
},
|
||||
rawBody: Buffer.from('test body'),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
expect(() => provider.verifyWebhookSignature(req)).not.toThrow();
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle unicode characters in timestamp', () => {
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519':
|
||||
'64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
'x-signature-timestamp': '123😀567890'
|
||||
},
|
||||
rawBody: Buffer.from('test body'),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
expect(() => provider.verifyWebhookSignature(req)).not.toThrow();
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null/undefined headers safely', () => {
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519': null,
|
||||
'x-signature-timestamp': undefined
|
||||
},
|
||||
rawBody: Buffer.from('test body'),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle Buffer conversion errors gracefully', () => {
|
||||
// Mock Buffer.from to throw an error
|
||||
const originalBufferFrom = Buffer.from;
|
||||
Buffer.from = jest.fn().mockImplementation(data => {
|
||||
if (typeof data === 'string' && data.includes('signature')) {
|
||||
throw new Error('Buffer conversion failed');
|
||||
}
|
||||
return originalBufferFrom(data);
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: {
|
||||
'x-signature-ed25519': 'invalid_signature_that_causes_buffer_error',
|
||||
'x-signature-timestamp': '1234567890'
|
||||
},
|
||||
rawBody: Buffer.from('test body'),
|
||||
body: { test: 'data' }
|
||||
};
|
||||
|
||||
expect(() => provider.verifyWebhookSignature(req)).not.toThrow();
|
||||
expect(provider.verifyWebhookSignature(req)).toBe(false);
|
||||
|
||||
Buffer.from = originalBufferFrom;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timing Attack Prevention', () => {
|
||||
beforeEach(async () => {
|
||||
await provider.initialize();
|
||||
});
|
||||
|
||||
it('should have consistent timing for different signature lengths', async () => {
|
||||
const shortSig = 'abc';
|
||||
const longSig = 'a'.repeat(128);
|
||||
const timestamp = '1234567890';
|
||||
|
||||
const req1 = {
|
||||
headers: {
|
||||
'x-signature-ed25519': shortSig,
|
||||
'x-signature-timestamp': timestamp
|
||||
},
|
||||
rawBody: Buffer.from('test'),
|
||||
body: {}
|
||||
};
|
||||
|
||||
const req2 = {
|
||||
headers: {
|
||||
'x-signature-ed25519': longSig,
|
||||
'x-signature-timestamp': timestamp
|
||||
},
|
||||
rawBody: Buffer.from('test'),
|
||||
body: {}
|
||||
};
|
||||
|
||||
// Both should return false, and ideally take similar time
|
||||
const start1 = process.hrtime.bigint();
|
||||
const result1 = provider.verifyWebhookSignature(req1);
|
||||
const end1 = process.hrtime.bigint();
|
||||
|
||||
const start2 = process.hrtime.bigint();
|
||||
const result2 = provider.verifyWebhookSignature(req2);
|
||||
const end2 = process.hrtime.bigint();
|
||||
|
||||
expect(result1).toBe(false);
|
||||
expect(result2).toBe(false);
|
||||
|
||||
// Both operations should complete in reasonable time (less than 100ms)
|
||||
const time1 = Number(end1 - start1) / 1000000; // Convert to milliseconds
|
||||
const time2 = Number(end2 - start2) / 1000000;
|
||||
|
||||
expect(time1).toBeLessThan(100);
|
||||
expect(time2).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable no-redeclare */
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
// Mock the logger
|
||||
|
||||
Reference in New Issue
Block a user