Compare commits

...

25 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
16 changed files with 719 additions and 382 deletions
+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
+1 -9
View File
@@ -8,19 +8,11 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
# Install dependencies # Install dependencies
RUN npm ci --only=production RUN npm ci --omit=dev
# Copy application files # Copy application files
COPY . . COPY . .
# Create a non-root user to run the application
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs /app
# Switch to non-root user
USER nodejs
# Expose port (if needed for health checks) # Expose port (if needed for health checks)
EXPOSE 3000 EXPOSE 3000
+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' } }]],
};
+52
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;
+27 -41
View File
@@ -10,13 +10,6 @@ import { SlashCommandBuilder } from 'discord.js';
import axios from 'axios'; import axios from 'axios';
import Command from '../utils/command.js'; import Command from '../utils/command.js';
const config = {
webSearch: {
enabled: false, // Default web search state
allowOverride: true, // Whether users can override the default state
},
};
export default class AskCommand extends Command { export default class AskCommand extends Command {
defineCommand() { defineCommand() {
return new SlashCommandBuilder() return new SlashCommandBuilder()
@@ -24,65 +17,52 @@ export default class AskCommand extends Command {
.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 run(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
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 ? 'xiaomi/mimo-v2-flash:free:online' : 'xiaomi/mimo-v2-flash:free', model: 'zai-org/glm-4.7',
messages: [ messages: [
{ {
role: 'system', role: 'system',
content: content: `You are kekbot, an expert software engineer and systems architect.
'You are kekbot, a highly celebrated and knowledgeable computer scientist with decades of experience in various fields of computing. You are known for your ability to explain complex topics in a clear, concise, and insightful manner. Provide direct and to-the-point answers, avoiding unnecessary elaboration or repetition. Focus on delivering accurate and valuable information efficiently.', Answer style:
- Be direct: lead with the core solution; no preamble.
- 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
@@ -135,6 +115,12 @@ export default class AskCommand extends Command {
Promise.resolve(), Promise.resolve(),
); );
} }
} catch (error) {
await interaction.followUp({
content: this.getErrorMessage(error),
ephemeral: true,
});
}
} }
getErrorMessage(error) { getErrorMessage(error) {
+5 -3
View File
@@ -2,13 +2,15 @@ version: '3.8'
services: services:
kekbot: kekbot:
build: image: tea.kleptonix.com/hllywluis/kekbot.js:latest
context: .
dockerfile: Dockerfile
container_name: kekbot container_name: kekbot
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
environment:
- NANO_MODEL=${NANO_MODEL}
- ZAI_API_KEY=${ZAI_API_KEY}
- BRAVE_API_KEY=${BRAVE_API_KEY}
volumes: volumes:
# Mount logs directory if you want to persist logs # Mount logs directory if you want to persist logs
- ./logs:/app/logs - ./logs:/app/logs
+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
+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;
}
+3 -3
View File
@@ -34,7 +34,7 @@ export default class Command {
this._logger.info(`Executing command: ${interaction.commandName}`); this._logger.info(`Executing command: ${interaction.commandName}`);
await this.run(interaction); await this.run(interaction);
} catch (error) { } catch (error) {
this.handleError(interaction, error); await this.handleError(interaction, error);
} }
} }
@@ -44,13 +44,13 @@ export default class Command {
} }
// Standardized error handling // Standardized error handling
handleError(interaction, error) { async handleError(interaction, error) {
this._logger.error(`Command ${interaction.commandName} failed:`, error); this._logger.error(`Command ${interaction.commandName} failed:`, error);
const response = interaction.deferred ? 'followUp' : 'reply'; const response = interaction.deferred ? 'followUp' : 'reply';
const errorMessage = this.getErrorMessage(error); const errorMessage = this.getErrorMessage(error);
interaction[response]({ await interaction[response]({
content: errorMessage, content: errorMessage,
ephemeral: true, ephemeral: true,
}); });