Compare commits
25 Commits
94494c1ca5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a2fdf73f8a | |||
| 7ff95a3d7f | |||
| a73a8901f2 | |||
| b1fff6f337 | |||
|
8b1ece727d
|
|||
| 7cf1582fee | |||
| 9dfdef46d5 | |||
| e5ebb203fa | |||
| 0e631bf029 | |||
| a7a6c1e321 | |||
|
ab91c98360
|
|||
|
4c38488161
|
|||
|
4cad39d0f6
|
|||
|
06ac40a6d7
|
|||
|
b362a43886
|
|||
|
e82b174bf5
|
|||
|
59590002b8
|
|||
|
2cee379491
|
|||
|
5e356d7b01
|
|||
|
f850e1ce1e
|
|||
|
abcc60e703
|
|||
|
af47efefcc
|
|||
|
940c2eb4cd
|
|||
|
7795662584
|
|||
|
2ac855b765
|
@@ -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
@@ -8,19 +8,11 @@ WORKDIR /app
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy application files
|
||||
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 3000
|
||||
|
||||
|
||||
+54
-187
@@ -1,10 +1,15 @@
|
||||
const axios = require('axios');
|
||||
const { createMockInteraction } = require('../utils/testUtils');
|
||||
|
||||
// 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', () => ({
|
||||
SlashCommandBuilder: jest.fn().mockReturnValue({
|
||||
setName: jest.fn().mockReturnThis(),
|
||||
@@ -17,33 +22,17 @@ jest.mock('discord.js', () => ({
|
||||
};
|
||||
callback(option);
|
||||
return {
|
||||
addBooleanOption: jest.fn().mockImplementation(boolCallback => {
|
||||
const boolOption = {
|
||||
setName: jest.fn().mockReturnThis(),
|
||||
setDescription: jest.fn().mockReturnThis(),
|
||||
setRequired: jest.fn().mockReturnThis(),
|
||||
};
|
||||
boolCallback(boolOption);
|
||||
return {
|
||||
toJSON: jest.fn().mockReturnValue({
|
||||
name: 'ask',
|
||||
description: 'Ask a question to the AI',
|
||||
options: [
|
||||
{
|
||||
name: 'prompt',
|
||||
description: 'Your question or prompt',
|
||||
type: 3,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'websearch',
|
||||
description: 'Enable web search for more up-to-date information',
|
||||
type: 5,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
toJSON: jest.fn().mockReturnValue({
|
||||
name: 'ask',
|
||||
description: 'Ask a question to the AI',
|
||||
options: [
|
||||
{
|
||||
name: 'prompt',
|
||||
description: 'Your question or prompt',
|
||||
type: 3,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}),
|
||||
@@ -51,33 +40,30 @@ jest.mock('discord.js', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const askCommand = require('../../commands/ask');
|
||||
const AskCommand = require('../../commands/ask').default;
|
||||
const askCommand = new AskCommand();
|
||||
|
||||
describe('Ask Command', () => {
|
||||
describe('Command Structure', () => {
|
||||
it('should have correct name and description', () => {
|
||||
const commandData = askCommand.data.toJSON();
|
||||
const commandData = askCommand.defineCommand().toJSON();
|
||||
expect(commandData.name).toBe('ask');
|
||||
expect(commandData.description).toBe('Ask a question to the AI');
|
||||
});
|
||||
|
||||
it('should have required command properties', () => {
|
||||
expect(askCommand).toHaveProperty('data');
|
||||
expect(askCommand).toHaveProperty('execute');
|
||||
expect(typeof askCommand.execute).toBe('function');
|
||||
expect(askCommand).toHaveProperty('defineCommand');
|
||||
expect(askCommand).toHaveProperty('run');
|
||||
expect(typeof askCommand.run).toBe('function');
|
||||
});
|
||||
|
||||
it('should have correct option configuration', () => {
|
||||
const commandData = askCommand.data.toJSON();
|
||||
const [promptOption, websearchOption] = commandData.options;
|
||||
const commandData = askCommand.defineCommand().toJSON();
|
||||
const [promptOption] = commandData.options;
|
||||
|
||||
expect(promptOption.name).toBe('prompt');
|
||||
expect(promptOption.description).toBe('Your question or prompt');
|
||||
expect(promptOption.required).toBe(true);
|
||||
|
||||
expect(websearchOption.name).toBe('websearch');
|
||||
expect(websearchOption.description).toBe('Enable web search for more up-to-date information');
|
||||
expect(websearchOption.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,8 +83,7 @@ describe('Ask Command', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.OPENROUTER_API_KEY = 'test-api-key';
|
||||
axios.post.mockReset();
|
||||
process.env.NANOGPT_API_KEY = 'test-api-key';
|
||||
jest.clearAllMocks();
|
||||
|
||||
interaction = createMockInteraction({
|
||||
@@ -106,22 +91,18 @@ describe('Ask Command', () => {
|
||||
stringOptions: {
|
||||
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);
|
||||
|
||||
await askCommand.execute(interaction);
|
||||
await askCommand.run(interaction);
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'https://openrouter.ai/api/v1/chat/completions',
|
||||
'https://nano-gpt.com/api/v1/chat/completions',
|
||||
expect.objectContaining({
|
||||
model: 'google/gemini-2.0-flash-001',
|
||||
plugins: undefined,
|
||||
model: 'zai-org/glm-4.7',
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
@@ -133,76 +114,7 @@ describe('Ask Command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle successful API response with websearch enabled', async () => {
|
||||
interaction = createMockInteraction({
|
||||
commandName: 'ask',
|
||||
stringOptions: {
|
||||
prompt: mockPrompt,
|
||||
},
|
||||
booleanOptions: {
|
||||
websearch: true,
|
||||
},
|
||||
});
|
||||
|
||||
axios.post.mockResolvedValueOnce(mockApiResponse);
|
||||
|
||||
await askCommand.execute(interaction);
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'https://openrouter.ai/api/v1/chat/completions',
|
||||
expect.objectContaining({
|
||||
model: 'google/gemini-2.0-flash-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,
|
||||
},
|
||||
});
|
||||
|
||||
it('should handle long responses with proper chunking', async () => {
|
||||
const longResponse = 'A'.repeat(2500);
|
||||
axios.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
@@ -216,24 +128,13 @@ describe('Ask Command', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await askCommand.execute(interaction);
|
||||
await askCommand.run(interaction);
|
||||
|
||||
expect(interaction.followUp).toHaveBeenCalledTimes(2);
|
||||
expect(interaction.followUp.mock.calls[0][0].content).toContain('Web search enabled');
|
||||
expect(interaction.followUp.mock.calls[1][0].content).toContain('(continued)');
|
||||
});
|
||||
|
||||
it('should handle code blocks in chunked responses with websearch', async () => {
|
||||
interaction = createMockInteraction({
|
||||
commandName: 'ask',
|
||||
stringOptions: {
|
||||
prompt: mockPrompt,
|
||||
},
|
||||
booleanOptions: {
|
||||
websearch: true,
|
||||
},
|
||||
});
|
||||
|
||||
it('should handle code blocks in chunked responses', async () => {
|
||||
const responseWithCodeBlock = "Here's a code example:\n```python\nprint('hello')\n```".repeat(
|
||||
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
|
||||
interaction.followUp.mock.calls.forEach(call => {
|
||||
@@ -257,23 +158,14 @@ describe('Ask Command', () => {
|
||||
const openBlocks = (content.match(/```/g) || []).length;
|
||||
expect(openBlocks % 2).toBe(0); // Should always be even
|
||||
});
|
||||
|
||||
// First chunk should contain websearch indicator
|
||||
expect(interaction.followUp.mock.calls[0][0].content).toContain('Web search enabled');
|
||||
});
|
||||
|
||||
it('should handle API errors with websearch enabled', async () => {
|
||||
interaction = createMockInteraction({
|
||||
commandName: 'ask',
|
||||
stringOptions: { prompt: mockPrompt },
|
||||
booleanOptions: { websearch: true },
|
||||
});
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
const error = new Error('API Error');
|
||||
error.response = { data: 'API Error Details' };
|
||||
axios.post.mockRejectedValueOnce(error);
|
||||
|
||||
await askCommand.execute(interaction);
|
||||
await askCommand.run(interaction);
|
||||
|
||||
expect(interaction.followUp).toHaveBeenCalledWith({
|
||||
content: 'Sorry, there was an error processing your request.',
|
||||
@@ -282,16 +174,11 @@ describe('Ask Command', () => {
|
||||
});
|
||||
|
||||
it('should handle rate limit errors', async () => {
|
||||
interaction = createMockInteraction({
|
||||
commandName: 'ask',
|
||||
stringOptions: { prompt: mockPrompt },
|
||||
});
|
||||
|
||||
const error = new Error('Rate Limit Exceeded');
|
||||
error.response = { status: 429, data: 'Too Many Requests' };
|
||||
axios.post.mockRejectedValueOnce(error);
|
||||
|
||||
await askCommand.execute(interaction);
|
||||
await askCommand.run(interaction);
|
||||
|
||||
expect(interaction.followUp).toHaveBeenCalledWith({
|
||||
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 () => {
|
||||
interaction = createMockInteraction({
|
||||
commandName: 'ask',
|
||||
stringOptions: { prompt: mockPrompt },
|
||||
});
|
||||
|
||||
const error = new Error('Timeout');
|
||||
error.code = 'ETIMEDOUT';
|
||||
axios.post.mockRejectedValueOnce(error);
|
||||
|
||||
await askCommand.execute(interaction);
|
||||
await askCommand.run(interaction);
|
||||
|
||||
expect(interaction.followUp).toHaveBeenCalledWith({
|
||||
content: 'The request timed out. Please try again.',
|
||||
@@ -318,16 +200,11 @@ describe('Ask Command', () => {
|
||||
});
|
||||
|
||||
it('should handle invalid request errors', async () => {
|
||||
interaction = createMockInteraction({
|
||||
commandName: 'ask',
|
||||
stringOptions: { prompt: mockPrompt },
|
||||
});
|
||||
|
||||
const error = new Error('Bad Request');
|
||||
error.response = { status: 400, data: 'Invalid Request' };
|
||||
axios.post.mockRejectedValueOnce(error);
|
||||
|
||||
await askCommand.execute(interaction);
|
||||
await askCommand.run(interaction);
|
||||
|
||||
expect(interaction.followUp).toHaveBeenCalledWith({
|
||||
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 () => {
|
||||
interaction = createMockInteraction({
|
||||
commandName: 'ask',
|
||||
stringOptions: {
|
||||
prompt: mockPrompt,
|
||||
},
|
||||
booleanOptions: {
|
||||
websearch: true,
|
||||
},
|
||||
});
|
||||
|
||||
it('should send API request with correct headers', async () => {
|
||||
axios.post.mockResolvedValueOnce(mockApiResponse);
|
||||
|
||||
await askCommand.execute(interaction);
|
||||
await askCommand.run(interaction);
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(expect.any(String), expect.any(Object), {
|
||||
headers: {
|
||||
Authorization: 'Bearer test-api-key',
|
||||
'HTTP-Referer': 'https://github.com/hllywluis/kekbot.js',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle defer reply failure with websearch', async () => {
|
||||
it('should handle defer reply failure', async () => {
|
||||
interaction = createMockInteraction({
|
||||
commandName: 'ask',
|
||||
stringOptions: {
|
||||
prompt: mockPrompt,
|
||||
},
|
||||
booleanOptions: {
|
||||
websearch: 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({
|
||||
commandName: 'ask',
|
||||
stringOptions: {
|
||||
prompt: mockPrompt,
|
||||
},
|
||||
booleanOptions: {
|
||||
websearch: true,
|
||||
},
|
||||
followUpFails: true,
|
||||
});
|
||||
|
||||
axios.post.mockResolvedValueOnce(mockApiResponse);
|
||||
|
||||
await expect(askCommand.execute(interaction)).rejects.toThrow('Failed to follow up');
|
||||
await expect(askCommand.run(interaction)).rejects.toThrow('Failed to follow up');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('Command Structure', () => {
|
||||
|
||||
@@ -13,12 +13,13 @@ jest.mock('discord.js', () => ({
|
||||
};
|
||||
callback(option);
|
||||
return {
|
||||
addStringOption: jest.fn().mockImplementation(callback => {
|
||||
const option = {
|
||||
addStringOption: jest.fn().mockImplementation(cb => {
|
||||
const strOption = {
|
||||
setName: jest.fn().mockReturnThis(),
|
||||
setDescription: jest.fn().mockReturnThis(),
|
||||
setRequired: jest.fn().mockReturnThis(),
|
||||
};
|
||||
callback(option);
|
||||
cb(strOption);
|
||||
return {
|
||||
setDefaultMemberPermissions: jest.fn().mockReturnThis(),
|
||||
toJSON: jest.fn().mockReturnValue({
|
||||
@@ -46,11 +47,12 @@ jest.mock('discord.js', () => ({
|
||||
toJSON: jest.fn(),
|
||||
}),
|
||||
PermissionFlagsBits: {
|
||||
KickMembers: 0x2n,
|
||||
KickMembers: 0x0000000000000002n,
|
||||
},
|
||||
}));
|
||||
|
||||
const kickCommand = require('../../commands/kick');
|
||||
const KickCommand = require('../../commands/kick').default;
|
||||
const kickCommand = new KickCommand();
|
||||
|
||||
describe('Kick Command', () => {
|
||||
describe('Command Structure', () => {
|
||||
@@ -152,7 +154,7 @@ describe('Kick Command', () => {
|
||||
|
||||
expect(mockKick).not.toHaveBeenCalled();
|
||||
expect(interaction.reply).toHaveBeenCalledWith({
|
||||
content: 'That user is not in this server!',
|
||||
content: 'Command failed: That user is not in this server!',
|
||||
ephemeral: true,
|
||||
});
|
||||
});
|
||||
@@ -173,7 +175,7 @@ describe('Kick Command', () => {
|
||||
expect(mockKick).not.toHaveBeenCalled();
|
||||
expect(interaction.reply).toHaveBeenCalledWith({
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -195,7 +197,7 @@ describe('Kick Command', () => {
|
||||
await kickCommand.execute(interaction);
|
||||
|
||||
expect(interaction.reply).toHaveBeenCalledWith({
|
||||
content: 'There was an error trying to kick this user!',
|
||||
content: 'Command failed: Failed to kick user',
|
||||
ephemeral: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('Command Structure', () => {
|
||||
@@ -47,7 +48,7 @@ describe('Ping Command', () => {
|
||||
|
||||
it('should handle interaction reply failure', async () => {
|
||||
// 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(
|
||||
'Failed to reply',
|
||||
|
||||
@@ -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('Command Structure', () => {
|
||||
@@ -103,7 +104,7 @@ describe('Prune Command', () => {
|
||||
await pruneCommand.execute(interaction);
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -116,7 +117,7 @@ describe('Prune Command', () => {
|
||||
await pruneCommand.execute(interaction);
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Test utilities for Discord.js bot testing
|
||||
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
/**
|
||||
* Creates a mock interaction object with common properties and methods
|
||||
* @param {Object} options - Customization options for the mock interaction
|
||||
* @returns {Object} Mock interaction object
|
||||
*/
|
||||
const createMockInteraction = (options = {}) => ({
|
||||
export const createMockInteraction = (options = {}) => ({
|
||||
commandName: options.commandName || 'test-command',
|
||||
user: {
|
||||
id: options.userId || 'mock-user-id',
|
||||
@@ -74,7 +76,7 @@ const createMockInteraction = (options = {}) => ({
|
||||
* @param {Object} options - Customization options for the mock client
|
||||
* @returns {Object} Mock client object
|
||||
*/
|
||||
const createMockClient = (options = {}) => ({
|
||||
export const createMockClient = (options = {}) => ({
|
||||
user: {
|
||||
id: options.clientId || 'mock-client-id',
|
||||
username: options.clientUsername || 'MockBot',
|
||||
@@ -89,8 +91,3 @@ const createMockClient = (options = {}) => ({
|
||||
login: jest.fn().mockResolvedValue('token'),
|
||||
destroy: jest.fn().mockResolvedValue(),
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
createMockInteraction,
|
||||
createMockClient,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import path from 'node:path';
|
||||
import { Client, Collection, Events, GatewayIntentBits } from 'discord.js';
|
||||
import Logger from './logger.js';
|
||||
import { loadCommands } from './utils/commandLoader.js';
|
||||
import { getAIResponse, isAIEnabled, clearHistory } from './utils/ai.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -40,6 +41,57 @@ client.once(Events.ClientReady, () => {
|
||||
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 => {
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
|
||||
|
||||
+88
-102
@@ -10,13 +10,6 @@ import { SlashCommandBuilder } from 'discord.js';
|
||||
import axios from 'axios';
|
||||
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 {
|
||||
defineCommand() {
|
||||
return new SlashCommandBuilder()
|
||||
@@ -24,116 +17,109 @@ export default class AskCommand extends Command {
|
||||
.setDescription('Ask a question to the AI')
|
||||
.addStringOption(option =>
|
||||
option.setName('prompt').setDescription('Your question or prompt').setRequired(true),
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option
|
||||
.setName('websearch')
|
||||
.setDescription('Enable web search for more up-to-date information')
|
||||
.setRequired(false),
|
||||
);
|
||||
}
|
||||
|
||||
async run(interaction) {
|
||||
await interaction.deferReply();
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
|
||||
const prompt = interaction.options.getString('prompt');
|
||||
const userWebSearchOption = interaction.options.getBoolean('websearch');
|
||||
const prompt = interaction.options.getString('prompt');
|
||||
|
||||
// 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(
|
||||
'https://nano-gpt.com/api/v1/chat/completions',
|
||||
{
|
||||
model: 'zai-org/glm-4.7',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are kekbot, an expert software engineer and systems architect.
|
||||
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.
|
||||
|
||||
const response = await axios.post(
|
||||
'https://openrouter.ai/api/v1/chat/completions',
|
||||
{
|
||||
model: webSearchEnabled ? 'xiaomi/mimo-v2-flash:free:online' : 'xiaomi/mimo-v2-flash:free',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'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.',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
plugins: webSearchEnabled
|
||||
? [
|
||||
{
|
||||
id: 'web',
|
||||
max_results: 3,
|
||||
search_prompt:
|
||||
`A web search was conducted on ${new Date().toISOString()}. ` +
|
||||
'Incorporate the following web search results into your response. ' +
|
||||
'IMPORTANT: Cite them using markdown links named using the domain of the source. ' +
|
||||
'Example: [nytimes.com](https://nytimes.com/some-page).',
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
'HTTP-Referer': 'https://github.com/hllywluis/kekbot.js',
|
||||
'Content-Type': 'application/json',
|
||||
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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.NANOGPT_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const aiResponse = response.data.choices[0].message.content;
|
||||
const webSearchStatus = webSearchEnabled ? '\n> *Web search enabled* 🔍\n' : '';
|
||||
const formattedResponse = `> **Question:** ${prompt}${webSearchStatus}\n${aiResponse}`;
|
||||
const aiResponse = response.data.choices[0].message.content;
|
||||
const formattedResponse = `> **Question:** ${prompt}\n${aiResponse}`;
|
||||
|
||||
if (formattedResponse.length <= 2000) {
|
||||
// Send as a single message with proper formatting
|
||||
await interaction.followUp({
|
||||
content: formattedResponse,
|
||||
split: false,
|
||||
allowedMentions: { parse: [] },
|
||||
});
|
||||
} else {
|
||||
// For longer messages, split while preserving markdown
|
||||
const maxLength = 2000;
|
||||
const chunks = [];
|
||||
let remainingText = formattedResponse;
|
||||
let isFirstChunk = true;
|
||||
|
||||
while (remainingText.length > 0) {
|
||||
let chunk = remainingText.slice(0, maxLength);
|
||||
|
||||
// If we're in the middle of a code block, find a safe split point
|
||||
const lastCodeBlock = chunk.lastIndexOf('```');
|
||||
if (lastCodeBlock !== -1 && !chunk.slice(lastCodeBlock).includes('\n```')) {
|
||||
// Find the last newline before maxLength
|
||||
const lastNewline = chunk.lastIndexOf('\n');
|
||||
if (lastNewline !== -1) {
|
||||
chunk = chunk.slice(0, lastNewline);
|
||||
}
|
||||
}
|
||||
|
||||
// For subsequent chunks, add continuation indicator
|
||||
if (!isFirstChunk) {
|
||||
chunk = `(continued)\n${chunk}`;
|
||||
}
|
||||
|
||||
chunks.push({
|
||||
content: chunk,
|
||||
if (formattedResponse.length <= 2000) {
|
||||
// Send as a single message with proper formatting
|
||||
await interaction.followUp({
|
||||
content: formattedResponse,
|
||||
split: false,
|
||||
allowedMentions: { parse: [] },
|
||||
});
|
||||
} else {
|
||||
// For longer messages, split while preserving markdown
|
||||
const maxLength = 2000;
|
||||
const chunks = [];
|
||||
let remainingText = formattedResponse;
|
||||
let isFirstChunk = true;
|
||||
|
||||
remainingText = remainingText.slice(chunk.length);
|
||||
isFirstChunk = false;
|
||||
while (remainingText.length > 0) {
|
||||
let chunk = remainingText.slice(0, maxLength);
|
||||
|
||||
// If we're in the middle of a code block, find a safe split point
|
||||
const lastCodeBlock = chunk.lastIndexOf('```');
|
||||
if (lastCodeBlock !== -1 && !chunk.slice(lastCodeBlock).includes('\n```')) {
|
||||
// Find the last newline before maxLength
|
||||
const lastNewline = chunk.lastIndexOf('\n');
|
||||
if (lastNewline !== -1) {
|
||||
chunk = chunk.slice(0, lastNewline);
|
||||
}
|
||||
}
|
||||
|
||||
// For subsequent chunks, add continuation indicator
|
||||
if (!isFirstChunk) {
|
||||
chunk = `(continued)\n${chunk}`;
|
||||
}
|
||||
|
||||
chunks.push({
|
||||
content: chunk,
|
||||
split: false,
|
||||
allowedMentions: { parse: [] },
|
||||
});
|
||||
|
||||
remainingText = remainingText.slice(chunk.length);
|
||||
isFirstChunk = false;
|
||||
}
|
||||
|
||||
// Send chunks sequentially using reduce
|
||||
await chunks.reduce(
|
||||
(promise, chunk) =>
|
||||
promise.then(async () => {
|
||||
await interaction.followUp(chunk);
|
||||
}),
|
||||
Promise.resolve(),
|
||||
);
|
||||
}
|
||||
|
||||
// Send chunks sequentially using reduce
|
||||
await chunks.reduce(
|
||||
(promise, chunk) =>
|
||||
promise.then(async () => {
|
||||
await interaction.followUp(chunk);
|
||||
}),
|
||||
Promise.resolve(),
|
||||
);
|
||||
} catch (error) {
|
||||
await interaction.followUp({
|
||||
content: this.getErrorMessage(error),
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-3
@@ -2,13 +2,15 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
kekbot:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
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
|
||||
|
||||
+23
-25
@@ -1,33 +1,31 @@
|
||||
module.exports = {
|
||||
// Automatically clear mock calls and instances between every test
|
||||
clearMocks: true,
|
||||
export default {
|
||||
// Automatically clear mock calls and instances between every test
|
||||
clearMocks: true,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
collectCoverage: true,
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
collectCoverage: true,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: "coverage",
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: 'coverage',
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: "node",
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: 'node',
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
testMatch: [
|
||||
"**/__tests__/**/*.test.[jt]s?(x)",
|
||||
"**/__tests__/**/*.spec.[jt]s?(x)",
|
||||
"**/?(*.)+(spec|test).[jt]s?(x)"
|
||||
],
|
||||
// The glob patterns Jest uses to detect test files
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.test.[jt]s?(x)',
|
||||
'**/__tests__/**/*.spec.[jt]s?(x)',
|
||||
'**/?(*.)+(spec|test).[jt]s?(x)',
|
||||
],
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
transform: {
|
||||
"^.+\\.jsx?$": "babel-jest"
|
||||
},
|
||||
// A map from regular expressions to paths to transformers
|
||||
transform: {
|
||||
'^.+\\.jsx?$': 'babel-jest',
|
||||
},
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test files
|
||||
testPathIgnorePatterns: [
|
||||
"/node_modules/"
|
||||
],
|
||||
// Ignore node_modules except for chalk (which is ESM only)
|
||||
transformIgnorePatterns: ['/node_modules/(?!(chalk)/)'],
|
||||
|
||||
// Setup files that will be run before each test
|
||||
setupFiles: ["<rootDir>/jest.setup.js"]
|
||||
// An array of regexp pattern strings that are matched against all test files
|
||||
testPathIgnorePatterns: ['/node_modules/'],
|
||||
};
|
||||
+32
-30
@@ -1,36 +1,38 @@
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Mock Discord.js Client and other commonly used classes
|
||||
jest.mock('discord.js', () => ({
|
||||
Client: jest.fn(() => ({
|
||||
login: jest.fn().mockResolvedValue('token'),
|
||||
destroy: jest.fn().mockResolvedValue(),
|
||||
on: jest.fn(),
|
||||
once: jest.fn(),
|
||||
user: {
|
||||
setActivity: jest.fn()
|
||||
}
|
||||
})),
|
||||
Collection: jest.fn(() => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
has: jest.fn(),
|
||||
delete: jest.fn()
|
||||
})),
|
||||
GatewayIntentBits: {
|
||||
Guilds: 1,
|
||||
GuildMessages: 2,
|
||||
MessageContent: 3
|
||||
Client: jest.fn(() => ({
|
||||
login: jest.fn().mockResolvedValue('token'),
|
||||
destroy: jest.fn().mockResolvedValue(),
|
||||
on: jest.fn(),
|
||||
once: jest.fn(),
|
||||
user: {
|
||||
setActivity: jest.fn(),
|
||||
},
|
||||
Events: {
|
||||
ClientReady: 'ready',
|
||||
InteractionCreate: 'interactionCreate'
|
||||
},
|
||||
SlashCommandBuilder: jest.fn().mockReturnValue({
|
||||
setName: jest.fn().mockReturnThis(),
|
||||
setDescription: jest.fn().mockReturnThis(),
|
||||
addStringOption: jest.fn().mockReturnThis(),
|
||||
addIntegerOption: jest.fn().mockReturnThis(),
|
||||
addUserOption: jest.fn().mockReturnThis()
|
||||
})
|
||||
})),
|
||||
Collection: jest.fn(() => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
has: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
})),
|
||||
GatewayIntentBits: {
|
||||
Guilds: 1,
|
||||
GuildMessages: 2,
|
||||
MessageContent: 3,
|
||||
},
|
||||
Events: {
|
||||
ClientReady: 'ready',
|
||||
InteractionCreate: 'interactionCreate',
|
||||
},
|
||||
SlashCommandBuilder: jest.fn().mockReturnValue({
|
||||
setName: jest.fn().mockReturnThis(),
|
||||
setDescription: jest.fn().mockReturnThis(),
|
||||
addStringOption: jest.fn().mockReturnThis(),
|
||||
addIntegerOption: jest.fn().mockReturnThis(),
|
||||
addUserOption: jest.fn().mockReturnThis(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock environment variables
|
||||
|
||||
+361
@@ -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
@@ -34,7 +34,7 @@ export default class Command {
|
||||
this._logger.info(`Executing command: ${interaction.commandName}`);
|
||||
await this.run(interaction);
|
||||
} catch (error) {
|
||||
this.handleError(interaction, error);
|
||||
await this.handleError(interaction, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,13 +44,13 @@ export default class Command {
|
||||
}
|
||||
|
||||
// Standardized error handling
|
||||
handleError(interaction, error) {
|
||||
async handleError(interaction, error) {
|
||||
this._logger.error(`Command ${interaction.commandName} failed:`, error);
|
||||
|
||||
const response = interaction.deferred ? 'followUp' : 'reply';
|
||||
const errorMessage = this.getErrorMessage(error);
|
||||
|
||||
interaction[response]({
|
||||
await interaction[response]({
|
||||
content: errorMessage,
|
||||
ephemeral: true,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user