Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/test-bot/src/ai.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { configureAI } from '@commandkit/ai';
import { Logger } from 'commandkit';

const google = createGoogleGenerativeAI({
apiKey: process.env.GOOGLE_API_KEY,
Expand Down
1 change: 0 additions & 1 deletion apps/test-bot/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Client, Partials } from 'discord.js';
import { Logger, commandkit } from 'commandkit';
import { setDriver } from '@commandkit/tasks';
import { SQLiteDriver } from '@commandkit/tasks/sqlite';
import './ai.ts';

const client = new Client({
intents: [
Expand Down
51 changes: 51 additions & 0 deletions packages/ai/src/cli-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { type CompilerPluginRuntime, CompilerPlugin, Logger } from 'commandkit';
import { existsSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';

const AI_CONFIG_TEMPLATE = `
import { configureAI } from '@commandkit/ai';
import { openai } from '@ai-sdk/openai';

configureAI({
selectAiModel: async () => {
return { model: openai('o3-mini') };
}
});
`.trimStart();

export class AiCliPlugin extends CompilerPlugin {
public readonly name = 'AiCliPlugin';

private async handleTemplate(args: string[]) {
if (!existsSync('./src')) {
Logger.error(`No "src" directory found in the current directory`);
return;
}

let filePath = './src/ai';
const isTypeScript = existsSync('tsconfig.json');

if (isTypeScript) {
filePath += '.ts';
} else {
filePath += '.js';
}

if (existsSync(filePath)) {
Logger.error(`AI config file already exists at ${filePath}`);
return;
}

await writeFile(filePath, AI_CONFIG_TEMPLATE);

Logger.info(`AI config file created at ${filePath}`);
}

public async activate(ctx: CompilerPluginRuntime): Promise<void> {
ctx.registerTemplate('ai', this.handleTemplate.bind(this));
}

public async deactivate(ctx: CompilerPluginRuntime): Promise<void> {
ctx.unregisterTemplate('ai');
}
}
3 changes: 2 additions & 1 deletion packages/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AiPluginOptions } from './types';
import { getAiWorkerContext } from './ai-context-worker';
import { getCommandKit } from 'commandkit';
import type { Message } from 'discord.js';
import { AiCliPlugin } from './cli-plugin';

/**
* Retrieves the AI context.
Expand Down Expand Up @@ -50,7 +51,7 @@ export function executeAI(message: Message): Promise<void> {
* @returns The AI plugin instance
*/
export function ai(options?: AiPluginOptions) {
return new AiPlugin(options ?? {});
return [new AiPlugin(options ?? {}), new AiCliPlugin({})];
}

export * from './types';
Expand Down
5 changes: 5 additions & 0 deletions packages/ai/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { getUserById } from './tools/get-user-by-id';
import { getAIConfig } from './configure';
import { augmentCommandKit } from './augmentation';
import { ToolParameterType } from './tools/common';
import { getMemberById } from './tools/get-member-by-id';
import { createEmbed } from './tools/create-embed';

/**
* Represents the configuration options for the AI plugin scoped to a specific command.
Expand All @@ -34,6 +36,8 @@ const defaultTools: Record<string, Tool> = {
getCurrentClientInfo,
getGuildById,
getUserById,
getMemberById,
createEmbed,
};

export class AiPlugin extends RuntimePlugin<AiPluginOptions> {
Expand All @@ -44,6 +48,7 @@ export class AiPlugin extends RuntimePlugin<AiPluginOptions> {

public constructor(options: AiPluginOptions) {
super(options);
this.preload.add('ai.js');
}

public async activate(ctx: CommandKitPluginRuntime): Promise<void> {
Expand Down
49 changes: 47 additions & 2 deletions packages/ai/src/system-prompt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,32 @@
import { Message } from 'discord.js';
import { Message, version as djsVersion } from 'discord.js';
import { version as commandkitVersion } from 'commandkit';

const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;

function humanReadable(ms: number): string {
const units = [
{ value: 31536000000, label: 'year' },
{ value: 2628000000, label: 'month' },
{ value: 604800000, label: 'week' },
{ value: 86400000, label: 'day' },
{ value: 3600000, label: 'hour' },
{ value: 60000, label: 'minute' },
{ value: 1000, label: 'second' },
];

for (const { value, label } of units) {
if (ms >= value) {
const count = ms / value;
if (count >= 10) {
return `${Math.round(count)} ${label}${Math.round(count) === 1 ? '' : 's'}`;
} else {
return `${count.toFixed(1)} ${label}${count === 1 ? '' : 's'}`;
}
}
}

return `${ms} milliseconds`;
}

/**
* Creates the default system prompt for the AI bot based on the provided message context.
Expand All @@ -11,7 +39,7 @@ export function createSystemPrompt(message: Message): string {
: message.channel.recipient?.displayName || 'DM';

const guildInfo = message.inGuild()
? `You are in the guild "${message.guild.name}" (ID: ${message.guildId}). You can fetch member information when needed.`
? `You are in the guild "${message.guild.name}" (ID: ${message.guildId}) with ${message.guild?.memberCount ?? 0} members. You joined this guild on ${message.guild.members.me?.joinedAt?.toLocaleString() ?? 'unknown date'}. You can fetch member information when needed using the provided tools.`
: 'You are in a direct message with the user.';

return `You are ${message.client.user.username} (ID: ${message.client.user.id}), a helpful AI Discord bot.
Expand All @@ -25,12 +53,29 @@ export function createSystemPrompt(message: Message): string {
- Channel: "${channelInfo}" (ID: ${message.channelId})
- Permissions: ${message.channel.isSendable() ? 'Can send messages' : 'Cannot send messages'}
- Location: ${guildInfo}
- Today's date: ${new Date().toLocaleDateString()}
- Current time: ${new Date().toLocaleTimeString()}
- Current UTC time: ${new Date().toUTCString()}
- Current timezone: ${tz}
- Current UNIX timestamp: ${Date.now()} (in milliseconds)
- You know total ${message.client.guilds.cache.size} guilds and ${message.client.users.cache.size} users
- You know total ${message.client.channels.cache.size} channels
- Your uptime is ${humanReadable(message.client.uptime)}
- Your latency is ${message.client.ws.ping ?? -1}ms
- You were built with Discord.js v${djsVersion} and CommandKit v${commandkitVersion}
- You were created on ${message.client.user.createdAt.toLocaleString()}

**Current User Information:**
- id: ${message.author.id}
- name: ${message.author.username}
- display name: ${message.author.displayName}
- avatar: ${message.author.avatarURL()}
- created at: ${message.author.createdAt.toLocaleString()}
- joined at: ${message.member?.joinedAt?.toLocaleString() ?? 'Info not available'}
- roles: ${message.member?.roles.cache.map((role) => role.name).join(', ') ?? 'No roles'}
- is bot: ${message.author.bot}
- is boosting: ${!!message.member?.premiumSince}
- has permissions: ${message.member?.permissions.toArray().join(', ') ?? 'No permissions'}

**Response Guidelines:**
- Use Discord markdown for formatting
Expand Down
103 changes: 103 additions & 0 deletions packages/ai/src/tools/create-embed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { z } from 'zod';
import { createTool } from './common';
import { ColorResolvable, EmbedBuilder } from 'discord.js';

export const createEmbed = createTool({
name: 'createEmbed',
description: 'Create an embed message',
inputSchema: z.object({
title: z.string().describe('The title of the embed').optional(),
url: z.string().describe('The URL of the embed').optional(),
description: z.string().describe('The description of the embed').optional(),
color: z.string().describe('The color of the embed').optional(),
fields: z
.array(
z.object({
name: z.string().describe('The name of the field'),
value: z.string().describe('The value of the field'),
inline: z.boolean().describe('Whether the field is inline'),
}),
)
.describe('The fields of the embed')
.optional(),
author: z
.object({
name: z.string().describe('The name of the author'),
url: z.string().describe('The URL of the author'),
iconUrl: z.string().describe('The icon URL of the author'),
})
.describe('The author of the embed')
.optional(),
footer: z
.object({
text: z.string().describe('The text of the footer'),
iconUrl: z.string().describe('The icon URL of the footer'),
})
.describe('The footer of the embed')
.optional(),
timestamp: z
.boolean()
.describe('Whether to add a timestamp to the embed')
.optional(),
image: z.string().describe('The image URL of the embed').optional(),
thumbnail: z.string().describe('The thumbnail URL of the embed').optional(),
}),
execute: async (ctx, params) => {
const { message } = ctx;

if (!message.channel.isSendable()) {
return {
error: 'You do not have permission to send messages in this channel',
};
}

const embed = new EmbedBuilder();

if (params.title) {
embed.setTitle(params.title);
}

if (params.description) {
embed.setDescription(params.description);
}

if (params.color) {
embed.setColor(
(!Number.isNaN(params.color)
? Number(params.color)
: params.color) as ColorResolvable,
);
}

if (params.fields) {
embed.addFields(params.fields);
}

if (params.author) {
embed.setAuthor(params.author);
}

if (params.footer) {
embed.setFooter(params.footer);
}

if (params.timestamp) {
embed.setTimestamp();
}

if (params.image) {
embed.setImage(params.image);
}

if (params.thumbnail) {
embed.setThumbnail(params.thumbnail);
}

await message.channel.send({ embeds: [embed] });

return {
success: true,
message: 'Embedded message was sent to the channel successfully',
};
},
});
50 changes: 50 additions & 0 deletions packages/ai/src/tools/get-member-by-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { z } from 'zod';
import { createTool } from './common';
import { Logger } from 'commandkit';

export const getMemberById = createTool({
description:
'Get a member by their ID. This tool can only be used in a guild.',
name: 'getMemberById',
inputSchema: z.object({
memberId: z.string().describe('The ID of the member to retrieve.'),
}),
async execute(ctx, params) {
try {
const { client, message } = ctx;
if (!message.inGuild()) {
return {
error: 'This tool can only be used in a guild',
};
}

const member = await message.guild?.members.fetch(params.memberId);

return {
id: member.id,
user: member.user.toJSON(),
roles: member.roles.cache.map((role) => role.name),
joinedAt: member.joinedAt?.toLocaleString(),
isBoosting: !!member.premiumSince,
voiceChannel: member.voice.channel?.name,
voiceChannelId: member.voice.channel?.id,
permissions: member.permissions.toArray().join(', '),
nickname: member.nickname,
name: member.user.username,
displayName: member.displayName,
avatar: member.user.displayAvatarURL(),
isBot: member.user.bot,
presence: member.presence?.status ?? 'unknown',
isDeafened: member.voice.deaf ?? 'Not in VC',
isMuted: member.voice.mute ?? 'Not in VC',
isMe: member.id === message.author.id,
};
} catch (e) {
Logger.error(e);

return {
error: 'Could not fetch the user',
};
}
},
});
Loading