Initial commit
This commit is contained in:
@@ -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
@@ -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
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"jsxSingleQuote": false,
|
||||
"proseWrap": "preserve"
|
||||
}
|
||||
@@ -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/>.
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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! 🏓');
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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"]
|
||||
};
|
||||
@@ -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';
|
||||
Generated
+8299
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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*['"][^'"]*['"]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user