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 ./
|
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
|
||||||
|
|
||||||
|
|||||||
+54
-187
@@ -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(),
|
||||||
@@ -17,33 +22,17 @@ jest.mock('discord.js', () => ({
|
|||||||
};
|
};
|
||||||
callback(option);
|
callback(option);
|
||||||
return {
|
return {
|
||||||
addBooleanOption: jest.fn().mockImplementation(boolCallback => {
|
toJSON: jest.fn().mockReturnValue({
|
||||||
const boolOption = {
|
name: 'ask',
|
||||||
setName: jest.fn().mockReturnThis(),
|
description: 'Ask a question to the AI',
|
||||||
setDescription: jest.fn().mockReturnThis(),
|
options: [
|
||||||
setRequired: jest.fn().mockReturnThis(),
|
{
|
||||||
};
|
name: 'prompt',
|
||||||
boolCallback(boolOption);
|
description: 'Your question or prompt',
|
||||||
return {
|
type: 3,
|
||||||
toJSON: jest.fn().mockReturnValue({
|
required: true,
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -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('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 () => {
|
it('should handle API errors', async () => {
|
||||||
interaction = createMockInteraction({
|
|
||||||
commandName: 'ask',
|
|
||||||
stringOptions: { prompt: mockPrompt },
|
|
||||||
booleanOptions: { websearch: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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 { 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;
|
||||||
|
|
||||||
|
|||||||
+88
-102
@@ -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,116 +17,109 @@ 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) {
|
||||||
await interaction.deferReply();
|
try {
|
||||||
|
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 response = await axios.post(
|
||||||
const webSearchEnabled =
|
'https://nano-gpt.com/api/v1/chat/completions',
|
||||||
config.webSearch.allowOverride && userWebSearchOption !== null
|
{
|
||||||
? userWebSearchOption // Use user's choice if override is allowed and option was provided
|
model: 'zai-org/glm-4.7',
|
||||||
: config.webSearch.enabled; // Otherwise use default config
|
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(
|
Formatting:
|
||||||
'https://openrouter.ai/api/v1/chat/completions',
|
- 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.`,
|
||||||
model: webSearchEnabled ? 'xiaomi/mimo-v2-flash:free:online' : 'xiaomi/mimo-v2-flash:free',
|
},
|
||||||
messages: [
|
{ role: 'user', content: prompt },
|
||||||
{
|
],
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
);
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.NANOGPT_API_KEY}`,
|
||||||
|
'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
|
||||||
await interaction.followUp({
|
await interaction.followUp({
|
||||||
content: formattedResponse,
|
content: formattedResponse,
|
||||||
split: false,
|
|
||||||
allowedMentions: { parse: [] },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// For longer messages, split while preserving markdown
|
|
||||||
const maxLength = 2000;
|
|
||||||
const chunks = [];
|
|
||||||
let remainingText = formattedResponse;
|
|
||||||
let isFirstChunk = true;
|
|
||||||
|
|
||||||
while (remainingText.length > 0) {
|
|
||||||
let chunk = remainingText.slice(0, maxLength);
|
|
||||||
|
|
||||||
// If we're in the middle of a code block, find a safe split point
|
|
||||||
const lastCodeBlock = chunk.lastIndexOf('```');
|
|
||||||
if (lastCodeBlock !== -1 && !chunk.slice(lastCodeBlock).includes('\n```')) {
|
|
||||||
// Find the last newline before maxLength
|
|
||||||
const lastNewline = chunk.lastIndexOf('\n');
|
|
||||||
if (lastNewline !== -1) {
|
|
||||||
chunk = chunk.slice(0, lastNewline);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For subsequent chunks, add continuation indicator
|
|
||||||
if (!isFirstChunk) {
|
|
||||||
chunk = `(continued)\n${chunk}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
chunks.push({
|
|
||||||
content: chunk,
|
|
||||||
split: false,
|
split: false,
|
||||||
allowedMentions: { parse: [] },
|
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);
|
while (remainingText.length > 0) {
|
||||||
isFirstChunk = false;
|
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(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
// Send chunks sequentially using reduce
|
await interaction.followUp({
|
||||||
await chunks.reduce(
|
content: this.getErrorMessage(error),
|
||||||
(promise, chunk) =>
|
ephemeral: true,
|
||||||
promise.then(async () => {
|
});
|
||||||
await interaction.followUp(chunk);
|
|
||||||
}),
|
|
||||||
Promise.resolve(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -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
|
||||||
|
|||||||
+23
-25
@@ -1,33 +1,31 @@
|
|||||||
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,
|
||||||
|
|
||||||
// Indicates whether the coverage information should be collected while executing the test
|
// Indicates whether the coverage information should be collected while executing the test
|
||||||
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/'],
|
||||||
};
|
};
|
||||||
+32
-30
@@ -1,36 +1,38 @@
|
|||||||
|
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(() => ({
|
||||||
login: jest.fn().mockResolvedValue('token'),
|
login: jest.fn().mockResolvedValue('token'),
|
||||||
destroy: jest.fn().mockResolvedValue(),
|
destroy: jest.fn().mockResolvedValue(),
|
||||||
on: jest.fn(),
|
on: jest.fn(),
|
||||||
once: jest.fn(),
|
once: jest.fn(),
|
||||||
user: {
|
user: {
|
||||||
setActivity: jest.fn()
|
setActivity: jest.fn(),
|
||||||
}
|
|
||||||
})),
|
|
||||||
Collection: jest.fn(() => ({
|
|
||||||
get: jest.fn(),
|
|
||||||
set: jest.fn(),
|
|
||||||
has: jest.fn(),
|
|
||||||
delete: jest.fn()
|
|
||||||
})),
|
|
||||||
GatewayIntentBits: {
|
|
||||||
Guilds: 1,
|
|
||||||
GuildMessages: 2,
|
|
||||||
MessageContent: 3
|
|
||||||
},
|
},
|
||||||
Events: {
|
})),
|
||||||
ClientReady: 'ready',
|
Collection: jest.fn(() => ({
|
||||||
InteractionCreate: 'interactionCreate'
|
get: jest.fn(),
|
||||||
},
|
set: jest.fn(),
|
||||||
SlashCommandBuilder: jest.fn().mockReturnValue({
|
has: jest.fn(),
|
||||||
setName: jest.fn().mockReturnThis(),
|
delete: jest.fn(),
|
||||||
setDescription: jest.fn().mockReturnThis(),
|
})),
|
||||||
addStringOption: jest.fn().mockReturnThis(),
|
GatewayIntentBits: {
|
||||||
addIntegerOption: jest.fn().mockReturnThis(),
|
Guilds: 1,
|
||||||
addUserOption: jest.fn().mockReturnThis()
|
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
|
// 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}`);
|
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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user