diff --git a/examples/with-ai/.env.example b/examples/with-ai/.env.example
new file mode 100644
index 00000000..14f278a9
--- /dev/null
+++ b/examples/with-ai/.env.example
@@ -0,0 +1,3 @@
+DISCORD_TOKEN="DISCORD_TOKEN"
+GOOGLE_API_KEY="GOOGLE_API_KEY"
+CLIPDROP_API_KEY="CLIPDROP_API_KEY"
\ No newline at end of file
diff --git a/examples/with-ai/.gitignore b/examples/with-ai/.gitignore
new file mode 100644
index 00000000..ca96ee36
--- /dev/null
+++ b/examples/with-ai/.gitignore
@@ -0,0 +1,40 @@
+# dependencies (bun install)
+node_modules
+
+# output
+out
+dist
+*.tgz
+
+# code coverage
+coverage
+*.lcov
+
+# logs
+logs
+_.log
+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# caches
+.eslintcache
+.cache
+*.tsbuildinfo
+
+# IntelliJ based IDEs
+.idea
+
+# Finder (MacOS) folder config
+.DS_Store
+
+# CommandKit
+.commandkit
+dist
+
+*.db*
\ No newline at end of file
diff --git a/examples/with-ai/LICENSE b/examples/with-ai/LICENSE
new file mode 100644
index 00000000..110535c9
--- /dev/null
+++ b/examples/with-ai/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Under Ctrl
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/examples/with-ai/README.md b/examples/with-ai/README.md
new file mode 100644
index 00000000..58f578c3
--- /dev/null
+++ b/examples/with-ai/README.md
@@ -0,0 +1,100 @@
+# Agent 🤖
+
+Agent is an advanced AI-powered Discord bot built with [CommandKit](https://commandkit.dev/) that leverages natural language processing to provide an intuitive and powerful interaction experience. Built with modern technologies, it offers a seamless way to manage Discord servers and engage with communities through various features.
+
+## ✨ Features
+
+- **Natural Language Commands**: Interact with the bot using everyday language
+- **Channel Management**: Create and manage Discord channels effortlessly
+- **Interactive Polls**: Create and manage polls with natural language inputs
+- **AI Image Generation**: Generate images from text descriptions using Clipdrop API
+- **Smart Chatbot**: Engage in natural conversations with context awareness
+- **Google AI Integration**: Leverage Google's AI capabilities for enhanced features
+
+> [!NOTE]
+> This repository is just a simple example of what can be built with Agent. The actual implementation may vary based on your specific needs and requirements.
+
+> Did you know that this project was vibe coded? 🤖 You can do that yourself by utilizing https://commandkit.dev/llms.txt in your code editor such as Cursor.
+
+## 🚀 Getting Started
+
+### Prerequisites
+
+- [Node.js](https://nodejs.org/) (v22 or higher)
+- A Discord bot token
+- Google API key
+- Clipdrop API key
+
+### Installation
+
+1. Clone the repository:
+
+```bash
+git clone https://github.com/underctrl-io/agent.git
+cd agent
+```
+
+2. Install dependencies:
+
+```bash
+npm install
+```
+
+3. Configure environment variables:
+ Create a `.env` file in the root directory with the following variables:
+
+```env
+DISCORD_TOKEN="your_discord_token"
+GOOGLE_API_KEY="your_google_api_key"
+CLIPDROP_API_KEY="your_clipdrop_api_key"
+```
+
+You can obtain the required API keys from:
+
+- Discord Token: [Discord Developer Portal](https://discord.com/developers/applications)
+- Google API Key: [Google AI Studio](https://aistudio.google.com/apikey)
+- Clipdrop API Key: [Clipdrop API Documentation](https://clipdrop.co/apis/docs/text-to-image)
+
+4. Start the bot:
+
+```bash
+npm run dev
+```
+
+## 🎯 Features Showcase
+
+### Natural Language Poll Creation
+
+Create polls using everyday language. The bot understands context and can handle follow-up questions naturally.
+
+
+
+
+### AI Image Generation
+
+Generate images from text descriptions using natural language prompts.
+
+
+
+### Context-Aware Chatbot
+
+Engage in natural conversations with the bot that maintains context and provides relevant responses.
+
+
+
+## 🤝 Contributing
+
+Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
+
+1. Fork the repository
+2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
+3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
+4. Push to the branch (`git push origin feature/AmazingFeature`)
+5. Open a Pull Request
+
+## 🙏 Acknowledgments
+
+- [Discord.js](https://discord.js.org/) for the Discord API wrapper
+- [CommandKit](https://commandkit.dev/) for the Discord.js framework with AI capabilities
+- [Google AI](https://ai.google/) for generative AI models
+- [Clipdrop](https://clipdrop.co/) for image generation API
diff --git a/examples/with-ai/assets/chatbot.png b/examples/with-ai/assets/chatbot.png
new file mode 100644
index 00000000..16f1a28b
Binary files /dev/null and b/examples/with-ai/assets/chatbot.png differ
diff --git a/examples/with-ai/assets/create-poll-2.png b/examples/with-ai/assets/create-poll-2.png
new file mode 100644
index 00000000..91d20b85
Binary files /dev/null and b/examples/with-ai/assets/create-poll-2.png differ
diff --git a/examples/with-ai/assets/create-poll.png b/examples/with-ai/assets/create-poll.png
new file mode 100644
index 00000000..6f9aa4bb
Binary files /dev/null and b/examples/with-ai/assets/create-poll.png differ
diff --git a/examples/with-ai/assets/image-generation.png b/examples/with-ai/assets/image-generation.png
new file mode 100644
index 00000000..925bf250
Binary files /dev/null and b/examples/with-ai/assets/image-generation.png differ
diff --git a/examples/with-ai/commandkit-env.d.ts b/examples/with-ai/commandkit-env.d.ts
new file mode 100644
index 00000000..cc2ba6df
--- /dev/null
+++ b/examples/with-ai/commandkit-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/with-ai/commandkit.config.ts b/examples/with-ai/commandkit.config.ts
new file mode 100644
index 00000000..ccc8d1a8
--- /dev/null
+++ b/examples/with-ai/commandkit.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'commandkit/config';
+import { ai } from '@commandkit/ai';
+import { tasks } from '@commandkit/tasks';
+
+export default defineConfig({
+ plugins: [ai(), tasks()],
+});
diff --git a/examples/with-ai/package.json b/examples/with-ai/package.json
new file mode 100644
index 00000000..e0893192
--- /dev/null
+++ b/examples/with-ai/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "commandkit-with-ai",
+ "type": "module",
+ "private": true,
+ "version": "0.1.0",
+ "description": "AI powered Discord bot built with CommandKit",
+ "scripts": {
+ "dev": "commandkit dev",
+ "build": "commandkit build",
+ "start": "commandkit start"
+ },
+ "dependencies": {
+ "@ai-sdk/google": "^2.0.19",
+ "@commandkit/ai": "^1.2.0-rc.12",
+ "@commandkit/tasks": "^1.2.0-rc.12",
+ "@types/ms": "^2.1.0",
+ "commandkit": "^1.2.0-rc.12",
+ "discord.js": "^14.23.2",
+ "ms": "^2.1.3",
+ "weather-js": "^2.0.0"
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ "@types/node": "^24.0.1",
+ "typescript": "^5.8.3"
+ }
+}
diff --git a/examples/with-ai/src/ai.ts b/examples/with-ai/src/ai.ts
new file mode 100644
index 00000000..23be52b0
--- /dev/null
+++ b/examples/with-ai/src/ai.ts
@@ -0,0 +1,27 @@
+import { configureAI } from '@commandkit/ai';
+import { createGoogleGenerativeAI } from '@ai-sdk/google';
+import { generateImageTool } from './tools/generate-image';
+
+const google = createGoogleGenerativeAI({
+ apiKey: process.env.GOOGLE_API_KEY,
+});
+
+const model = google.languageModel('gemini-2.0-flash');
+
+configureAI({
+ async selectAiModel() {
+ return {
+ model,
+ tools: {
+ generateImage: generateImageTool,
+ },
+ };
+ },
+ messageFilter: async (commandkit, message) => {
+ return (
+ !message.author.bot &&
+ message.inGuild() &&
+ message.mentions.users.has(message.client.user.id)
+ );
+ },
+});
diff --git a/examples/with-ai/src/app.ts b/examples/with-ai/src/app.ts
new file mode 100644
index 00000000..ed8d0d48
--- /dev/null
+++ b/examples/with-ai/src/app.ts
@@ -0,0 +1,7 @@
+import { Client } from 'discord.js';
+
+const client = new Client({
+ intents: ['Guilds', 'GuildMembers', 'GuildMessages', 'MessageContent'],
+});
+
+export default client;
diff --git a/examples/with-ai/src/app/commands/channel.ts b/examples/with-ai/src/app/commands/channel.ts
new file mode 100644
index 00000000..b84fab94
--- /dev/null
+++ b/examples/with-ai/src/app/commands/channel.ts
@@ -0,0 +1,124 @@
+import type { CommandData, MessageCommand } from 'commandkit';
+import type { AiCommand, AiConfig } from 'commandkit/ai';
+import {
+ ChannelType,
+ GuildChannelCreateOptions,
+ PermissionsBitField,
+ OverwriteData,
+} from 'discord.js';
+import { z } from 'zod';
+
+export const command: CommandData = {
+ name: 'channel',
+ description: 'Manage channels in the server',
+};
+
+const channelConfig = z.object({
+ name: z.string().trim().min(1).max(100).describe('The name of the channel'),
+ type: z.enum(['text', 'voice']).describe('The type of channel to create'),
+ topic: z.string().trim().optional().describe('The topic for text channels'),
+ user_limit: z
+ .number()
+ .int()
+ .min(0)
+ .max(99)
+ .optional()
+ .describe('User limit for voice channels'),
+ nsfw: z
+ .boolean()
+ .optional()
+ .default(false)
+ .describe('Whether the channel is NSFW'),
+ private: z
+ .boolean()
+ .optional()
+ .default(false)
+ .describe('Whether the channel is private'),
+ parent: z
+ .string()
+ .optional()
+ .describe('The ID of the category to create the channel in'),
+});
+
+export const aiConfig = {
+ inputSchema: z.object({
+ channels: z
+ .array(channelConfig)
+ .min(1)
+ .max(5)
+ .describe('Array of channels to create'),
+ }),
+} satisfies AiConfig;
+
+export const message: MessageCommand = async (ctx) => {
+ await ctx.message.reply('This command can only be used via AI');
+};
+
+export const ai: AiCommand = async (ctx) => {
+ if (!ctx.message.inGuild()) {
+ return {
+ error: 'Channel management can only be used in a server',
+ };
+ }
+
+ const hasPermission = ctx.message.channel
+ .permissionsFor(ctx.message.client.user!)
+ ?.has([
+ PermissionsBitField.Flags.ManageChannels,
+ PermissionsBitField.Flags.ViewChannel,
+ ]);
+
+ if (!hasPermission) {
+ return {
+ error: 'Bot does not have the required permissions to manage channels',
+ };
+ }
+
+ const { channels } = ctx.ai.params;
+ const createdChannels = [];
+
+ for (const channel of channels) {
+ try {
+ const permissionOverwrites: OverwriteData[] = channel.private
+ ? [
+ {
+ id: ctx.message.guild.id,
+ deny: [PermissionsBitField.Flags.ViewChannel],
+ },
+ {
+ id: ctx.message.author.id,
+ allow: [PermissionsBitField.Flags.ViewChannel],
+ },
+ ]
+ : [];
+
+ const channelOptions: GuildChannelCreateOptions = {
+ name: channel.name,
+ type:
+ channel.type === 'text'
+ ? ChannelType.GuildText
+ : ChannelType.GuildVoice,
+ topic: channel.topic,
+ nsfw: channel.nsfw,
+ userLimit: channel.user_limit,
+ parent: channel.parent,
+ permissionOverwrites,
+ };
+
+ const newChannel =
+ await ctx.message.guild.channels.create(channelOptions);
+ createdChannels.push(newChannel);
+ } catch (err) {
+ const error = err as Error;
+ return {
+ error: `Failed to create channel ${channel.name}: ${error.message}`,
+ };
+ }
+ }
+
+ return {
+ content: `Successfully created ${
+ createdChannels.length
+ } channel(s): ${createdChannels.map((c) => c.toString()).join(', ')}`,
+ };
+};
diff --git a/examples/with-ai/src/app/commands/ping.ts b/examples/with-ai/src/app/commands/ping.ts
new file mode 100644
index 00000000..638398b5
--- /dev/null
+++ b/examples/with-ai/src/app/commands/ping.ts
@@ -0,0 +1,20 @@
+import type { ChatInputCommand, MessageCommand, CommandData } from 'commandkit';
+
+export const command: CommandData = {
+ name: 'ping',
+ description: "Ping the bot to check if it's online.",
+};
+
+export const chatInput: ChatInputCommand = async (ctx) => {
+ const latency = (ctx.client.ws.ping ?? -1).toString();
+ const response = `Pong! Latency: ${latency}ms`;
+
+ await ctx.interaction.reply(response);
+};
+
+export const message: MessageCommand = async (ctx) => {
+ const latency = (ctx.client.ws.ping ?? -1).toString();
+ const response = `Pong! Latency: ${latency}ms`;
+
+ await ctx.message.reply(response);
+};
diff --git a/examples/with-ai/src/app/commands/poll.ts b/examples/with-ai/src/app/commands/poll.ts
new file mode 100644
index 00000000..eb154d02
--- /dev/null
+++ b/examples/with-ai/src/app/commands/poll.ts
@@ -0,0 +1,127 @@
+import type { CommandData, ChatInputCommand, MessageCommand } from 'commandkit';
+import type { AiCommand, AiConfig } from 'commandkit/ai';
+import { ApplicationCommandOptionType, PermissionsBitField } from 'discord.js';
+import { z } from 'zod';
+
+export const command: CommandData = {
+ name: 'poll',
+ description: 'Create a poll',
+ options: [
+ {
+ name: 'question',
+ description: 'The question of the poll',
+ type: ApplicationCommandOptionType.String,
+ required: true,
+ },
+ {
+ name: 'answers',
+ description: 'The answers of the poll separated by commas',
+ type: ApplicationCommandOptionType.String,
+ required: true,
+ },
+ {
+ name: 'allow_multiselect',
+ description: 'Whether the poll allows multiple selections',
+ type: ApplicationCommandOptionType.Boolean,
+ required: false,
+ },
+ {
+ name: 'duration',
+ description: 'The duration of the poll in hours',
+ type: ApplicationCommandOptionType.Number,
+ required: false,
+ },
+ ],
+};
+
+const pollMediaObject = z
+ .object({
+ text: z.string().trim().describe('The question text of the poll'),
+ emoji: z
+ .string()
+ .trim()
+ .optional()
+ .describe('An optional emoji associated with the poll question. Eg: 👍'),
+ })
+ .describe(
+ 'An object representing the media for a poll question, containing the text of the question. Emoji cannot be used in question text.',
+ );
+
+export const aiConfig = {
+ inputSchema: z
+ .object({
+ question: pollMediaObject,
+ answers: z
+ .array(pollMediaObject)
+ .min(1)
+ .max(10)
+ .describe('An array of answers for the poll'),
+ allow_multiselect: z
+ .boolean()
+ .optional()
+ .default(false)
+ .describe('Whether the poll allows multiple selections'),
+ duration: z
+ .number()
+ .int()
+ .min(1)
+ .max(32)
+ .optional()
+ .default(24)
+ .describe('The duration of the poll in hours'),
+ })
+ .describe('An object representing a poll to include in the message'),
+} satisfies AiConfig;
+
+export const chatInput: ChatInputCommand = async (ctx) => {
+ const question = ctx.options.getString('question', true);
+ const answers = ctx.options.getString('answers', true);
+ const allowMultiselect = ctx.options.getBoolean('allow_multiselect');
+ const duration = ctx.options.getNumber('duration');
+
+ await ctx.interaction.reply({
+ poll: {
+ allowMultiselect: !!allowMultiselect,
+ answers: answers.split(',').map((answer) => {
+ return {
+ text: answer.trim(),
+ emoji: '',
+ };
+ }),
+ duration: duration ?? 24,
+ question: { text: question },
+ },
+ });
+};
+
+export const ai: AiCommand = async (ctx) => {
+ if (!ctx.message.inGuild()) {
+ return {
+ error: 'Poll can only be created in a server',
+ };
+ }
+
+ const hasPermission = ctx.message.channel
+ .permissionsFor(ctx.message.client.user!)
+ ?.has(PermissionsBitField.Flags.SendMessages);
+
+ if (!hasPermission) {
+ return {
+ error: 'Bot does not have the permission to send polls in this channel',
+ };
+ }
+
+ const { question, answers, allow_multiselect, duration } = ctx.ai.params;
+
+ await ctx.message.channel.send({
+ poll: {
+ allowMultiselect: !!allow_multiselect,
+ answers: answers.map((answer) => ({
+ text: answer.text,
+ emoji: answer.emoji,
+ })),
+ duration: duration ?? 24,
+ question: { text: question.text },
+ },
+ });
+};
diff --git a/examples/with-ai/src/app/commands/remind.ts b/examples/with-ai/src/app/commands/remind.ts
new file mode 100644
index 00000000..10d6c1b5
--- /dev/null
+++ b/examples/with-ai/src/app/commands/remind.ts
@@ -0,0 +1,131 @@
+import type { CommandData, ChatInputCommand, MessageCommand } from 'commandkit';
+import { ApplicationCommandOptionType } from 'discord.js';
+import { createTask } from '@commandkit/tasks';
+import { RemindTaskData } from '../tasks/remind';
+import ms from 'ms';
+import { AiCommand, AiConfig } from '@commandkit/ai';
+import { z } from 'zod';
+
+export const command: CommandData = {
+ name: 'remind',
+ description: 'remind command',
+ options: [
+ {
+ name: 'time',
+ description: 'The time to remind you',
+ type: ApplicationCommandOptionType.String,
+ required: true,
+ },
+ {
+ name: 'message',
+ description: 'The message to remind you',
+ type: ApplicationCommandOptionType.String,
+ required: true,
+ },
+ ],
+};
+
+export const aiConfig = {
+ inputSchema: z.object({
+ time: z
+ .string()
+ .describe(
+ 'The time to remind after. Example: 10s, 10m, 10h, 10d, 10w, 10y',
+ ),
+ message: z
+ .string()
+ .describe('The message to show when the reminder is triggered'),
+ }),
+} satisfies AiConfig;
+
+const createReminder = async (time: number, data: RemindTaskData) => {
+ if (time + Date.now() < Date.now()) {
+ return {
+ error: 'The time is in the past',
+ };
+ }
+
+ const schedule = time + Date.now();
+
+ await createTask({
+ name: 'remind',
+ schedule,
+ data,
+ });
+
+ return {
+ timer: schedule,
+ };
+};
+
+export const chatInput: ChatInputCommand = async (ctx) => {
+ const time = ctx.interaction.options.getString('time', true);
+ const message = ctx.interaction.options.getString('message', true);
+ const timeMs = ms(time as `${number}`);
+
+ const { error, timer } = await createReminder(timeMs, {
+ userId: ctx.interaction.user.id,
+ channelId: ctx.interaction.channelId,
+ message,
+ });
+
+ if (error) {
+ await ctx.interaction.reply(error);
+ return;
+ }
+
+ await ctx.interaction.reply(
+ `Reminder set for `,
+ );
+};
+
+export const message: MessageCommand = async (ctx) => {
+ const [time, ...messageParts] = ctx.args();
+ const message = messageParts.join(' ');
+
+ if (!time || !message) {
+ await ctx.message.reply('Please provide a time and message');
+ return;
+ }
+
+ const timeMs = ms(time as `${number}`);
+
+ const { error, timer } = await createReminder(timeMs, {
+ userId: ctx.message.author.id,
+ channelId: ctx.message.channel.id,
+ message,
+ });
+
+ if (error) {
+ await ctx.message.reply(error);
+ return;
+ }
+
+ await ctx.message.reply(
+ `Reminder set for `,
+ );
+};
+
+export const ai: AiCommand = async (ctx) => {
+ const { time, message } = ctx.ai.params;
+
+ const timeMs = ms(time as `${number}`);
+
+ const { error, timer } = await createReminder(timeMs, {
+ userId: ctx.message.author.id,
+ channelId: ctx.message.channel.id,
+ message,
+ });
+
+ if (error) {
+ return {
+ error,
+ };
+ }
+
+ return {
+ success: `Reminder set for . Show this markdown to the user for live updates on discord.`,
+ };
+};
diff --git a/examples/with-ai/src/app/commands/weather.ts b/examples/with-ai/src/app/commands/weather.ts
new file mode 100644
index 00000000..85418cc6
--- /dev/null
+++ b/examples/with-ai/src/app/commands/weather.ts
@@ -0,0 +1,178 @@
+import type { CommandData, ChatInputCommand } from 'commandkit';
+import type { AiCommand } from 'commandkit/ai';
+import { ApplicationCommandOptionType, EmbedBuilder } from 'discord.js';
+import { z } from 'zod';
+import weather from 'weather-js';
+
+const findWeather = (
+ location: string,
+ unit: 'C' | 'F',
+): Promise => {
+ return new Promise((resolve, reject) => {
+ weather.find({ search: location, degreeType: unit }, (err, result) => {
+ if (err) return reject(err);
+ if (!result || result.length === 0 || !result[0]) {
+ return reject(new Error('Location not found'));
+ }
+ resolve(result[0]);
+ });
+ });
+};
+
+export const command: CommandData = {
+ name: 'weather',
+ description: 'Get the current weather for a location',
+ options: [
+ {
+ name: 'location',
+ description: 'The location to get weather for',
+ type: ApplicationCommandOptionType.String,
+ required: true,
+ },
+ {
+ name: 'unit',
+ description: 'The temperature unit (C/F)',
+ type: ApplicationCommandOptionType.String,
+ required: false,
+ choices: [
+ { name: 'Celsius', value: 'C' },
+ { name: 'Fahrenheit', value: 'F' },
+ ],
+ },
+ ],
+};
+
+export const aiConfig = {
+ inputSchema: z.object({
+ location: z.string().describe('The location to get weather for'),
+ unit: z
+ .enum(['C', 'F'])
+ .optional()
+ .default('C')
+ .describe('The temperature unit (C/F)'),
+ }),
+};
+
+export const chatInput: ChatInputCommand = async (ctx) => {
+ const location = ctx.options.getString('location', true);
+ const unit = (ctx.options.getString('unit') as 'C' | 'F') || 'C';
+
+ await ctx.interaction.deferReply();
+
+ try {
+ const weatherData = await findWeather(location, unit);
+
+ const embed = new EmbedBuilder()
+ .setTitle(`Weather in ${weatherData.location.name}`)
+ .setColor('#0099ff')
+ .setThumbnail(weatherData.current.imageUrl)
+ .addFields(
+ {
+ name: 'Temperature',
+ value: `${weatherData.current.temperature}°${unit}`,
+ inline: true,
+ },
+ {
+ name: 'Feels Like',
+ value: `${weatherData.current.feelslike}°${unit}`,
+ inline: true,
+ },
+ { name: 'Sky', value: weatherData.current.skytext, inline: true },
+ { name: 'Humidity', value: weatherData.current.humidity, inline: true },
+ {
+ name: 'Wind Speed',
+ value: weatherData.current.windspeed,
+ inline: true,
+ },
+ );
+
+ // Add forecast information
+ const forecast = weatherData.forecast
+ .slice(0, 3)
+ .map(
+ (day) =>
+ `${day.shortday}: ${day.skytextday} (${day.low}°${unit} - ${day.high}°${unit})`,
+ )
+ .join('\n');
+
+ embed.addFields({ name: '3-Day Forecast', value: forecast });
+
+ if (weatherData.location.alert) {
+ embed.addFields({
+ name: '⚠️ Weather Alert',
+ value: weatherData.location.alert,
+ });
+ }
+
+ embed.setTimestamp();
+
+ await ctx.interaction.editReply({ embeds: [embed] });
+ } catch (error) {
+ await ctx.interaction.editReply(
+ 'Could not find weather information for that location.',
+ );
+ }
+};
+
+export const ai: AiCommand = async (ctx) => {
+ const { location, unit } = ctx.ai.params;
+
+ try {
+ const weatherData = await findWeather(location, unit);
+
+ const embed = new EmbedBuilder()
+ .setTitle(`Weather in ${weatherData.location.name}`)
+ .setColor('#0099ff')
+ .setThumbnail(weatherData.current.imageUrl)
+ .addFields(
+ {
+ name: 'Temperature',
+ value: `${weatherData.current.temperature}°${unit}`,
+ inline: true,
+ },
+ {
+ name: 'Feels Like',
+ value: `${weatherData.current.feelslike}°${unit}`,
+ inline: true,
+ },
+ { name: 'Sky', value: weatherData.current.skytext, inline: true },
+ { name: 'Humidity', value: weatherData.current.humidity, inline: true },
+ {
+ name: 'Wind Speed',
+ value: weatherData.current.windspeed,
+ inline: true,
+ },
+ );
+
+ // Add forecast information
+ const forecast = weatherData.forecast
+ .slice(0, 3)
+ .map(
+ (day) =>
+ `${day.shortday}: ${day.skytextday} (${day.low}°${unit} - ${day.high}°${unit})`,
+ )
+ .join('\n');
+
+ embed.addFields({ name: '3-Day Forecast', value: forecast });
+
+ if (weatherData.location.alert) {
+ embed.addFields({
+ name: '⚠️ Weather Alert',
+ value: weatherData.location.alert,
+ });
+ }
+
+ embed.setTimestamp();
+
+ await ctx.message.reply({ embeds: [embed] });
+
+ return {
+ success: true,
+ message: 'Weather information sent in the channel successfully.',
+ };
+ } catch (error) {
+ return {
+ error: 'Could not find weather information for that location.',
+ };
+ }
+};
diff --git a/examples/with-ai/src/app/events/ready/log.ts b/examples/with-ai/src/app/events/ready/log.ts
new file mode 100644
index 00000000..4414a153
--- /dev/null
+++ b/examples/with-ai/src/app/events/ready/log.ts
@@ -0,0 +1,6 @@
+import type { Client } from 'discord.js';
+import { Logger } from 'commandkit/logger';
+
+export default function log(client: Client) {
+ Logger.info(`Logged in as ${client.user.username}!`);
+}
diff --git a/examples/with-ai/src/app/tasks/remind.ts b/examples/with-ai/src/app/tasks/remind.ts
new file mode 100644
index 00000000..5a13eedc
--- /dev/null
+++ b/examples/with-ai/src/app/tasks/remind.ts
@@ -0,0 +1,42 @@
+import { task } from '@commandkit/tasks';
+
+export interface RemindTaskData {
+ userId: string;
+ channelId: string;
+ message: string;
+}
+
+export default task({
+ name: 'remind',
+ async execute(ctx) {
+ const { userId, channelId, message } = ctx.data;
+ const client = ctx.client;
+ const channel = await client.channels.fetch(channelId);
+
+ if (!channel?.isSendable()) {
+ const user = await client.users.fetch(userId);
+ await user.send({
+ embeds: [
+ {
+ title: 'You asked me to remind you about:',
+ description: message,
+ color: 0x0099ff,
+ },
+ ],
+ });
+
+ return;
+ }
+
+ await channel.send({
+ content: `<@${userId}>`,
+ embeds: [
+ {
+ title: 'You asked me to remind you about:',
+ description: message,
+ color: 0x0099ff,
+ },
+ ],
+ });
+ },
+});
diff --git a/examples/with-ai/src/tools/generate-image.ts b/examples/with-ai/src/tools/generate-image.ts
new file mode 100644
index 00000000..cbb27586
--- /dev/null
+++ b/examples/with-ai/src/tools/generate-image.ts
@@ -0,0 +1,94 @@
+import { createTool } from '@commandkit/ai';
+import { z } from 'zod';
+import {
+ AttachmentBuilder,
+ GuildTextBasedChannel,
+ PermissionFlagsBits,
+} from 'discord.js';
+import { Logger } from 'commandkit';
+
+async function generateImage(prompt: string) {
+ const form = new FormData();
+ form.append('prompt', prompt.slice(0, 1000));
+
+ const response = await fetch('https://clipdrop-api.co/text-to-image/v1', {
+ method: 'POST',
+ headers: {
+ 'x-api-key': process.env.CLIPDROP_API_KEY!,
+ },
+ body: form,
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to generate image: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ const buffer = await response.arrayBuffer();
+
+ return Buffer.from(buffer);
+}
+
+export const generateImageTool = createTool({
+ name: 'generate-image',
+ description: 'Generate an image with the given prompt',
+ inputSchema: z.object({
+ prompt: z
+ .string()
+ .describe('The prompt to use in order to generate an image'),
+ }),
+ async execute(ctx, parameters) {
+ const { prompt } = parameters;
+ const { message } = ctx;
+
+ const channel = message.channel;
+
+ if (!channel.isSendable()) {
+ return {
+ error: 'Cannot send message in this channel',
+ };
+ }
+
+ if (message.inGuild()) {
+ const me = await message.guild.members.fetchMe({
+ cache: true,
+ force: false,
+ });
+ const canSendImage = (channel as GuildTextBasedChannel)
+ .permissionsFor(me)
+ .has(PermissionFlagsBits.AttachFiles);
+
+ if (!canSendImage) {
+ return {
+ error: 'No permission to send attachments in this channel',
+ };
+ }
+ }
+
+ try {
+ const result = await generateImage(prompt);
+
+ const file = new AttachmentBuilder(result, {
+ name: `attachment-${message.id}.png`,
+ description: message.content.slice(0, 30),
+ });
+
+ await channel.send({
+ content: `Here is the image I generated for you:`,
+ files: [file],
+ });
+
+ return {
+ success: true,
+ message: 'Image was generated and sent to the channel successfully',
+ };
+ } catch (e) {
+ Logger.error(e instanceof Error ? e.stack : e);
+
+ return {
+ error: 'Could not generate image due to an unknown error',
+ };
+ }
+ },
+});
diff --git a/examples/with-ai/src/weather-js.ts b/examples/with-ai/src/weather-js.ts
new file mode 100644
index 00000000..4d92f74c
--- /dev/null
+++ b/examples/with-ai/src/weather-js.ts
@@ -0,0 +1,37 @@
+declare module 'weather-js' {
+ interface Weather {
+ location: {
+ name: string;
+ country: string;
+ lat: string;
+ long: string;
+ timezone: string;
+ alert: string;
+ degreetype: 'C' | 'F';
+ imagerelativeurl: string;
+ };
+ current: {
+ temperature: string;
+ imageUrl: string;
+ skytext: string;
+ feelslike: string;
+ humidity: string;
+ windspeed: string;
+ };
+ forecast: Array<{
+ low: string;
+ high: string;
+ skycodeday: string;
+ skytextday: string;
+ date: string;
+ day: string;
+ shortday: string;
+ precip: string;
+ }>;
+ }
+
+ export function find(
+ options: { search: string; degreeType: 'C' | 'F' },
+ callback: (err: Error | null, result: Weather[]) => void,
+ ): void;
+}
diff --git a/examples/with-ai/tsconfig.json b/examples/with-ai/tsconfig.json
new file mode 100644
index 00000000..2c06b336
--- /dev/null
+++ b/examples/with-ai/tsconfig.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "lib": ["ESNext", "DOM"],
+ "target": "ESNext",
+ "moduleResolution": "Node",
+ "module": "Preserve",
+ "allowImportingTsExtensions": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "skipDefaultLibCheck": true,
+ "noUncheckedIndexedAccess": true,
+ "removeComments": true,
+ "allowJs": true,
+ "strict": true,
+ "alwaysStrict": true,
+ "noEmit": true,
+ "declaration": false,
+ "jsx": "react-jsx",
+ "jsxImportSource": "commandkit",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src", "commandkit.config.ts", "commandkit-env.d.ts"],
+ "exclude": ["dist", "node_modules", ".commandkit"]
+}
diff --git a/examples/with-leveling-system/.env.example b/examples/with-leveling-system/.env.example
new file mode 100644
index 00000000..7494c06b
--- /dev/null
+++ b/examples/with-leveling-system/.env.example
@@ -0,0 +1,18 @@
+# The discord bot token
+DISCORD_TOKEN="xxx"
+
+# Redis url
+REDIS_URL=redis://localhost:6379
+
+# This was inserted by `prisma init`:
+# Environment variables declared in this file are automatically made available to Prisma.
+# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
+
+# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
+# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
+
+DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
+
+# Umami
+UMAMI_WEBSITE_ID=123456-abcdef-12344-325556
+UMAMI_HOST_URL=https://us.umami.is
\ No newline at end of file
diff --git a/examples/with-leveling-system/.gitignore b/examples/with-leveling-system/.gitignore
new file mode 100644
index 00000000..881fb089
--- /dev/null
+++ b/examples/with-leveling-system/.gitignore
@@ -0,0 +1,36 @@
+# dependencies
+node_modules
+
+# build output
+build
+out
+dist
+
+# commandkit
+.commandkit
+compiled-commandkit.config.mjs
+
+# env
+**/*.env*
+!**/*.env.example*
+
+# logging
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# yarn v2+
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+# other
+**/*.DS_Store
+
+src/database/prisma/*
\ No newline at end of file
diff --git a/examples/with-leveling-system/README.md b/examples/with-leveling-system/README.md
new file mode 100644
index 00000000..d4e1bb24
--- /dev/null
+++ b/examples/with-leveling-system/README.md
@@ -0,0 +1,67 @@
+# Leveling Bot
+
+Leveling Bot is a Discord bot for managing user levels and experience points (XP) in a Discord server. This bot is built with [discord.js](https://discord.js.org), using [CommandKit](https://commandkit.dev) framework.
+
+## Features
+
+- User leveling system based on messages
+- Rank card & Leaderboard card powered by [canvacord](https://canvacord.neplex.dev)
+- Redis powered caching with on-demand cache invalidation
+- Rate limiting to prevent spam
+- Hybrid commands (use interactions or message commands)
+- Customizable prefix for message commands
+- Multi-language support
+- Components v2
+- Analytics with [Umami](https://umami.is)
+
+## Tech Stack
+
+- Node.js
+- Discord.js
+- CommandKit
+- Redis
+- Prisma
+- TypeScript
+
+## Getting Started
+
+Ensure you have Node.js and npm installed. Clone the repository and install the dependencies:
+
+```bash
+git clone https://github.com/underctrl-io/leveling-bot
+cd leveling-bot
+npm install
+```
+
+### Environment Variables
+
+Create a `.env` file in the root directory and add the following variables:
+
+```env
+# The discord bot token
+DISCORD_TOKEN="xxx"
+
+# Redis url
+REDIS_URL="redis://localhost:6379"
+
+# Database url
+DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
+```
+
+### Running the Bot
+
+```bash
+npm run dev
+```
+
+### Building the Bot
+
+```bash
+npm run build
+```
+
+### Running in Production
+
+```bash
+npm run start
+```
diff --git a/examples/with-leveling-system/assets/deer.jpg b/examples/with-leveling-system/assets/deer.jpg
new file mode 100644
index 00000000..4c6b5fab
Binary files /dev/null and b/examples/with-leveling-system/assets/deer.jpg differ
diff --git a/examples/with-leveling-system/assets/level-up.png b/examples/with-leveling-system/assets/level-up.png
new file mode 100644
index 00000000..d5039fbe
Binary files /dev/null and b/examples/with-leveling-system/assets/level-up.png differ
diff --git a/examples/with-leveling-system/commandkit-env.d.ts b/examples/with-leveling-system/commandkit-env.d.ts
new file mode 100644
index 00000000..cc2ba6df
--- /dev/null
+++ b/examples/with-leveling-system/commandkit-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/with-leveling-system/commandkit.config.ts b/examples/with-leveling-system/commandkit.config.ts
new file mode 100644
index 00000000..a4fe88c8
--- /dev/null
+++ b/examples/with-leveling-system/commandkit.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from 'commandkit/config';
+import { cache } from '@commandkit/cache';
+import { i18n } from '@commandkit/i18n';
+import { umami } from '@commandkit/analytics/umami';
+
+export default defineConfig({
+ plugins: [
+ cache(),
+ i18n(),
+ umami({
+ umamiOptions: {
+ websiteId: process.env.UMAMI_WEBSITE_ID,
+ hostUrl: process.env.UMAMI_HOST_URL,
+ },
+ }),
+ ],
+});
diff --git a/examples/with-leveling-system/package.json b/examples/with-leveling-system/package.json
new file mode 100644
index 00000000..29db77d0
--- /dev/null
+++ b/examples/with-leveling-system/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "leveling-bot",
+ "description": "A leveling bot for Discord using CommandKit.",
+ "version": "0.1.0",
+ "main": "./dist/index.js",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "dev": "commandkit dev",
+ "build": "commandkit build",
+ "start": "commandkit start",
+ "prepare": "prisma generate"
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ "@types/node": "^22.15.18"
+ },
+ "peerDependencies": {
+ "typescript": "^5.8.3"
+ },
+ "dependencies": {
+ "@commandkit/analytics": "^1.2.0-rc.12",
+ "@commandkit/cache": "^1.2.0-rc.12",
+ "@commandkit/i18n": "^1.2.0-rc.12",
+ "@commandkit/redis": "^1.2.0-rc.12",
+ "@prisma/client": "^6.8.2",
+ "@umami/node": "^0.4.0",
+ "canvacord": "^6.0.2",
+ "commandkit": "^1.2.0-rc.12",
+ "discord.js": "^14.23.2",
+ "murmurhash": "^2.0.1",
+ "prisma": "^6.8.2",
+ "sharp": "^0.34.2"
+ }
+}
diff --git a/examples/with-leveling-system/prisma/migrations/20250513155538_init/migration.sql b/examples/with-leveling-system/prisma/migrations/20250513155538_init/migration.sql
new file mode 100644
index 00000000..72a32624
--- /dev/null
+++ b/examples/with-leveling-system/prisma/migrations/20250513155538_init/migration.sql
@@ -0,0 +1,36 @@
+-- CreateTable
+CREATE TABLE "Guild" (
+ "id" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "messagePrefix" TEXT NOT NULL DEFAULT '!',
+
+ CONSTRAINT "Guild_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Level" (
+ "id" TEXT NOT NULL,
+ "guildId" TEXT NOT NULL,
+ "level" INTEGER NOT NULL DEFAULT 0,
+ "xp" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Level_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "Level_guildId_idx" ON "Level"("guildId");
+
+-- CreateIndex
+CREATE INDEX "Level_level_idx" ON "Level"("level");
+
+-- CreateIndex
+CREATE INDEX "Level_xp_idx" ON "Level"("xp");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Level_id_guildId_key" ON "Level"("id", "guildId");
+
+-- AddForeignKey
+ALTER TABLE "Level" ADD CONSTRAINT "Level_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/examples/with-leveling-system/prisma/migrations/migration_lock.toml b/examples/with-leveling-system/prisma/migrations/migration_lock.toml
new file mode 100644
index 00000000..044d57cd
--- /dev/null
+++ b/examples/with-leveling-system/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (e.g., Git)
+provider = "postgresql"
diff --git a/examples/with-leveling-system/prisma/schema.prisma b/examples/with-leveling-system/prisma/schema.prisma
new file mode 100644
index 00000000..7186b29d
--- /dev/null
+++ b/examples/with-leveling-system/prisma/schema.prisma
@@ -0,0 +1,39 @@
+// This is your Prisma schema file,
+// learn more about it in the docs: https://pris.ly/d/prisma-schema
+
+// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
+// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
+
+generator client {
+ provider = "prisma-client"
+ output = "../src/database/prisma"
+ moduleFormat = "esm"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+model Guild {
+ id String @id // discord guild id
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ messagePrefix String @default("!") // prefix for message commands
+ level Level[]
+}
+
+model Level {
+ id String @id // discord user id
+ guildId String // discord guild id
+ guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
+ level Int @default(0)
+ xp Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([id, guildId])
+ @@index([guildId])
+ @@index([level])
+ @@index([xp])
+}
diff --git a/examples/with-leveling-system/src/app.ts b/examples/with-leveling-system/src/app.ts
new file mode 100644
index 00000000..97655908
--- /dev/null
+++ b/examples/with-leveling-system/src/app.ts
@@ -0,0 +1,28 @@
+import { Client, IntentsBitField } from 'discord.js';
+import { commandkit } from 'commandkit';
+import { setCacheProvider } from '@commandkit/cache';
+import { RedisCache } from '@commandkit/redis';
+import { Font } from 'canvacord';
+import { fetchGuildPrefix } from './utils/prefix-resolver';
+import { redis } from './redis/redis';
+
+// load the default font for canvacord
+Font.loadDefault();
+
+// set the prefix resolver for message commands
+commandkit.setPrefixResolver((message) =>
+ message.inGuild() ? fetchGuildPrefix(message.guildId) : '!',
+);
+
+setCacheProvider(new RedisCache(redis));
+
+const client = new Client({
+ intents: [
+ IntentsBitField.Flags.Guilds,
+ IntentsBitField.Flags.GuildMembers,
+ IntentsBitField.Flags.GuildMessages,
+ IntentsBitField.Flags.MessageContent,
+ ],
+});
+
+export default client;
diff --git a/examples/with-leveling-system/src/app/commands/(configuration)/+middleware.ts b/examples/with-leveling-system/src/app/commands/(configuration)/+middleware.ts
new file mode 100644
index 00000000..9c742818
--- /dev/null
+++ b/examples/with-leveling-system/src/app/commands/(configuration)/+middleware.ts
@@ -0,0 +1,52 @@
+import type { MiddlewareContext } from 'commandkit';
+import { GuildMember, PermissionFlagsBits } from 'discord.js';
+
+export async function beforeExecute(ctx: MiddlewareContext) {
+ const guild = ctx.interaction?.guild ?? ctx.message?.guild;
+
+ if (guild) {
+ const member = (ctx.isMessage()
+ ? ctx.message.member
+ : ctx.interaction.member)! as unknown as GuildMember;
+
+ if (!member.permissions.has(PermissionFlagsBits.Administrator)) {
+ const message = 'You do not have permission to use this command.';
+
+ if (ctx.isMessage()) {
+ await ctx.message.reply({ content: message });
+ } else if (
+ ctx.isChatInputCommand() ||
+ ctx.isUserContextMenu() ||
+ ctx.isMessageContextMenu()
+ ) {
+ await ctx.interaction.reply({
+ content: message,
+ ephemeral: true,
+ });
+ }
+
+ return ctx.cancel();
+ }
+
+ return;
+ }
+
+ if (
+ ctx.isChatInputCommand() ||
+ ctx.isUserContextMenu() ||
+ ctx.isMessageContextMenu()
+ ) {
+ await ctx.interaction.reply({
+ content: 'This command can only be used in a server.',
+ ephemeral: true,
+ });
+
+ return ctx.cancel();
+ }
+
+ await ctx.message.reply({
+ content: 'This command can only be used in a server.',
+ });
+
+ return ctx.cancel();
+}
diff --git a/examples/with-leveling-system/src/app/commands/(configuration)/set-prefix.ts b/examples/with-leveling-system/src/app/commands/(configuration)/set-prefix.ts
new file mode 100644
index 00000000..0efde5d3
--- /dev/null
+++ b/examples/with-leveling-system/src/app/commands/(configuration)/set-prefix.ts
@@ -0,0 +1,48 @@
+import type { CommandData, ChatInputCommand, MessageCommand } from 'commandkit';
+import { ApplicationCommandOptionType } from 'discord.js';
+import { prisma } from '@/database/db';
+
+export const command: CommandData = {
+ name: 'set-prefix',
+ description: 'set-prefix command',
+ options: [
+ {
+ name: 'prefix',
+ description: 'prefix to set',
+ type: ApplicationCommandOptionType.String,
+ required: true,
+ },
+ ],
+};
+
+async function updatePrefix(guildId: string, prefix: string) {
+ const result = await prisma.guild.upsert({
+ where: { id: guildId },
+ update: { messagePrefix: prefix },
+ create: { id: guildId, messagePrefix: prefix },
+ });
+
+ return result;
+}
+
+export const chatInput: ChatInputCommand = async (ctx) => {
+ const { t } = ctx.locale();
+ const prefix = ctx.options.getString('prefix', true);
+
+ const result = await updatePrefix(ctx.interaction.guildId!, prefix);
+
+ await ctx.interaction.reply({
+ content: t('prefix_set', { prefix: result.messagePrefix }),
+ });
+};
+
+export const message: MessageCommand = async (ctx) => {
+ const { t } = ctx.locale();
+ const prefix = ctx.options.getString('prefix', true);
+
+ const result = await updatePrefix(ctx.message.guildId!, prefix);
+
+ await ctx.message.reply({
+ content: t('prefix_set', { prefix: result.messagePrefix }),
+ });
+};
diff --git a/examples/with-leveling-system/src/app/commands/(leveling)/+middleware.ts b/examples/with-leveling-system/src/app/commands/(leveling)/+middleware.ts
new file mode 100644
index 00000000..681568d3
--- /dev/null
+++ b/examples/with-leveling-system/src/app/commands/(leveling)/+middleware.ts
@@ -0,0 +1,26 @@
+import type { MiddlewareContext } from 'commandkit';
+
+export async function beforeExecute(ctx: MiddlewareContext) {
+ const guild = ctx.interaction?.guild ?? ctx.message?.guild;
+
+ if (guild) return;
+
+ if (
+ ctx.isChatInputCommand() ||
+ ctx.isUserContextMenu() ||
+ ctx.isMessageContextMenu()
+ ) {
+ await ctx.interaction.reply({
+ content: 'This command can only be used in a server.',
+ ephemeral: true,
+ });
+
+ return ctx.cancel();
+ }
+
+ await ctx.message.reply({
+ content: 'This command can only be used in a server.',
+ });
+
+ return ctx.cancel();
+}
diff --git a/examples/with-leveling-system/src/app/commands/(leveling)/_leaderboard.utils.ts b/examples/with-leveling-system/src/app/commands/(leveling)/_leaderboard.utils.ts
new file mode 100644
index 00000000..d9b301df
--- /dev/null
+++ b/examples/with-leveling-system/src/app/commands/(leveling)/_leaderboard.utils.ts
@@ -0,0 +1,108 @@
+import {
+ ImageSource,
+ LeaderboardBuilder,
+ LeaderboardVariants,
+} from 'canvacord';
+import { AttachmentBuilder, Guild } from 'discord.js';
+import { LevelingModule } from '@/modules/leveling-module';
+import { useClient } from 'commandkit/hooks';
+import { cacheTag } from '@commandkit/cache';
+
+async function fetchLeaderboard(guildId: string) {
+ 'use cache';
+
+ cacheTag(`leaderboard:${guildId}`);
+
+ const client = useClient();
+
+ const leaderboard = await LevelingModule.computeLeaderboard(guildId);
+ const total = await LevelingModule.countEntries(guildId);
+
+ const players: {
+ displayName: string;
+ username: string;
+ level: number;
+ xp: number;
+ rank: number;
+ avatar: ImageSource;
+ }[] = [];
+
+ let rank = 1;
+
+ for (const entry of leaderboard) {
+ const user = await client.users.fetch(entry.id).catch(() => null);
+
+ if (!user) {
+ players.push({
+ displayName: `Unknown User ${rank}`,
+ username: 'unknown-user',
+ level: entry.level,
+ xp: entry.xp,
+ rank: rank++,
+ avatar: 'https://cdn.discordapp.com/embed/avatars/0.png',
+ });
+
+ continue;
+ }
+
+ players.push({
+ displayName: user.username,
+ username: user.username,
+ level: entry.level,
+ xp: entry.xp,
+ rank: rank++,
+ avatar: user.displayAvatarURL({
+ forceStatic: true,
+ extension: 'png',
+ size: 512,
+ }),
+ });
+ }
+
+ return { players, total };
+}
+
+async function createLeaderboardCard(data: {
+ leaderboard: Awaited>;
+ guildName: string;
+ guildIcon: string | null;
+}) {
+ const card = new LeaderboardBuilder()
+ .setVariant(LeaderboardVariants.Horizontal)
+ .setHeader({
+ image: data.guildIcon ?? 'https://cdn.discordapp.com/embed/avatars/0.png',
+ subtitle: `Total ${data.leaderboard.total} players`,
+ title: data.guildName,
+ })
+ .setBackground('./assets/deer.jpg')
+ .setPlayers(data.leaderboard.players);
+
+ const image = await card.build({
+ format: 'webp',
+ });
+
+ const attachment = new AttachmentBuilder(image, {
+ name: `leaderboard-${data.guildName.replace(/ /g, '-')}.webp`,
+ description: `Leaderboard for ${data.guildName}`,
+ });
+
+ return attachment;
+}
+
+export async function getLeaderboardCard(guild: Guild) {
+ const leaderboard = await fetchLeaderboard(guild.id);
+
+ if (!leaderboard.players.length) return null;
+
+ const card = await createLeaderboardCard({
+ leaderboard,
+ guildName: guild!.name,
+ guildIcon: guild!.iconURL({
+ forceStatic: true,
+ extension: 'png',
+ size: 512,
+ }),
+ });
+
+ return card;
+}
diff --git a/examples/with-leveling-system/src/app/commands/(leveling)/_rank.utils.ts b/examples/with-leveling-system/src/app/commands/(leveling)/_rank.utils.ts
new file mode 100644
index 00000000..3cf129fc
--- /dev/null
+++ b/examples/with-leveling-system/src/app/commands/(leveling)/_rank.utils.ts
@@ -0,0 +1,73 @@
+import { cacheLife, cacheTag } from '@commandkit/cache';
+import { LevelingModule } from '@/modules/leveling-module';
+import { BuiltInGraphemeProvider, RankCardBuilder } from 'canvacord';
+import { AttachmentBuilder, User } from 'discord.js';
+
+async function fetchLevel(guildId: string, userId: string) {
+ 'use cache';
+
+ cacheTag(`xp:${guildId}:${userId}`);
+ cacheLife('1h');
+
+ const level = await LevelingModule.getLevel(guildId, userId);
+
+ if (!level) return null;
+
+ const rank = (await LevelingModule.getRank(guildId, userId)) ?? 0;
+
+ return { level, rank };
+}
+
+async function createRankCard(
+ levelingData: {
+ level: { xp: number; level: number };
+ rank: number;
+ },
+ target: User,
+) {
+ const { level, rank } = levelingData;
+
+ const card = new RankCardBuilder()
+ .setAvatar(
+ target.displayAvatarURL({
+ forceStatic: true,
+ extension: 'png',
+ size: 512,
+ }),
+ )
+ .setCurrentXP(level.xp)
+ .setRequiredXP(LevelingModule.calculateLevelXP(level.level))
+ .setLevel(level.level)
+ .setRank(rank)
+ .setUsername(target.username)
+ .setDisplayName(target.globalName ?? target.username)
+ .setStatus('none')
+ .setOverlay(90)
+ .setBackground('#23272a')
+ .setGraphemeProvider(BuiltInGraphemeProvider.Twemoji);
+
+ const image = await card.build({
+ format: 'webp',
+ });
+
+ const attachment = new AttachmentBuilder(image, {
+ name: `rank-${target.id}.webp`,
+ description: `Rank card for ${target.username}`,
+ });
+
+ return attachment;
+}
+
+export async function getRankCard(guildId: string, user: User) {
+ const userId = user.id;
+
+ const levelingData = await fetchLevel(guildId, userId);
+
+ if (!levelingData) {
+ return null;
+ }
+
+ const attachment = await createRankCard(levelingData, user);
+
+ return attachment;
+}
diff --git a/examples/with-leveling-system/src/app/commands/(leveling)/leaderboard.tsx b/examples/with-leveling-system/src/app/commands/(leveling)/leaderboard.tsx
new file mode 100644
index 00000000..b8c85b3e
--- /dev/null
+++ b/examples/with-leveling-system/src/app/commands/(leveling)/leaderboard.tsx
@@ -0,0 +1,73 @@
+import {
+ type CommandData,
+ type ChatInputCommand,
+ type MessageCommand,
+ Container,
+ MediaGallery,
+ MediaGalleryItem,
+ TextDisplay,
+ Separator,
+} from 'commandkit';
+import { AttachmentBuilder, Colors, Guild, MessageFlags } from 'discord.js';
+import { getLeaderboardCard } from './_leaderboard.utils';
+import { locale } from '@commandkit/i18n';
+
+export const command: CommandData = {
+ name: 'leaderboard',
+ description: 'leaderboard command',
+};
+
+function Component({ attachment }: { attachment: AttachmentBuilder }) {
+ const { t } = locale();
+ const url = `attachment://${attachment.name}`;
+
+ return (
+
+ # {t('title')}
+
+
+
+
+
+ );
+}
+
+export const chatInput: ChatInputCommand = async (ctx) => {
+ const { t } = ctx.locale();
+ await ctx.interaction.deferReply();
+
+ const leaderboard = await getLeaderboardCard(ctx.guild!);
+
+ if (!leaderboard) {
+ await ctx.interaction.editReply({
+ content: t('no_players'),
+ });
+
+ return;
+ }
+
+ await ctx.interaction.editReply({
+ components: [],
+ files: [leaderboard],
+ flags: MessageFlags.IsComponentsV2,
+ });
+};
+
+export const message: MessageCommand = async (ctx) => {
+ const { t } = ctx.locale();
+ const leaderboard = await getLeaderboardCard(ctx.guild!);
+
+ if (!leaderboard) {
+ await ctx.message.reply({
+ content: t('no_players'),
+ });
+
+ return;
+ }
+
+ await ctx.message.reply({
+ files: [leaderboard],
+ components: [],
+ flags: MessageFlags.IsComponentsV2,
+ });
+};
diff --git a/examples/with-leveling-system/src/app/commands/(leveling)/rank.tsx b/examples/with-leveling-system/src/app/commands/(leveling)/rank.tsx
new file mode 100644
index 00000000..830b7502
--- /dev/null
+++ b/examples/with-leveling-system/src/app/commands/(leveling)/rank.tsx
@@ -0,0 +1,146 @@
+import {
+ type CommandData,
+ type ChatInputCommand,
+ type MessageCommand,
+ type UserContextMenuCommand,
+ TextDisplay,
+ Container,
+ Separator,
+ MediaGallery,
+ MediaGalleryItem,
+} from 'commandkit';
+import {
+ ApplicationCommandOptionType,
+ AttachmentBuilder,
+ ChatInputCommandInteraction,
+ Colors,
+ MessageFlags,
+ UserContextMenuCommandInteraction,
+ type User,
+} from 'discord.js';
+import { getRankCard } from './_rank.utils';
+import { locale } from '@commandkit/i18n';
+
+export const command: CommandData = {
+ name: 'rank',
+ description: 'rank command',
+ options: [
+ {
+ name: 'user',
+ description: 'user to get rank for',
+ type: ApplicationCommandOptionType.User,
+ required: false,
+ },
+ ],
+};
+
+function Component({
+ attachment,
+ target,
+}: {
+ attachment: AttachmentBuilder;
+ target: User;
+}) {
+ const { t } = locale();
+ const url = `attachment://${attachment.name}`;
+
+ return (
+
+
+ # {t('rank_title', { username: target.username })}
+
+
+
+
+
+
+ );
+}
+
+async function commonInteraction(
+ interaction: ChatInputCommandInteraction | UserContextMenuCommandInteraction,
+ t: (key: string, params?: Record) => string,
+) {
+ const guildId = interaction.guildId!;
+ const target = interaction.isUserContextMenuCommand()
+ ? interaction.targetUser
+ : (interaction.options.getUser('user', false) ?? interaction.user);
+
+ if (target.bot) {
+ await interaction.reply({
+ content: t('bot_not_allowed'),
+ ephemeral: true,
+ });
+ return;
+ }
+
+ await interaction.deferReply();
+
+ const attachment = await getRankCard(guildId, target);
+
+ if (!attachment) {
+ await interaction.editReply({
+ content: t('not_ranked', { username: target.username }),
+ });
+
+ return;
+ }
+
+ await interaction.editReply({
+ files: [attachment],
+ components: [],
+ flags: MessageFlags.IsComponentsV2,
+ });
+}
+
+export const userContextMenu: UserContextMenuCommand = async (ctx) => {
+ const { t } = ctx.locale();
+ await commonInteraction(ctx.interaction, t);
+};
+
+export const chatInput: ChatInputCommand = async (ctx) => {
+ const { t } = ctx.locale();
+ await commonInteraction(ctx.interaction, t);
+};
+
+export const message: MessageCommand = async (ctx) => {
+ const { t } = ctx.locale();
+ const guildId = ctx.message.guildId!;
+ const target =
+ ctx.message.mentions.users
+ .filter((u) => u.id !== ctx.client.user!.id)
+ .first() ?? ctx.message.author;
+
+ if (!target) {
+ await ctx.message.reply({
+ content:
+ ctx.message.mentions.users.size > 0
+ ? t('bot_no_rank')
+ : t('no_mention'),
+ });
+ return;
+ }
+
+ if (target.bot) {
+ await ctx.message.reply({
+ content: t('bot_not_allowed'),
+ });
+ return;
+ }
+
+ const attachment = await getRankCard(guildId, target);
+
+ if (!attachment) {
+ await ctx.message.reply({
+ content: t('not_ranked', { username: target.username }),
+ });
+
+ return;
+ }
+
+ await ctx.message.reply({
+ files: [attachment],
+ components: [],
+ flags: MessageFlags.IsComponentsV2,
+ });
+};
diff --git a/examples/with-leveling-system/src/app/commands/ping.ts b/examples/with-leveling-system/src/app/commands/ping.ts
new file mode 100644
index 00000000..d5938346
--- /dev/null
+++ b/examples/with-leveling-system/src/app/commands/ping.ts
@@ -0,0 +1,22 @@
+import type { ChatInputCommand, MessageCommand, CommandData } from 'commandkit';
+
+export const command: CommandData = {
+ name: 'ping',
+ description: "Ping the bot to check if it's online.",
+};
+
+export const chatInput: ChatInputCommand = async (ctx) => {
+ const { t } = ctx.locale();
+ const latency = (ctx.client.ws.ping ?? -1).toString();
+ const response = t('pong', { latency });
+
+ await ctx.interaction.reply(response);
+};
+
+export const message: MessageCommand = async (ctx) => {
+ const { t } = ctx.locale();
+ const latency = (ctx.client.ws.ping ?? -1).toString();
+ const response = t('pong', { latency });
+
+ await ctx.message.reply(response);
+};
diff --git a/examples/with-leveling-system/src/app/events/(leveling)/levelUp/notify.ts b/examples/with-leveling-system/src/app/events/(leveling)/levelUp/notify.ts
new file mode 100644
index 00000000..98d7fe58
--- /dev/null
+++ b/examples/with-leveling-system/src/app/events/(leveling)/levelUp/notify.ts
@@ -0,0 +1,18 @@
+import { determineLevelUpMessageType } from '@/feature-flags/level-up-message';
+import { Message } from 'discord.js';
+import { plainResponse } from './responses/_text.response';
+import { imageResponse } from './responses/_image.response';
+
+// this is a custom event that is triggered when a user levels up
+export default async function onLevelUp(
+ message: Message,
+ newLevel: number,
+) {
+ const levelUpMessageType = await determineLevelUpMessageType();
+
+ if (levelUpMessageType === 'plain') {
+ return plainResponse(message, newLevel);
+ }
+
+ return imageResponse(message, newLevel);
+}
diff --git a/examples/with-leveling-system/src/app/events/(leveling)/levelUp/responses/_image.response.tsx b/examples/with-leveling-system/src/app/events/(leveling)/levelUp/responses/_image.response.tsx
new file mode 100644
index 00000000..9ef96dbf
--- /dev/null
+++ b/examples/with-leveling-system/src/app/events/(leveling)/levelUp/responses/_image.response.tsx
@@ -0,0 +1,43 @@
+import { locale } from '@commandkit/i18n';
+import {
+ Container,
+ MediaGallery,
+ MediaGalleryItem,
+ TextDisplay,
+} from 'commandkit';
+import { AttachmentBuilder, Colors, Message, MessageFlags } from 'discord.js';
+import { randomInt } from 'node:crypto';
+
+export async function imageResponse(message: Message, newLevel: number) {
+ const { t } = locale(message.guild!.preferredLocale);
+ const colors = Object.values(Colors);
+ const randomColor = colors[randomInt(colors.length)];
+ const levelUpImage = new AttachmentBuilder('./assets/level-up.png')
+ .setName('level-up.png')
+ .setDescription('Level up image');
+
+ const container = (
+
+
+ {t('level_up', {
+ user: message.author.toString(),
+ level: newLevel.toLocaleString(),
+ })}
+
+
+
+
+
+ );
+
+ await message
+ .reply({
+ components: [container],
+ files: [levelUpImage],
+ flags: MessageFlags.IsComponentsV2,
+ })
+ .catch(console.error);
+}
diff --git a/examples/with-leveling-system/src/app/events/(leveling)/levelUp/responses/_text.response.tsx b/examples/with-leveling-system/src/app/events/(leveling)/levelUp/responses/_text.response.tsx
new file mode 100644
index 00000000..7ca9587d
--- /dev/null
+++ b/examples/with-leveling-system/src/app/events/(leveling)/levelUp/responses/_text.response.tsx
@@ -0,0 +1,28 @@
+import { locale } from '@commandkit/i18n';
+import { Container, TextDisplay } from 'commandkit';
+import { Colors, Message, MessageFlags } from 'discord.js';
+import { randomInt } from 'node:crypto';
+
+export async function plainResponse(message: Message, newLevel: number) {
+ const { t } = locale(message.guild!.preferredLocale);
+ const colors = Object.values(Colors);
+ const randomColor = colors[randomInt(colors.length)];
+
+ const container = (
+
+
+ {t('level_up', {
+ user: message.author.toString(),
+ level: newLevel.toLocaleString(),
+ })}
+
+
+ );
+
+ await message
+ .reply({
+ components: [container],
+ flags: MessageFlags.IsComponentsV2,
+ })
+ .catch(console.error);
+}
diff --git a/examples/with-leveling-system/src/app/events/messageCreate/give-xp.ts b/examples/with-leveling-system/src/app/events/messageCreate/give-xp.ts
new file mode 100644
index 00000000..411aca43
--- /dev/null
+++ b/examples/with-leveling-system/src/app/events/messageCreate/give-xp.ts
@@ -0,0 +1,72 @@
+import type { Message } from 'discord.js';
+import { randomInt } from 'node:crypto';
+import { LevelingModule } from '@/modules/leveling-module';
+import { revalidateTag } from '@commandkit/cache';
+import { getCommandKit } from 'commandkit';
+import { fetchGuildPrefix } from '@/utils/prefix-resolver';
+import { isRateLimited } from '@/utils/throttle';
+
+export default async function onMessageCreate(message: Message) {
+ // ignore DMs
+ if (!message.inGuild()) return;
+ // ignore bot messages
+ if (message.author.bot) return;
+
+ const prefix = await fetchGuildPrefix(message.guildId);
+
+ // ignore messages that don't start with the prefix
+ if (message.content.startsWith(prefix)) return;
+
+ const rateLimited = await isRateLimited(
+ `xp_ratelimit:${message.guildId}:${message.author.id}`,
+ 60_000,
+ );
+
+ if (rateLimited) return;
+
+ const commandkit = getCommandKit(true);
+
+ const isBooster = message.member?.premiumSinceTimestamp != null;
+
+ const currentLevel = await LevelingModule.getLevel(
+ message.guildId,
+ message.author.id,
+ );
+
+ // random xp between 1 and 30
+ // boosters get random 0-10 extra xp
+ const randomXP = randomInt(1, 20) + (isBooster ? randomInt(0, 10) : 0);
+ const nextXP = (currentLevel?.xp ?? 0) + randomXP;
+
+ // level up if the user has enough xp
+ if (currentLevel) {
+ const currentLevelXP = LevelingModule.calculateLevelXP(currentLevel.level);
+
+ if (nextXP > currentLevelXP) {
+ await LevelingModule.incrementLevel(message.guildId, message.author.id);
+
+ // emit a custom event to notify the user
+ commandkit.events
+ .to('leveling')
+ .emit('levelUp', message, currentLevel.level + 1);
+
+ // revalidate the cache for the user and leaderboard
+ await revalidateTag(`xp:${message.guildId}:${message.author.id}`);
+ await revalidateTag(`leaderboard:${message.guildId}`);
+
+ return;
+ }
+ }
+
+ // assign xp to the user
+ await LevelingModule.assignXP({
+ guildId: message.guildId,
+ userId: message.author.id,
+ xp: nextXP,
+ level: currentLevel?.level ?? 1,
+ });
+
+ // revalidate the cache for the user and leaderboard
+ await revalidateTag(`xp:${message.guildId}:${message.author.id}`);
+ await revalidateTag(`leaderboard:${message.guildId}`);
+}
diff --git a/examples/with-leveling-system/src/app/events/ready/log.ts b/examples/with-leveling-system/src/app/events/ready/log.ts
new file mode 100644
index 00000000..c1065f0e
--- /dev/null
+++ b/examples/with-leveling-system/src/app/events/ready/log.ts
@@ -0,0 +1,5 @@
+import type { Client } from 'discord.js';
+
+export default function log(client: Client) {
+ console.log(`Logged in as ${client.user.username}!`);
+}
diff --git a/examples/with-leveling-system/src/app/locales/de/leaderboard.json b/examples/with-leveling-system/src/app/locales/de/leaderboard.json
new file mode 100644
index 00000000..bdb69666
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/de/leaderboard.json
@@ -0,0 +1,8 @@
+{
+ "$command": {
+ "name": "leaderboard",
+ "description": "Zeigt die Bestenliste des Servers"
+ },
+ "no_players": "Keine Spieler in der Bestenliste gefunden.",
+ "title": "Bestenliste 🏆"
+}
diff --git a/examples/with-leveling-system/src/app/locales/de/levelUp.event.json b/examples/with-leveling-system/src/app/locales/de/levelUp.event.json
new file mode 100644
index 00000000..774318a0
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/de/levelUp.event.json
@@ -0,0 +1,3 @@
+{
+ "level_up": "🎉 {{user}} hat Level **{{level}}** erreicht!"
+}
diff --git a/examples/with-leveling-system/src/app/locales/de/ping.json b/examples/with-leveling-system/src/app/locales/de/ping.json
new file mode 100644
index 00000000..120dc50a
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/de/ping.json
@@ -0,0 +1,7 @@
+{
+ "$command": {
+ "name": "ping",
+ "description": "Pingt den Bot, um zu prüfen, ob er online ist."
+ },
+ "pong": "Pong! Latenz: {{latency}}ms"
+}
diff --git a/examples/with-leveling-system/src/app/locales/de/rank.json b/examples/with-leveling-system/src/app/locales/de/rank.json
new file mode 100644
index 00000000..e47b8e8d
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/de/rank.json
@@ -0,0 +1,11 @@
+{
+ "$command": {
+ "name": "rank",
+ "description": "Zeigt deine Rangkarte oder die eines anderen Benutzers"
+ },
+ "bot_not_allowed": "Du kannst den Rang eines Bots nicht überprüfen.",
+ "not_ranked": "{{username}} hat noch keinen Rang. Sag ihnen, sie sollen eine Nachricht im Server senden, um einen Rang zu erhalten!",
+ "no_mention": "Du musst einen Benutzer erwähnen, um seinen Rang zu überprüfen.",
+ "bot_no_rank": "Ich habe keinen Rang, Dummerchen!",
+ "rank_title": "Rang von {{username}} 🏆"
+}
diff --git a/examples/with-leveling-system/src/app/locales/de/set-prefix.json b/examples/with-leveling-system/src/app/locales/de/set-prefix.json
new file mode 100644
index 00000000..06e74661
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/de/set-prefix.json
@@ -0,0 +1,7 @@
+{
+ "$command": {
+ "name": "set-prefix",
+ "description": "Legt das Befehlspräfix des Servers fest"
+ },
+ "prefix_set": "Präfix auf `{{prefix}}` gesetzt"
+}
diff --git a/examples/with-leveling-system/src/app/locales/en-US/leaderboard.json b/examples/with-leveling-system/src/app/locales/en-US/leaderboard.json
new file mode 100644
index 00000000..7a6fad70
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/en-US/leaderboard.json
@@ -0,0 +1,8 @@
+{
+ "$command": {
+ "name": "leaderboard",
+ "description": "Shows the server's leaderboard"
+ },
+ "no_players": "No players found in the leaderboard.",
+ "title": "Leaderboard 🏆"
+}
diff --git a/examples/with-leveling-system/src/app/locales/en-US/levelUp.event.json b/examples/with-leveling-system/src/app/locales/en-US/levelUp.event.json
new file mode 100644
index 00000000..d75e9030
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/en-US/levelUp.event.json
@@ -0,0 +1,3 @@
+{
+ "level_up": "🎉 {{user}} has reached level **{{level}}**!"
+}
diff --git a/examples/with-leveling-system/src/app/locales/en-US/ping.json b/examples/with-leveling-system/src/app/locales/en-US/ping.json
new file mode 100644
index 00000000..44566502
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/en-US/ping.json
@@ -0,0 +1,7 @@
+{
+ "$command": {
+ "name": "ping",
+ "description": "Ping the bot to check if it's online."
+ },
+ "pong": "Pong! Latency: {{latency}}ms"
+}
diff --git a/examples/with-leveling-system/src/app/locales/en-US/rank.json b/examples/with-leveling-system/src/app/locales/en-US/rank.json
new file mode 100644
index 00000000..db6e8198
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/en-US/rank.json
@@ -0,0 +1,11 @@
+{
+ "$command": {
+ "name": "rank",
+ "description": "Shows your rank card or another user's rank card"
+ },
+ "bot_not_allowed": "You cannot check the rank of a bot.",
+ "not_ranked": "{{username}} is not ranked yet. Tell them to send a message in the server to get ranked!",
+ "no_mention": "You need to mention a user to check their rank.",
+ "bot_no_rank": "I don't have rank silly",
+ "rank_title": "Rank of {{username}} 🏆"
+}
diff --git a/examples/with-leveling-system/src/app/locales/en-US/set-prefix.json b/examples/with-leveling-system/src/app/locales/en-US/set-prefix.json
new file mode 100644
index 00000000..d8cba2a4
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/en-US/set-prefix.json
@@ -0,0 +1,7 @@
+{
+ "$command": {
+ "name": "set-prefix",
+ "description": "Sets the server's command prefix"
+ },
+ "prefix_set": "Prefix set to `{{prefix}}`"
+}
diff --git a/examples/with-leveling-system/src/app/locales/es-ES/leaderboard.json b/examples/with-leveling-system/src/app/locales/es-ES/leaderboard.json
new file mode 100644
index 00000000..e8ab9b1b
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/es-ES/leaderboard.json
@@ -0,0 +1,8 @@
+{
+ "$command": {
+ "name": "leaderboard",
+ "description": "Muestra la tabla de clasificación del servidor"
+ },
+ "no_players": "No se encontraron jugadores en la tabla de clasificación.",
+ "title": "Tabla de Clasificación 🏆"
+}
diff --git a/examples/with-leveling-system/src/app/locales/es-ES/levelUp.event.json b/examples/with-leveling-system/src/app/locales/es-ES/levelUp.event.json
new file mode 100644
index 00000000..7d477106
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/es-ES/levelUp.event.json
@@ -0,0 +1,3 @@
+{
+ "level_up": "🎉 {{user}} ha alcanzado el nivel **{{level}}**!"
+}
diff --git a/examples/with-leveling-system/src/app/locales/es-ES/ping.json b/examples/with-leveling-system/src/app/locales/es-ES/ping.json
new file mode 100644
index 00000000..93a67d15
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/es-ES/ping.json
@@ -0,0 +1,7 @@
+{
+ "$command": {
+ "name": "ping",
+ "description": "Haz ping al bot para comprobar si está en línea."
+ },
+ "pong": "Pong! Latencia: {{latency}}ms"
+}
diff --git a/examples/with-leveling-system/src/app/locales/es-ES/rank.json b/examples/with-leveling-system/src/app/locales/es-ES/rank.json
new file mode 100644
index 00000000..9224e4af
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/es-ES/rank.json
@@ -0,0 +1,11 @@
+{
+ "$command": {
+ "name": "rank",
+ "description": "Muestra tu tarjeta de rango o la de otro usuario"
+ },
+ "bot_not_allowed": "No puedes verificar el rango de un bot.",
+ "not_ranked": "{{username}} aún no tiene rango. ¡Dile que envíe un mensaje en el servidor para obtener rango!",
+ "no_mention": "Necesitas mencionar a un usuario para verificar su rango.",
+ "bot_no_rank": "No tengo rango, ¡tonto!",
+ "rank_title": "Rango de {{username}} 🏆"
+}
diff --git a/examples/with-leveling-system/src/app/locales/es-ES/set-prefix.json b/examples/with-leveling-system/src/app/locales/es-ES/set-prefix.json
new file mode 100644
index 00000000..54dc87ce
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/es-ES/set-prefix.json
@@ -0,0 +1,7 @@
+{
+ "$command": {
+ "name": "set-prefix",
+ "description": "Establece el prefijo de comandos del servidor"
+ },
+ "prefix_set": "Prefijo establecido en `{{prefix}}`"
+}
diff --git a/examples/with-leveling-system/src/app/locales/fr/leaderboard.json b/examples/with-leveling-system/src/app/locales/fr/leaderboard.json
new file mode 100644
index 00000000..1f2a8f36
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/fr/leaderboard.json
@@ -0,0 +1,8 @@
+{
+ "$command": {
+ "name": "leaderboard",
+ "description": "Affiche le classement du serveur"
+ },
+ "no_players": "Aucun joueur trouvé dans le classement.",
+ "title": "Classement 🏆"
+}
diff --git a/examples/with-leveling-system/src/app/locales/fr/levelUp.event.json b/examples/with-leveling-system/src/app/locales/fr/levelUp.event.json
new file mode 100644
index 00000000..f564912e
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/fr/levelUp.event.json
@@ -0,0 +1,3 @@
+{
+ "level_up": "🎉 {{user}} a atteint le niveau **{{level}}**!"
+}
diff --git a/examples/with-leveling-system/src/app/locales/fr/ping.json b/examples/with-leveling-system/src/app/locales/fr/ping.json
new file mode 100644
index 00000000..7f4c5495
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/fr/ping.json
@@ -0,0 +1,7 @@
+{
+ "$command": {
+ "name": "ping",
+ "description": "Ping le bot pour vérifier s'il est en ligne."
+ },
+ "pong": "Pong! Latence: {{latency}}ms"
+}
diff --git a/examples/with-leveling-system/src/app/locales/fr/rank.json b/examples/with-leveling-system/src/app/locales/fr/rank.json
new file mode 100644
index 00000000..22e63fd0
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/fr/rank.json
@@ -0,0 +1,11 @@
+{
+ "$command": {
+ "name": "rank",
+ "description": "Affiche votre carte de rang ou celle d'un autre utilisateur"
+ },
+ "bot_not_allowed": "Vous ne pouvez pas vérifier le rang d'un bot.",
+ "not_ranked": "{{username}} n'a pas encore de rang. Dites-lui d'envoyer un message sur le serveur pour obtenir un rang !",
+ "no_mention": "Vous devez mentionner un utilisateur pour vérifier son rang.",
+ "bot_no_rank": "Je n'ai pas de rang, voyons !",
+ "rank_title": "Rang de {{username}} 🏆"
+}
diff --git a/examples/with-leveling-system/src/app/locales/fr/set-prefix.json b/examples/with-leveling-system/src/app/locales/fr/set-prefix.json
new file mode 100644
index 00000000..48ca9e98
--- /dev/null
+++ b/examples/with-leveling-system/src/app/locales/fr/set-prefix.json
@@ -0,0 +1,7 @@
+{
+ "$command": {
+ "name": "set-prefix",
+ "description": "Définit le préfixe des commandes du serveur"
+ },
+ "prefix_set": "Préfixe défini sur `{{prefix}}`"
+}
diff --git a/examples/with-leveling-system/src/database/db.ts b/examples/with-leveling-system/src/database/db.ts
new file mode 100644
index 00000000..c5a910cc
--- /dev/null
+++ b/examples/with-leveling-system/src/database/db.ts
@@ -0,0 +1,3 @@
+import { PrismaClient } from './prisma/index';
+
+export const prisma = new PrismaClient();
diff --git a/examples/with-leveling-system/src/feature-flags/level-up-message.ts b/examples/with-leveling-system/src/feature-flags/level-up-message.ts
new file mode 100644
index 00000000..b15b0c8b
--- /dev/null
+++ b/examples/with-leveling-system/src/feature-flags/level-up-message.ts
@@ -0,0 +1,25 @@
+import { flag } from 'commandkit/flag';
+import { Events } from 'discord.js';
+import murmurhash from 'murmurhash';
+
+export const determineLevelUpMessageType = flag({
+ key: 'level-up-message-type',
+ description:
+ 'The type of level up message to send, either plain text or image',
+ identify(ctx) {
+ const [message] = ctx.event?.argumentsAs(Events.MessageCreate) ?? [];
+
+ return { id: message?.guildId ?? null };
+ },
+ decide({ entities }) {
+ const guildId = entities.id;
+ if (!guildId) return 'plain';
+
+ const hash = murmurhash.v3(guildId);
+ const bucket = hash % 100;
+
+ // 50% chance of plain text, 50% chance of image, based on guild id
+ if (bucket < 50) return 'plain';
+ return 'image';
+ },
+});
diff --git a/examples/with-leveling-system/src/modules/leveling-module.ts b/examples/with-leveling-system/src/modules/leveling-module.ts
new file mode 100644
index 00000000..0fb091e4
--- /dev/null
+++ b/examples/with-leveling-system/src/modules/leveling-module.ts
@@ -0,0 +1,141 @@
+import { prisma } from '@/database/db';
+
+export interface AssignXPInput {
+ guildId: string;
+ userId: string;
+ xp: number;
+ level: number;
+}
+
+export class LevelingModule extends null {
+ public static async ensureGuild(guildId: string) {
+ const guild = await prisma.guild.findUnique({
+ where: { id: guildId },
+ });
+
+ if (guild) return guild;
+
+ const entity = await prisma.guild.create({
+ data: {
+ id: guildId,
+ },
+ });
+
+ return entity;
+ }
+
+ public static async assignXP(input: AssignXPInput) {
+ const { guildId, userId, xp, level } = input;
+ const guild = await this.ensureGuild(guildId);
+
+ const updated = await prisma.level.upsert({
+ where: {
+ id_guildId: {
+ id: userId,
+ guildId: guild.id,
+ },
+ },
+ create: {
+ id: userId,
+ guildId: guild.id,
+ xp,
+ level,
+ },
+ update: {
+ xp: {
+ increment: xp,
+ },
+ level,
+ },
+ });
+
+ return updated;
+ }
+
+ public static async incrementLevel(guildId: string, userId: string) {
+ const guild = await this.ensureGuild(guildId);
+ const level = await prisma.level.upsert({
+ where: {
+ id_guildId: {
+ id: userId,
+ guildId: guild.id,
+ },
+ },
+ create: {
+ id: userId,
+ guildId: guild.id,
+ xp: 0,
+ level: 1,
+ },
+ update: {
+ level: {
+ increment: 1,
+ },
+ xp: 0,
+ },
+ });
+
+ return level;
+ }
+
+ public static async getLevel(guildId: string, userId: string) {
+ const guild = await this.ensureGuild(guildId);
+
+ const level = await prisma.level.findUnique({
+ where: {
+ id_guildId: {
+ id: userId,
+ guildId: guild.id,
+ },
+ },
+ });
+
+ return level;
+ }
+
+ public static async getRank(guildId: string, userId: string) {
+ // rank = index of user in leaderboard ordered by DESC xp
+ const rank: [{ rank?: bigint }] = await prisma.$queryRaw`
+ SELECT rank FROM (
+ SELECT
+ "id",
+ "guildId",
+ ROW_NUMBER() OVER (PARTITION BY "guildId" ORDER BY "level" DESC, "xp" DESC) AS rank
+ FROM "Level"
+ ) AS ranked
+ WHERE "id" = ${userId} AND "guildId" = ${guildId}`;
+
+ const val = rank?.[0]?.rank;
+
+ return Number(val ?? 0);
+ }
+
+ public static async computeLeaderboard(guildId: string) {
+ const guild = await this.ensureGuild(guildId);
+
+ const leaderboard = await prisma.level.findMany({
+ where: {
+ guildId: guild.id,
+ },
+ orderBy: {
+ xp: 'desc',
+ },
+ take: 10,
+ });
+
+ return leaderboard;
+ }
+
+ public static async countEntries(guildId: string) {
+ return prisma.level.count({
+ where: {
+ guildId,
+ },
+ });
+ }
+
+ public static calculateLevelXP(level: number) {
+ // 100 * (level ^ 2) + 100 * level
+ return 100 * Math.pow(level, 2) + 100 * level;
+ }
+}
diff --git a/examples/with-leveling-system/src/redis/redis.ts b/examples/with-leveling-system/src/redis/redis.ts
new file mode 100644
index 00000000..ec449244
--- /dev/null
+++ b/examples/with-leveling-system/src/redis/redis.ts
@@ -0,0 +1,3 @@
+import { Redis } from 'ioredis';
+
+export const redis = new Redis(process.env.REDIS_URL!);
diff --git a/examples/with-leveling-system/src/utils/prefix-resolver.ts b/examples/with-leveling-system/src/utils/prefix-resolver.ts
new file mode 100644
index 00000000..f931120f
--- /dev/null
+++ b/examples/with-leveling-system/src/utils/prefix-resolver.ts
@@ -0,0 +1,13 @@
+import { cacheTag } from '@commandkit/cache';
+import { prisma } from '@/database/db';
+
+async function fetchGuildPrefix(guildId: string) {
+ 'use cache';
+
+ cacheTag(`guild_prefix:${guildId}`);
+
+ const guild = await prisma.guild.findUnique({ where: { id: guildId } });
+ return guild?.messagePrefix ?? '!';
+}
+
+export { fetchGuildPrefix };
diff --git a/examples/with-leveling-system/src/utils/throttle.ts b/examples/with-leveling-system/src/utils/throttle.ts
new file mode 100644
index 00000000..fc78bf82
--- /dev/null
+++ b/examples/with-leveling-system/src/utils/throttle.ts
@@ -0,0 +1,12 @@
+import { redis } from '@/redis/redis';
+
+export async function isRateLimited(key: string, time: number) {
+ const exists = await redis.get(key);
+
+ if (exists != null) return true;
+
+ // set with expiration ttl where time is in milliseconds
+ await redis.set(key, '1', 'PX', time);
+
+ return false;
+}
diff --git a/examples/with-leveling-system/tsconfig.json b/examples/with-leveling-system/tsconfig.json
new file mode 100644
index 00000000..2c06b336
--- /dev/null
+++ b/examples/with-leveling-system/tsconfig.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "lib": ["ESNext", "DOM"],
+ "target": "ESNext",
+ "moduleResolution": "Node",
+ "module": "Preserve",
+ "allowImportingTsExtensions": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "skipDefaultLibCheck": true,
+ "noUncheckedIndexedAccess": true,
+ "removeComments": true,
+ "allowJs": true,
+ "strict": true,
+ "alwaysStrict": true,
+ "noEmit": true,
+ "declaration": false,
+ "jsx": "react-jsx",
+ "jsxImportSource": "commandkit",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src", "commandkit.config.ts", "commandkit-env.d.ts"],
+ "exclude": ["dist", "node_modules", ".commandkit"]
+}