feat: add repository and branch parameters to Discord chatbot

- Add required 'repo' parameter for repository specification
- Add optional 'branch' parameter (defaults to 'main')
- Implement extractRepoAndBranch() method in DiscordProvider
- Add repository validation in chatbotController
- Update parseWebhookPayload to include repo/branch context
- Enhanced error messages for missing repository parameter
- Updated all tests to handle new repo/branch fields
- Added comprehensive test coverage for new functionality

Discord slash command now requires:
/claude repo:owner/repository command:your-instruction
/claude repo:owner/repository branch:feature command:your-instruction

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jonathan Flatt
2025-05-27 20:02:48 -05:00
parent 8906d7ce56
commit 30401a93c6
7 changed files with 348 additions and 20 deletions

121
docs/CHATBOT_SETUP.md Normal file
View File

@@ -0,0 +1,121 @@
# 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

View File

@@ -69,8 +69,18 @@ DISCORD_BOT_MENTION=claude
DISCORD_AUTHORIZED_USERS=user1,user2
```
4. **Test the Bot**
- Use slash commands: `/claude help me with this code`
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

View File

@@ -199,19 +199,43 @@ async function handleChatbotWebhook(req, res, providerName) {
);
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: null, // Not repository-specific for chatbot commands
repoFullName: repoFullName,
issueNumber: null,
command: commandInfo.command,
isPullRequest: false,
branchName: null,
branchName: branchName,
chatbotContext: {
provider: providerName,
userId: userId,
username: messageContext.username,
channelId: messageContext.channelId,
guildId: messageContext.guildId
guildId: messageContext.guildId,
repo: repoFullName,
branch: branchName
}
});

View File

@@ -98,6 +98,7 @@ class DiscordProvider extends ChatbotProvider {
};
case 2: // APPLICATION_COMMAND
const repoInfo = this.extractRepoAndBranch(payload.data);
return {
type: 'command',
command: payload.data?.name,
@@ -108,7 +109,9 @@ class DiscordProvider extends ChatbotProvider {
username: payload.member?.user?.username || payload.user?.username,
content: this.buildCommandContent(payload.data),
interactionToken: payload.token,
interactionId: payload.id
interactionId: payload.id,
repo: repoInfo.repo,
branch: repoInfo.branch
};
case 3: // MESSAGE_COMPONENT
@@ -152,6 +155,24 @@ class DiscordProvider extends ChatbotProvider {
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
*/

View File

@@ -52,7 +52,7 @@ describe('chatbotController', () => {
sendResponse: jest.fn().mockResolvedValue(),
getUserId: jest.fn(),
isUserAuthorized: jest.fn().mockReturnValue(true),
formatErrorMessage: jest.fn().mockReturnValue('Error message'),
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')
};
@@ -79,7 +79,9 @@ describe('chatbotController', () => {
content: 'help me',
userId: 'user123',
username: 'testuser',
channelId: 'channel123'
channelId: 'channel123',
repo: 'owner/test-repo',
branch: 'main'
});
mockProvider.extractBotCommand.mockReturnValue({
command: 'help me',
@@ -92,17 +94,19 @@ describe('chatbotController', () => {
expect(mockProvider.verifyWebhookSignature).toHaveBeenCalledWith(req);
expect(mockProvider.parseWebhookPayload).toHaveBeenCalledWith(req.body);
expect(claudeService.processCommand).toHaveBeenCalledWith({
repoFullName: null,
repoFullName: 'owner/test-repo',
issueNumber: null,
command: 'help me',
isPullRequest: false,
branchName: null,
branchName: 'main',
chatbotContext: {
provider: 'discord',
userId: 'user123',
username: 'testuser',
channelId: 'channel123',
guildId: undefined
guildId: undefined,
repo: 'owner/test-repo',
branch: 'main'
}
});
expect(mockProvider.sendResponse).toHaveBeenCalled();
@@ -214,12 +218,42 @@ describe('chatbotController', () => {
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'
username: 'testuser',
repo: 'owner/test-repo',
branch: 'main'
});
mockProvider.extractBotCommand.mockReturnValue({
command: 'help me'
@@ -232,7 +266,7 @@ describe('chatbotController', () => {
expect(mockProvider.sendResponse).toHaveBeenCalledWith(
expect.anything(),
'Error message'
expect.stringContaining('🚫 **Error Processing Command**')
);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
@@ -285,13 +319,17 @@ describe('chatbotController', () => {
describe('handleDiscordWebhook', () => {
it('should call handleChatbotWebhook with discord provider', async () => {
const spy = jest.spyOn(chatbotController, 'handleChatbotWebhook');
spy.mockResolvedValue();
// Mock a simple provider response to avoid validation
mockProvider.parseWebhookPayload.mockReturnValue({
type: 'ping',
shouldRespond: true,
responseData: { type: 1 }
});
await chatbotController.handleDiscordWebhook(req, res);
expect(spy).toHaveBeenCalledWith(req, res, 'discord');
spy.mockRestore();
expect(res.json).toHaveBeenCalledWith({ type: 1 });
expect(res.status).not.toHaveBeenCalledWith(400); // Should not trigger repo validation
});
});

View File

@@ -178,6 +178,71 @@ describe('DiscordProvider', () => {
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', () => {
@@ -256,6 +321,49 @@ describe('DiscordProvider', () => {
});
});
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();

View File

@@ -78,7 +78,9 @@ describe('Discord Payload Processing Tests', () => {
username: 'testuser',
content: 'claude',
interactionToken: 'unique_interaction_token',
interactionId: '123456789012345678'
interactionId: '123456789012345678',
repo: null,
branch: null
});
});
@@ -130,7 +132,9 @@ describe('Discord Payload Processing Tests', () => {
username: 'developer',
content: 'claude prompt:Help me debug this Python function',
interactionToken: 'another_interaction_token',
interactionId: '123456789012345678'
interactionId: '123456789012345678',
repo: null,
branch: null
});
});
@@ -300,7 +304,9 @@ describe('Discord Payload Processing Tests', () => {
username: 'minimaluser',
content: 'claude',
interactionToken: 'minimal_token',
interactionId: '123456789012345678'
interactionId: '123456789012345678',
repo: null,
branch: null
});
});
});