diff --git a/apps/test-bot/src/ai.ts b/apps/test-bot/src/ai.ts index 12f79cb1..7a5f09cf 100644 --- a/apps/test-bot/src/ai.ts +++ b/apps/test-bot/src/ai.ts @@ -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, diff --git a/apps/test-bot/src/app.ts b/apps/test-bot/src/app.ts index 3421fd62..b094a2fb 100644 --- a/apps/test-bot/src/app.ts +++ b/apps/test-bot/src/app.ts @@ -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: [ diff --git a/packages/ai/src/cli-plugin.ts b/packages/ai/src/cli-plugin.ts new file mode 100644 index 00000000..6e3c48fd --- /dev/null +++ b/packages/ai/src/cli-plugin.ts @@ -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 { + ctx.registerTemplate('ai', this.handleTemplate.bind(this)); + } + + public async deactivate(ctx: CompilerPluginRuntime): Promise { + ctx.unregisterTemplate('ai'); + } +} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 7fb2c259..feb708ee 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -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. @@ -50,7 +51,7 @@ export function executeAI(message: Message): Promise { * @returns The AI plugin instance */ export function ai(options?: AiPluginOptions) { - return new AiPlugin(options ?? {}); + return [new AiPlugin(options ?? {}), new AiCliPlugin({})]; } export * from './types'; diff --git a/packages/ai/src/plugin.ts b/packages/ai/src/plugin.ts index 09e5331b..f75ee633 100644 --- a/packages/ai/src/plugin.ts +++ b/packages/ai/src/plugin.ts @@ -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. @@ -34,6 +36,8 @@ const defaultTools: Record = { getCurrentClientInfo, getGuildById, getUserById, + getMemberById, + createEmbed, }; export class AiPlugin extends RuntimePlugin { @@ -44,6 +48,7 @@ export class AiPlugin extends RuntimePlugin { public constructor(options: AiPluginOptions) { super(options); + this.preload.add('ai.js'); } public async activate(ctx: CommandKitPluginRuntime): Promise { diff --git a/packages/ai/src/system-prompt.ts b/packages/ai/src/system-prompt.ts index 842c99fa..e854e848 100644 --- a/packages/ai/src/system-prompt.ts +++ b/packages/ai/src/system-prompt.ts @@ -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. @@ -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. @@ -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 diff --git a/packages/ai/src/tools/create-embed.ts b/packages/ai/src/tools/create-embed.ts new file mode 100644 index 00000000..a2da1cce --- /dev/null +++ b/packages/ai/src/tools/create-embed.ts @@ -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', + }; + }, +}); diff --git a/packages/ai/src/tools/get-member-by-id.ts b/packages/ai/src/tools/get-member-by-id.ts new file mode 100644 index 00000000..a2331949 --- /dev/null +++ b/packages/ai/src/tools/get-member-by-id.ts @@ -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', + }; + } + }, +}); diff --git a/packages/commandkit/src/cli/generators.ts b/packages/commandkit/src/cli/generators.ts index 62ebca1f..3ad45419 100644 --- a/packages/commandkit/src/cli/generators.ts +++ b/packages/commandkit/src/cli/generators.ts @@ -75,6 +75,39 @@ export const message = async (ctx) => { `; } +/** + * @private + * @internal + */ +function TS_EVENT_SOURCE(name: string) { + const eventName = name[0].toUpperCase() + name.slice(1); + return `import type { EventHandler } from 'commandkit'; + +const ${eventName}: EventHandler<'${name}'> = async () => { + console.log('${name} event fired!'); +}; + +export default ${eventName}; +`; +} + +/** + * @private + * @internal + */ +function JS_EVENT_SOURCE(name: string) { + const eventName = `on${name[0].toUpperCase() + name.slice(1)}`; + return `/** + * @type {import('commandkit').EventHandler<'${name}'>} + */ +const ${eventName} = async () => { + console.log('${name} event fired!'); +}; + +export default ${eventName}; +`; +} + /** * @private * @internal @@ -114,6 +147,7 @@ export async function generateEvent(name: string, customPath?: string) { if (!existsSync(eventPath)) await mkdir(eventPath, { recursive: true }); const ext = determineExtension(); + const isTypeScript = ext === 'ts'; let filename = `event.${ext}`; @@ -122,11 +156,9 @@ export async function generateEvent(name: string, customPath?: string) { filename = `${String(count).padStart(2, '0')}_${filename}`; } - const eventFile = ` -export default async function on${name[0].toUpperCase() + name.slice(1)}() { - console.log('${name} event fired!'); -}; -`.trim(); + const eventFile = isTypeScript + ? TS_EVENT_SOURCE(name) + : JS_EVENT_SOURCE(name); await writeFile(join(eventPath, filename), eventFile); diff --git a/packages/commandkit/src/plugins/RuntimePlugin.ts b/packages/commandkit/src/plugins/RuntimePlugin.ts index d1157035..0a346289 100644 --- a/packages/commandkit/src/plugins/RuntimePlugin.ts +++ b/packages/commandkit/src/plugins/RuntimePlugin.ts @@ -19,6 +19,8 @@ export abstract class RuntimePlugin< T extends PluginOptions = PluginOptions, > extends PluginCommon { public readonly type = PluginType.Runtime; + public readonly preload = new Set(); + /** * Called before commands are loaded */ diff --git a/packages/commandkit/src/plugins/plugin-runtime/CommandKitPluginRuntime.ts b/packages/commandkit/src/plugins/plugin-runtime/CommandKitPluginRuntime.ts index c4d595ac..b607110b 100644 --- a/packages/commandkit/src/plugins/plugin-runtime/CommandKitPluginRuntime.ts +++ b/packages/commandkit/src/plugins/plugin-runtime/CommandKitPluginRuntime.ts @@ -8,6 +8,8 @@ import { } from '../../utils/error-codes'; import { Logger } from '../../logger/Logger'; import { AsyncFunction } from '../../context/async-context'; +import { getCurrentDirectory } from '../../utils/utilities'; +import { toFileURL } from '../../utils/resolve-file-url'; /** * Represents the runtime environment for CommandKit plugins. @@ -47,6 +49,16 @@ export class CommandKitPluginRuntime { return p as InstanceType | null; } + /** + * Pre-loads the specified entrypoints for the given plugin. + * @param plugin The plugin to pre-load. + */ + public async preload(plugin: RuntimePlugin) { + for (const entrypoint of plugin.preload) { + await import(toFileURL(`${getCurrentDirectory()}/${entrypoint}`)); + } + } + /** * Soft registers a plugin in the runtime. * @param plugin The plugin to be registered. @@ -57,9 +69,11 @@ export class CommandKitPluginRuntime { if (this.plugins.has(pluginName)) return; try { + await this.preload(plugin); await plugin.activate(this); this.plugins.set(pluginName, plugin); } catch (e: any) { + this.plugins.delete(pluginName); throw new Error( `Failed to activate plugin "${pluginName}": ${e?.stack || e}`, ); @@ -79,9 +93,11 @@ export class CommandKitPluginRuntime { } try { + await this.preload(plugin); await plugin.activate(this); this.plugins.set(pluginName, plugin); } catch (e: any) { + this.plugins.delete(pluginName); throw new Error( `Failed to activate plugin "${pluginName}": ${e?.stack || e}`, );