Initial commit

This commit is contained in:
2025-02-02 14:10:51 -05:00
commit 9c74e724a8
28 changed files with 10554 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
jest: true,
es6: true,
},
extends: [
'airbnb-base',
'eslint:recommended',
'prettier',
'plugin:prettier/recommended',
'plugin:import/errors',
'plugin:import/warnings',
],
parserOptions: {
ecmaVersion: 2023,
sourceType: 'module',
ecmaFeatures: {
impliedStrict: true,
},
},
rules: {
'no-console': 'warn',
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-underscore-dangle': 'off',
'import/prefer-default-export': 'off',
'no-param-reassign': ['error', { props: false }],
'arrow-body-style': ['error', 'as-needed'],
'import/extensions': [
'error',
'always',
{
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
},
],
// Defer to Prettier for all formatting concerns
'prettier/prettier': 'error',
},
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
overrides: [
{
files: ['**/*.test.js', '**/*.spec.js'],
env: {
jest: true,
},
rules: {
'no-unused-expressions': 'off',
},
},
],
};
+40
View File
@@ -0,0 +1,40 @@
# Dependencies
node_modules/
package-lock.json
# Environment variables
.env
.env.*
inventory.ini
# IDE
.vscode/
*.code-workspace
.idea/
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Generated files
response_*/
# Test Coverage
coverage/
.coverage/
.nyc_output/
*.lcov
coverage.json
clover.xml
+12
View File
@@ -0,0 +1,12 @@
{
"semi": true,
"tabWidth": 2,
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf",
"jsxSingleQuote": false,
"proseWrap": "preserve"
}
+16
View File
@@ -0,0 +1,16 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2025 Luis Bauza
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
+60
View File
@@ -0,0 +1,60 @@
# Kekbot.js
A simple Discord bot with essential moderation and utility commands.
## License
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
Copyright (C) 2025 Luis Bauza
This program comes with ABSOLUTELY NO WARRANTY; for details see the LICENSE file.
This is free software, and you are welcome to redistribute it
under certain conditions described in the license.
## Features
- Modern Discord.js v14 implementation
- Slash commands for better user experience
- Essential moderation commands (kick, prune)
- Help command for easy reference
## Commands
- `/help` - Lists all available commands
- `/ping` - Check if the bot is responsive
- `/kick` - Kick a user from the server (requires Kick Members permission)
- `/prune` - Delete up to 99 messages (requires Manage Messages permission)
## Setup
1. Create a `.env` file with your bot token and client ID:
```
DISCORD_TOKEN=your_token_here
CLIENT_ID=your_client_id_here
```
2. Install dependencies:
```bash
npm install
```
3. Deploy slash commands:
```bash
npm run deploy
```
4. Start the bot:
```bash
npm start
```
## Requirements
- Node.js 16.9.0 or higher
- Discord.js v14
- A Discord bot token and application ID
+208
View File
@@ -0,0 +1,208 @@
# Security Policy and Best Practices
## Credential Management
### Current Security Measures
- Environment variables for sensitive credentials (Discord token, API keys)
- Git pre-commit hooks to prevent credential exposure
- .gitignore rules for sensitive files
- Test environment using mock credentials
### Cleaning Git History of Credentials
If you need to remove sensitive data from git history, follow these steps carefully:
#### Prerequisites
1. Install git-filter-repo:
```bash
# macOS
brew install git-filter-repo
# Linux
pip3 install git-filter-repo
# Windows
pip install git-filter-repo
```
2. Create a backup of your repository:
```bash
cp -r your-repo your-repo-backup
```
#### Cleaning Process
1. **Prepare Your Repository**
```bash
# Change to your repository directory
cd your-repo
# Create a fresh clone (recommended for cleaning)
git clone --mirror git@github.com:username/your-repo.git repo-mirror
cd repo-mirror
```
2. **Create a Pattern File**
Create a file named `patterns.txt` containing patterns of credentials to remove:
```
# Discord tokens (common patterns)
['"](N[a-zA-Z0-9]{23}\.[a-zA-Z0-9]{6}\.[a-zA-Z0-9_-]{27})['"]
# API keys
['"](sk-[a-zA-Z0-9]{48}|pk-[a-zA-Z0-9]{48})['"]
# Generic tokens/keys
['"]([a-zA-Z0-9_-]{32,64})['"]
# Add more patterns as needed
```
3. **Clean the Repository**
```bash
# Remove files containing credentials
git filter-repo --invert-paths --paths-from-file patterns.txt
# Clean all refs
git reflog expire --expire=now --all
git gc --prune=now --aggressive
```
4. **Verify the Cleaning**
```bash
# Check for any remaining credentials
git log -p | grep -i -f patterns.txt
```
5. **Update Remote Repository**
```bash
# Force push all branches
git push origin --force --all
# Force push all tags
git push origin --force --tags
```
6. **Update All Local Clones**
For each local clone of the repository:
```bash
# In each local clone
git fetch origin
git reset --hard origin/main # or your default branch
```
#### Important Notes
- This process is irreversible and rewrites git history
- All collaborators must re-clone the repository after cleaning
- Immediately revoke and rotate any exposed credentials
- Old credentials may still exist in:
- GitHub Actions logs
- Issue comments
- Pull request descriptions
- Cached views on GitHub
- Other users' local clones
### Setting Up Environment Variables
1. Create a `.env` file in the project root (this file is automatically ignored by git)
2. Add your credentials using this format:
```
DISCORD_TOKEN=your_discord_token_here
OPENROUTER_API_KEY=your_openrouter_api_key_here
```
3. Never commit the `.env` file to version control
### Development Guidelines
#### DO
- Use environment variables for all sensitive data
- Keep the `.env` file secure and local to your development environment
- Use mock/fake data for tests
- Review code for credential exposure before committing
- Use the pre-commit hook to catch potential credential leaks
#### DON'T
- Hardcode credentials in source code
- Commit `.env` files to version control
- Share credentials through insecure channels
- Disable the pre-commit hook
- Log sensitive information
### Secure Credential Storage
For production environments:
1. Use a secrets management service or secure environment variable storage
2. Rotate credentials regularly
3. Use the principle of least privilege
4. Monitor for unauthorized access
5. Keep backup copies of credentials in a secure location
### What to Do If Credentials Are Exposed
If credentials are accidentally committed:
1. Immediately invalidate and rotate the exposed credentials
2. Use tools like `git-filter-repo` to remove the credentials from git history
3. Force push the cleaned history
4. Notify relevant team members/security personnel
5. Review access logs for potential unauthorized use
### Pre-commit Hook
The project includes a pre-commit hook that scans for potential credentials. The hook:
- Checks staged files for common credential patterns
- Excludes test files and dependencies
- Provides clear error messages if potential credentials are found
To bypass the hook in exceptional cases (NOT RECOMMENDED):
```bash
git commit --no-verify
```
### Continuous Integration
For CI/CD pipelines:
1. Use secure environment variables in CI configuration
2. Never display environment variables in build logs
3. Rotate CI/CD credentials regularly
4. Limit CI/CD service permissions to only what's necessary
### Security Checks
Regular security maintenance:
1. Update dependencies regularly
2. Run security audits (`npm audit`)
3. Review access logs
4. Verify environment variable usage
5. Check git history for any exposed credentials
### Reporting Security Issues
If you discover a security vulnerability:
1. Do NOT open a public issue
2. Contact the maintainers directly
3. Provide detailed information about the vulnerability
4. Allow time for the issue to be addressed before public disclosure
## Additional Resources
- [Node.js Security Best Practices](https://nodejs.org/en/security/)
- [OWASP Secure Coding Practices](https://owasp.org/www-project-secure-coding-practices-quick-reference-guide/)
- [Discord Bot Security](https://discord.com/developers/docs/topics/security)
+115
View File
@@ -0,0 +1,115 @@
# Discord Bot Testing Guide
## Setup
The project uses Jest for testing with the following configuration:
- Unit tests for commands and utilities
- Mocked Discord.js components
- 100% code coverage tracking
- Automated test running
### Test File Patterns
Only files matching these patterns are treated as test files:
- `**/__tests__/**/*.test.[jt]s?(x)`
- `**/__tests__/**/*.spec.[jt]s?(x)`
- `**/?(*.)+(spec|test).[jt]s?(x)`
Helper files (like testUtils.js) should not use these patterns to avoid being treated as test files.
## Running Tests
```bash
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage report
npm run test:coverage
```
## Test Structure
### Test Utilities (`__tests__/utils/testUtils.js`)
- `createMockInteraction()`: Creates mock Discord.js interactions for testing commands
- `createMockClient()`: Creates mock Discord.js client instances
### Command Tests (`__tests__/commands/`)
Each command has its own test file that verifies:
- Command structure (name, description, options)
- Command execution
- Error handling
- Edge cases
## Writing New Tests
1. Create test files in the `__tests__` directory following the naming convention `*.test.js`
2. Use the provided test utilities to mock Discord.js components
3. Structure tests using describe/it blocks for clarity
4. Test both success and error cases
5. Ensure proper cleanup in beforeEach/afterEach blocks if needed
### Example Test Structure
```javascript
const { createMockInteraction } = require('../utils/testUtils');
describe('Command Name', () => {
describe('Command Structure', () => {
// Test command properties
});
describe('Command Execution', () => {
let interaction;
beforeEach(() => {
interaction = createMockInteraction({
// Custom options
});
});
it('should handle successful execution', async () => {
// Test success case
});
it('should handle errors', async () => {
// Test error case
});
});
});
```
## Best Practices
1. Mock external dependencies
2. Test edge cases and error conditions
3. Keep tests focused and isolated
4. Use descriptive test names
5. Maintain test coverage above 90%
6. Clean up resources after tests
7. Use appropriate assertions
8. Test asynchronous code properly
9. Keep utility files separate from test files
10. Follow proper naming conventions for test files
## Coverage Reports
Coverage reports are generated in the `coverage` directory after running:
```bash
npm run test:coverage
```
The report includes:
- Statement coverage
- Branch coverage
- Function coverage
- Line coverage
+360
View File
@@ -0,0 +1,360 @@
const axios = require('axios');
const { createMockInteraction } = require('../utils/testUtils');
// Mock axios
jest.mock('axios');
// Mock the discord.js module
jest.mock('discord.js', () => ({
SlashCommandBuilder: jest.fn().mockReturnValue({
setName: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(),
addStringOption: jest.fn().mockImplementation(callback => {
const option = {
setName: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(),
setRequired: jest.fn().mockReturnThis(),
};
callback(option);
return {
addBooleanOption: jest.fn().mockImplementation(boolCallback => {
const boolOption = {
setName: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(),
setRequired: jest.fn().mockReturnThis(),
};
boolCallback(boolOption);
return {
toJSON: jest.fn().mockReturnValue({
name: 'ask',
description: 'Ask a question to the AI',
options: [
{
name: 'prompt',
description: 'Your question or prompt',
type: 3,
required: true,
},
{
name: 'websearch',
description:
'Enable web search for more up-to-date information',
type: 5,
required: false,
},
],
}),
};
}),
};
}),
toJSON: jest.fn(),
}),
}));
const askCommand = require('../../commands/ask');
describe('Ask Command', () => {
describe('Command Structure', () => {
it('should have correct name and description', () => {
const commandData = askCommand.data.toJSON();
expect(commandData.name).toBe('ask');
expect(commandData.description).toBe('Ask a question to the AI');
});
it('should have required command properties', () => {
expect(askCommand).toHaveProperty('data');
expect(askCommand).toHaveProperty('execute');
expect(typeof askCommand.execute).toBe('function');
});
it('should have correct option configuration', () => {
const commandData = askCommand.data.toJSON();
const [promptOption, websearchOption] = commandData.options;
expect(promptOption.name).toBe('prompt');
expect(promptOption.description).toBe('Your question or prompt');
expect(promptOption.required).toBe(true);
expect(websearchOption.name).toBe('websearch');
expect(websearchOption.description).toBe(
'Enable web search for more up-to-date information'
);
expect(websearchOption.required).toBe(false);
});
});
describe('Command Execution', () => {
let interaction;
const mockPrompt = 'What is the meaning of life?';
const mockApiResponse = {
data: {
choices: [
{
message: {
content: 'The meaning of life is 42.',
},
},
],
},
};
beforeEach(() => {
process.env.OPENROUTER_API_KEY = 'test-api-key';
axios.post.mockReset();
jest.clearAllMocks();
interaction = createMockInteraction({
commandName: 'ask',
stringOptions: {
prompt: mockPrompt,
},
booleanOptions: {
websearch: false,
},
});
});
it('should handle successful API response with websearch disabled', async () => {
axios.post.mockResolvedValueOnce(mockApiResponse);
await askCommand.execute(interaction);
expect(axios.post).toHaveBeenCalledWith(
'https://openrouter.ai/api/v1/chat/completions',
expect.objectContaining({
model: 'google/gemini-2.0-flash-exp:free',
plugins: undefined,
}),
expect.any(Object)
);
expect(interaction.followUp).toHaveBeenCalledWith({
content: expect.not.stringContaining('Web search enabled'),
split: false,
allowedMentions: { parse: [] },
});
});
it('should handle successful API response with websearch enabled', async () => {
interaction = createMockInteraction({
commandName: 'ask',
stringOptions: {
prompt: mockPrompt,
},
booleanOptions: {
websearch: true,
},
});
axios.post.mockResolvedValueOnce(mockApiResponse);
await askCommand.execute(interaction);
expect(axios.post).toHaveBeenCalledWith(
'https://openrouter.ai/api/v1/chat/completions',
expect.objectContaining({
model: 'google/gemini-2.0-flash-exp:free:online',
plugins: expect.arrayContaining([
expect.objectContaining({
id: 'web',
max_results: 3,
}),
]),
}),
expect.any(Object)
);
expect(interaction.followUp).toHaveBeenCalledWith({
content: expect.stringContaining('Web search enabled'),
split: false,
allowedMentions: { parse: [] },
});
});
it('should use default websearch value when not provided', async () => {
interaction = createMockInteraction({
commandName: 'ask',
stringOptions: {
prompt: mockPrompt,
},
// Not providing websearch option
});
axios.post.mockResolvedValueOnce(mockApiResponse);
await askCommand.execute(interaction);
expect(axios.post).toHaveBeenCalledWith(
'https://openrouter.ai/api/v1/chat/completions',
expect.objectContaining({
model: 'google/gemini-2.0-flash-exp:free',
plugins: undefined,
}),
expect.any(Object)
);
});
it('should handle long responses with proper chunking and websearch enabled', async () => {
interaction = createMockInteraction({
commandName: 'ask',
stringOptions: {
prompt: mockPrompt,
},
booleanOptions: {
websearch: true,
},
});
const longResponse = 'A'.repeat(2500);
axios.post.mockResolvedValueOnce({
data: {
choices: [
{
message: {
content: longResponse,
},
},
],
},
});
await askCommand.execute(interaction);
expect(interaction.followUp).toHaveBeenCalledTimes(2);
expect(interaction.followUp.mock.calls[0][0].content).toContain(
'Web search enabled'
);
expect(interaction.followUp.mock.calls[1][0].content).toContain(
'(continued)'
);
});
it('should handle code blocks in chunked responses with websearch', async () => {
interaction = createMockInteraction({
commandName: 'ask',
stringOptions: {
prompt: mockPrompt,
},
booleanOptions: {
websearch: true,
},
});
const responseWithCodeBlock =
"Here's a code example:\n```python\nprint('hello')\n```".repeat(20);
axios.post.mockResolvedValueOnce({
data: {
choices: [
{
message: {
content: responseWithCodeBlock,
},
},
],
},
});
await askCommand.execute(interaction);
// Verify that code blocks are not split in the middle
interaction.followUp.mock.calls.forEach(call => {
const { content } = call[0];
const openBlocks = (content.match(/```/g) || []).length;
expect(openBlocks % 2).toBe(0); // Should always be even
});
// First chunk should contain websearch indicator
expect(interaction.followUp.mock.calls[0][0].content).toContain(
'Web search enabled'
);
});
it('should handle API errors with websearch enabled', async () => {
interaction = createMockInteraction({
commandName: 'ask',
stringOptions: {
prompt: mockPrompt,
},
booleanOptions: {
websearch: true,
},
});
const error = new Error('API Error');
error.response = { data: 'API Error Details' };
axios.post.mockRejectedValueOnce(error);
await askCommand.execute(interaction);
expect(interaction.followUp).toHaveBeenCalledWith({
content: 'Sorry, there was an error processing your request.',
ephemeral: true,
});
});
it('should send API request with correct headers regardless of websearch', async () => {
interaction = createMockInteraction({
commandName: 'ask',
stringOptions: {
prompt: mockPrompt,
},
booleanOptions: {
websearch: true,
},
});
axios.post.mockResolvedValueOnce(mockApiResponse);
await askCommand.execute(interaction);
expect(axios.post).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
{
headers: {
Authorization: 'Bearer test-api-key',
'HTTP-Referer': 'https://github.com/hllywluis/kekbot.js',
'Content-Type': 'application/json',
},
}
);
});
it('should handle defer reply failure with websearch', async () => {
interaction = createMockInteraction({
commandName: 'ask',
stringOptions: {
prompt: mockPrompt,
},
booleanOptions: {
websearch: true,
},
deferFails: true,
});
await expect(askCommand.execute(interaction)).rejects.toThrow(
'Failed to defer reply'
);
});
it('should handle follow up failure with websearch', async () => {
interaction = createMockInteraction({
commandName: 'ask',
stringOptions: {
prompt: mockPrompt,
},
booleanOptions: {
websearch: true,
},
followUpFails: true,
});
axios.post.mockResolvedValueOnce(mockApiResponse);
await expect(askCommand.execute(interaction)).rejects.toThrow(
'Failed to follow up'
);
});
});
});
+106
View File
@@ -0,0 +1,106 @@
const { createMockInteraction } = require('../utils/testUtils');
// Mock the discord.js module
jest.mock('discord.js', () => ({
SlashCommandBuilder: jest.fn().mockReturnValue({
setName: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(),
toJSON: jest.fn().mockReturnValue({
name: 'help',
description: 'Lists all available commands',
}),
}),
EmbedBuilder: jest.fn().mockReturnValue({
setColor: jest.fn().mockReturnThis(),
setTitle: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(),
setTimestamp: jest.fn().mockReturnThis(),
addFields: jest.fn().mockReturnThis(),
}),
}));
const helpCommand = require('../../commands/help');
describe('Help Command', () => {
describe('Command Structure', () => {
it('should have correct name and description', () => {
const commandData = helpCommand.data.toJSON();
expect(commandData.name).toBe('help');
expect(commandData.description).toBe('Lists all available commands');
});
it('should have required command properties', () => {
expect(helpCommand).toHaveProperty('data');
expect(helpCommand).toHaveProperty('execute');
expect(typeof helpCommand.execute).toBe('function');
});
});
describe('Command Execution', () => {
let interaction;
const mockCommands = new Map([
['ping', { data: { name: 'ping', description: 'Replies with Pong!' } }],
[
'help',
{ data: { name: 'help', description: 'Lists all available commands' } },
],
]);
beforeEach(() => {
interaction = createMockInteraction({
commandName: 'help',
});
interaction.client.commands = mockCommands;
});
it('should create an embed with all commands', async () => {
await helpCommand.execute(interaction);
expect(interaction.reply).toHaveBeenCalledWith(
expect.objectContaining({
embeds: expect.arrayContaining([expect.any(Object)]),
ephemeral: true,
})
);
});
it('should handle empty commands collection', async () => {
interaction.client.commands = new Map();
await helpCommand.execute(interaction);
expect(interaction.reply).toHaveBeenCalledWith(
expect.objectContaining({
embeds: expect.arrayContaining([expect.any(Object)]),
ephemeral: true,
})
);
});
it('should handle interaction reply failure', async () => {
interaction = createMockInteraction({
commandName: 'help',
replyFails: true,
});
interaction.client.commands = mockCommands;
await expect(helpCommand.execute(interaction)).rejects.toThrow(
'Failed to reply',
);
});
it('should create embed with correct structure', async () => {
const { EmbedBuilder } = require('discord.js');
await helpCommand.execute(interaction);
const embedInstance = EmbedBuilder.mock.results[0].value;
expect(embedInstance.setColor).toHaveBeenCalledWith('#5dc67b');
expect(embedInstance.setTitle).toHaveBeenCalledWith('Available Commands');
expect(embedInstance.setDescription).toHaveBeenCalledWith(
'Here are all my commands:',
);
expect(embedInstance.setTimestamp).toHaveBeenCalled();
expect(embedInstance.addFields).toHaveBeenCalledTimes(mockCommands.size);
});
});
});
+221
View File
@@ -0,0 +1,221 @@
const { createMockInteraction } = require('../utils/testUtils');
// Mock the discord.js module
jest.mock('discord.js', () => ({
SlashCommandBuilder: jest.fn().mockReturnValue({
setName: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(),
addUserOption: jest.fn().mockImplementation(callback => {
const option = {
setName: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(),
setRequired: jest.fn().mockReturnThis(),
};
callback(option);
return {
addStringOption: jest.fn().mockImplementation(callback => {
const option = {
setName: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(),
};
callback(option);
return {
setDefaultMemberPermissions: jest.fn().mockReturnThis(),
toJSON: jest.fn().mockReturnValue({
name: 'kick',
description: 'Kick a user from the server',
options: [
{
name: 'target',
description: 'The user to kick',
type: 6,
required: true,
},
{
name: 'reason',
description: 'Reason for kicking',
type: 3,
required: false,
},
],
}),
};
}),
};
}),
toJSON: jest.fn(),
}),
PermissionFlagsBits: {
KickMembers: 0x2n,
},
}));
const kickCommand = require('../../commands/kick');
describe('Kick Command', () => {
describe('Command Structure', () => {
it('should have correct name and description', () => {
const commandData = kickCommand.data.toJSON();
expect(commandData.name).toBe('kick');
expect(commandData.description).toBe('Kick a user from the server');
});
it('should have required command properties', () => {
expect(kickCommand).toHaveProperty('data');
expect(kickCommand).toHaveProperty('execute');
expect(typeof kickCommand.execute).toBe('function');
});
it('should have correct option configuration', () => {
const commandData = kickCommand.data.toJSON();
const [targetOption, reasonOption] = commandData.options;
expect(targetOption.name).toBe('target');
expect(targetOption.description).toBe('The user to kick');
expect(targetOption.required).toBe(true);
expect(reasonOption.name).toBe('reason');
expect(reasonOption.description).toBe('Reason for kicking');
expect(reasonOption.required).toBe(false);
});
});
describe('Command Execution', () => {
let interaction;
const mockKick = jest.fn();
beforeEach(() => {
mockKick.mockReset();
const mockTarget = {
kickable: true,
kick: mockKick,
user: {
tag: 'TestUser#1234',
},
};
interaction = createMockInteraction({
commandName: 'kick',
memberOptions: {
target: mockTarget,
},
stringOptions: {
reason: 'Test reason',
},
});
});
it('should successfully kick a user with reason', async () => {
await kickCommand.execute(interaction);
expect(mockKick).toHaveBeenCalledWith('Test reason');
expect(interaction.reply).toHaveBeenCalledWith({
content: expect.stringContaining('Successfully kicked TestUser#1234'),
ephemeral: true,
});
});
it('should use default reason when none provided', async () => {
interaction = createMockInteraction({
commandName: 'kick',
memberOptions: {
target: {
kickable: true,
kick: mockKick,
user: { tag: 'TestUser#1234' },
},
},
stringOptions: {
reason: null,
},
});
await kickCommand.execute(interaction);
expect(mockKick).toHaveBeenCalledWith('No reason provided');
expect(interaction.reply).toHaveBeenCalledWith({
content: expect.stringContaining('No reason provided'),
ephemeral: true,
});
});
it('should handle non-existent target', async () => {
interaction = createMockInteraction({
commandName: 'kick',
memberOptions: {
target: null,
},
});
await kickCommand.execute(interaction);
expect(mockKick).not.toHaveBeenCalled();
expect(interaction.reply).toHaveBeenCalledWith({
content: 'That user is not in this server!',
ephemeral: true,
});
});
it('should handle non-kickable target', async () => {
interaction = createMockInteraction({
commandName: 'kick',
memberOptions: {
target: {
kickable: false,
user: { tag: 'Admin#1234' },
},
},
});
await kickCommand.execute(interaction);
expect(mockKick).not.toHaveBeenCalled();
expect(interaction.reply).toHaveBeenCalledWith({
content:
'I cannot kick this user! They may have higher permissions than me.',
ephemeral: true,
});
});
it('should handle kick failure', async () => {
const mockTarget = {
kickable: true,
kick: jest.fn().mockRejectedValueOnce(new Error('Failed to kick user')),
user: { tag: 'TestUser#1234' },
};
interaction = createMockInteraction({
commandName: 'kick',
memberOptions: {
target: mockTarget,
},
});
await kickCommand.execute(interaction);
expect(interaction.reply).toHaveBeenCalledWith({
content: 'There was an error trying to kick this user!',
ephemeral: true,
});
});
it('should handle interaction reply failure', async () => {
interaction = createMockInteraction({
commandName: 'kick',
memberOptions: {
target: {
kickable: true,
kick: mockKick,
user: { tag: 'TestUser#1234' },
},
},
replyFails: true,
});
await expect(kickCommand.execute(interaction)).rejects.toThrow(
'Failed to reply',
);
});
});
});
+57
View File
@@ -0,0 +1,57 @@
const { createMockInteraction } = require('../utils/testUtils');
// Mock the discord.js module
jest.mock('discord.js', () => ({
SlashCommandBuilder: jest.fn().mockReturnValue({
setName: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(),
toJSON: jest.fn().mockReturnValue({
name: 'ping',
description: 'Replies with Pong!',
}),
}),
}));
const pingCommand = require('../../commands/ping');
describe('Ping Command', () => {
describe('Command Structure', () => {
it('should have correct name and description', () => {
const commandData = pingCommand.data.toJSON();
expect(commandData.name).toBe('ping');
expect(commandData.description).toBe('Replies with Pong!');
});
it('should have required command properties', () => {
expect(pingCommand).toHaveProperty('data');
expect(pingCommand).toHaveProperty('execute');
expect(typeof pingCommand.execute).toBe('function');
});
});
describe('Command Execution', () => {
let interaction;
beforeEach(() => {
interaction = createMockInteraction({
commandName: 'ping',
});
});
it('should reply with "Pong! 🏓"', async () => {
await pingCommand.execute(interaction);
expect(interaction.reply).toHaveBeenCalledTimes(1);
expect(interaction.reply).toHaveBeenCalledWith('Pong! 🏓');
});
it('should handle interaction reply failure', async () => {
// Mock a failed reply
interaction.reply.mockRejectedValueOnce(new Error('Failed to reply'));
await expect(pingCommand.execute(interaction)).rejects.toThrow(
'Failed to reply',
);
});
});
});
+140
View File
@@ -0,0 +1,140 @@
const { createMockInteraction } = require('../utils/testUtils');
// Mock the discord.js module
jest.mock('discord.js', () => ({
SlashCommandBuilder: jest.fn().mockReturnValue({
setName: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(),
addIntegerOption: jest.fn().mockImplementation(callback => {
const option = {
setName: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(),
setMinValue: jest.fn().mockReturnThis(),
setMaxValue: jest.fn().mockReturnThis(),
setRequired: jest.fn().mockReturnThis(),
};
callback(option);
return {
setDefaultMemberPermissions: jest.fn().mockReturnThis(),
toJSON: jest.fn().mockReturnValue({
name: 'prune',
description: 'Prune up to 99 messages.',
options: [
{
name: 'amount',
description: 'Number of messages to prune',
type: 4,
required: true,
min_value: 1,
max_value: 99,
},
],
}),
};
}),
toJSON: jest.fn(),
}),
PermissionFlagsBits: {
ManageMessages: 0x2000n,
},
}));
const pruneCommand = require('../../commands/prune');
describe('Prune Command', () => {
describe('Command Structure', () => {
it('should have correct name and description', () => {
const commandData = pruneCommand.data.toJSON();
expect(commandData.name).toBe('prune');
expect(commandData.description).toBe('Prune up to 99 messages.');
});
it('should have required command properties', () => {
expect(pruneCommand).toHaveProperty('data');
expect(pruneCommand).toHaveProperty('execute');
expect(typeof pruneCommand.execute).toBe('function');
});
it('should have correct option configuration', () => {
const commandData = pruneCommand.data.toJSON();
const option = commandData.options[0];
expect(option.name).toBe('amount');
expect(option.description).toBe('Number of messages to prune');
expect(option.required).toBe(true);
expect(option.min_value).toBe(1);
expect(option.max_value).toBe(99);
});
});
describe('Command Execution', () => {
let interaction;
const mockBulkDelete = jest.fn();
beforeEach(() => {
mockBulkDelete.mockReset();
interaction = createMockInteraction({
commandName: 'prune',
integerOptions: {
amount: 5,
},
});
interaction.channel.bulkDelete = mockBulkDelete;
});
it('should successfully delete messages', async () => {
mockBulkDelete.mockResolvedValueOnce({ size: 5 });
await pruneCommand.execute(interaction);
expect(mockBulkDelete).toHaveBeenCalledWith(5, true);
expect(interaction.reply).toHaveBeenCalledWith({
content: 'Successfully deleted 5 message(s).',
ephemeral: true,
});
});
it('should handle bulk delete failure', async () => {
mockBulkDelete.mockRejectedValueOnce(
new Error('Failed to delete messages'),
);
await pruneCommand.execute(interaction);
expect(interaction.reply).toHaveBeenCalledWith({
content: 'There was an error trying to prune messages in this channel!',
ephemeral: true,
});
});
it('should handle messages older than 14 days', async () => {
mockBulkDelete.mockRejectedValueOnce(
new Error('Messages older than 14 days cannot be deleted'),
);
await pruneCommand.execute(interaction);
expect(interaction.reply).toHaveBeenCalledWith({
content: 'There was an error trying to prune messages in this channel!',
ephemeral: true,
});
});
it('should handle interaction reply failure', async () => {
mockBulkDelete.mockResolvedValueOnce({ size: 5 });
interaction = createMockInteraction({
commandName: 'prune',
integerOptions: {
amount: 5,
},
replyFails: true,
});
interaction.channel.bulkDelete = mockBulkDelete;
await expect(pruneCommand.execute(interaction)).rejects.toThrow(
'Failed to reply',
);
});
});
});
+96
View File
@@ -0,0 +1,96 @@
// Test utilities for Discord.js bot testing
/**
* Creates a mock interaction object with common properties and methods
* @param {Object} options - Customization options for the mock interaction
* @returns {Object} Mock interaction object
*/
const createMockInteraction = (options = {}) => ({
commandName: options.commandName || 'test-command',
user: {
id: options.userId || 'mock-user-id',
username: options.username || 'MockUser',
tag: options.userTag || 'MockUser#0000',
},
guild: {
id: options.guildId || 'mock-guild-id',
name: options.guildName || 'Mock Guild',
},
channel: {
id: options.channelId || 'mock-channel-id',
name: options.channelName || 'mock-channel',
send: jest.fn().mockResolvedValue({ id: 'mock-message-id' }),
bulkDelete: jest.fn().mockResolvedValue({ size: 0 }),
},
client: {
commands: new Map(),
user: {
id: 'mock-client-id',
username: 'MockBot',
setActivity: jest.fn(),
},
},
reply: jest.fn().mockImplementation(async _response => {
if (options.replyFails) {
throw new Error('Failed to reply');
}
return { id: 'mock-reply-id' };
}),
deferReply: jest.fn().mockImplementation(async () => {
if (options.deferFails) {
throw new Error('Failed to defer reply');
}
return true;
}),
editReply: jest.fn().mockResolvedValue({ id: 'mock-edit-id' }),
followUp: jest.fn().mockImplementation(async _response => {
if (options.followUpFails) {
throw new Error('Failed to follow up');
}
return { id: 'mock-followup-id' };
}),
options: {
getString: jest.fn(name => options.stringOptions?.[name]),
getInteger: jest.fn(name => options.integerOptions?.[name]),
getBoolean: jest.fn(name => options.booleanOptions?.[name]),
getUser: jest.fn(name => options.userOptions?.[name]),
getMember: jest.fn(name => options.memberOptions?.[name]),
get: jest.fn(name => options.options?.[name]),
},
member: {
permissions: {
has: jest.fn().mockReturnValue(options.hasPermission ?? true),
},
roles: {
cache: new Map(),
},
},
isCommand: () => true,
isChatInputCommand: () => true,
});
/**
* Creates a mock client object with common properties and methods
* @param {Object} options - Customization options for the mock client
* @returns {Object} Mock client object
*/
const createMockClient = (options = {}) => ({
user: {
id: options.clientId || 'mock-client-id',
username: options.clientUsername || 'MockBot',
setActivity: jest.fn(),
},
guilds: {
cache: new Map(),
},
commands: new Map(),
on: jest.fn(),
once: jest.fn(),
login: jest.fn().mockResolvedValue('token'),
destroy: jest.fn().mockResolvedValue(),
});
module.exports = {
createMockInteraction,
createMockClient,
};
+61
View File
@@ -0,0 +1,61 @@
const { createMockInteraction, createMockClient } = require('./testUtils');
describe('Test Utilities', () => {
describe('createMockInteraction', () => {
it('should create a mock interaction with default values', () => {
const interaction = createMockInteraction();
expect(interaction.commandName).toBe('test-command');
expect(interaction.user.id).toBe('mock-user-id');
expect(interaction.guild.id).toBe('mock-guild-id');
expect(interaction.channel.id).toBe('mock-channel-id');
expect(typeof interaction.reply).toBe('function');
expect(typeof interaction.deferReply).toBe('function');
expect(typeof interaction.editReply).toBe('function');
expect(typeof interaction.followUp).toBe('function');
});
it('should create a mock interaction with custom values', () => {
const customOptions = {
commandName: 'custom-command',
userId: 'custom-user-id',
username: 'CustomUser',
guildId: 'custom-guild-id',
channelId: 'custom-channel-id',
};
const interaction = createMockInteraction(customOptions);
expect(interaction.commandName).toBe('custom-command');
expect(interaction.user.id).toBe('custom-user-id');
expect(interaction.user.username).toBe('CustomUser');
expect(interaction.guild.id).toBe('custom-guild-id');
expect(interaction.channel.id).toBe('custom-channel-id');
});
});
describe('createMockClient', () => {
it('should create a mock client with default values', () => {
const client = createMockClient();
expect(client.user.id).toBe('mock-client-id');
expect(client.user.username).toBe('MockBot');
expect(typeof client.login).toBe('function');
expect(typeof client.destroy).toBe('function');
expect(typeof client.on).toBe('function');
expect(typeof client.once).toBe('function');
});
it('should create a mock client with custom values', () => {
const customOptions = {
clientId: 'custom-client-id',
clientUsername: 'CustomBot',
};
const client = createMockClient(customOptions);
expect(client.user.id).toBe('custom-client-id');
expect(client.user.username).toBe('CustomBot');
});
});
});
+64
View File
@@ -0,0 +1,64 @@
# Ansible Deployment for Kekbot
This directory contains Ansible configuration for deploying the Discord bot to a server.
## Prerequisites
1. Ansible must be installed on your local machine. Install it with:
```bash
# On macOS
brew install ansible
# On Ubuntu/Debian
sudo apt-get install ansible
```
2. Make sure your `.env` file contains all necessary Discord bot tokens and configuration.
## Files
- `playbook.yml`: Contains all the deployment tasks including:
- Installing Node.js and npm
- Setting up the application directory
- Installing dependencies
- Creating and managing a systemd service
- Deploying Discord bot commands
- `inventory.ini`: Contains the server connection details
## Deployment
To deploy the bot, run:
```bash
ansible-playbook -i inventory.ini playbook.yml
```
## Service Management
After deployment, you can manage the bot service on the server using:
```bash
# Check status
sudo systemctl status kekbot
# Stop the bot
sudo systemctl stop kekbot
# Start the bot
sudo systemctl start kekbot
# View logs
sudo journalctl -u kekbot
```
## Troubleshooting
1. If the deployment fails due to SSH connection issues:
- Verify that you can SSH into the server manually
- Check that the server details in `inventory.ini` are correct
2. If the bot fails to start:
- Check the logs using `sudo journalctl -u kekbot`
- Verify that the `.env` file was properly copied and contains valid tokens
+68
View File
@@ -0,0 +1,68 @@
// bot.js - Discord bot for moderation and utilities
// Copyright (C) 2025 Luis Bauza
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
require('dotenv').config();
const fs = require('node:fs');
const path = require('node:path');
const { Client, Collection, Events, GatewayIntentBits } = require('discord.js');
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
]
});
client.commands = new Collection();
const commandsPath = path.join(__dirname, 'commands');
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = require(filePath);
if ('data' in command && 'execute' in command) {
client.commands.set(command.data.name, command);
} else {
console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
}
}
client.once(Events.ClientReady, () => {
console.log(`Ready! Logged in as ${client.user.tag}`);
});
client.on(Events.InteractionCreate, async interaction => {
if (!interaction.isChatInputCommand()) return;
const command = client.commands.get(interaction.commandName);
if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`);
return;
}
try {
await command.execute(interaction);
} catch (error) {
console.error(error);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({
content: 'There was an error while executing this command!',
ephemeral: true
});
} else {
await interaction.reply({
content: 'There was an error while executing this command!',
ephemeral: true
});
}
}
});
client.login(process.env.DISCORD_TOKEN);
+152
View File
@@ -0,0 +1,152 @@
// ask.js - Discord bot AI question command
// Copyright (C) 2025 Luis Bauza
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
const { SlashCommandBuilder } = require('discord.js');
const axios = require('axios');
const config = {
webSearch: {
enabled: false, // Default web search state
allowOverride: true, // Whether users can override the default state
},
};
module.exports = {
data: new SlashCommandBuilder()
.setName('ask')
.setDescription('Ask a question to the AI')
.addStringOption(option =>
option
.setName('prompt')
.setDescription('Your question or prompt')
.setRequired(true)
)
.addBooleanOption(option =>
option
.setName('websearch')
.setDescription('Enable web search for more up-to-date information')
.setRequired(false)
)
// Only show the websearch option if overrides are allowed
.setDMPermission(false),
async execute(interaction) {
await interaction.deferReply();
const prompt = interaction.options.getString('prompt');
const userWebSearchOption = interaction.options.getBoolean('websearch');
// Determine if web search should be enabled based on config and user option
const webSearchEnabled =
config.webSearch.allowOverride && userWebSearchOption !== null
? userWebSearchOption // Use user's choice if override is allowed and option was provided
: config.webSearch.enabled; // Otherwise use default config
try {
const response = await axios.post(
'https://openrouter.ai/api/v1/chat/completions',
{
model: webSearchEnabled
? 'google/gemini-2.0-flash-exp:free:online'
: 'google/gemini-2.0-flash-exp:free',
messages: [
{
role: 'system',
content:
'You are a helpful AI assistant. Provide clear, concise, and accurate responses. ' +
'Keep your answers brief while ensuring they are informative and to the point. ' +
'Avoid unnecessary elaboration or repetition. ',
},
{ role: 'user', content: prompt },
],
plugins: webSearchEnabled
? [
{
id: 'web',
max_results: 3,
search_prompt:
`A web search was conducted on ${new Date().toISOString()}. ` +
'Incorporate the following web search results into your response. ' +
'IMPORTANT: Cite them using markdown links named using the domain of the source. ' +
'Example: [nytimes.com](https://nytimes.com/some-page).',
},
]
: undefined,
},
{
headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
'HTTP-Referer': 'https://github.com/hllywluis/kekbot.js',
'Content-Type': 'application/json',
},
}
);
const aiResponse = response.data.choices[0].message.content;
const webSearchStatus = webSearchEnabled
? '\n> *Web search enabled* 🔍\n'
: '';
const formattedResponse = `> **Question:** ${prompt}${webSearchStatus}\n${aiResponse}`;
if (formattedResponse.length <= 2000) {
// Send as a single message with proper formatting
await interaction.followUp({
content: formattedResponse,
split: false,
allowedMentions: { parse: [] },
});
} else {
// For longer messages, split while preserving markdown
const maxLength = 2000;
const chunks = [];
let remainingText = formattedResponse;
let isFirstChunk = true;
while (remainingText.length > 0) {
let chunk = remainingText.slice(0, maxLength);
// If we're in the middle of a code block, find a safe split point
const lastCodeBlock = chunk.lastIndexOf('```');
if (
lastCodeBlock !== -1 &&
!chunk.slice(lastCodeBlock).includes('\n```')
) {
// Find the last newline before maxLength
const lastNewline = chunk.lastIndexOf('\n');
if (lastNewline !== -1) {
chunk = chunk.slice(0, lastNewline);
}
}
// For subsequent chunks, add continuation indicator
if (!isFirstChunk) {
chunk = `(continued)\n${chunk}`;
}
chunks.push({
content: chunk,
split: false,
allowedMentions: { parse: [] },
});
remainingText = remainingText.slice(chunk.length);
isFirstChunk = false;
}
// Send all chunks in sequence
await Promise.all(chunks.map(chunk => interaction.followUp(chunk)));
}
} catch (error) {
console.error('Error:', error.response?.data || error.message);
await interaction.followUp({
content: 'Sorry, there was an error processing your request.',
ephemeral: true,
});
}
},
};
+32
View File
@@ -0,0 +1,32 @@
// help.js - Discord bot help command
// Copyright (C) 2025 Luis Bauza
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('help')
.setDescription('Lists all available commands'),
async execute(interaction) {
const { commands } = interaction.client;
const helpEmbed = new EmbedBuilder()
.setColor('#5dc67b')
.setTitle('Available Commands')
.setDescription('Here are all my commands:')
.setTimestamp();
commands.forEach(command => {
helpEmbed.addFields({
name: `/${command.data.name}`,
value: command.data.description,
});
});
await interaction.reply({ embeds: [helpEmbed], ephemeral: true });
},
};
+59
View File
@@ -0,0 +1,59 @@
// kick.js - Discord bot kick command
// Copyright (C) 2025 Luis Bauza
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
const { SlashCommandBuilder, PermissionFlagsBits } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('kick')
.setDescription('Kick a user from the server')
.addUserOption(option =>
option
.setName('target')
.setDescription('The user to kick')
.setRequired(true),
)
.addStringOption(option =>
option.setName('reason').setDescription('Reason for kicking'),
)
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers),
async execute(interaction) {
const target = interaction.options.getMember('target');
const reason =
interaction.options.getString('reason') ?? 'No reason provided';
if (!target) {
return interaction.reply({
content: 'That user is not in this server!',
ephemeral: true,
});
}
if (!target.kickable) {
return interaction.reply({
content:
'I cannot kick this user! They may have higher permissions than me.',
ephemeral: true,
});
}
try {
await target.kick(reason);
await interaction.reply({
content: `Successfully kicked ${target.user.tag}\nReason: ${reason}`,
ephemeral: true,
});
} catch (error) {
console.error(error);
await interaction.reply({
content: 'There was an error trying to kick this user!',
ephemeral: true,
});
}
},
};
+18
View File
@@ -0,0 +1,18 @@
// ping.js - Discord bot ping command
// Copyright (C) 2025 Luis Bauza
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
const { SlashCommandBuilder } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with Pong!'),
async execute(interaction) {
await interaction.reply('Pong! 🏓');
},
};
+41
View File
@@ -0,0 +1,41 @@
// prune.js - Discord bot message pruning command
// Copyright (C) 2025 Luis Bauza
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
const { SlashCommandBuilder, PermissionFlagsBits } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('prune')
.setDescription('Prune up to 99 messages.')
.addIntegerOption(option =>
option
.setName('amount')
.setDescription('Number of messages to prune')
.setMinValue(1)
.setMaxValue(99)
.setRequired(true),
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
async execute(interaction) {
const amount = interaction.options.getInteger('amount');
try {
const deleted = await interaction.channel.bulkDelete(amount, true);
await interaction.reply({
content: `Successfully deleted ${deleted.size} message(s).`,
ephemeral: true,
});
} catch (error) {
console.error(error);
await interaction.reply({
content: 'There was an error trying to prune messages in this channel!',
ephemeral: true,
});
}
},
};
+43
View File
@@ -0,0 +1,43 @@
require('dotenv').config();
// deploy-commands.js - Discord bot command deployment
// Copyright (C) 2025 Luis Bauza
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
const { REST, Routes } = require('discord.js');
const fs = require('node:fs');
const path = require('node:path');
const commands = [];
const commandsPath = path.join(__dirname, 'commands');
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = require(filePath);
if ('data' in command && 'execute' in command) {
commands.push(command.data.toJSON());
} else {
console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
}
}
const rest = new REST().setToken(process.env.DISCORD_TOKEN);
(async () => {
try {
console.log(`Started refreshing ${commands.length} application (/) commands.`);
const data = await rest.put(
Routes.applicationCommands(process.env.CLIENT_ID),
{ body: commands },
);
console.log(`Successfully reloaded ${data.length} application (/) commands.`);
} catch (error) {
console.error(error);
}
})();
+33
View File
@@ -0,0 +1,33 @@
module.exports = {
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,
// The directory where Jest should output its coverage files
coverageDirectory: "coverage",
// The test environment that will be used for testing
testEnvironment: "node",
// The glob patterns Jest uses to detect test files
testMatch: [
"**/__tests__/**/*.test.[jt]s?(x)",
"**/__tests__/**/*.spec.[jt]s?(x)",
"**/?(*.)+(spec|test).[jt]s?(x)"
],
// A map from regular expressions to paths to transformers
transform: {
"^.+\\.jsx?$": "babel-jest"
},
// An array of regexp pattern strings that are matched against all test files
testPathIgnorePatterns: [
"/node_modules/"
],
// Setup files that will be run before each test
setupFiles: ["<rootDir>/jest.setup.js"]
};
+39
View File
@@ -0,0 +1,39 @@
// Mock Discord.js Client and other commonly used classes
jest.mock('discord.js', () => ({
Client: jest.fn(() => ({
login: jest.fn().mockResolvedValue('token'),
destroy: jest.fn().mockResolvedValue(),
on: jest.fn(),
once: jest.fn(),
user: {
setActivity: jest.fn()
}
})),
Collection: jest.fn(() => ({
get: jest.fn(),
set: jest.fn(),
has: jest.fn(),
delete: jest.fn()
})),
GatewayIntentBits: {
Guilds: 1,
GuildMessages: 2,
MessageContent: 3
},
Events: {
ClientReady: 'ready',
InteractionCreate: 'interactionCreate'
},
SlashCommandBuilder: jest.fn().mockReturnValue({
setName: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(),
addStringOption: jest.fn().mockReturnThis(),
addIntegerOption: jest.fn().mockReturnThis(),
addUserOption: jest.fn().mockReturnThis()
})
}));
// Mock environment variables
process.env.DISCORD_TOKEN = 'mock-token';
process.env.CLIENT_ID = 'mock-client-id';
process.env.GUILD_ID = 'mock-guild-id';
+8299
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "kekbot.js",
"version": "1.0.0",
"description": "A simple Discord bot",
"main": "bot.js",
"scripts": {
"start": "node bot.js",
"deploy": "node deploy-commands.js",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"keywords": [],
"author": "",
"license": "GPL-3.0",
"dependencies": {
"axios": "^1.7.9",
"discord.js": "^14.17.3",
"dotenv": "^16.3.1"
},
"devDependencies": {
"@babel/core": "^7.26.7",
"@babel/preset-env": "^7.26.7",
"@types/jest": "^29.5.14",
"babel-jest": "^29.7.0",
"eslint": "^8.57.1",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.3",
"jest": "^29.7.0",
"prettier": "^3.4.2"
}
}
+22
View File
@@ -0,0 +1,22 @@
# Discord tokens (common patterns)
['"](N[a-zA-Z0-9]{23}\.[a-zA-Z0-9]{6}\.[a-zA-Z0-9_-]{27})['"]
# OpenRouter API keys
['"](sk-or-[a-zA-Z0-9-]{32,64})['"]
# Generic API keys and tokens
['"]([a-zA-Z0-9_-]{32,64})['"]
['"](api[_-]?key|api[_-]?secret|access[_-]?token|auth[_-]?token|client[_-]?secret)['"]\s*[:=]\s*['"][^'"]+['"]
# Environment variables with values
^[^#].*=(["'])[^'"]+\1
# URLs with embedded credentials
[a-zA-Z]{3,10}://[^/\s:@]+:[^/\s:@]+@[^/\s]+
# Private keys and certificates
-----BEGIN.*PRIVATE KEY-----[a-zA-Z0-9\s+/=\n]+-----END.*PRIVATE KEY-----
-----BEGIN.*CERTIFICATE-----[a-zA-Z0-9\s+/=\n]+-----END.*CERTIFICATE-----
# Common credential variable names with values
(password|secret|token|key|pwd|apikey|api_key|auth)[\s]*[=:]\s*['"][^'"]*['"]
+96
View File
@@ -0,0 +1,96 @@
---
- name: Deploy Discord Bot
hosts: discord_bot
become: yes # This enables sudo privileges
vars:
app_dir: /opt/kekbot
node_version: "20.x" # Latest LTS version
tasks:
- name: Install Node.js repository
shell: |
curl -fsSL https://deb.nodesource.com/setup_{{ node_version }} | bash -
- name: Install Node.js, npm, and git
apt:
name:
- nodejs
- npm
- git
state: present
update_cache: yes
- name: Create application directory
file:
path: "{{ app_dir }}"
state: directory
mode: '0755'
- name: Copy application files
copy:
src: "{{ item }}"
dest: "{{ app_dir }}/"
mode: '0644'
with_items:
- package.json
- package-lock.json
- bot.js
- deploy-commands.js
- .env
- .prettierrc
- name: Copy commands directory
copy:
src: commands/
dest: "{{ app_dir }}/commands/"
mode: '0644'
- name: Install npm dependencies
npm:
path: "{{ app_dir }}"
state: present
production: yes
- name: Deploy commands
command: npm run deploy
args:
chdir: "{{ app_dir }}"
- name: Create systemd service file
copy:
dest: /etc/systemd/system/kekbot.service
content: |
[Unit]
Description=Kek Discord Bot
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory={{ app_dir }}
ExecStart=/usr/bin/npm start
Restart=always
RestartSec=10
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=kekbot
[Install]
WantedBy=multi-user.target
mode: '0644'
- name: Reload systemd
systemd:
daemon_reload: yes
- name: Stop existing bot service if running
systemd:
name: kekbot
state: stopped
ignore_errors: yes
- name: Enable and start bot service
systemd:
name: kekbot
state: started
enabled: yes