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. + +![Create poll with natural language](./assets/create-poll.png) +![Create poll with natural language](./assets/create-poll-2.png) + +### AI Image Generation + +Generate images from text descriptions using natural language prompts. + +![Generate image with natural language](./assets/image-generation.png) + +### Context-Aware Chatbot + +Engage in natural conversations with the bot that maintains context and provides relevant responses. + +![Chatbot](./assets/chatbot.png) + +## 🤝 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"] +}