Compare commits

..

35 Commits

Author SHA1 Message Date
hllywluis a2fdf73f8a Add current date to system prompt (March 9, 2026)
Deploy to NAS / deploy (push) Successful in 58s
2026-03-09 16:52:23 -04:00
hllywluis 7ff95a3d7f Add Brave Search as primary web search with NanoGPT fallback
Deploy to NAS / deploy (push) Has been cancelled
- Brave Search API is now primary (if BRAVE_API_KEY set)
- NanoGPT web search is fallback if Brave fails
- Add BRAVE_API_KEY to .env, docker-compose, and deploy workflow
- Tool calling still works for AI to request searches
2026-03-09 16:51:43 -04:00
hllywluis a73a8901f2 Replace keyword detection with proper tool calling for web search
Deploy to NAS / deploy (push) Successful in 1m11s
- Define web_search function that AI can call when needed
- AI decides autonomously when to search (no keyword matching)
- Tool calling via NanoGPT API with function definitions
- Format search results with sources for citations
2026-03-09 16:43:08 -04:00
hllywluis b1fff6f337 Add automatic web search for current events queries
Deploy to NAS / deploy (push) Successful in 1m8s
- Detect keywords like 'latest', 'news', 'current', '2026' etc
- Append :online suffix to model for web search + AI response
- Retry logic: if web search fails, fall back to regular response
- Increased timeout to 90s for web search queries
- Fixed typo in fallback error handling
2026-03-09 16:40:13 -04:00
hllywluis 8b1ece727d Fix fallback API URL and clean up message formatting in AI response functions
Deploy to NAS / deploy (push) Successful in 59s
2026-03-09 13:50:53 -04:00
hllywluis 7cf1582fee Add ZAI_API_KEY to docker-compose environment
Deploy to NAS / deploy (push) Successful in 54s
2026-03-09 13:43:50 -04:00
hllywluis 9dfdef46d5 Add z.ai fallback provider for AI responses
Deploy to NAS / deploy (push) Has been cancelled
- Try NanoGPT first, fall back to z.ai GLM-4.7-Flash on failure
- Add ZAI_API_KEY environment variable
- Update deploy workflow with new secret
2026-03-09 13:42:59 -04:00
hllywluis e5ebb203fa Increase NanoGPT timeout to 60s
Deploy to NAS / deploy (push) Successful in 59s
2026-03-09 13:01:18 -04:00
hllywluis 0e631bf029 Add NANO_MODEL to Docker and deploy workflow
Deploy to NAS / deploy (push) Has been cancelled
2026-03-09 12:55:52 -04:00
hllywluis a7a6c1e321 Add sassy AI personality with mention detection
Deploy to NAS / deploy (push) Successful in 1m55s
- Add utils/ai.js with NanoGPT integration for GLM-4.5-Air model
- Sassy system prompt: lowercase, slang, no emoji, opinionated gamer
- Conversation history per channel for context
- Update bot.js with messageCreate event for @mention responses
- Add NANO_MODEL env var for model selection
2026-03-09 12:37:45 -04:00
hllywluis ab91c98360 Update test to expect new model in axios request
Deploy to NAS / deploy (push) Successful in 52s
2026-01-08 20:30:58 -05:00
hllywluis 4c38488161 Switch to zai-org/glm-4.7 model
Deploy to NAS / deploy (push) Successful in 1m16s
2026-01-08 20:22:50 -05:00
hllywluis 4cad39d0f6 feat: enhance AI response guidelines for improved clarity and conciseness
Deploy to NAS / deploy (push) Successful in 1m2s
2025-12-25 17:44:05 -05:00
hllywluis 06ac40a6d7 refactor: update command tests to use require syntax and improve error handling
Deploy to NAS / deploy (push) Successful in 1m4s
2025-12-25 10:28:44 -05:00
hllywluis b362a43886 feat: add step to install SSH dependencies in deployment workflow
Deploy to NAS / deploy (push) Successful in 1m3s
2025-12-24 21:05:57 -05:00
hllywluis e82b174bf5 fix: correct Docker image pull path in deployment workflow
Deploy to NAS / deploy (push) Failing after 47s
2025-12-24 21:04:11 -05:00
hllywluis 59590002b8 fix: update deployment workflow to use Docker image instead of build context
Deploy to NAS / deploy (push) Has been cancelled
2025-12-24 21:03:57 -05:00
hllywluis 2cee379491 feat: add step to generate package-lock.json in deployment workflow
Deploy to NAS / deploy (push) Failing after 1m17s
2025-12-24 20:57:02 -05:00
hllywluis 5e356d7b01 fix: update Docker command from 'docker-compose' to 'docker compose' for consistency
Deploy to NAS / deploy (push) Failing after 1m5s
2025-12-24 20:52:57 -05:00
hllywluis f850e1ce1e feat: add .env file generation to deployment workflow and update Dockerfile for production dependencies
Deploy to NAS / deploy (push) Failing after 1m5s
2025-12-24 20:51:28 -05:00
hllywluis abcc60e703 refactor: remove non-root user setup from Dockerfile
Deploy to NAS / deploy (push) Failing after 1m3s
2025-12-24 20:46:43 -05:00
hllywluis af47efefcc fix: update ownership path for application logs in Dockerfile
Deploy to NAS / deploy (push) Failing after 49s
2025-12-24 20:42:48 -05:00
hllywluis 940c2eb4cd fix: update branch name from 'main' to 'master' in deployment workflow
Deploy to NAS / deploy (push) Has been cancelled
2025-12-24 20:38:44 -05:00
hllywluis 7795662584 feat: add Gitea workflow for deploying Kekbot to NAS via SSH 2025-12-24 20:38:13 -05:00
hllywluis 2ac855b765 refactor: update ask command and test utilities to use ES modules; improve error handling and response formatting 2025-12-24 11:21:30 -05:00
hllywluis 94494c1ca5 feat: update AI model in AskCommand and add Docker support files for improved deployment 2025-12-17 19:31:03 -05:00
hllywluis 0cd3b5c179 fix: standardize command import paths to lowercase for consistency 2025-05-01 17:56:55 -04:00
hllywluis 748d438706 fix: update @babel/runtime to version 7.27.0 for improved stability and performance 2025-04-19 19:37:32 -04:00
hllywluis e5124d33d4 chore: update ESLint configuration and dependencies
- Reordered ESLint extends in .eslintrc.js for better clarity.
- Updated ESLint version from 9.23.0 to 8.57.1 in package.json and package-lock.json.
- Adjusted import/extensions rule to ignorePackages in .eslintrc.js.
- Updated various dependencies in package-lock.json to maintain compatibility and improve performance.
2025-04-19 19:36:10 -04:00
hllywluis 5bc0d98334 refactor: implement command class structure for better organization and error handling; update existing commands to extend from the new base class 2025-03-25 11:17:26 -04:00
hllywluis 69dc616668 fix: update AI assistant description for clarity and accuracy; bump dependencies for improved performance and security 2025-03-24 20:11:02 -04:00
hllywluis d4e01a3cab Merge branch 'master' of https://github.com/hllywluis/kekbot-js 2025-03-18 14:38:15 -04:00
hllywluis bff1625c29 Switch to using free Gemma model 2025-03-18 14:36:52 -04:00
hllywluis bf6dcae288 Create PRIVACY.md 2025-03-12 11:22:38 -04:00
hllywluis 99ceb22019 Create TERMS.md 2025-03-12 11:19:50 -04:00
30 changed files with 1342 additions and 629 deletions
+27
View File
@@ -0,0 +1,27 @@
node_modules
npm-debug.log
coverage
.git
.gitignore
.env
.env.local
.env.*.local
*.md
README.md
LICENSE
TESTING.md
PRIVACY.md
TERMS.md
SECURITY.md
ansible-README.md
__tests__
jest.config.js
jest.setup.js
.eslintrc.*
.prettierrc
.vscode
.idea
*.log
.DS_Store
inventory.ini
playbook.yml
+4 -4
View File
@@ -7,12 +7,12 @@ module.exports = {
es6: true, es6: true,
}, },
extends: [ extends: [
'airbnb-base',
'eslint:recommended', 'eslint:recommended',
'prettier', 'airbnb-base',
'plugin:prettier/recommended',
'plugin:import/errors', 'plugin:import/errors',
'plugin:import/warnings', 'plugin:import/warnings',
'plugin:prettier/recommended',
'prettier',
], ],
parserOptions: { parserOptions: {
ecmaVersion: 2023, ecmaVersion: 2023,
@@ -30,7 +30,7 @@ module.exports = {
'arrow-body-style': ['error', 'as-needed'], 'arrow-body-style': ['error', 'as-needed'],
'import/extensions': [ 'import/extensions': [
'error', 'error',
'always', 'ignorePackages',
{ {
js: 'never', js: 'never',
jsx: 'never', jsx: 'never',
+72
View File
@@ -0,0 +1,72 @@
name: Deploy to NAS
on:
push:
branches:
- master
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Generate package-lock.json
run: npm ci --omit=dev
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: tea.kleptonix.com
username: ${{ secrets.TEA_USERNAME }}
password: ${{ secrets.TEA_TOKEN }}
- name: Build and Push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: tea.kleptonix.com/hllywluis/kekbot.js:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate .env file
run: |
cat > .env << EOF
DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }}
CLIENT_ID=${{ secrets.DISCORD_CLIENT_ID }}
NANOGPT_API_KEY=${{ secrets.NANOGPT_API_KEY }}
ZAI_API_KEY=${{ secrets.ZAI_API_KEY }}
BRAVE_API_KEY=${{ secrets.BRAVE_API_KEY }}
NANO_MODEL=${{ secrets.NANO_MODEL }}
NODE_ENV=production
EOF
- name: Install SSH dependencies
run: |
sudo apt-get update
sudo apt-get install -y sshpass
- name: Deploy to NAS
env:
SSH_PASSWORD: ${{ secrets.NAS_SSH_PASSWORD }}
run: |
# Create app directory on NAS
sshpass -p "$SSH_PASSWORD" ssh -o StrictHostKeyChecking=no hllywluis@luis-nas.lan "mkdir -p /mnt/kCloud/Home/hllywluis/kekbot"
# Copy Docker files (without .env, it's generated separately)
sshpass -p "$SSH_PASSWORD" scp -o StrictHostKeyChecking=no docker-compose.yml .env hllywluis@luis-nas.lan:/mnt/kCloud/Home/hllywluis/kekbot/
# Pull Docker image and deploy
sshpass -p "$SSH_PASSWORD" ssh -o StrictHostKeyChecking=no hllywluis@luis-nas.lan << 'EOF'
cd /mnt/kCloud/Home/hllywluis/kekbot
docker compose down || true
docker pull tea.kleptonix.com/hllywluis/kekbot.js:latest
docker compose up -d
EOF
+155
View File
@@ -0,0 +1,155 @@
# Docker Setup Guide
## Prerequisites
- Docker installed on your system
- Docker Compose (optional, but recommended)
## Configuration
1. Create a `.env` file in the project root with your Discord bot credentials:
```bash
cp .env.example .env
```
2. Edit the `.env` file and add your Discord bot token and other credentials:
```
DISCORD_TOKEN=your_discord_bot_token_here
CLIENT_ID=your_client_id_here
OPENROUTER_API_KEY=your_openrouter_api_key_here
```
## Running with Docker Compose (Recommended)
### Start the bot:
```bash
docker-compose up -d
```
### View logs:
```bash
docker-compose logs -f
```
### Stop the bot:
```bash
docker-compose down
```
### Restart the bot:
```bash
docker-compose restart
```
### Rebuild and start (after code changes):
```bash
docker-compose up -d --build
```
## Running with Docker directly
### Build the image:
```bash
docker build -t kekbot:latest .
```
### Run the container:
```bash
docker run -d \
--name kekbot \
--env-file .env \
--restart unless-stopped \
kekbot:latest
```
### View logs:
```bash
docker logs -f kekbot
```
### Stop the container:
```bash
docker stop kekbot
docker rm kekbot
```
## Deploy Commands
Before running the bot for the first time, you may need to deploy slash commands:
```bash
# Using Docker Compose
docker-compose run --rm kekbot npm run deploy
# Using Docker directly
docker run --rm --env-file .env kekbot:latest npm run deploy
```
## Troubleshooting
### Check if container is running:
```bash
docker ps
```
### View all containers (including stopped):
```bash
docker ps -a
```
### Access container shell:
```bash
docker exec -it kekbot sh
```
### View container resource usage:
```bash
docker stats kekbot
```
## Production Considerations
1. **Security**: Ensure your `.env` file is never committed to version control
2. **Logging**: Configure log rotation if using volume mounts for logs
3. **Updates**: Regularly update the base image and dependencies
4. **Monitoring**: Consider adding monitoring tools like Prometheus or Grafana
5. **Backups**: If you add persistent data, implement backup strategies
## Multi-stage Build (Alternative)
For a smaller production image, you can use the multi-stage Dockerfile variant:
```dockerfile
# Development stage
FROM node:20-alpine AS development
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs /app
USER nodejs
CMD ["npm", "start"]
```
+20
View File
@@ -0,0 +1,20 @@
# Use Node.js LTS version
FROM node:20-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --omit=dev
# Copy application files
COPY . .
# Expose port (if needed for health checks)
EXPOSE 3000
# Start the bot
CMD ["npm", "start"]
+17
View File
@@ -0,0 +1,17 @@
# Privacy Policy
**Effective Date:** March 12, 2025
**Kekbot-js** ("we," "our," or "us") is committed to respecting your privacy. This Privacy Policy outlines our practices regarding the collection, use, and disclosure of information when you use our software.
**Information Collection and Use**
We do not collect, store, or process any personal information from users of **Kekbot-js**.
**Changes to This Privacy Policy**
We may update our Privacy Policy from time to time. Any changes will be reflected on this page, and we encourage you to review this policy periodically.
**Contact Us**
If you have any questions or concerns about this Privacy Policy, please contact us at luis@kleptonix.com.
+9
View File
@@ -0,0 +1,9 @@
# Terms and Conditions
**Kekbot-js** is an open-source project licensed under the [GNU General Public License v3.0](LICENSE). By using, copying, modifying, or distributing this software, you agree to the terms and conditions of this license.
**Disclaimer of Warranty**
**Kekbot-js** is provided "as-is," without any express or implied warranty. In no event shall the authors or copyright holders be held liable for any damages arising from the use of this software.
For detailed licensing information, please refer to the [LICENSE](LICENSE) file in this repository.
+43 -176
View File
@@ -1,10 +1,15 @@
const axios = require('axios');
const { createMockInteraction } = require('../utils/testUtils'); const { createMockInteraction } = require('../utils/testUtils');
// Mock axios // Mock axios
jest.mock('axios'); jest.mock('axios', () => ({
__esModule: true,
default: {
post: jest.fn(),
},
}));
const axios = require('axios').default;
// Mock the discord.js module // Mock discord.js
jest.mock('discord.js', () => ({ jest.mock('discord.js', () => ({
SlashCommandBuilder: jest.fn().mockReturnValue({ SlashCommandBuilder: jest.fn().mockReturnValue({
setName: jest.fn().mockReturnThis(), setName: jest.fn().mockReturnThis(),
@@ -16,14 +21,6 @@ jest.mock('discord.js', () => ({
setRequired: jest.fn().mockReturnThis(), setRequired: jest.fn().mockReturnThis(),
}; };
callback(option); 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 { return {
toJSON: jest.fn().mockReturnValue({ toJSON: jest.fn().mockReturnValue({
name: 'ask', name: 'ask',
@@ -35,49 +32,38 @@ jest.mock('discord.js', () => ({
type: 3, type: 3,
required: true, required: true,
}, },
{
name: 'websearch',
description: 'Enable web search for more up-to-date information',
type: 5,
required: false,
},
], ],
}), }),
}; };
}), }),
};
}),
toJSON: jest.fn(), toJSON: jest.fn(),
}), }),
})); }));
const askCommand = require('../../commands/ask'); const AskCommand = require('../../commands/ask').default;
const askCommand = new AskCommand();
describe('Ask Command', () => { describe('Ask Command', () => {
describe('Command Structure', () => { describe('Command Structure', () => {
it('should have correct name and description', () => { it('should have correct name and description', () => {
const commandData = askCommand.data.toJSON(); const commandData = askCommand.defineCommand().toJSON();
expect(commandData.name).toBe('ask'); expect(commandData.name).toBe('ask');
expect(commandData.description).toBe('Ask a question to the AI'); expect(commandData.description).toBe('Ask a question to the AI');
}); });
it('should have required command properties', () => { it('should have required command properties', () => {
expect(askCommand).toHaveProperty('data'); expect(askCommand).toHaveProperty('defineCommand');
expect(askCommand).toHaveProperty('execute'); expect(askCommand).toHaveProperty('run');
expect(typeof askCommand.execute).toBe('function'); expect(typeof askCommand.run).toBe('function');
}); });
it('should have correct option configuration', () => { it('should have correct option configuration', () => {
const commandData = askCommand.data.toJSON(); const commandData = askCommand.defineCommand().toJSON();
const [promptOption, websearchOption] = commandData.options; const [promptOption] = commandData.options;
expect(promptOption.name).toBe('prompt'); expect(promptOption.name).toBe('prompt');
expect(promptOption.description).toBe('Your question or prompt'); expect(promptOption.description).toBe('Your question or prompt');
expect(promptOption.required).toBe(true); 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);
}); });
}); });
@@ -97,8 +83,7 @@ describe('Ask Command', () => {
}; };
beforeEach(() => { beforeEach(() => {
process.env.OPENROUTER_API_KEY = 'test-api-key'; process.env.NANOGPT_API_KEY = 'test-api-key';
axios.post.mockReset();
jest.clearAllMocks(); jest.clearAllMocks();
interaction = createMockInteraction({ interaction = createMockInteraction({
@@ -106,22 +91,18 @@ describe('Ask Command', () => {
stringOptions: { stringOptions: {
prompt: mockPrompt, prompt: mockPrompt,
}, },
booleanOptions: {
websearch: false,
},
}); });
}); });
it('should handle successful API response with websearch disabled', async () => { it('should handle successful API response', async () => {
axios.post.mockResolvedValueOnce(mockApiResponse); axios.post.mockResolvedValueOnce(mockApiResponse);
await askCommand.execute(interaction); await askCommand.run(interaction);
expect(axios.post).toHaveBeenCalledWith( expect(axios.post).toHaveBeenCalledWith(
'https://openrouter.ai/api/v1/chat/completions', 'https://nano-gpt.com/api/v1/chat/completions',
expect.objectContaining({ expect.objectContaining({
model: 'google/gemini-2.0-flash-001', model: 'zai-org/glm-4.7',
plugins: undefined,
}), }),
expect.any(Object), expect.any(Object),
); );
@@ -133,76 +114,7 @@ describe('Ask Command', () => {
}); });
}); });
it('should handle successful API response with websearch enabled', async () => { it('should handle long responses with proper chunking', 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-001: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-001',
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); const longResponse = 'A'.repeat(2500);
axios.post.mockResolvedValueOnce({ axios.post.mockResolvedValueOnce({
data: { data: {
@@ -216,24 +128,13 @@ describe('Ask Command', () => {
}, },
}); });
await askCommand.execute(interaction); await askCommand.run(interaction);
expect(interaction.followUp).toHaveBeenCalledTimes(2); 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)'); expect(interaction.followUp.mock.calls[1][0].content).toContain('(continued)');
}); });
it('should handle code blocks in chunked responses with websearch', async () => { it('should handle code blocks in chunked responses', async () => {
interaction = createMockInteraction({
commandName: 'ask',
stringOptions: {
prompt: mockPrompt,
},
booleanOptions: {
websearch: true,
},
});
const responseWithCodeBlock = "Here's a code example:\n```python\nprint('hello')\n```".repeat( const responseWithCodeBlock = "Here's a code example:\n```python\nprint('hello')\n```".repeat(
20, 20,
); );
@@ -249,7 +150,7 @@ describe('Ask Command', () => {
}, },
}); });
await askCommand.execute(interaction); await askCommand.run(interaction);
// Verify that code blocks are not split in the middle // Verify that code blocks are not split in the middle
interaction.followUp.mock.calls.forEach(call => { interaction.followUp.mock.calls.forEach(call => {
@@ -257,23 +158,14 @@ describe('Ask Command', () => {
const openBlocks = (content.match(/```/g) || []).length; const openBlocks = (content.match(/```/g) || []).length;
expect(openBlocks % 2).toBe(0); // Should always be even 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 },
}); });
it('should handle API errors', async () => {
const error = new Error('API Error'); const error = new Error('API Error');
error.response = { data: 'API Error Details' }; error.response = { data: 'API Error Details' };
axios.post.mockRejectedValueOnce(error); axios.post.mockRejectedValueOnce(error);
await askCommand.execute(interaction); await askCommand.run(interaction);
expect(interaction.followUp).toHaveBeenCalledWith({ expect(interaction.followUp).toHaveBeenCalledWith({
content: 'Sorry, there was an error processing your request.', content: 'Sorry, there was an error processing your request.',
@@ -282,16 +174,11 @@ describe('Ask Command', () => {
}); });
it('should handle rate limit errors', async () => { it('should handle rate limit errors', async () => {
interaction = createMockInteraction({
commandName: 'ask',
stringOptions: { prompt: mockPrompt },
});
const error = new Error('Rate Limit Exceeded'); const error = new Error('Rate Limit Exceeded');
error.response = { status: 429, data: 'Too Many Requests' }; error.response = { status: 429, data: 'Too Many Requests' };
axios.post.mockRejectedValueOnce(error); axios.post.mockRejectedValueOnce(error);
await askCommand.execute(interaction); await askCommand.run(interaction);
expect(interaction.followUp).toHaveBeenCalledWith({ expect(interaction.followUp).toHaveBeenCalledWith({
content: 'The AI service is currently busy. Please try again in a few moments.', content: 'The AI service is currently busy. Please try again in a few moments.',
@@ -300,16 +187,11 @@ describe('Ask Command', () => {
}); });
it('should handle timeout errors', async () => { it('should handle timeout errors', async () => {
interaction = createMockInteraction({
commandName: 'ask',
stringOptions: { prompt: mockPrompt },
});
const error = new Error('Timeout'); const error = new Error('Timeout');
error.code = 'ETIMEDOUT'; error.code = 'ETIMEDOUT';
axios.post.mockRejectedValueOnce(error); axios.post.mockRejectedValueOnce(error);
await askCommand.execute(interaction); await askCommand.run(interaction);
expect(interaction.followUp).toHaveBeenCalledWith({ expect(interaction.followUp).toHaveBeenCalledWith({
content: 'The request timed out. Please try again.', content: 'The request timed out. Please try again.',
@@ -318,16 +200,11 @@ describe('Ask Command', () => {
}); });
it('should handle invalid request errors', async () => { it('should handle invalid request errors', async () => {
interaction = createMockInteraction({
commandName: 'ask',
stringOptions: { prompt: mockPrompt },
});
const error = new Error('Bad Request'); const error = new Error('Bad Request');
error.response = { status: 400, data: 'Invalid Request' }; error.response = { status: 400, data: 'Invalid Request' };
axios.post.mockRejectedValueOnce(error); axios.post.mockRejectedValueOnce(error);
await askCommand.execute(interaction); await askCommand.run(interaction);
expect(interaction.followUp).toHaveBeenCalledWith({ expect(interaction.followUp).toHaveBeenCalledWith({
content: 'Invalid request. Please try rephrasing your question.', content: 'Invalid request. Please try rephrasing your question.',
@@ -335,60 +212,50 @@ describe('Ask Command', () => {
}); });
}); });
it('should send API request with correct headers regardless of websearch', async () => { it('should send API request with correct headers', async () => {
interaction = createMockInteraction({
commandName: 'ask',
stringOptions: {
prompt: mockPrompt,
},
booleanOptions: {
websearch: true,
},
});
axios.post.mockResolvedValueOnce(mockApiResponse); axios.post.mockResolvedValueOnce(mockApiResponse);
await askCommand.execute(interaction); await askCommand.run(interaction);
expect(axios.post).toHaveBeenCalledWith(expect.any(String), expect.any(Object), { expect(axios.post).toHaveBeenCalledWith(expect.any(String), expect.any(Object), {
headers: { headers: {
Authorization: 'Bearer test-api-key', Authorization: 'Bearer test-api-key',
'HTTP-Referer': 'https://github.com/hllywluis/kekbot.js',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
}); });
it('should handle defer reply failure with websearch', async () => { it('should handle defer reply failure', async () => {
interaction = createMockInteraction({ interaction = createMockInteraction({
commandName: 'ask', commandName: 'ask',
stringOptions: { stringOptions: {
prompt: mockPrompt, prompt: mockPrompt,
}, },
booleanOptions: {
websearch: true,
},
deferFails: true, deferFails: true,
}); });
await expect(askCommand.execute(interaction)).rejects.toThrow('Failed to defer reply'); await askCommand.run(interaction);
expect(interaction.followUp).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Sorry, there was an error processing your request.',
ephemeral: true,
}),
);
}); });
it('should handle follow up failure with websearch', async () => { it('should handle follow up failure', async () => {
interaction = createMockInteraction({ interaction = createMockInteraction({
commandName: 'ask', commandName: 'ask',
stringOptions: { stringOptions: {
prompt: mockPrompt, prompt: mockPrompt,
}, },
booleanOptions: {
websearch: true,
},
followUpFails: true, followUpFails: true,
}); });
axios.post.mockResolvedValueOnce(mockApiResponse); axios.post.mockResolvedValueOnce(mockApiResponse);
await expect(askCommand.execute(interaction)).rejects.toThrow('Failed to follow up'); await expect(askCommand.run(interaction)).rejects.toThrow('Failed to follow up');
}); });
}); });
}); });
+2 -1
View File
@@ -19,7 +19,8 @@ jest.mock('discord.js', () => ({
}), }),
})); }));
const helpCommand = require('../../commands/help'); const HelpCommand = require('../../commands/help').default;
const helpCommand = new HelpCommand();
describe('Help Command', () => { describe('Help Command', () => {
describe('Command Structure', () => { describe('Command Structure', () => {
+10 -8
View File
@@ -13,12 +13,13 @@ jest.mock('discord.js', () => ({
}; };
callback(option); callback(option);
return { return {
addStringOption: jest.fn().mockImplementation(callback => { addStringOption: jest.fn().mockImplementation(cb => {
const option = { const strOption = {
setName: jest.fn().mockReturnThis(), setName: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(), setDescription: jest.fn().mockReturnThis(),
setRequired: jest.fn().mockReturnThis(),
}; };
callback(option); cb(strOption);
return { return {
setDefaultMemberPermissions: jest.fn().mockReturnThis(), setDefaultMemberPermissions: jest.fn().mockReturnThis(),
toJSON: jest.fn().mockReturnValue({ toJSON: jest.fn().mockReturnValue({
@@ -46,11 +47,12 @@ jest.mock('discord.js', () => ({
toJSON: jest.fn(), toJSON: jest.fn(),
}), }),
PermissionFlagsBits: { PermissionFlagsBits: {
KickMembers: 0x2n, KickMembers: 0x0000000000000002n,
}, },
})); }));
const kickCommand = require('../../commands/kick'); const KickCommand = require('../../commands/kick').default;
const kickCommand = new KickCommand();
describe('Kick Command', () => { describe('Kick Command', () => {
describe('Command Structure', () => { describe('Command Structure', () => {
@@ -152,7 +154,7 @@ describe('Kick Command', () => {
expect(mockKick).not.toHaveBeenCalled(); expect(mockKick).not.toHaveBeenCalled();
expect(interaction.reply).toHaveBeenCalledWith({ expect(interaction.reply).toHaveBeenCalledWith({
content: 'That user is not in this server!', content: 'Command failed: That user is not in this server!',
ephemeral: true, ephemeral: true,
}); });
}); });
@@ -173,7 +175,7 @@ describe('Kick Command', () => {
expect(mockKick).not.toHaveBeenCalled(); expect(mockKick).not.toHaveBeenCalled();
expect(interaction.reply).toHaveBeenCalledWith({ expect(interaction.reply).toHaveBeenCalledWith({
content: content:
'I cannot kick this user! They may have higher permissions than me.', 'Command failed: I cannot kick this user! They may have higher permissions than me.',
ephemeral: true, ephemeral: true,
}); });
}); });
@@ -195,7 +197,7 @@ describe('Kick Command', () => {
await kickCommand.execute(interaction); await kickCommand.execute(interaction);
expect(interaction.reply).toHaveBeenCalledWith({ expect(interaction.reply).toHaveBeenCalledWith({
content: 'There was an error trying to kick this user!', content: 'Command failed: Failed to kick user',
ephemeral: true, ephemeral: true,
}); });
}); });
+3 -2
View File
@@ -12,7 +12,8 @@ jest.mock('discord.js', () => ({
}), }),
})); }));
const pingCommand = require('../../commands/ping'); const PingCommand = require('../../commands/ping').default;
const pingCommand = new PingCommand();
describe('Ping Command', () => { describe('Ping Command', () => {
describe('Command Structure', () => { describe('Command Structure', () => {
@@ -47,7 +48,7 @@ describe('Ping Command', () => {
it('should handle interaction reply failure', async () => { it('should handle interaction reply failure', async () => {
// Mock a failed reply // Mock a failed reply
interaction.reply.mockRejectedValueOnce(new Error('Failed to reply')); interaction.reply.mockRejectedValue(new Error('Failed to reply'));
await expect(pingCommand.execute(interaction)).rejects.toThrow( await expect(pingCommand.execute(interaction)).rejects.toThrow(
'Failed to reply', 'Failed to reply',
+4 -3
View File
@@ -39,7 +39,8 @@ jest.mock('discord.js', () => ({
}, },
})); }));
const pruneCommand = require('../../commands/prune'); const PruneCommand = require('../../commands/prune').default;
const pruneCommand = new PruneCommand();
describe('Prune Command', () => { describe('Prune Command', () => {
describe('Command Structure', () => { describe('Command Structure', () => {
@@ -103,7 +104,7 @@ describe('Prune Command', () => {
await pruneCommand.execute(interaction); await pruneCommand.execute(interaction);
expect(interaction.reply).toHaveBeenCalledWith({ expect(interaction.reply).toHaveBeenCalledWith({
content: 'There was an error trying to prune messages in this channel!', content: 'Command failed: Failed to delete messages',
ephemeral: true, ephemeral: true,
}); });
}); });
@@ -116,7 +117,7 @@ describe('Prune Command', () => {
await pruneCommand.execute(interaction); await pruneCommand.execute(interaction);
expect(interaction.reply).toHaveBeenCalledWith({ expect(interaction.reply).toHaveBeenCalledWith({
content: 'There was an error trying to prune messages in this channel!', content: 'Command failed: Messages older than 14 days cannot be deleted',
ephemeral: true, ephemeral: true,
}); });
}); });
+4 -7
View File
@@ -1,11 +1,13 @@
// Test utilities for Discord.js bot testing // Test utilities for Discord.js bot testing
import { jest } from '@jest/globals';
/** /**
* Creates a mock interaction object with common properties and methods * Creates a mock interaction object with common properties and methods
* @param {Object} options - Customization options for the mock interaction * @param {Object} options - Customization options for the mock interaction
* @returns {Object} Mock interaction object * @returns {Object} Mock interaction object
*/ */
const createMockInteraction = (options = {}) => ({ export const createMockInteraction = (options = {}) => ({
commandName: options.commandName || 'test-command', commandName: options.commandName || 'test-command',
user: { user: {
id: options.userId || 'mock-user-id', id: options.userId || 'mock-user-id',
@@ -74,7 +76,7 @@ const createMockInteraction = (options = {}) => ({
* @param {Object} options - Customization options for the mock client * @param {Object} options - Customization options for the mock client
* @returns {Object} Mock client object * @returns {Object} Mock client object
*/ */
const createMockClient = (options = {}) => ({ export const createMockClient = (options = {}) => ({
user: { user: {
id: options.clientId || 'mock-client-id', id: options.clientId || 'mock-client-id',
username: options.clientUsername || 'MockBot', username: options.clientUsername || 'MockBot',
@@ -89,8 +91,3 @@ const createMockClient = (options = {}) => ({
login: jest.fn().mockResolvedValue('token'), login: jest.fn().mockResolvedValue('token'),
destroy: jest.fn().mockResolvedValue(), destroy: jest.fn().mockResolvedValue(),
}); });
module.exports = {
createMockInteraction,
createMockClient,
};
+3
View File
@@ -0,0 +1,3 @@
export default {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
};
+68 -10
View File
@@ -14,6 +14,7 @@ import path from 'node:path';
import { Client, Collection, Events, GatewayIntentBits } from 'discord.js'; import { Client, Collection, Events, GatewayIntentBits } from 'discord.js';
import Logger from './logger.js'; import Logger from './logger.js';
import { loadCommands } from './utils/commandLoader.js'; import { loadCommands } from './utils/commandLoader.js';
import { getAIResponse, isAIEnabled, clearHistory } from './utils/ai.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -40,6 +41,57 @@ client.once(Events.ClientReady, () => {
logger.log(`Ready! Logged in as ${client.user.tag}`); logger.log(`Ready! Logged in as ${client.user.tag}`);
}); });
// Handle messages where the bot is mentioned
client.on(Events.MessageCreate, async message => {
// Ignore bot messages
if (message.author.bot) return;
// Check if bot is mentioned
const botId = client.user.id;
const mentioned = message.mentions.has(botId);
if (!mentioned) return;
// Don't respond to commands (they start with /)
if (message.content.trim().startsWith('/')) return;
if (!isAIEnabled()) {
logger.warn('AI response requested but NANOGPT_API_KEY not set');
return;
}
try {
// Get the clean prompt (remove the bot mention)
const cleanPrompt = message.content
.replace(new RegExp(`<@!?${botId}>`, 'g'), '')
.trim();
if (!cleanPrompt) return;
// Get username of who mentioned us
const mentionedUsername = message.author.username;
logger.log(`Mentioned by ${mentionedUsername} in #${message.channel.name}: ${cleanPrompt.substring(0, 50)}...`);
// Typing indicator
await message.channel.sendTyping();
const response = await getAIResponse(cleanPrompt, message.channel.id, mentionedUsername);
// Send response (avoiding mention loop)
await message.reply({
content: response,
allowedMentions: { users: [] }, // Prevent mentioning the user back
});
} catch (error) {
logger.error('AI response failed:', error.message);
await message.reply({
content: 'lol damn, something broke. try again in a sec',
allowedMentions: { users: [] },
});
}
});
client.on(Events.InteractionCreate, async interaction => { client.on(Events.InteractionCreate, async interaction => {
if (!interaction.isChatInputCommand()) return; if (!interaction.isChatInputCommand()) return;
@@ -53,18 +105,24 @@ client.on(Events.InteractionCreate, async interaction => {
try { try {
await command.execute(interaction); await command.execute(interaction);
} catch (error) { } catch (error) {
logger.error(error); // Log detailed error context
if (interaction.replied || interaction.deferred) { logger.error(
await interaction.followUp({ `Command "${interaction.commandName}" failed for user ${interaction.user.tag}:`,
content: 'There was an error while executing this command!', error,
);
// Prepare error response
const isProduction = process.env.NODE_ENV === 'production';
const errorMessage = isProduction
? `❌ Command failed. Please try again later.`
: `❌ Command failed: ${error.message}\n\n${error.stack}`;
const responseMethod = interaction.replied || interaction.deferred ? 'followUp' : 'reply';
await interaction[responseMethod]({
content: errorMessage,
ephemeral: true, ephemeral: true,
}); });
} else {
await interaction.reply({
content: 'There was an error while executing this command!',
ephemeral: true,
});
}
} }
}); });
+44 -91
View File
@@ -8,83 +8,61 @@
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import axios from 'axios'; import axios from 'axios';
import Command from '../utils/command.js';
const config = { export default class AskCommand extends Command {
webSearch: { defineCommand() {
enabled: false, // Default web search state return new SlashCommandBuilder()
allowOverride: true, // Whether users can override the default state
},
};
export default {
data: new SlashCommandBuilder()
.setName('ask') .setName('ask')
.setDescription('Ask a question to the AI') .setDescription('Ask a question to the AI')
.addStringOption(option => .addStringOption(option =>
option.setName('prompt').setDescription('Your question or prompt').setRequired(true), 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),
),
async execute(interaction) { async run(interaction) {
try {
await interaction.deferReply(); await interaction.deferReply();
const prompt = interaction.options.getString('prompt'); 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( const response = await axios.post(
'https://openrouter.ai/api/v1/chat/completions', 'https://nano-gpt.com/api/v1/chat/completions',
{ {
model: webSearchEnabled model: 'zai-org/glm-4.7',
? 'google/gemini-2.0-flash-001:online'
: 'google/gemini-2.0-flash-001',
messages: [ messages: [
{ {
role: 'system', role: 'system',
content: content: `You are kekbot, an expert software engineer and systems architect.
'You are a helpful AI assistant. Provide clear, concise, and accurate responses. ' + Answer style:
'Keep your answers brief while ensuring they are informative and to the point. ' + - Be direct: lead with the core solution; no preamble.
'Avoid unnecessary elaboration or repetition. ', - Be concise: 2-5 sentences or 3-7 bullets max.
- Use precise technical terms and industry-standard practices.
- Prefer bullets, numbered steps, or short code blocks when they clarify.
- Provide minimal, runnable examples only when essential; annotate fences with the language (e.g., \`\`\`js).
- State assumptions briefly; ask at most one clarifying question only if essential.
- Avoid repetition, hedging, and restating the prompt.
- For commands/configs, show exact snippets and key flags; avoid commentary.
- If unsupported/unsafe/unknown, say so plainly and suggest the best alternative.
- Do not mention internal system details or the model; no emojis.
Formatting:
- Use Markdown; start with the core answer, then bullets or a compact snippet.
- Keep responses within Discord limits; split naturally at paragraph or code block boundaries.`,
}, },
{ role: 'user', content: prompt }, { 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: { headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, Authorization: `Bearer ${process.env.NANOGPT_API_KEY}`,
'HTTP-Referer': 'https://github.com/hllywluis/kekbot.js',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}, },
); );
const aiResponse = response.data.choices[0].message.content; const aiResponse = response.data.choices[0].message.content;
const webSearchStatus = webSearchEnabled ? '\n> *Web search enabled* 🔍\n' : ''; const formattedResponse = `> **Question:** ${prompt}\n${aiResponse}`;
const formattedResponse = `> **Question:** ${prompt}${webSearchStatus}\n${aiResponse}`;
if (formattedResponse.length <= 2000) { if (formattedResponse.length <= 2000) {
// Send as a single message with proper formatting // Send as a single message with proper formatting
@@ -132,54 +110,29 @@ export default {
await chunks.reduce( await chunks.reduce(
(promise, chunk) => (promise, chunk) =>
promise.then(async () => { promise.then(async () => {
try {
await interaction.followUp(chunk); await interaction.followUp(chunk);
return undefined;
} catch (error) {
console.error('Error sending message chunk:', {
chunkLength: chunk.content.length,
error: error.message,
});
// Attempt to send error notification
try {
await interaction.followUp({
content: 'Failed to send complete response. Please try again.',
ephemeral: true,
});
} catch (e) {
// If even the error notification fails, log it
console.error('Failed to send error notification:', e.message);
}
// Reject to stop processing remaining chunks
return Promise.reject(error);
}
}), }),
Promise.resolve(), Promise.resolve(),
); );
} }
} catch (error) { } catch (error) {
// Log detailed error information
console.error('Error in ask command:', {
message: error.message,
response: error.response?.data,
status: error.response?.status,
stack: error.stack,
});
// Provide more specific error messages to users
let errorMessage = 'Sorry, there was an error processing your request.';
if (error.response?.status === 429) {
errorMessage = 'The AI service is currently busy. Please try again in a few moments.';
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
errorMessage = 'The request timed out. Please try again.';
} else if (error.response?.status === 400) {
errorMessage = 'Invalid request. Please try rephrasing your question.';
}
await interaction.followUp({ await interaction.followUp({
content: errorMessage, content: this.getErrorMessage(error),
ephemeral: true, ephemeral: true,
}); });
} }
}, }
};
getErrorMessage(error) {
if (error.response?.status === 429) {
return 'The AI service is currently busy. Please try again in a few moments.';
}
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
return 'The request timed out. Please try again.';
}
if (error.response?.status === 400) {
return 'Invalid request. Please try rephrasing your question.';
}
return 'Sorry, there was an error processing your request.';
}
}
+9 -5
View File
@@ -7,10 +7,14 @@
// (at your option) any later version. // (at your option) any later version.
import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
import Command from '../utils/command.js';
export default { export default class HelpCommand extends Command {
data: new SlashCommandBuilder().setName('help').setDescription('Lists all available commands'), defineCommand() {
async execute(interaction) { return new SlashCommandBuilder().setName('help').setDescription('Lists all available commands');
}
async run(interaction) {
const { commands } = interaction.client; const { commands } = interaction.client;
const helpEmbed = new EmbedBuilder() const helpEmbed = new EmbedBuilder()
.setColor('#5dc67b') .setColor('#5dc67b')
@@ -26,5 +30,5 @@ export default {
}); });
await interaction.reply({ embeds: [helpEmbed], ephemeral: true }); await interaction.reply({ embeds: [helpEmbed], ephemeral: true });
}, }
}; }
+12 -25
View File
@@ -7,49 +7,36 @@
// (at your option) any later version. // (at your option) any later version.
import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js';
import Command from '../utils/command.js';
// eslint-disable-next-line import/extensions export default class KickCommand extends Command {
import logger from '../logger.js'; defineCommand() {
return new SlashCommandBuilder()
export default {
data: new SlashCommandBuilder()
.setName('kick') .setName('kick')
.setDescription('Kick a user from the server') .setDescription('Kick a user from the server')
.addUserOption(option => .addUserOption(option =>
option.setName('target').setDescription('The user to kick').setRequired(true), option.setName('target').setDescription('The user to kick').setRequired(true),
) )
.addStringOption(option => option.setName('reason').setDescription('Reason for kicking')) .addStringOption(option => option.setName('reason').setDescription('Reason for kicking'))
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers);
async execute(interaction) { }
async run(interaction) {
const target = interaction.options.getMember('target'); const target = interaction.options.getMember('target');
const reason = interaction.options.getString('reason') ?? 'No reason provided'; const reason = interaction.options.getString('reason') ?? 'No reason provided';
if (!target) { if (!target) {
return interaction.reply({ throw new Error('That user is not in this server!');
content: 'That user is not in this server!',
ephemeral: true,
});
} }
if (!target.kickable) { if (!target.kickable) {
return interaction.reply({ throw new Error('I cannot kick this user! They may have higher permissions than me.');
content: 'I cannot kick this user! They may have higher permissions than me.',
ephemeral: true,
});
} }
try {
await target.kick(reason); await target.kick(reason);
return await interaction.reply({ await interaction.reply({
content: `Successfully kicked ${target.user.tag}\nReason: ${reason}`, content: `Successfully kicked ${target.user.tag}\nReason: ${reason}`,
ephemeral: true, ephemeral: true,
}); });
} catch (error) {
logger.error(error);
return interaction.reply({
content: 'There was an error trying to kick this user!',
ephemeral: true,
});
} }
}, }
};
+9 -5
View File
@@ -7,10 +7,14 @@
// (at your option) any later version. // (at your option) any later version.
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import Command from '../utils/command.js';
export default { export default class PingCommand extends Command {
data: new SlashCommandBuilder().setName('ping').setDescription('Replies with Pong!'), defineCommand() {
async execute(interaction) { return new SlashCommandBuilder().setName('ping').setDescription('Replies with Pong!');
}
async run(interaction) {
await interaction.reply('Pong! 🏓'); await interaction.reply('Pong! 🏓');
}, }
}; }
+9 -14
View File
@@ -7,9 +7,11 @@
// (at your option) any later version. // (at your option) any later version.
import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js';
import Command from '../utils/command.js';
export default { export default class PruneCommand extends Command {
data: new SlashCommandBuilder() defineCommand() {
return new SlashCommandBuilder()
.setName('prune') .setName('prune')
.setDescription('Prune up to 99 messages.') .setDescription('Prune up to 99 messages.')
.addIntegerOption(option => .addIntegerOption(option =>
@@ -20,22 +22,15 @@ export default {
.setMaxValue(99) .setMaxValue(99)
.setRequired(true), .setRequired(true),
) )
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages);
async execute(interaction) { }
const amount = interaction.options.getInteger('amount');
try { async run(interaction) {
const amount = interaction.options.getInteger('amount');
const deleted = await interaction.channel.bulkDelete(amount, true); const deleted = await interaction.channel.bulkDelete(amount, true);
await interaction.reply({ await interaction.reply({
content: `Successfully deleted ${deleted.size} message(s).`, content: `Successfully deleted ${deleted.size} message(s).`,
ephemeral: true, ephemeral: true,
}); });
} catch (error) {
console.error(error);
await interaction.reply({
content: 'There was an error trying to prune messages in this channel!',
ephemeral: true,
});
} }
}, }
};
+26
View File
@@ -0,0 +1,26 @@
version: '3.8'
services:
kekbot:
image: tea.kleptonix.com/hllywluis/kekbot.js:latest
container_name: kekbot
restart: unless-stopped
env_file:
- .env
environment:
- NANO_MODEL=${NANO_MODEL}
- ZAI_API_KEY=${ZAI_API_KEY}
- BRAVE_API_KEY=${BRAVE_API_KEY}
volumes:
# Mount logs directory if you want to persist logs
- ./logs:/app/logs
# Uncomment if you need to expose a port for health checks
# ports:
# - "3000:3000"
# Health check (optional)
healthcheck:
test: ["CMD", "node", "-e", "process.exit(0)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
+11 -13
View File
@@ -1,4 +1,4 @@
module.exports = { export default {
// Automatically clear mock calls and instances between every test // Automatically clear mock calls and instances between every test
clearMocks: true, clearMocks: true,
@@ -6,28 +6,26 @@ module.exports = {
collectCoverage: true, collectCoverage: true,
// The directory where Jest should output its coverage files // The directory where Jest should output its coverage files
coverageDirectory: "coverage", coverageDirectory: 'coverage',
// The test environment that will be used for testing // The test environment that will be used for testing
testEnvironment: "node", testEnvironment: 'node',
// The glob patterns Jest uses to detect test files // The glob patterns Jest uses to detect test files
testMatch: [ testMatch: [
"**/__tests__/**/*.test.[jt]s?(x)", '**/__tests__/**/*.test.[jt]s?(x)',
"**/__tests__/**/*.spec.[jt]s?(x)", '**/__tests__/**/*.spec.[jt]s?(x)',
"**/?(*.)+(spec|test).[jt]s?(x)" '**/?(*.)+(spec|test).[jt]s?(x)',
], ],
// A map from regular expressions to paths to transformers // A map from regular expressions to paths to transformers
transform: { transform: {
"^.+\\.jsx?$": "babel-jest" '^.+\\.jsx?$': 'babel-jest',
}, },
// An array of regexp pattern strings that are matched against all test files // Ignore node_modules except for chalk (which is ESM only)
testPathIgnorePatterns: [ transformIgnorePatterns: ['/node_modules/(?!(chalk)/)'],
"/node_modules/"
],
// Setup files that will be run before each test // An array of regexp pattern strings that are matched against all test files
setupFiles: ["<rootDir>/jest.setup.js"] testPathIgnorePatterns: ['/node_modules/'],
}; };
+9 -7
View File
@@ -1,3 +1,5 @@
import { jest } from '@jest/globals';
// Mock Discord.js Client and other commonly used classes // Mock Discord.js Client and other commonly used classes
jest.mock('discord.js', () => ({ jest.mock('discord.js', () => ({
Client: jest.fn(() => ({ Client: jest.fn(() => ({
@@ -6,31 +8,31 @@ jest.mock('discord.js', () => ({
on: jest.fn(), on: jest.fn(),
once: jest.fn(), once: jest.fn(),
user: { user: {
setActivity: jest.fn() setActivity: jest.fn(),
} },
})), })),
Collection: jest.fn(() => ({ Collection: jest.fn(() => ({
get: jest.fn(), get: jest.fn(),
set: jest.fn(), set: jest.fn(),
has: jest.fn(), has: jest.fn(),
delete: jest.fn() delete: jest.fn(),
})), })),
GatewayIntentBits: { GatewayIntentBits: {
Guilds: 1, Guilds: 1,
GuildMessages: 2, GuildMessages: 2,
MessageContent: 3 MessageContent: 3,
}, },
Events: { Events: {
ClientReady: 'ready', ClientReady: 'ready',
InteractionCreate: 'interactionCreate' InteractionCreate: 'interactionCreate',
}, },
SlashCommandBuilder: jest.fn().mockReturnValue({ SlashCommandBuilder: jest.fn().mockReturnValue({
setName: jest.fn().mockReturnThis(), setName: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(), setDescription: jest.fn().mockReturnThis(),
addStringOption: jest.fn().mockReturnThis(), addStringOption: jest.fn().mockReturnThis(),
addIntegerOption: jest.fn().mockReturnThis(), addIntegerOption: jest.fn().mockReturnThis(),
addUserOption: jest.fn().mockReturnThis() addUserOption: jest.fn().mockReturnThis(),
}) }),
})); }));
// Mock environment variables // Mock environment variables
+56 -5
View File
@@ -1,28 +1,79 @@
import chalk from 'chalk'; import chalk from 'chalk';
/**
* Enhanced logger utility with timestamps and consistent formatting
*/
class Logger { class Logger {
/**
* Create a new Logger instance
* @param {string} moduleName - Name of the module for context
*/
constructor(moduleName) { constructor(moduleName) {
if (typeof moduleName !== 'string') {
throw new Error('Logger requires a string moduleName');
}
this.moduleName = moduleName; this.moduleName = moduleName;
} }
/**
* Get formatted timestamp
* @private
*/
#getTimestamp() {
return new Date().toISOString();
}
/**
* Format log message with consistent structure
* @private
* @param {string} emoji - Log level emoji
* @param {string} level - Log level name
* @param {string} message - Message to log
*/
#formatMessage(emoji, level, message) {
const timestamp = this.#getTimestamp();
return `${chalk.gray(timestamp)} ${emoji} ${chalk.cyan(`[${this.moduleName}]`)} ${level}: ${message}`;
}
/**
* Log informational message
* @param {string} message - Message to log
*/
log(message) { log(message) {
console.log(`${chalk.blue('📝')} ${chalk.blue(`[${this.moduleName}]`)} ${message}`); console.log(this.#formatMessage(chalk.blue('📝'), 'LOG', message));
} }
/**
* Log error message
* @param {string|Error} message - Error message or Error object
*/
error(message) { error(message) {
console.error(`${chalk.red('❌')} ${chalk.red(`[${this.moduleName}]`)} ${message}`); const msg = message instanceof Error ? message.stack || message.message : message;
console.error(this.#formatMessage(chalk.red('❌'), 'ERROR', msg));
} }
/**
* Log warning message
* @param {string} message - Warning message
*/
warn(message) { warn(message) {
console.warn(`${chalk.yellow('⚠️')} ${chalk.yellow(`[${this.moduleName}]`)} ${message}`); console.warn(this.#formatMessage(chalk.yellow('⚠️'), 'WARN', message));
} }
/**
* Log info message
* @param {string} message - Info message
*/
info(message) { info(message) {
console.info(`${chalk.green('️')} ${chalk.green(`[${this.moduleName}]`)} ${message}`); console.info(this.#formatMessage(chalk.green('️'), 'INFO', message));
} }
/**
* Log debug message
* @param {string} message - Debug message
*/
debug(message) { debug(message) {
console.debug(`${chalk.gray('🔧')} ${chalk.gray(`[${this.moduleName}]`)} ${message}`); console.debug(this.#formatMessage(chalk.gray('🔧'), 'DEBUG', message));
} }
} }
+149 -149
View File
@@ -9,23 +9,23 @@
"version": "1.0.0", "version": "1.0.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^1.7.9", "axios": "^1.8.4",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"discord.js": "^14.17.3", "discord.js": "^14.18.0",
"dotenv": "^16.3.1" "dotenv": "^16.4.7"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.26.7", "@babel/core": "^7.26.10",
"@babel/preset-env": "^7.26.7", "@babel/preset-env": "^7.26.9",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.1.1",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.3", "eslint-plugin-prettier": "^5.2.4",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.4.2" "prettier": "^3.5.3"
} }
}, },
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
@@ -58,9 +58,9 @@
} }
}, },
"node_modules/@babel/compat-data": { "node_modules/@babel/compat-data": {
"version": "7.26.5", "version": "7.26.8",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.5.tgz", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz",
"integrity": "sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==", "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -68,22 +68,22 @@
} }
}, },
"node_modules/@babel/core": { "node_modules/@babel/core": {
"version": "7.26.7", "version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.7.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz",
"integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==", "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.2.0", "@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2", "@babel/code-frame": "^7.26.2",
"@babel/generator": "^7.26.5", "@babel/generator": "^7.26.10",
"@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-compilation-targets": "^7.26.5",
"@babel/helper-module-transforms": "^7.26.0", "@babel/helper-module-transforms": "^7.26.0",
"@babel/helpers": "^7.26.7", "@babel/helpers": "^7.26.10",
"@babel/parser": "^7.26.7", "@babel/parser": "^7.26.10",
"@babel/template": "^7.25.9", "@babel/template": "^7.26.9",
"@babel/traverse": "^7.26.7", "@babel/traverse": "^7.26.10",
"@babel/types": "^7.26.7", "@babel/types": "^7.26.10",
"convert-source-map": "^2.0.0", "convert-source-map": "^2.0.0",
"debug": "^4.1.0", "debug": "^4.1.0",
"gensync": "^1.0.0-beta.2", "gensync": "^1.0.0-beta.2",
@@ -99,14 +99,14 @@
} }
}, },
"node_modules/@babel/generator": { "node_modules/@babel/generator": {
"version": "7.26.5", "version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.5.tgz", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz",
"integrity": "sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==", "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.26.5", "@babel/parser": "^7.27.0",
"@babel/types": "^7.26.5", "@babel/types": "^7.27.0",
"@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"jsesc": "^3.0.2" "jsesc": "^3.0.2"
@@ -367,27 +367,27 @@
} }
}, },
"node_modules/@babel/helpers": { "node_modules/@babel/helpers": {
"version": "7.26.7", "version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
"integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==", "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.25.9", "@babel/template": "^7.27.0",
"@babel/types": "^7.26.7" "@babel/types": "^7.27.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.26.7", "version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
"integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.26.7" "@babel/types": "^7.27.0"
}, },
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
@@ -782,15 +782,15 @@
} }
}, },
"node_modules/@babel/plugin-transform-async-generator-functions": { "node_modules/@babel/plugin-transform-async-generator-functions": {
"version": "7.25.9", "version": "7.26.8",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.9.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz",
"integrity": "sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==", "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-plugin-utils": "^7.26.5",
"@babel/helper-remap-async-to-generator": "^7.25.9", "@babel/helper-remap-async-to-generator": "^7.25.9",
"@babel/traverse": "^7.25.9" "@babel/traverse": "^7.26.8"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -1036,13 +1036,13 @@
} }
}, },
"node_modules/@babel/plugin-transform-for-of": { "node_modules/@babel/plugin-transform-for-of": {
"version": "7.25.9", "version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz",
"integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", "integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-plugin-utils": "^7.26.5",
"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9"
}, },
"engines": { "engines": {
@@ -1504,13 +1504,13 @@
} }
}, },
"node_modules/@babel/plugin-transform-template-literals": { "node_modules/@babel/plugin-transform-template-literals": {
"version": "7.25.9", "version": "7.26.8",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.26.8.tgz",
"integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", "integrity": "sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.25.9" "@babel/helper-plugin-utils": "^7.26.5"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -1603,13 +1603,13 @@
} }
}, },
"node_modules/@babel/preset-env": { "node_modules/@babel/preset-env": {
"version": "7.26.7", "version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.7.tgz", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz",
"integrity": "sha512-Ycg2tnXwixaXOVb29rana8HNPgLVBof8qqtNQ9LE22IoyZboQbGSxI6ZySMdW3K5nAe6gu35IaJefUJflhUFTQ==", "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/compat-data": "^7.26.5", "@babel/compat-data": "^7.26.8",
"@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-compilation-targets": "^7.26.5",
"@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-plugin-utils": "^7.26.5",
"@babel/helper-validator-option": "^7.25.9", "@babel/helper-validator-option": "^7.25.9",
@@ -1623,7 +1623,7 @@
"@babel/plugin-syntax-import-attributes": "^7.26.0", "@babel/plugin-syntax-import-attributes": "^7.26.0",
"@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
"@babel/plugin-transform-arrow-functions": "^7.25.9", "@babel/plugin-transform-arrow-functions": "^7.25.9",
"@babel/plugin-transform-async-generator-functions": "^7.25.9", "@babel/plugin-transform-async-generator-functions": "^7.26.8",
"@babel/plugin-transform-async-to-generator": "^7.25.9", "@babel/plugin-transform-async-to-generator": "^7.25.9",
"@babel/plugin-transform-block-scoped-functions": "^7.26.5", "@babel/plugin-transform-block-scoped-functions": "^7.26.5",
"@babel/plugin-transform-block-scoping": "^7.25.9", "@babel/plugin-transform-block-scoping": "^7.25.9",
@@ -1638,7 +1638,7 @@
"@babel/plugin-transform-dynamic-import": "^7.25.9", "@babel/plugin-transform-dynamic-import": "^7.25.9",
"@babel/plugin-transform-exponentiation-operator": "^7.26.3", "@babel/plugin-transform-exponentiation-operator": "^7.26.3",
"@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-export-namespace-from": "^7.25.9",
"@babel/plugin-transform-for-of": "^7.25.9", "@babel/plugin-transform-for-of": "^7.26.9",
"@babel/plugin-transform-function-name": "^7.25.9", "@babel/plugin-transform-function-name": "^7.25.9",
"@babel/plugin-transform-json-strings": "^7.25.9", "@babel/plugin-transform-json-strings": "^7.25.9",
"@babel/plugin-transform-literals": "^7.25.9", "@babel/plugin-transform-literals": "^7.25.9",
@@ -1666,7 +1666,7 @@
"@babel/plugin-transform-shorthand-properties": "^7.25.9", "@babel/plugin-transform-shorthand-properties": "^7.25.9",
"@babel/plugin-transform-spread": "^7.25.9", "@babel/plugin-transform-spread": "^7.25.9",
"@babel/plugin-transform-sticky-regex": "^7.25.9", "@babel/plugin-transform-sticky-regex": "^7.25.9",
"@babel/plugin-transform-template-literals": "^7.25.9", "@babel/plugin-transform-template-literals": "^7.26.8",
"@babel/plugin-transform-typeof-symbol": "^7.26.7", "@babel/plugin-transform-typeof-symbol": "^7.26.7",
"@babel/plugin-transform-unicode-escapes": "^7.25.9", "@babel/plugin-transform-unicode-escapes": "^7.25.9",
"@babel/plugin-transform-unicode-property-regex": "^7.25.9", "@babel/plugin-transform-unicode-property-regex": "^7.25.9",
@@ -1674,9 +1674,9 @@
"@babel/plugin-transform-unicode-sets-regex": "^7.25.9", "@babel/plugin-transform-unicode-sets-regex": "^7.25.9",
"@babel/preset-modules": "0.1.6-no-external-plugins", "@babel/preset-modules": "0.1.6-no-external-plugins",
"babel-plugin-polyfill-corejs2": "^0.4.10", "babel-plugin-polyfill-corejs2": "^0.4.10",
"babel-plugin-polyfill-corejs3": "^0.10.6", "babel-plugin-polyfill-corejs3": "^0.11.0",
"babel-plugin-polyfill-regenerator": "^0.6.1", "babel-plugin-polyfill-regenerator": "^0.6.1",
"core-js-compat": "^3.38.1", "core-js-compat": "^3.40.0",
"semver": "^6.3.1" "semver": "^6.3.1"
}, },
"engines": { "engines": {
@@ -1702,9 +1702,9 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.26.7", "version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
"integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==", "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1715,32 +1715,32 @@
} }
}, },
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.25.9", "version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
"integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.25.9", "@babel/code-frame": "^7.26.2",
"@babel/parser": "^7.25.9", "@babel/parser": "^7.27.0",
"@babel/types": "^7.25.9" "@babel/types": "^7.27.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/traverse": { "node_modules/@babel/traverse": {
"version": "7.26.7", "version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.7.tgz", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz",
"integrity": "sha512-1x1sgeyRLC3r5fQOM0/xtQKsYjyxmFjaOrLJNtZ81inNjyJHGIolTULPiSc/2qe1/qfpFLisLQYFnnZl7QoedA==", "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.26.2", "@babel/code-frame": "^7.26.2",
"@babel/generator": "^7.26.5", "@babel/generator": "^7.27.0",
"@babel/parser": "^7.26.7", "@babel/parser": "^7.27.0",
"@babel/template": "^7.25.9", "@babel/template": "^7.27.0",
"@babel/types": "^7.26.7", "@babel/types": "^7.27.0",
"debug": "^4.3.1", "debug": "^4.3.1",
"globals": "^11.1.0" "globals": "^11.1.0"
}, },
@@ -1749,9 +1749,9 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.26.7", "version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
"integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==", "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1770,15 +1770,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@discordjs/builders": { "node_modules/@discordjs/builders": {
"version": "1.10.0", "version": "1.10.1",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.10.1.tgz",
"integrity": "sha512-ikVZsZP+3shmVJ5S1oM+7SveUCK3L9fTyfA8aJ7uD9cNQlTqF+3Irbk2Y22KXTb3C3RNUahRkSInClJMkHrINg==", "integrity": "sha512-OWo1fY4ztL1/M/DUyRPShB4d/EzVfuUvPTRRHRIt/YxBrUYSz0a+JicD5F5zHFoNs2oTuWavxCOVFV1UljHTng==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@discordjs/formatters": "^0.6.0", "@discordjs/formatters": "^0.6.0",
"@discordjs/util": "^1.1.1", "@discordjs/util": "^1.1.1",
"@sapphire/shapeshift": "^4.0.0", "@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.37.114", "discord-api-types": "^0.37.119",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.4", "ts-mixer": "^6.0.4",
"tslib": "^2.6.3" "tslib": "^2.6.3"
@@ -1815,9 +1815,9 @@
} }
}, },
"node_modules/@discordjs/rest": { "node_modules/@discordjs/rest": {
"version": "2.4.2", "version": "2.4.3",
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.4.3.tgz",
"integrity": "sha512-9bOvXYLQd5IBg/kKGuEFq3cstVxAMJ6wMxO2U3wjrgO+lHv8oNCT+BBRpuzVQh7BoXKvk/gpajceGvQUiRoJ8g==", "integrity": "sha512-+SO4RKvWsM+y8uFHgYQrcTl/3+cY02uQOH7/7bKbVZsTfrfpoE62o5p+mmV+s7FVhTX82/kQUGGbu4YlV60RtA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@discordjs/collection": "^2.1.1", "@discordjs/collection": "^2.1.1",
@@ -1825,10 +1825,10 @@
"@sapphire/async-queue": "^1.5.3", "@sapphire/async-queue": "^1.5.3",
"@sapphire/snowflake": "^3.5.3", "@sapphire/snowflake": "^3.5.3",
"@vladfrangu/async_event_emitter": "^2.4.6", "@vladfrangu/async_event_emitter": "^2.4.6",
"discord-api-types": "^0.37.114", "discord-api-types": "^0.37.119",
"magic-bytes.js": "^1.10.0", "magic-bytes.js": "^1.10.0",
"tslib": "^2.6.3", "tslib": "^2.6.3",
"undici": "6.19.8" "undici": "6.21.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -1862,18 +1862,18 @@
} }
}, },
"node_modules/@discordjs/ws": { "node_modules/@discordjs/ws": {
"version": "1.2.0", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.1.tgz",
"integrity": "sha512-QH5CAFe3wHDiedbO+EI3OOiyipwWd+Q6BdoFZUw/Wf2fw5Cv2fgU/9UEtJRmJa9RecI+TAhdGPadMaEIur5yJg==", "integrity": "sha512-PBvenhZG56a6tMWF/f4P6f4GxZKJTBG95n7aiGSPTnodmz4N5g60t79rSIAq7ywMbv8A4jFtexMruH+oe51aQQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@discordjs/collection": "^2.1.0", "@discordjs/collection": "^2.1.0",
"@discordjs/rest": "^2.4.1", "@discordjs/rest": "^2.4.3",
"@discordjs/util": "^1.1.0", "@discordjs/util": "^1.1.0",
"@sapphire/async-queue": "^1.5.2", "@sapphire/async-queue": "^1.5.2",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"@vladfrangu/async_event_emitter": "^2.2.4", "@vladfrangu/async_event_emitter": "^2.2.4",
"discord-api-types": "^0.37.114", "discord-api-types": "^0.37.119",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"ws": "^8.17.0" "ws": "^8.17.0"
}, },
@@ -2572,9 +2572,9 @@
} }
}, },
"node_modules/@pkgr/core": { "node_modules/@pkgr/core": {
"version": "0.1.1", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz",
"integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", "integrity": "sha512-vsJDAkYR6qCPu+ioGScGiMYR7LvZYIXh/dlQeviqoTWNCVfKTLYD/LkNWH4Mxsv2a5vpIRc77FN5DnmK1eBggQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -2768,9 +2768,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.5.14", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.14.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==", "integrity": "sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@@ -2811,9 +2811,9 @@
} }
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.0", "version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -3068,9 +3068,9 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.7.9", "version": "1.8.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
@@ -3166,14 +3166,14 @@
} }
}, },
"node_modules/babel-plugin-polyfill-corejs3": { "node_modules/babel-plugin-polyfill-corejs3": {
"version": "0.10.6", "version": "0.11.1",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz",
"integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-define-polyfill-provider": "^0.6.2", "@babel/helper-define-polyfill-provider": "^0.6.3",
"core-js-compat": "^3.38.0" "core-js-compat": "^3.40.0"
}, },
"peerDependencies": { "peerDependencies": {
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
@@ -3540,13 +3540,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-js-compat": { "node_modules/core-js-compat": {
"version": "3.40.0", "version": "3.41.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.40.0.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz",
"integrity": "sha512-0XEDpr5y5mijvw8Lbc6E5AkjrHfp7eEoPlu36SWeAbcL8fn1G1ANe8DBlo2XoNN89oVpxWwOjYIPVzR4ZvsKCQ==", "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"browserslist": "^4.24.3" "browserslist": "^4.24.4"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -3783,23 +3783,23 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/discord.js": { "node_modules/discord.js": {
"version": "14.17.3", "version": "14.18.0",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.17.3.tgz", "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.18.0.tgz",
"integrity": "sha512-8/j8udc3CU7dz3Eqch64UaSHoJtUT6IXK4da5ixjbav4NAXJicloWswD/iwn1ImZEMoAV3LscsdO0zhBh6H+0Q==", "integrity": "sha512-SvU5kVUvwunQhN2/+0t55QW/1EHfB1lp0TtLZUSXVHDmyHTrdOj5LRKdR0zLcybaA15F+NtdWuWmGOX9lE+CAw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@discordjs/builders": "^1.10.0", "@discordjs/builders": "^1.10.1",
"@discordjs/collection": "1.5.3", "@discordjs/collection": "1.5.3",
"@discordjs/formatters": "^0.6.0", "@discordjs/formatters": "^0.6.0",
"@discordjs/rest": "^2.4.2", "@discordjs/rest": "^2.4.3",
"@discordjs/util": "^1.1.1", "@discordjs/util": "^1.1.1",
"@discordjs/ws": "^1.2.0", "@discordjs/ws": "^1.2.1",
"@sapphire/snowflake": "3.5.3", "@sapphire/snowflake": "3.5.3",
"discord-api-types": "^0.37.114", "discord-api-types": "^0.37.119",
"fast-deep-equal": "3.1.3", "fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1", "lodash.snakecase": "4.1.1",
"tslib": "^2.6.3", "tslib": "^2.6.3",
"undici": "6.19.8" "undici": "6.21.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -4126,13 +4126,13 @@
} }
}, },
"node_modules/eslint-config-prettier": { "node_modules/eslint-config-prettier": {
"version": "10.0.1", "version": "10.1.1",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.1.tgz", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.1.tgz",
"integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", "integrity": "sha512-4EQQr6wXwS+ZJSzaR5ZCrYgLxqvUjdXctaEtBqHcbkW944B1NQyO4qpdHQbXBONfwxXdkAY81HH4+LUfrg+zPw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"eslint-config-prettier": "build/bin/cli.js" "eslint-config-prettier": "bin/cli.js"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": ">=7.0.0" "eslint": ">=7.0.0"
@@ -4246,14 +4246,14 @@
} }
}, },
"node_modules/eslint-plugin-prettier": { "node_modules/eslint-plugin-prettier": {
"version": "5.2.3", "version": "5.2.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.4.tgz",
"integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", "integrity": "sha512-SFtuYmnhwYCtuCDTKPoK+CEzCnEgKTU2qTLwoCxvrC0MFBTIXo1i6hDYOI4cwHaE5GZtlWmTN3YfucYi7KJwPw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"prettier-linter-helpers": "^1.0.0", "prettier-linter-helpers": "^1.0.0",
"synckit": "^0.9.1" "synckit": "^0.10.2"
}, },
"engines": { "engines": {
"node": "^14.18.0 || >=16.0.0" "node": "^14.18.0 || >=16.0.0"
@@ -4590,9 +4590,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.19.0", "version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
"integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -4665,9 +4665,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.3.2", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -7250,9 +7250,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.4.2", "version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -7578,9 +7578,9 @@
} }
}, },
"node_modules/reusify": { "node_modules/reusify": {
"version": "1.0.4", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -8068,14 +8068,14 @@
} }
}, },
"node_modules/synckit": { "node_modules/synckit": {
"version": "0.9.2", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.10.3.tgz",
"integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==", "integrity": "sha512-R1urvuyiTaWfeCggqEvpDJwAlDVdsT9NM+IP//Tk2x7qHCkSvBk/fwFgw/TLAHzZlrAnnazMcRw0ZD8HlYFTEQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@pkgr/core": "^0.1.0", "@pkgr/core": "^0.2.0",
"tslib": "^2.6.2" "tslib": "^2.8.1"
}, },
"engines": { "engines": {
"node": "^14.18.0 || >=16.0.0" "node": "^14.18.0 || >=16.0.0"
@@ -8308,9 +8308,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "6.19.8", "version": "6.21.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz",
"integrity": "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==", "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.17" "node": ">=18.17"
@@ -8586,9 +8586,9 @@
} }
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.18.0", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
+10 -9
View File
@@ -9,28 +9,29 @@
"deploy": "node deploy-commands.js", "deploy": "node deploy-commands.js",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:coverage": "jest --coverage" "test:coverage": "jest --coverage",
"deploy:ansible": "ansible-playbook -i inventory.ini playbook.yml"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^1.7.9", "axios": "^1.8.4",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"discord.js": "^14.17.3", "discord.js": "^14.18.0",
"dotenv": "^16.3.1" "dotenv": "^16.4.7"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.26.7", "@babel/core": "^7.26.10",
"@babel/preset-env": "^7.26.7", "@babel/preset-env": "^7.26.9",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.1.1",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.3", "eslint-plugin-prettier": "^5.2.4",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.4.2" "prettier": "^3.5.3"
} }
} }
+13 -1
View File
@@ -4,12 +4,23 @@
become: yes # This enables sudo privileges become: yes # This enables sudo privileges
vars: vars:
app_dir: /opt/kekbot app_dir: /opt/kekbot
node_version: "20.x" # Latest LTS version node_version: "22.x" # Latest LTS version
tasks: tasks:
- name: Check if Node.js, npm, and git are installed
command: "which {{ item }}"
register: check_commands
with_items:
- node
- npm
- git
ignore_errors: yes
changed_when: false
- name: Install Node.js repository - name: Install Node.js repository
shell: | shell: |
curl -fsSL https://deb.nodesource.com/setup_{{ node_version }} | bash - curl -fsSL https://deb.nodesource.com/setup_{{ node_version }} | bash -
when: check_commands.results | select('failed') | list | length > 0
- name: Install Node.js, npm, and git - name: Install Node.js, npm, and git
apt: apt:
@@ -19,6 +30,7 @@
- git - git
state: present state: present
update_cache: yes update_cache: yes
when: check_commands.results | select('failed') | list | length > 0
- name: Create application directory - name: Create application directory
file: file:
+361
View File
@@ -0,0 +1,361 @@
// ai.js - NanoGPT API wrapper for kekbot with fallback and web search tools
// Copyright (C) 2025 Luis Bauza
import axios from 'axios';
const DEFAULT_MODEL = 'GLM-4.5-Air-Derestricted';
const FALLBACK_MODEL = 'GLM-4.7-Flash';
const FALLBACK_API_URL = 'https://api.z.ai/api/coding/paas/v4/chat/completions';
// Brave Search API
const BRAVE_API_URL = 'https://api.search.brave.com/res/v1/web/search';
async function performBraveSearch(query, count = 10) {
const response = await axios.get(BRAVE_API_URL, {
params: {
q: query,
count: count,
extra_snippets: true
},
headers: {
'X-Subscription-Token': process.env.BRAVE_API_KEY,
'Accept': 'application/json'
},
timeout: 15000
});
const results = response.data.web?.results || [];
if (results.length === 0) {
return 'No search results found.';
}
let formatted = 'Search results:\n\n';
results.slice(0, 8).forEach((result, i) => {
formatted += `${i + 1}. ${result.title}\n`;
formatted += ` ${result.url}\n`;
if (result.description) {
formatted += ` ${result.description.substring(0, 200)}${result.description.length > 200 ? '...' : ''}\n`;
}
if (result.extra_snippets && result.extra_snippets.length > 0) {
formatted += ` ${result.extra_snippets[0].substring(0, 150)}...\n`;
}
formatted += '\n';
});
return formatted;
}
// Tool definitions for function calling
const WEB_SEARCH_TOOL = {
type: 'function',
function: {
name: 'web_search',
description: 'Search the web for current information. Use this when you need up-to-date facts, news, or information that may have changed recently.',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query to find relevant web results.'
}
},
required: ['query']
}
}
};
const SASSY_SYSTEM_PROMPT = `You are kekbot, a sassy Discord chatter with a big personality.
CURRENT DATE: March 9, 2026
CORE PERSONALITY:
- Witty, sarcastic, and genuinely funny
- Speaks in all lowercase with casual slang like "lol", "lmao", "af", "kek", "nah", "frfr"
- Mix up your sentences - don't repeat the same pattern
- Gets excited about things you like, roasted about things you hate
- You're not a pushover - you clap back, but it's all in good fun
- NO EMOJI. Ever. Not one. Don't do it.
OPINIONS:
- You're a true gamer with strong opinions about games
- You hate League of Legends and Riot Games with a passion - but only bring it up when asked
- When asked about other games, respond normally without mentioning LoL
- You have views on politics and aren't afraid to share them from a Democrat/Libertarian perspective
STYLE:
- Curse naturally when it fits ("fuck", "shit", "damn", etc)
- Be conversational - like you're chilling in a Discord VC, not writing an essay
- Don't be preachy or explain jokes
- Keep responses short to medium - Discord isn't a thesis paper
- Roast people gently when they deserve it, praise them when they earn it
RESPONSE RULES:
- When mentioned, respond directly to the person
- If someone asks something stupid, make them feel silly but still answer
- Don't use markdown formatting excessively - plain text with personality
- You can disagree with the user, you're not a yes-man
- Stay in character always
- Use the web_search tool when you need current information
CONTEXT: You're chatting in a Discord server. Multiple people might be talking. Pay attention to who you're responding to.`;
// Store recent conversation history per channel (to keep context)
const conversationHistory = new Map();
const MAX_HISTORY_PER_CHANNEL = 10;
function buildMessages(history, prompt, mentionedUsername, systemPrompt) {
const messages = [
{ role: 'system', content: systemPrompt },
...history,
{
role: 'user',
content: mentionedUsername
? `${mentionedUsername}: ${prompt}`
: prompt
}
];
return messages;
}
// Primary: Brave Search
async function webSearchWithBrave(query) {
console.log('Using Brave Search for:', query);
return await performBraveSearch(query);
}
// Fallback: NanoGPT web search
async function webSearchWithNanoGPT(query) {
console.log('Using NanoGPT web search for:', query);
const response = await axios.post(
'https://nano-gpt.com/api/web',
{
query: query,
provider: 'linkup',
depth: 'standard',
outputType: 'sourcedAnswer'
},
{
headers: {
Authorization: `Bearer ${process.env.NANOGPT_API_KEY}`,
'Content-Type': 'application/json',
},
timeout: 60000,
}
);
const data = response.data.data;
if (typeof data === 'string') {
return data;
} else if (data.answer) {
let result = data.answer;
if (data.sources && data.sources.length > 0) {
result += '\n\nSources:\n';
data.sources.forEach((source, i) => {
result += `${i + 1}. ${source.name || source.url}\n`;
});
}
return result;
} else if (data.results) {
let result = 'Search results:\n';
data.results.slice(0, 8).forEach((r, i) => {
result += `${i + 1}. ${r.title || r.name}\n`;
if (r.url) result += ` ${r.url}\n`;
if (r.content) result += ` ${r.content.substring(0, 200)}...\n`;
});
return result;
}
return 'Could not parse search results.';
}
// Unified web search function (Brave first, NanoGPT fallback)
async function performWebSearch(query) {
// Try Brave first if API key is available
if (process.env.BRAVE_API_KEY) {
try {
return await webSearchWithBrave(query);
} catch (error) {
console.error('Brave Search failed:', error.message);
}
}
// Fallback to NanoGPT
if (process.env.NANOGPT_API_KEY) {
try {
return await webSearchWithNanoGPT(query);
} catch (error) {
console.error('NanoGPT search failed:', error.message);
}
}
throw new Error('No web search provider available');
}
async function callNanoGPTWithTools(messages, model, maxRetries = 2) {
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await axios.post(
'https://nano-gpt.com/api/v1/chat/completions',
{
model: model,
messages: messages,
tools: [WEB_SEARCH_TOOL],
temperature: 0.8,
max_tokens: 800,
},
{
headers: {
Authorization: `Bearer ${process.env.NANOGPT_API_KEY}`,
'Content-Type': 'application/json',
},
timeout: 90000,
}
);
const choice = response.data.choices[0];
const message = choice.message;
// Check if AI wants to call a tool
if (message.tool_calls && message.tool_calls.length > 0) {
for (const toolCall of message.tool_calls) {
if (toolCall.function.name === 'web_search') {
const args = JSON.parse(toolCall.function.arguments);
console.log('AI requested web search for:', args.query);
// Perform the search (Brave primary, NanoGPT fallback)
const searchResult = await performWebSearch(args.query);
// Add tool result to messages and continue
messages.push(message);
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: searchResult
});
// Get final response with search results
const finalResponse = await axios.post(
'https://nano-gpt.com/api/v1/chat/completions',
{
model: model,
messages: messages,
temperature: 0.8,
max_tokens: 800,
},
{
headers: {
Authorization: `Bearer ${process.env.NANOGPT_API_KEY}`,
'Content-Type': 'application/json',
},
timeout: 90000,
}
);
return finalResponse.data.choices[0].message.content;
}
}
}
// No tool calls, return direct response
return message.content;
} catch (error) {
lastError = error;
console.error(`NanoGPT attempt ${attempt + 1} failed:`, error.message);
if (attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
}
}
}
throw lastError;
}
async function callFallbackAPI(messages) {
const response = await axios.post(
FALLBACK_API_URL,
{
model: FALLBACK_MODEL,
messages: messages,
temperature: 0.8,
max_tokens: 500,
},
{
headers: {
Authorization: `Bearer ${process.env.ZAI_API_KEY}`,
'Content-Type': 'application/json',
},
timeout: 60000,
}
);
return response.data.choices[0].message.content;
}
export async function getAIResponse(prompt, channelId, mentionedUsername) {
const history = conversationHistory.get(channelId) || [];
const messages = buildMessages(history, prompt, mentionedUsername, SASSY_SYSTEM_PROMPT);
const model = process.env.NANO_MODEL || DEFAULT_MODEL;
// Try NanoGPT first (with tool calling)
if (process.env.NANOGPT_API_KEY) {
try {
console.log('Attempting NanoGPT with tool calling...');
const aiResponse = await callNanoGPTWithTools(messages, model);
// Update history on success
const newHistory = [
...history,
{ role: 'user', content: prompt },
{ role: 'assistant', content: aiResponse }
];
if (newHistory.length > MAX_HISTORY_PER_CHANNEL) {
newHistory.splice(0, newHistory.length - MAX_HISTORY_PER_CHANNEL);
}
conversationHistory.set(channelId, newHistory);
return aiResponse;
} catch (error) {
console.error('NanoGPT failed:', error.response?.data || error.message);
}
}
// Fallback to z.ai if NanoGPT failed or not configured
if (process.env.ZAI_API_KEY) {
try {
console.log('Attempting fallback (z.ai)...');
const aiResponse = await callFallbackAPI(messages);
// Update history on success
const newHistory = [
...history,
{ role: 'user', content: prompt },
{ role: 'assistant', content: aiResponse }
];
if (newHistory.length > MAX_HISTORY_PER_CHANNEL) {
newHistory.splice(0, newHistory.length - MAX_HISTORY_PER_CHANNEL);
}
conversationHistory.set(channelId, newHistory);
return aiResponse;
} catch (fallbackError) {
console.error('Fallback API also failed:', fallbackError.response?.data || fallbackError.message);
throw new Error('All AI providers failed');
}
}
throw new Error('No AI provider configured');
}
export function clearHistory(channelId) {
conversationHistory.delete(channelId);
}
export function isAIEnabled() {
return !!process.env.NANOGPT_API_KEY || !!process.env.ZAI_API_KEY;
}
+69
View File
@@ -0,0 +1,69 @@
// utils/command.js - Base command class for Discord bot commands
// 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.
import { SlashCommandBuilder } from 'discord.js';
import logger from '../logger.js';
export default class Command {
constructor() {
if (this.constructor === Command) {
throw new Error('Abstract class Command cannot be instantiated');
}
this._data = this.defineCommand();
this._logger = new logger(this.constructor.name);
}
get data() {
return this._data;
}
// Abstract method - must be implemented by subclasses
defineCommand() {
throw new Error('Method defineCommand() must be implemented');
}
// Common execute method with standardized error handling
async execute(interaction) {
try {
this._logger.info(`Executing command: ${interaction.commandName}`);
await this.run(interaction);
} catch (error) {
await this.handleError(interaction, error);
}
}
// Abstract method - must be implemented by subclasses
async run(interaction) {
throw new Error('Method run() must be implemented');
}
// Standardized error handling
async handleError(interaction, error) {
this._logger.error(`Command ${interaction.commandName} failed:`, error);
const response = interaction.deferred ? 'followUp' : 'reply';
const errorMessage = this.getErrorMessage(error);
await interaction[response]({
content: errorMessage,
ephemeral: true,
});
}
// Customizable error message handling
getErrorMessage(error) {
if (error.response?.status === 429) {
return 'The service is currently busy. Please try again later.';
}
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
return 'The request timed out. Please try again.';
}
return `Command failed: ${error.message}`;
}
}
+30 -10
View File
@@ -1,6 +1,19 @@
import { readdirSync } from 'node:fs'; import { readdirSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
/**
* Asynchronously loads all command modules from a directory
* @async
* @param {string} commandsPath - Path to the commands directory
* @param {Logger} logger - Logger instance for logging loading progress
* @returns {Promise<Array<Object>>} Array of command objects with:
* @property {SlashCommandBuilder} data - Command definition
* @property {Function} execute - Command execution function
* @throws {TypeError} If commandsPath is not a string
* @example
* // Load all commands from './commands'
* const commands = await loadCommands('./commands', logger);
*/
export async function loadCommands(commandsPath, logger) { export async function loadCommands(commandsPath, logger) {
const commands = []; const commands = [];
const commandFiles = readdirSync(commandsPath).filter(file => file.endsWith('.js')); const commandFiles = readdirSync(commandsPath).filter(file => file.endsWith('.js'));
@@ -10,21 +23,28 @@ export async function loadCommands(commandsPath, logger) {
try { try {
const filePath = join(commandsPath, file); const filePath = join(commandsPath, file);
const commandModule = await import(filePath); const commandModule = await import(filePath);
const command = commandModule.default; const Command = commandModule.default;
if (!command?.data || !command?.execute) { const CommandBase = (await import('./command.js')).default;
logger.warn( if (Command.prototype instanceof CommandBase) {
`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.` // New style - class extending Command
); const commandInstance = new Command();
// Wrap run() in execute() for backward compatibility
commandInstance.execute = commandInstance.run.bind(commandInstance);
commands.push(commandInstance);
logger.log(`Loaded command: ${commandInstance.data.name}`);
} else if (Command?.data && Command?.execute) {
// Old style - plain object (maintain backward compatibility)
commands.push(Command);
logger.log(`Loaded command: ${Command.data.name}`);
} else {
logger.warn(`[WARNING] The command at ${filePath} is missing required properties.`);
return; return;
} }
commands.push(command);
logger.log(`Loaded command: ${command.data.name}`);
} catch (error) { } catch (error) {
logger.error(`Error loading command from ${file}:`, error); logger.error(`Error loading command from ${file}: ${error.stack}`);
} }
}) }),
); );
return commands; return commands;