This commit is contained in:
Badstagram 2024-12-09 12:01:58 +00:00
commit 0f8e912093
138 changed files with 3657 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
node_modules
.pnp
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
.next/
out/
build
dist
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
*.pem

0
.npmrc Normal file
View File

81
README.md Normal file
View File

@ -0,0 +1,81 @@
# Turborepo starter
This is an official starter Turborepo.
## Using this example
Run the following command:
```sh
npx create-turbo@latest
```
## What's inside?
This Turborepo includes the following packages/apps:
### Apps and Packages
- `docs`: a [Next.js](https://nextjs.org/) app
- `web`: another [Next.js](https://nextjs.org/) app
- `@wyvern/ui`: a stub React component library shared by both `web` and `docs` applications
- `@wyvern/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`)
- `@wyvern/typescript-config`: `tsconfig.json`s used throughout the monorepo
Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
### Utilities
This Turborepo has some additional tools already setup for you:
- [TypeScript](https://www.typescriptlang.org/) for static type checking
- [ESLint](https://eslint.org/) for code linting
- [Prettier](https://prettier.io) for code formatting
### Build
To build all apps and packages, run the following command:
```
cd my-turborepo
pnpm build
```
### Develop
To develop all apps and packages, run the following command:
```
cd my-turborepo
pnpm dev
```
### Remote Caching
Turborepo can use a technique known as [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines.
By default, Turborepo will cache locally. To enable Remote Caching you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup), then enter the following commands:
```
cd my-turborepo
npx turbo login
```
This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview).
Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your Turborepo:
```
npx turbo link
```
## Useful Links
Learn more about the power of Turborepo:
- [Tasks](https://turbo.build/repo/docs/core-concepts/monorepos/running-tasks)
- [Caching](https://turbo.build/repo/docs/core-concepts/caching)
- [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching)
- [Filtering](https://turbo.build/repo/docs/core-concepts/monorepos/filtering)
- [Configuration Options](https://turbo.build/repo/docs/reference/configuration)
- [CLI Usage](https://turbo.build/repo/docs/reference/command-line-reference)

View File

@ -0,0 +1,15 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Argument } from '@sapphire/framework';
@ApplyOptions<Argument.Options>({})
export class UserArgument extends Argument<string> {
public override run(parameter: string) {
return this.ok(parameter);
}
}
declare module '@sapphire/framework' {
interface ArgType {
/*{{name}}*/: string;
}
}

View File

@ -0,0 +1,20 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { ApplicationCommandType } from 'discord.js';
@ApplyOptions<Command.Options>({
description: 'A basic contextMenu command'
})
export class UserCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerContextMenuCommand((builder) =>
builder //
.setName(this.name)
.setType(ApplicationCommandType.Message)
);
}
public override async contextMenuRun(interaction: Command.ContextMenuCommandInteraction) {
return interaction.reply({ content: 'Hello world!' });
}
}

View File

@ -0,0 +1,12 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import type { Message } from 'discord.js';
@ApplyOptions<Command.Options>({
description: 'A basic command'
})
export class UserCommand extends Command {
public override async messageRun(message: Message) {
return message.channel.send('Hello world!');
}
}

View File

@ -0,0 +1,19 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
@ApplyOptions<Command.Options>({
description: 'A basic slash command'
})
export class UserCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand((builder) =>
builder //
.setName(this.name)
.setDescription(this.description)
);
}
public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) {
return interaction.reply({ content: 'Hello world!' });
}
}

View File

@ -0,0 +1,30 @@
import { ApplyOptions } from '@sapphire/decorators';
import { InteractionHandler, InteractionHandlerTypes } from '@sapphire/framework';
import { AutocompleteInteraction, type ApplicationCommandOptionChoiceData } from 'discord.js';
@ApplyOptions<InteractionHandler.Options>({
interactionHandlerType: InteractionHandlerTypes.Autocomplete
})
export class AutocompleteHandler extends InteractionHandler {
public override async run(interaction: AutocompleteInteraction, result: ApplicationCommandOptionChoiceData[]) {
return interaction.respond(result);
}
public override async parse(interaction: AutocompleteInteraction) {
// Only run this interaction for the command with ID '1000000000000000000'
if (interaction.commandId !== '1000000000000000000') return this.none();
// Get the focussed (current) option
const focusedOption = interaction.options.getFocused(true);
// Ensure that the option name is one that can be autocompleted, or return none if not.
switch (focusedOption.name) {
case 'search': {
// Search your API or similar. This is example code!
const searchResult = await myApi.searchForSomething(focusedOption.value);
// Map the search results to the structure required for Autocomplete
return this.some(searchResult.map((match) => ({ name: match.name, value: match.key })));
}
default:
return this.none();
}
}
}

View File

@ -0,0 +1,22 @@
import { ApplyOptions } from '@sapphire/decorators';
import { InteractionHandler, InteractionHandlerTypes } from '@sapphire/framework';
import type { ButtonInteraction } from 'discord.js';
@ApplyOptions<InteractionHandler.Options>({
interactionHandlerType: InteractionHandlerTypes.Button
})
export class ButtonHandler extends InteractionHandler {
public async run(interaction: ButtonInteraction) {
await interaction.reply({
content: 'Hello from a button interaction handler!',
// Let's make it so only the person who pressed the button can see this message!
ephemeral: true
});
}
public override parse(interaction: ButtonInteraction) {
if (interaction.customId !== 'my-awesome-button') return this.none();
return this.some();
}
}

View File

@ -0,0 +1,21 @@
import { ApplyOptions } from '@sapphire/decorators';
import { InteractionHandler, InteractionHandlerTypes } from '@sapphire/framework';
import type { ModalSubmitInteraction } from 'discord.js';
@ApplyOptions<InteractionHandler.Options>({
interactionHandlerType: InteractionHandlerTypes.ModalSubmit
})
export class ModalHandler extends InteractionHandler {
public async run(interaction: ModalSubmitInteraction) {
await interaction.reply({
content: 'Thank you for submitting the form!',
ephemeral: true
});
}
public override parse(interaction: ModalSubmitInteraction) {
if (interaction.customId !== 'hello-popup') return this.none();
return this.some();
}
}

View File

@ -0,0 +1,21 @@
import { ApplyOptions } from '@sapphire/decorators';
import { InteractionHandler, InteractionHandlerTypes } from '@sapphire/framework';
import type { StringSelectMenuInteraction } from 'discord.js';
@ApplyOptions<InteractionHandler.Options>({
interactionHandlerType: InteractionHandlerTypes.SelectMenu
})
export class MenuHandler extends InteractionHandler {
public override async run(interaction: StringSelectMenuInteraction) {
await interaction.reply({
// Remember how we can have multiple values? Let's get the first one!
content: `You selected: ${interaction.values[0]}`
});
}
public override parse(interaction: StringSelectMenuInteraction) {
if (interaction.customId !== 'my-echo-select') return this.none();
return this.some();
}
}

View File

@ -0,0 +1,7 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Listener } from '@sapphire/framework';
@ApplyOptions<Listener.Options>({})
export class UserEvent extends Listener {
public override run() {}
}

View File

@ -0,0 +1,22 @@
import { Precondition } from '@sapphire/framework';
import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js';
export class UserPrecondition extends Precondition {
public override messageRun(message: Message) {
return this.ok();
}
public override chatInputRun(interaction: ChatInputCommandInteraction) {
return this.ok();
}
public override contextMenuRun(interaction: ContextMenuCommandInteraction) {
return this.ok();
}
}
declare module '@sapphire/framework' {
interface Preconditions {
/*{{name}}*/: never;
}
}

View File

@ -0,0 +1,15 @@
import { ApplyOptions } from '@sapphire/decorators';
import { methods, Route, type ApiRequest, type ApiResponse } from '@sapphire/plugin-api';
@ApplyOptions<Route.Options>({
route: '/*{{name}}*/'
})
export class UserRoute extends Route {
public [methods.GET](_request: ApiRequest, response: ApiResponse) {
response.json({ message: 'Hello World' });
}
public [methods.POST](_request: ApiRequest, response: ApiResponse) {
response.json({ message: 'Hello World' });
}
}

32
apps/bot/README.md Normal file
View File

@ -0,0 +1,32 @@
# TypeScript Complete Sapphire Bot example
This is a more complete setup of a Discord bot using the [sapphire framework][sapphire] written in TypeScript.
It is similar to the [starter setup](../with-typescript-starter/), but adds more data structures and a more complete setup.
## How to use it?
### Prerequisite
```sh
npm install
```
### Development
This example can be run with `tsc-watch` to watch the files and automatically restart your bot.
```sh
npm run watch:start
```
### Production
You can also run the bot with `npm dev`, this will first build your code and then run `node ./dist/index.js`. But this is not the recommended way to run a bot in production.
## License
Dedicated to the public domain via the [Unlicense], courtesy of the Sapphire Community and its contributors.
[sapphire]: https://github.com/sapphiredev/framework
[unlicense]: https://github.com/sapphiredev/examples/blob/main/LICENSE.md

71
apps/bot/package.json Normal file
View File

@ -0,0 +1,71 @@
{
"name": "@wyvern/bot",
"version": "1.0.0",
"main": "dist/index.js",
"author": "@sapphire",
"license": "UNLICENSE",
"type": "module",
"dependencies": {
"@wyvern/config": "*",
"@wyvern/database": "*",
"@wyvern/plugin-custom-logger": "*",
"@wyvern/urban-dictionary": "*",
"@wyvern/error-handler": "*",
"@sapphire/decorators": "^6.0.4",
"@sapphire/discord-utilities": "^3.2.2",
"@sapphire/discord.js-utilities": "7.1.6",
"@sapphire/fetch": "^3.0.2",
"@sapphire/framework": "^5.0.7",
"@sapphire/plugin-api": "^6.1.1",
"@sapphire/plugin-editable-commands": "^4.0.2",
"@sapphire/plugin-logger": "^4.0.2",
"@sapphire/plugin-subcommands": "^6.0.3",
"@sapphire/string-store": "^1.0.1",
"@sapphire/time-utilities": "^1.7.12",
"@sapphire/type": "^2.4.4",
"@sapphire/utilities": "^3.15.3",
"@skyra/env-utilities": "^1.3.0",
"colorette": "^2.0.20",
"discord.js": "^14.14.1"
},
"devDependencies": {
"@sapphire/cli": "^1.9.3",
"@sapphire/prettier-config": "^2.0.0",
"@sapphire/ts-config": "^5.0.0",
"@types/node": "^20.11.5",
"@types/ws": "^8.5.10",
"npm-run-all2": "^6.1.1",
"prettier": "^3.2.4",
"tsc-watch": "^6.0.4",
"typescript": "~5.4.5",
"tsup": "latest"
},
"scripts": {
"sapphire": "sapphire",
"generate": "sapphire generate",
"build": "tsup",
"watch": "tsup --watch",
"start": "node dist/index.js",
"dev": "tsup --watch --onSuccess \"node ./dist/index.js\"",
"watch:start": "tsc-watch --onSuccess \"node ./dist/index.js\"",
"format": "prettier --write \"src/**/*.ts\"",
"typecheck": "tsc --noEmit --skipLibCheck"
},
"imports": {
"#lib/structures": "./dist/lib/structures/index.js",
"#lib/interfaces": "./dist/lib/interfaces/index.js",
"#lib/parsers": "./dist/lib/parsers/index.js",
"#lib/errors": "./dist/lib/errors/index.js",
"#lib/managers": "./dist/lib/managers/index.js",
"#lib/utils": "./dist/lib/utils.js",
"#lib/config": "./dist/lib/config.js",
"#lib/constants": "./dist/lib/constants.js",
"#lib/types": "./dist/lib/types.js",
"#lib/customIds": "./dist/lib/customIdTypes/index.js",
"#lib/markdown": "./dist/lib/markdown/index.js",
"#drizzle": "./dist/drizzle/schema.js",
"#prisma": "./dist/lib/prisma.js",
"#stringFormatters": "./dist/lib/stringFormatters/index.js"
},
"prettier": "@sapphire/prettier-config"
}

38
apps/bot/sapphire.toml Normal file
View File

@ -0,0 +1,38 @@
[project]
language = "ts"
module_system = "cjs"
base = "src"
[variables]
[categories]
arguments = { source_path = "arguments", target_path = "arguments" }
commands = { source_path = "commands", target_path = "commands" }
interaction-handlers = { source_path = "interaction-handlers", target_path = "interaction-handlers" }
listeners = { source_path = "listeners", target_path = "listeners" }
preconditions = { source_path = "preconditions", target_path = "preconditions" }
routes = { source_path = "routes", target_path = "routes" }
[templates.arguments]
argument = { aliases = ["a", "arg"] }
[templates.commands]
context-menu-command = { aliases = ["cmc", "context", "contextmenu", "contextmenucommand"] }
message-command = { aliases = ["mc", "message", "messagecommand"] }
slash-command = { aliases = ["sc", "command", "slash", "slashcommand"] }
[templates.interaction-handlers]
autocomplete-interaction-handler = { aliases = ["aih", "auto", "autocomplete"] }
button-interaction-handler = { aliases = ["bih", "button"] }
modal-interaction-handler = { aliases = ["mih", "modal"] }
select-menu-interaction-handler = { aliases = ["smih", "select"] }
[templates.listeners]
listener = { aliases = ["l"] }
[templates.preconditions]
precondition = { aliases = ["p"] }
[templates.routes]
route = { aliases = ["r"] }

View File

@ -0,0 +1,24 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { Subcommand } from '@sapphire/plugin-subcommands';
import { adminErrorTest, adminSyncDatabase } from './_index.js';
@ApplyOptions<Subcommand.Options>({
description: 'Admin commands',
preconditions: ['OwnerOnly'],
subcommands: [
{ name: 'error_test', chatInputRun: adminErrorTest },
{ name: 'sync_database', chatInputRun: adminSyncDatabase }
]
})
export class UserCommand extends Subcommand {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand((builder) =>
builder //
.setName(this.name)
.setDescription(this.description)
.addSubcommand((b) => b.setName('error_test').setDescription('Throw an error'))
.addSubcommand((b) => b.setName('sync_database').setDescription('Sync the database'))
);
}
}

View File

@ -0,0 +1,7 @@
import { Subcommand } from '@sapphire/plugin-subcommands';
export function adminErrorTest(_i: Subcommand.ChatInputCommandInteraction) {
const a: string = null!;
a!.charAt(1);
}

View File

@ -0,0 +1,44 @@
import { InternalError } from '#lib/errors';
import { container, Result } from '@sapphire/framework';
import { Subcommand } from '@sapphire/plugin-subcommands';
import { prismaClient } from '@wyvern/database';
export async function adminSyncDatabase(i: Subcommand.ChatInputCommandInteraction) {
const res = await Result.fromAsync(container.client.guilds.fetch());
const guildIds = res.match({
ok: (x) => x.map((g) => g.id),
err: (e) => {
throw new InternalError(`${e}`);
}
});
const guildCreateManyArgs = guildIds.map((id) => {
return { guildId: id };
});
const welcomeMessageCreateManyArgs = guildIds.map((id) => {
return { guildId: id };
});
const leaveMessageCreateManyArgs = guildIds.map((id) => {
return { guildId: id };
});
await prismaClient.guild.createMany({
data: guildCreateManyArgs,
skipDuplicates: true
});
await prismaClient.guildWelcomeMessage.createMany({
data: welcomeMessageCreateManyArgs
});
await prismaClient.guildLeaveMessage.createMany({
data: leaveMessageCreateManyArgs
});
await i.reply({
content: 'Synced database',
ephemeral: true
});
}

View File

@ -0,0 +1,2 @@
export * from './_adminErrorTest.js';
export * from './_adminSyncDatabase.js';

View File

@ -0,0 +1,64 @@
import { createEmbed } from '#lib/utils';
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { prismaClient } from '@wyvern/database';
import { codeBlock, Status } from 'discord.js';
@ApplyOptions<Command.Options>({
description: 'ping pong'
})
export class UserCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand({
name: this.name,
description: this.description
});
}
public override async chatInputRun(i: Command.ChatInputCommandInteraction) {
const { client } = this.container;
const dbTime = performance.now();
await prismaClient.$queryRaw`SELECT 1`;
const dbTiming = performance.now() - dbTime;
const waitEmbed = createEmbed(i.user).setDescription('🏓 Pong!...');
const message = await i.reply({ embeds: [waitEmbed] });
const thisServerShard = client.ws.shards.get(i.guild!.shardId);
if (!thisServerShard) throw 'Unable to get server shard';
const pingMessage = createEmbed(i.user).addFields([
{
name: 'Host Latency',
value: codeBlock('yaml', client.ws.ping > 0 ? `${Math.floor(client.ws.ping)}ms` : 'Calculating...'),
inline: true
},
{
name: 'Client Latency',
value: codeBlock('yaml', `${Math.floor(message.createdTimestamp - i.createdTimestamp)}ms`),
inline: true
},
{
name: 'Database Latency',
value: codeBlock('yaml', `${Math.floor(dbTiming)}ms`),
inline: true
},
{
name: 'Websocket',
value: codeBlock('yaml', `${Status[thisServerShard.status]}`),
inline: true
},
{
name: 'Shard',
value: codeBlock(
'yaml',
`${thisServerShard.id}/${client.ws.shards.size} (${thisServerShard.ping > 0 ? `${Math.floor(thisServerShard.ping)}ms` : 'Calculating...'})`
),
inline: true
}
]);
await message.edit({ embeds: [pingMessage] });
}
}

View File

@ -0,0 +1,81 @@
import { InternalError } from '#lib/errors';
import { compressCustomId } from '#lib/utils';
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import {
ActionRowBuilder,
InteractionContextType,
Message,
MessageContextMenuCommandInteraction,
ModalBuilder,
TextInputBuilder,
User,
UserContextMenuCommandInteraction
} from 'discord.js';
import { ReportMessageCustomIdParams, ReportUserCustomIdParams } from 'src/lib/customIdParams/report.js';
@ApplyOptions<Command.Options>({
description: 'Report',
preconditions: [{ name: 'RequiredSetting', context: { setting: 'reportChannel' } }]
})
export class UserCommand extends Command {
public override registerApplicationCommands(registry: Command.Registry) {
// register user context menu
registry.registerContextMenuCommand((b) =>
b //
.setName('Report User')
.setType(2) // User context menu - idk why i cant use ApplicationCommandType.User here
.setContexts(InteractionContextType.Guild)
);
// register message context menu
registry.registerContextMenuCommand((b) =>
b //
.setName('Report Message')
.setType(3) // Message context menu - idk why i cant use ApplicationCommandType.Message here
.setContexts(InteractionContextType.Guild)
);
}
public override async contextMenuRun(i: Command.ContextMenuCommandInteraction) {
if (i.isMessageContextMenuCommand()) this._messageReport(i.targetMessage, i);
else if (i.isUserContextMenuCommand()) this._userReport(i.targetUser, i);
else throw new InternalError('Interaction was an unexpected type');
}
private async _messageReport(msg: Message, i: MessageContextMenuCommandInteraction) {
const row = new ActionRowBuilder<TextInputBuilder>().setComponents([
new TextInputBuilder().setLabel('Reason').setPlaceholder('Why are you reporting this message').setRequired(true).setCustomId('reason')
]);
const idRes = compressCustomId<ReportMessageCustomIdParams>({
name: 'report-msg',
channelId: msg.channelId,
messageId: msg.id
});
if (idRes.isErr()) throw new InternalError(idRes.unwrapErr());
const modal = new ModalBuilder().setTitle('Report Message').setComponents(row).setCustomId(idRes.unwrap());
await i.showModal(modal);
}
private async _userReport(user: User, i: UserContextMenuCommandInteraction) {
const row = new ActionRowBuilder<TextInputBuilder>().setComponents([
new TextInputBuilder().setLabel('Reason').setPlaceholder('Why are you reporting this user').setRequired(true).setCustomId('reason')
]);
const idRes = compressCustomId<ReportUserCustomIdParams>({
name: 'report-user',
userId: user.id
});
if (idRes.isErr()) throw new InternalError(idRes.unwrapErr());
const modal = new ModalBuilder().setTitle('Report User').setComponents(row).setCustomId(idRes.unwrap());
await i.showModal(modal);
}
}

View File

@ -0,0 +1,137 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { Subcommand } from '@sapphire/plugin-subcommands';
import {
autoRole_create,
autoRole_remove,
reportChannel_set,
reportChannel_unset,
welcomeMessage_disable,
welcomeMessage_enable,
welcomeMessage_preview,
welcomeMessage_setColor,
welcomeMessage_setMessage,
welcomeMessage_setTitle
} from './_index.js';
import { ChannelType } from 'discord.js';
@ApplyOptions<Subcommand.Options>({
description: 'Tag commands',
requiredUserPermissions: ['ManageGuild'],
subcommands: [
{
name: 'auto_role',
type: 'group',
entries: [
{ name: 'create', chatInputRun: autoRole_create },
{ name: 'remove', chatInputRun: autoRole_remove }
]
},
{
name: 'report_channel',
type: 'group',
entries: [
{ name: 'set', chatInputRun: reportChannel_set },
{ name: 'unset', chatInputRun: reportChannel_unset }
]
},
{
name: 'welcome_message',
type: 'group',
entries: [
{ name: 'enable', chatInputRun: welcomeMessage_enable },
{ name: 'disable', chatInputRun: welcomeMessage_disable },
{ name: 'preview', chatInputRun: welcomeMessage_preview },
{ name: 'set_title', chatInputRun: welcomeMessage_setTitle },
{ name: 'set_message', chatInputRun: welcomeMessage_setMessage },
{ name: 'set_color', chatInputRun: welcomeMessage_setColor }
]
}
]
})
export class UserCommand extends Subcommand {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand((builder) =>
builder //
.setName(this.name)
.setDescription(this.description)
.addSubcommandGroup((b) =>
b //
.setName('auto_role')
.setDescription('Auto Role commands')
.addSubcommand((b) =>
b //
.setName('create')
.setDescription('Create an auto role')
.addRoleOption((b) => b.setName('role').setDescription('The role to create').setRequired(true))
)
.addSubcommand((b) =>
b //
.setName('remove')
.setDescription('Remove an auto role')
.addRoleOption((b) => b.setName('role').setDescription('The role to remove').setRequired(true))
)
)
.addSubcommandGroup((b) =>
b //
.setName('report_channel')
.setDescription('report channel commands')
.addSubcommand((b) =>
b //
.setName('set')
.setDescription('Set the report channel')
.addChannelOption((b) =>
b.setName('channel').setDescription('The channel to set').setRequired(true).addChannelTypes(ChannelType.GuildText)
)
)
.addSubcommand((b) =>
b //
.setName('unset')
.setDescription('Unset the report channel')
)
)
.addSubcommandGroup((b) =>
b //
.setName('welcome_message')
.setDescription('Welcome message commands')
.addSubcommand((b) => b.setName('enable').setDescription('Enable the welcome message'))
.addSubcommand((b) => b.setName('disable').setDescription('Disable the welcome message'))
.addSubcommand((b) => b.setName('preview').setDescription('Preview the welcome message'))
.addSubcommand((b) =>
b //
.setName('set_title')
.setDescription('Set the title of the welcome message')
.addStringOption((b) =>
b //
.setName('title')
.setDescription('The title')
.setRequired(true)
)
)
.addSubcommand((b) =>
b //
.setName('set_message')
.setDescription('Set the message of the welcome message')
.addStringOption((b) =>
b //
.setName('message')
.setDescription('The message')
.setRequired(true)
)
)
.addSubcommand((b) =>
b //
.setName('set_color')
.setDescription('Set the color of the welcome message')
.addStringOption((b) =>
b //
.setName('color')
.setDescription('The color')
.setRequired(true)
)
)
)
);
}
}

View File

@ -0,0 +1,12 @@
export * from './autoRole/_create.js';
export * from './autoRole/_remove.js';
export * from './reportChannel/_set.js';
export * from './reportChannel/_unset.js';
export * from './welcomeMessage/_enable.js';
export * from './welcomeMessage/_disable.js';
export * from './welcomeMessage/_setTitle.js';
export * from './welcomeMessage/_setMessage.js';
export * from './welcomeMessage/_setColor.js';
export * from './welcomeMessage/_preview.js';

View File

@ -0,0 +1,27 @@
import { CommandFailedError, InternalError } from '#lib/errors';
import { createEmbed } from '#lib/utils';
import { Subcommand } from '@sapphire/plugin-subcommands';
import { createAutoRole, getGuild, prismaClient } from '@wyvern/database';
import { mention } from '@wyvern/markdown';
export async function autoRole_create(i: Subcommand.ChatInputCommandInteraction) {
const role = i.options.getRole('role', true);
const guildRes = await getGuild(i.guild!.id);
if (guildRes.isErr()) {
throw new InternalError(guildRes.unwrapErr().message);
}
const { autoRoles: existingRoles } = guildRes.unwrap();
if (existingRoles.includes(role.id)) throw new CommandFailedError('That role already exists');
await prismaClient.$queryRawTyped(createAutoRole(role.id, i.guild!.id));
const embed = createEmbed(i.user)
.setTitle('Auto Role Created')
.setDescription(`${mention('role', role.id)} will now be given to new members!`);
await i.reply({ embeds: [embed] });
}

View File

@ -0,0 +1,27 @@
import { CommandFailedError, InternalError } from '#lib/errors';
import { createEmbed } from '#lib/utils';
import { Subcommand } from '@sapphire/plugin-subcommands';
import { removeAutoRole, getGuild, prismaClient } from '@wyvern/database';
import { mention } from '@wyvern/markdown';
export async function autoRole_remove(i: Subcommand.ChatInputCommandInteraction) {
const role = i.options.getRole('role', true);
const guildRes = await getGuild(i.guild!.id);
if (guildRes.isErr()) {
throw new InternalError(guildRes.unwrapErr().message);
}
const { autoRoles: existingRoles } = guildRes.unwrap();
if (!existingRoles.includes(role.id)) throw new CommandFailedError("That role isn't configured as an auto role");
await prismaClient.$queryRawTyped(removeAutoRole(role.id, i.guild!.id));
const embed = createEmbed(i.user, 'error')
.setTitle('Auto Role Removed')
.setDescription(`${mention('role', role.id)} will no longer be given to new members!`);
await i.reply({ embeds: [embed] });
}

View File

@ -0,0 +1,28 @@
import { CommandFailedError } from '#lib/errors';
import { Subcommand } from '@sapphire/plugin-subcommands';
import { prismaClient } from '@wyvern/database';
import { TextChannel } from 'discord.js';
import { mention } from '@wyvern/markdown';
export async function reportChannel_set(i: Subcommand.ChatInputCommandInteraction) {
const channel = i.options.getChannel('channel', true);
const guild = i.guild!;
if (!(channel instanceof TextChannel)) throw new CommandFailedError('Channel is not a text channel');
const perms = channel.permissionsFor(guild.members.me!);
if (!perms.any(['SendMessages', 'EmbedLinks'])) throw new CommandFailedError("I don't have the required permissions for that channel");
await prismaClient.guild.update({
data: {
reportChannel: channel.id
},
where: { id: guild.id }
});
await i.reply({
content: `User reports will now be sent to ${mention('channel', channel.id)}`,
ephemeral: true
});
}

View File

@ -0,0 +1,16 @@
import { Subcommand } from '@sapphire/plugin-subcommands';
import { prismaClient } from '@wyvern/database';
export async function reportChannel_unset(i: Subcommand.ChatInputCommandInteraction) {
await prismaClient.guild.update({
data: {
reportChannel: null
},
where: { id: i.guild!.id }
});
await i.reply({
content: `User reports will no longer be sent`,
ephemeral: true
});
}

View File

@ -0,0 +1,2 @@
import { Subcommand } from '@sapphire/plugin-subcommands';
export async function welcomeMessage_disable(i: Subcommand.ChatInputCommandInteraction) {}

View File

@ -0,0 +1,2 @@
import { Subcommand } from '@sapphire/plugin-subcommands';
export async function welcomeMessage_enable(i: Subcommand.ChatInputCommandInteraction) {}

View File

@ -0,0 +1,26 @@
import { InternalError } from '#lib/errors';
import { createEmbed, formatWelcomeLeaveMessage } from '#lib/utils';
import { Result } from '@sapphire/framework';
import { Subcommand } from '@sapphire/plugin-subcommands';
import { isNullish } from '@sapphire/utilities';
import { getGuild, Prisma, prismaClient } from '@wyvern/database';
import { ColorResolvable, GuildMember } from 'discord.js';
export async function welcomeMessage_preview(i: Subcommand.ChatInputCommandInteraction) {
await i.deferReply({ ephemeral: true });
const res = await prismaClient.guild.findFirstOrThrow({
where: { guildId: i.guild!.id },
include: { guildWelcomeMessage: true }
});
const { title, description, embedColor } = res!.guildWelcomeMessage!;
const embed = createEmbed(i.user)
.setTitle(formatWelcomeLeaveMessage(title, i.member as GuildMember, i.guild!))
.setDescription(formatWelcomeLeaveMessage(description, i.member as GuildMember, i.guild!))
.setColor(embedColor as ColorResolvable);
await i.editReply({
embeds: [embed]
});
}

View File

@ -0,0 +1,2 @@
import { Subcommand } from '@sapphire/plugin-subcommands';
export async function welcomeMessage_setColor(i: Subcommand.ChatInputCommandInteraction) {}

View File

@ -0,0 +1,2 @@
import { Subcommand } from '@sapphire/plugin-subcommands';
export async function welcomeMessage_setMessage(i: Subcommand.ChatInputCommandInteraction) {}

View File

@ -0,0 +1,2 @@
import { Subcommand } from '@sapphire/plugin-subcommands';
export async function welcomeMessage_setTitle(i: Subcommand.ChatInputCommandInteraction) {}

View File

@ -0,0 +1,23 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { Subcommand } from '@sapphire/plugin-subcommands';
import { createTag } from './_index.js';
@ApplyOptions<Subcommand.Options>({
description: 'Tag commands',
subcommands: [{ name: 'create', chatInputRun: createTag }]
})
export class UserCommand extends Subcommand {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand((builder) =>
builder //
.setName(this.name)
.setDescription(this.description)
.addSubcommand((b) =>
b //
.setName('create')
.setDescription('Create a tag')
)
);
}
}

View File

@ -0,0 +1,44 @@
import { Subcommand } from '@sapphire/plugin-subcommands';
import { ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js';
export async function createTag(i: Subcommand.ChatInputCommandInteraction) {
const row1 = new ActionRowBuilder<TextInputBuilder>().setComponents([
new TextInputBuilder() //
.setCustomId('name')
.setPlaceholder('The tag name')
.setLabel('Name')
.setMinLength(1)
.setMaxLength(255)
.setStyle(TextInputStyle.Short)
.setRequired(true)
]);
const row2 = new ActionRowBuilder<TextInputBuilder>().setComponents([
new TextInputBuilder() //
.setCustomId('content')
.setLabel('Content')
.setPlaceholder('The tag content')
.setMinLength(1)
.setMaxLength(255)
.setStyle(TextInputStyle.Paragraph)
.setRequired(true)
]);
const row3 = new ActionRowBuilder<TextInputBuilder>().setComponents([
new TextInputBuilder() //
.setCustomId('image')
.setLabel('Image')
.setPlaceholder('An image Url')
.setMinLength(1)
.setMaxLength(255)
.setStyle(TextInputStyle.Short)
.setRequired(false)
]);
const modal = new ModalBuilder() //
.setCustomId('create-tag')
.setTitle('Create Tag')
.setComponents([row1, row2, row3]);
await i.showModal(modal);
}

View File

@ -0,0 +1 @@
export * from './_createTag.js';

View File

@ -0,0 +1,29 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { Subcommand } from '@sapphire/plugin-subcommands';
import { urbanDefine, urbanRandom } from './_index.js';
@ApplyOptions<Subcommand.Options>({
description: 'Tag commands',
nsfw: true,
subcommands: [
{ name: 'define', chatInputRun: urbanDefine },
{ name: 'random', chatInputRun: urbanRandom }
]
})
export class UserCommand extends Subcommand {
public override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand((builder) =>
builder //
.setName(this.name)
.setDescription(this.description)
.addSubcommand((b) =>
b //
.setName('define')
.setDescription('Define a term on Urban Dictionary')
.addStringOption((b) => b.setName('term').setDescription('The term').setRequired(true))
)
.addSubcommand((b) => b.setName('random').setDescription('Define a random term'))
);
}
}

View File

@ -0,0 +1,26 @@
import { container } from '@sapphire/framework';
import { Subcommand } from '@sapphire/plugin-subcommands';
import { PaginatedMessage } from '@sapphire/discord.js-utilities';
import { createEmbed } from '#lib/utils';
export async function urbanDefine(i: Subcommand.ChatInputCommandInteraction) {
const { urban } = container;
const term = i.options.getString('term', true);
const { list } = await urban.defineWord(term);
const pMsg = new PaginatedMessage();
list.forEach(({ definition, word, written_on, thumbs_down, thumbs_up, author }) => {
pMsg.addPageEmbed(
createEmbed(i.user)
.setTimestamp(new Date(written_on))
.setTitle(urban.removeBrackets(word))
.setDescription(urban.removeBrackets(definition))
.setFooter({ text: `👍 ${thumbs_up} | 👎 ${thumbs_down} | Written by ${author}` })
);
});
await pMsg.run(i);
}

View File

@ -0,0 +1,2 @@
export * from './_define.js';
export * from './_random.js';

View File

@ -0,0 +1,24 @@
import { createEmbed } from '#lib/utils';
import { container } from '@sapphire/framework';
import { Subcommand } from '@sapphire/plugin-subcommands';
import { pickRandom } from '@sapphire/utilities';
export async function urbanRandom(i: Subcommand.ChatInputCommandInteraction) {
const { urban } = container;
const { list } = await urban.randomDefinition();
const { definition, word, written_on, thumbs_down, thumbs_up, author } = pickRandom(list)
const embed = createEmbed(i.user)
.setTimestamp(new Date(written_on))
.setTitle(urban.removeBrackets(word))
.setDescription(urban.removeBrackets(definition))
.setFooter({ text: `👍 ${thumbs_up} | 👎 ${thumbs_down} | Written by ${author}` })
await i.reply({ embeds: [embed] })
}

45
apps/bot/src/index.ts Normal file
View File

@ -0,0 +1,45 @@
import { UrbanDictionary } from '@wyvern/urban-dictionary';
import './lib/setup.js';
import { container, LogLevel, SapphireClient } from '@sapphire/framework';
import { GatewayIntentBits, Partials } from 'discord.js';
const client = new SapphireClient({
defaultPrefix: '!',
regexPrefix: /^(hey +)?bot[,! ]/i,
caseInsensitiveCommands: true,
logger: {
level: LogLevel.Debug
},
shards: 'auto',
intents: [
GatewayIntentBits.DirectMessageReactions,
GatewayIntentBits.DirectMessages,
GatewayIntentBits.GuildModeration,
GatewayIntentBits.GuildEmojisAndStickers,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.MessageContent
],
partials: [Partials.Channel],
loadMessageCommandListeners: true
});
const main = async () => {
try {
client.logger.info('Logging in');
await client.login();
client.logger.info('logged in');
container.urban = new UrbanDictionary();
} catch (error) {
client.logger.fatal(error);
await client.destroy();
process.exit(1);
}
};
void main();

View File

@ -0,0 +1,40 @@
import { createEmbed, validateUrl } from '#lib/utils';
import { createTag, prismaClient } from '@wyvern/database';
import { ApplyOptions } from '@sapphire/decorators';
import { InteractionHandler, InteractionHandlerTypes } from '@sapphire/framework';
import { inlineCode, ModalSubmitInteraction } from 'discord.js';
import { createId } from '@paralleldrive/cuid2';
@ApplyOptions<InteractionHandler.Options>({
interactionHandlerType: InteractionHandlerTypes.ModalSubmit
})
export class ModalHandler extends InteractionHandler {
override async run(i: ModalSubmitInteraction, { content, image, name }: InteractionHandler.ParseResult<this>) {
const imageUrl = validateUrl(image).match({
ok: (url) => url.href,
err: (_) => ''
});
await prismaClient.$queryRawTyped(createTag(content, imageUrl, name, i.guildId!, i.user.id, createId()));
const embed = createEmbed(i.user) //
.setTitle('Tag Created')
.setDescription(`Created a tag with title ${inlineCode(name)}`);
await i.reply({
embeds: [embed]
});
}
override parse(i: ModalSubmitInteraction) {
if (i.customId !== 'create-tag') return this.none();
const fields = i.fields;
const name = fields.getTextInputValue('name');
const content = fields.getTextInputValue('content');
const image = fields.getTextInputValue('image');
return this.some({ name, content, image });
}
}

View File

@ -0,0 +1,16 @@
import { join } from 'path';
console.log(import.meta.dirname);
const up = (count: number) => {
const foo: Array<string> = [];
for (let i = 0; i < count; i++) {
foo.push('..');
}
return foo;
};
export const rootDir = join(import.meta.dirname, ...up(4));
export const srcDir = join(rootDir, 'src');
export const RandomLoadingMessage = ['Computing...', 'Thinking...', 'Cooking some food', 'Give me a moment', 'Loading...'];

View File

@ -0,0 +1,3 @@
export interface BaseCustomIdParams {
name: string;
}

View File

@ -0,0 +1,2 @@
export * from './baseCustomIdParams.js';
export * from './report.js';

View File

@ -0,0 +1,10 @@
import { BaseCustomIdParams } from './index.js';
export interface ReportMessageCustomIdParams extends BaseCustomIdParams {
channelId: string;
messageId: string;
}
export interface ReportUserCustomIdParams extends BaseCustomIdParams {
userId: string;
}

View File

@ -0,0 +1,8 @@
export class CommandFailedError extends Error {
/**
*
*/
constructor(msg: string) {
super(msg);
}
}

View File

@ -0,0 +1,8 @@
export class InternalError extends Error {
/**
*
*/
constructor(msg: string) {
super(msg);
}
}

View File

@ -0,0 +1,2 @@
export * from './CommandFailedError.js';
export * from './InternalError.js';

46
apps/bot/src/lib/setup.ts Normal file
View File

@ -0,0 +1,46 @@
// Unless explicitly defined, set NODE_ENV as development:
process.env.NODE_ENV ??= 'development';
import { ApplicationCommandRegistries, RegisterBehavior } from '@sapphire/framework';
import '@sapphire/plugin-api/register';
import '@sapphire/plugin-editable-commands/register';
// import '@sapphire/plugin-logger/register';
import '@wyvern/plugin-custom-logger/register';
import '@sapphire/plugin-subcommands/register';
import { setup, type ArrayString } from '@skyra/env-utilities';
import * as colorette from 'colorette';
import { join } from 'path';
import { inspect } from 'util';
import { rootDir } from './constants.js';
import { UrbanDictionary } from '@wyvern/urban-dictionary';
// Set default behavior to bulk overwrite
ApplicationCommandRegistries.setDefaultBehaviorWhenNotIdentical(RegisterBehavior.BulkOverwrite);
ApplicationCommandRegistries.setDefaultGuildIds(['1072895701970858026', '739115569311383634']);
// Read env var
console.log(join(rootDir, '.env'));
setup({ path: join(rootDir, '.env') });
// Set default inspection depth
inspect.defaultOptions.depth = 1;
// Enable colorette
colorette.createColors({ useColor: true });
declare module '@skyra/env-utilities' {
interface Env {
OWNERS: ArrayString;
}
}
declare module '@sapphire/pieces' {
interface Container {
urban: UrbanDictionary;
}
}

185
apps/bot/src/lib/utils.ts Normal file
View File

@ -0,0 +1,185 @@
import {
ChatInputCommandSuccessPayload,
Command,
ContextMenuCommandSuccessPayload,
err,
MessageCommandSuccessPayload,
ok,
Result
} from '@sapphire/framework';
import { container } from '@sapphire/framework';
import { send } from '@sapphire/plugin-editable-commands';
import { cyan } from 'colorette';
import {
ChatInputCommandInteraction,
EmbedBuilder,
GuildMember,
Role,
RoleResolvable,
type APIUser,
type Guild,
type Message,
type User
} from 'discord.js';
import { RandomLoadingMessage } from '#lib/constants';
import { globalConfig } from '@wyvern/config';
import { isNullish } from '@sapphire/utilities';
import { brotliCompressSync, brotliDecompressSync } from 'node:zlib';
import { serialize, deserialize } from 'binarytf';
/**
* Picks a random item from an array
* @param array The array to pick a random item from
* @example
* const randomEntry = pickRandom([1, 2, 3, 4]) // 1
*/
export function pickRandom<T>(array: readonly T[]): T {
const { length } = array;
return array[Math.floor(Math.random() * length)];
}
/**
* Sends a loading message to the current channel
* @param message The message data for which to send the loading message
*/
export function sendLoadingMessage(message: Message): Promise<typeof message> {
return send(message, { embeds: [new EmbedBuilder().setDescription(pickRandom(RandomLoadingMessage)).setColor('#FF0000')] });
}
export function logSuccessCommand(payload: ContextMenuCommandSuccessPayload | ChatInputCommandSuccessPayload | MessageCommandSuccessPayload): void {
let successLoggerData: ReturnType<typeof getSuccessLoggerData>;
if ('interaction' in payload) {
successLoggerData = getSuccessLoggerData(payload.interaction.guild, payload.interaction.user, payload.command);
} else {
successLoggerData = getSuccessLoggerData(payload.message.guild, payload.message.author, payload.command);
}
container.logger.debug(`${successLoggerData.shard} - ${successLoggerData.commandName} ${successLoggerData.author} ${successLoggerData.sentAt}`);
}
export function getSuccessLoggerData(guild: Guild | null, user: User, command: Command) {
const shard = getShardInfo(guild?.shardId ?? 0);
const commandName = getCommandInfo(command);
const author = getAuthorInfo(user);
const sentAt = getGuildInfo(guild);
return { shard, commandName, author, sentAt };
}
function getShardInfo(id: number) {
return `[${cyan(id.toString())}]`;
}
function getCommandInfo(command: Command) {
return cyan(command.name);
}
function getAuthorInfo(author: User | APIUser) {
return `${author.username}[${cyan(author.id)}]`;
}
function getGuildInfo(guild: Guild | null) {
if (guild === null) return 'Direct Messages';
return `${guild.name}[${cyan(guild.id)}]`;
}
export function createEmbed(userOrMember: User | GuildMember, type: keyof typeof globalConfig.colors = 'success'): EmbedBuilder {
const color = globalConfig.colors[type];
const user = userOrMember instanceof GuildMember ? userOrMember.user : userOrMember;
return new EmbedBuilder() //
.setAuthor({
name: user.username,
iconURL: user.avatarURL({ forceStatic: false }) ?? ''
})
.setColor(color)
.setTimestamp(new Date());
}
export function validateUrl(url: string): Result<URL, TypeError> {
return Result.from<URL, TypeError>(() => new URL(url));
}
export function getFullCommandName(i: ChatInputCommandInteraction) {
const opts = i.options;
const baseName = i.commandName;
const subcommandGroup = opts.getSubcommandGroup(false);
const subcommand = opts.getSubcommand(false);
const parts: string[] = [baseName];
if (!isNullish(subcommandGroup)) parts.push(subcommandGroup);
if (!isNullish(subcommand)) parts.push(subcommand);
return parts.join(' ');
}
export function isEmpty<T>(arr: T[]) {
return arr.length > 0;
}
export async function tryAddRole(role: RoleResolvable, member: GuildMember): Promise<Result<GuildMember, string>> {
const a = await member.roles
.add(role)
.then((r) => r)
.catch((e) => e as string);
return a instanceof GuildMember ? ok(a) : err(a);
}
export async function bulkAddRoles(roles: RoleResolvable[], member: GuildMember) {
const failed: string[] = [];
const successful: string[] = [];
roles.forEach(async (role) => {
const res = await tryAddRole(role, member);
res.match({
ok: () => successful.push(role instanceof Role ? role.id : role),
err: () => failed.push(role instanceof Role ? role.id : role)
});
});
return { failed, successful };
}
export function compressCustomId<T>(params: T, customMessagePart?: string): Result<string, string> {
const serializedId = brotliCompressSync(serialize<T>(params)).toString('binary');
if (serializedId.length > 80) {
const resolvedCustomMessagePart = customMessagePart ?? '';
return err(
`Due to Discord API limitations I was unable to resolve that request. ${resolvedCustomMessagePart}This issue will be fixed in the future.`
);
}
return ok(serializedId);
}
export function decompressCustomId<T>(content: string): Result<T, Error> {
const result = Result.from<T, Error>(() =>
//
deserialize<T>(brotliDecompressSync(Buffer.from(content, 'binary')))
);
return result;
}
export function formatWelcomeLeaveMessage(msg: string, member: GuildMember, guild: Guild) {
return msg //
.replaceAll(/{user}/g, member.user.username)
.replaceAll(/{guild}/g, guild.name);
}
export function isJson(x: any): boolean {
try {
JSON.parse(x);
return true;
} catch (_) {
return false;
}
}

View File

@ -0,0 +1,23 @@
import type { ChatInputCommandDeniedPayload, Events } from '@sapphire/framework';
import { Listener, UserError } from '@sapphire/framework';
export class UserEvent extends Listener<typeof Events.ChatInputCommandDenied> {
public override async run({ context, message: content }: UserError, { interaction }: ChatInputCommandDeniedPayload) {
// `context: { silent: true }` should make UserError silent:
// Use cases for this are for example permissions error when running the `eval` command.
if (Reflect.get(Object(context), 'silent')) return;
if (interaction.deferred || interaction.replied) {
return interaction.editReply({
content,
allowedMentions: { users: [interaction.user.id], roles: [] }
});
}
return interaction.reply({
content,
allowedMentions: { users: [interaction.user.id], roles: [] },
ephemeral: true
});
}
}

View File

@ -0,0 +1,14 @@
import { Listener, LogLevel, type ChatInputCommandSuccessPayload } from '@sapphire/framework';
import type { Logger } from '@sapphire/plugin-logger';
import { logSuccessCommand } from '#lib/utils';
export class UserListener extends Listener {
public override run(payload: ChatInputCommandSuccessPayload) {
logSuccessCommand(payload);
}
public override onLoad() {
this.enabled = (this.container.logger as Logger).level <= LogLevel.Debug;
return super.onLoad();
}
}

View File

@ -0,0 +1,23 @@
import type { ContextMenuCommandDeniedPayload, Events } from '@sapphire/framework';
import { Listener, UserError } from '@sapphire/framework';
export class UserEvent extends Listener<typeof Events.ContextMenuCommandDenied> {
public override async run({ context, message: content }: UserError, { interaction }: ContextMenuCommandDeniedPayload) {
// `context: { silent: true }` should make UserError silent:
// Use cases for this are for example permissions error when running the `eval` command.
if (Reflect.get(Object(context), 'silent')) return;
if (interaction.deferred || interaction.replied) {
return interaction.editReply({
content,
allowedMentions: { users: [interaction.user.id], roles: [] }
});
}
return interaction.reply({
content,
allowedMentions: { users: [interaction.user.id], roles: [] },
ephemeral: true
});
}
}

View File

@ -0,0 +1,14 @@
import { Listener, LogLevel, type ContextMenuCommandSuccessPayload } from '@sapphire/framework';
import type { Logger } from '@sapphire/plugin-logger';
import { logSuccessCommand } from '#lib/utils';
export class UserListener extends Listener {
public override run(payload: ContextMenuCommandSuccessPayload) {
logSuccessCommand(payload);
}
public override onLoad() {
this.enabled = (this.container.logger as Logger).level <= LogLevel.Debug;
return super.onLoad();
}
}

View File

@ -0,0 +1,12 @@
import type { Events, MessageCommandDeniedPayload } from '@sapphire/framework';
import { Listener, type UserError } from '@sapphire/framework';
export class UserEvent extends Listener<typeof Events.MessageCommandDenied> {
public override async run({ context, message: content }: UserError, { message }: MessageCommandDeniedPayload) {
// `context: { silent: true }` should make UserError silent:
// Use cases for this are for example permissions error when running the `eval` command.
if (Reflect.get(Object(context), 'silent')) return;
return message.reply({ content, allowedMentions: { users: [message.author.id], roles: [] } });
}
}

View File

@ -0,0 +1,15 @@
import type { MessageCommandSuccessPayload } from '@sapphire/framework';
import { Listener, LogLevel } from '@sapphire/framework';
import type { Logger } from '@sapphire/plugin-logger';
import { logSuccessCommand } from '#lib/utils';
export class UserEvent extends Listener {
public override run(payload: MessageCommandSuccessPayload) {
logSuccessCommand(payload);
}
public override onLoad() {
this.enabled = (this.container.logger as Logger).level <= LogLevel.Debug;
return super.onLoad();
}
}

View File

@ -0,0 +1,59 @@
import { ChatInputCommand, Listener } from '@sapphire/framework';
import { createEmbed, getFullCommandName } from '#lib/utils';
import { ApplyOptions } from '@sapphire/decorators';
import { ChatInputSubcommandErrorPayload, SubcommandPluginEvents } from '@sapphire/plugin-subcommands';
import { WyvernErrorHandler } from '@wyvern/error-handler';
import { CommandFailedError } from '#lib/errors';
import { codeblock } from '@wyvern/markdown';
@ApplyOptions<Listener.Options>({
event: SubcommandPluginEvents.ChatInputSubcommandError
})
export class UserListener extends Listener<typeof SubcommandPluginEvents.ChatInputSubcommandError> {
public override async run(error: Error, payload: ChatInputSubcommandErrorPayload) {
const { interaction, context } = payload;
if (error instanceof CommandFailedError) {
await this.handleCommandFailedError(error, interaction);
return;
}
if (error instanceof CommandFailedError) {
await this.handleCommandFailedError(error, interaction);
return;
}
const handler = new WyvernErrorHandler();
handler.handleCommandError({
error,
commandId: context.commandId,
commandName: getFullCommandName(interaction),
guildId: interaction.guildId!,
guildName: interaction.guild!.name!,
userId: interaction.user.id,
username: interaction.user.username
});
const embed = createEmbed(interaction.user, 'error') //
.setTitle('This is embarrassing')
.setDescription(codeblock(error.message));
const method = interaction.deferred || interaction.replied ? 'editReply' : 'reply';
await interaction[method]({ embeds: [embed] });
}
private async handleCommandFailedError(error: CommandFailedError, interaction: ChatInputCommand.Interaction) {
const embed = createEmbed(interaction.user, 'error') //
.setTitle('Something went wrong')
.setDescription(error.message);
const method = interaction.deferred || interaction.replied ? 'editReply' : 'reply';
await interaction[method]({ embeds: [embed] });
}
}

View File

@ -0,0 +1,48 @@
import { bulkAddRoles, createEmbed, formatWelcomeLeaveMessage } from '#lib/utils';
import { ApplyOptions } from '@sapphire/decorators';
import { Events } from '@sapphire/framework';
import { Listener } from '@sapphire/framework';
import { isNullish } from '@sapphire/utilities';
import { getGuild, prismaClient } from '@wyvern/database';
import type { ColorResolvable, GuildMember } from 'discord.js';
@ApplyOptions<Listener.Options>({
event: Events.GuildMemberAdd
})
export class UserEvent extends Listener<typeof Events.GuildMemberAdd> {
public override async run(member: GuildMember) {
this.autoRole(member);
this.welcome(member);
}
private async autoRole(member: GuildMember) {
const { autoRoles: roles } = await prismaClient.guild.findFirstOrThrow({
where: { guildId: member.guild.id },
select: { autoRoles: true }
});
await bulkAddRoles(roles, member);
}
private async welcome(member: GuildMember) {
const res = await prismaClient.guild.findFirstOrThrow({
where: { guildId: member.guild!.id },
include: { guildWelcomeMessage: true }
});
const { title, description, embedColor, channelId } = res!.guildWelcomeMessage!;
if (isNullish(channelId)) return;
const channel = await member.guild.channels.fetch(channelId);
if (isNullish(channel) || channel?.isTextBased() || !channel.isSendable()) return;
const embed = createEmbed(member)
.setTitle(formatWelcomeLeaveMessage(title, member, member.guild))
.setDescription(formatWelcomeLeaveMessage(description, member, member.guild))
.setColor(embedColor as ColorResolvable);
await channel.send({ embeds: [embed] });
}
}

View File

@ -0,0 +1,59 @@
import { InternalError } from '#lib/errors';
import { ApplyOptions } from '@sapphire/decorators';
import { Events, Listener } from '@sapphire/framework';
import { getGuild } from '@wyvern/database';
import { Message } from 'discord.js';
import { fetch, FetchResultTypes } from '@sapphire/fetch';
@ApplyOptions<Listener.Options>({
event: Events.MessageCreate
})
export class UserEvent extends Listener<typeof Events.MessageCreate> {
override async run(msg: Message) {
await this._autoCarbon(msg);
}
private async _autoCarbon(msg: Message<boolean>) {
const guild = await getGuild(msg.guild!.id);
const autoCarbonEnabled = guild.match({
ok: (g) => g.autoCarbon,
err: (e) => {
throw new InternalError(e.message);
}
});
if (!autoCarbonEnabled) return;
const re = /^```(?<lang>\w+)?\n(?<code>.+?)(?:\n)?```$/s;
if (!re.test(msg.content)) return;
const groups = re.exec(msg.content)!.groups!;
const result = await fetch(
'https://carbonara.aero.bot/api/cook',
{
body: JSON.stringify({
code: groups.code,
language: 'auto',
theme: 'one-dark',
backgroundColor: 'rgb(54, 57, 63)',
fontFamily: 'JetBrains Mono',
paddingHorizontal: '20px',
paddingVertical: '20px',
windowControls: 'false',
dropShadowBlurRadius: '10px',
dropShadowOffsetY: '0px'
}),
method: 'POST',
headers: [['Content-Type', 'application/json']]
},
FetchResultTypes.Buffer
);
const body = null;
this.container.logger.debug(body);
}
}

View File

@ -0,0 +1,53 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Listener } from '@sapphire/framework';
import type { StoreRegistryValue } from '@sapphire/pieces';
import { blue, green, magenta, magentaBright, white } from 'colorette';
const dev = process.env.NODE_ENV !== 'production';
@ApplyOptions<Listener.Options>({ once: true })
export class UserEvent extends Listener {
// private readonly style = dev ? yellow : blue;
public override async run() {
this.printBanner();
this.printStoreDebugInformation();
}
private printBanner() {
const success = green('+');
const llc = dev ? magentaBright : white;
const blc = dev ? magenta : blue;
const line01 = llc('');
const line02 = llc('');
const line03 = llc('');
// Offset Pad
const pad = ' '.repeat(7);
console.log(
String.raw`
${line01} ${pad}${blc('1.0.0')}
${line02} ${pad}[${success}] Gateway
${line03}${dev ? ` ${pad}${blc('<')}${llc('/')}${blc('>')} ${llc('DEVELOPMENT MODE')}` : ''}
`.trim()
);
}
private printStoreDebugInformation() {
const { client, logger } = this.container;
const stores = [...client.stores.values()];
const last = stores.pop()!;
for (const store of stores) logger.info(this.styleStore(store, false));
logger.info(this.styleStore(last, true));
}
private styleStore(store: StoreRegistryValue, last: boolean) {
return `${last ? '└─' : '├─'} Loaded ${store.size.toString().padEnd(3, ' ')} ${store.name}.`;
// return gray(`${last ? '└─' : '├─'} Loaded ${this.style(store.size.toString().padEnd(3, ' '))} ${store.name}.`);
}
}

View File

@ -0,0 +1,30 @@
import { AllFlowsPrecondition } from '@sapphire/framework';
import type { CommandInteraction, ContextMenuCommandInteraction, Message, Snowflake } from 'discord.js';
const OWNERS = ['424239181296959507'];
export class UserPrecondition extends AllFlowsPrecondition {
#message = 'This command can only be used by the owner.';
public override chatInputRun(interaction: CommandInteraction) {
return this.doOwnerCheck(interaction.user.id);
}
public override contextMenuRun(interaction: ContextMenuCommandInteraction) {
return this.doOwnerCheck(interaction.user.id);
}
public override messageRun(message: Message) {
return this.doOwnerCheck(message.author.id);
}
private doOwnerCheck(userId: Snowflake) {
return OWNERS.includes(userId) ? this.ok() : this.error({ message: this.#message });
}
}
declare module '@sapphire/framework' {
interface Preconditions {
OwnerOnly: never;
}
}

View File

@ -0,0 +1,40 @@
import { AllFlowsPrecondition, Command } from '@sapphire/framework';
import { isNullish } from '@sapphire/utilities';
import { getGuild, Guild } from '@wyvern/database';
import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message, Snowflake } from 'discord.js';
export interface RequiredSettingPreconditionContext extends AllFlowsPrecondition.Context {
setting: keyof Omit<Guild, 'id'>;
}
export class UserPrecondition extends AllFlowsPrecondition {
public override chatInputRun(interaction: ChatInputCommandInteraction, _: Command, context: RequiredSettingPreconditionContext) {
return this.sharedRun(interaction.user.id, context);
}
public override contextMenuRun(interaction: ContextMenuCommandInteraction, _: Command, context: RequiredSettingPreconditionContext) {
return this.sharedRun(interaction.user.id, context);
}
public override messageRun(message: Message, _: Command, context: RequiredSettingPreconditionContext) {
return this.sharedRun(message.author.id, context);
}
private async sharedRun(guildId: Snowflake, { setting }: RequiredSettingPreconditionContext) {
const guildRes = await getGuild(guildId);
if (guildRes.isErr()) return this.error({ identifier: 'NO_SERVER_SETTING' });
const guild = guildRes.unwrap();
const value = guild[setting];
return isNullish(value) ? this.error({ message: 'A required server setting is unset', context: { setting } }) : this.ok();
}
}
declare module '@sapphire/framework' {
interface Preconditions {
RequiredSetting: RequiredSettingPreconditionContext;
}
}

View File

@ -0,0 +1,13 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Route, methods, type ApiRequest, type ApiResponse } from '@sapphire/plugin-api';
@ApplyOptions<Route.Options>({ route: 'hello-world' })
export class UserRoute extends Route {
public override [methods.GET](_request: ApiRequest, response: ApiResponse) {
response.json({ message: 'Hello World' });
}
public override [methods.POST](_request: ApiRequest, response: ApiResponse) {
response.json({ message: 'Hello World' });
}
}

View File

@ -0,0 +1,13 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Route, methods, type ApiRequest, type ApiResponse } from '@sapphire/plugin-api';
@ApplyOptions<Route.Options>({ route: `` })
export class UserRoute extends Route {
public override [methods.GET](_request: ApiRequest, response: ApiResponse) {
response.json({ message: 'Landing Page!' });
}
public override [methods.POST](_request: ApiRequest, response: ApiResponse) {
response.json({ message: 'Landing Page!' });
}
}

63
apps/bot/tsconfig.json Normal file
View File

@ -0,0 +1,63 @@
{
"extends": [
"@sapphire/ts-config",
"@sapphire/ts-config/extra-strict",
"@sapphire/ts-config/decorators"
],
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"baseUrl": ".",
"tsBuildInfoFile": "dist/.tsbuildinfo",
"allowJs": true,
"moduleDetection": "auto",
"esModuleInterop": true,
"paths": {
"#lib/structures": [
"src/lib/structures/index.ts"
],
"#lib/interfaces": [
"src/lib/interfaces/index.ts"
],
"#lib/parsers": [
"src/lib/parsers/index.ts"
],
"#lib/utils": [
"src/lib/utils.ts"
],
"#lib/config": [
"src/lib/config.ts"
],
"#lib/types": [
"src/lib/types.ts"
],
"#lib/constants": [
"src/lib/constants.ts"
],
"#lib/errors": [
"src/lib/errors/index.ts"
],
"#lib/managers": [
"src/lib/managers/index.ts"
],
"#lib/customIds": [
"src/lib/customIdTypes/index.ts"
],
"#lib/markdown": [
"src/lib/markdown/index.ts"
],
"#drizzle": [
"src/drizzle/schema.ts"
],
"#prisma": [
"src/lib/prisma.ts"
],
"#stringFormatters": [
"src/lib/stringFormatters/index.ts"
]
},
},
"include": [
"src"
]
}

18
apps/bot/tsup.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from 'tsup';
export default defineConfig({
clean: true,
bundle: false,
dts: false,
entry: ['src/**/*.ts', '!src/**/*.d.ts'],
format: ['esm'],
minify: false,
tsconfig: 'tsconfig.json',
// target: 'es2020',
target: 'esnext',
splitting: false,
skipNodeModulesBundle: true,
sourcemap: true,
shims: false,
keepNames: true
});

BIN
bun.lockb Normal file

Binary file not shown.

46
docker-compose.yaml Normal file
View File

@ -0,0 +1,46 @@
services:
db:
image: postgres:latest
container_name: majoexe-db
restart: always
user: postgres
environment:
- POSTGRES_DB=majoexe
- POSTGRES_PASSWORD=0ce267bcae32edcb51e257d0f4439e7fb1e3df3c6577782468550d7cd5a531ea
volumes:
- database:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- app_network
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
seq:
image: datalust/seq:latest
container_name: seq
restart: always
networks:
- app_network
environment:
- ACCEPT_EULA=Y
- SEQ_FIRSTRUN_ADMINPASSWORDHASH=QJklU79+04hywc4hPkuRIDzsDzqhuVQ4aoVfA0IfP/LLEy8HwWWMfhA8YVnCo4gv18+igc70YaUWRnMdKZAzBc/Cms0hL/+hdXNBDYb4daWu
volumes:
- seq_data:/data
ports:
- 5341:80
networks:
app_network:
driver: bridge
volumes:
database:
driver: local
cache:
driver: local
seq_data:
driver: local

43
package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "@wyvern/monorepo",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"prisma:generate": "turbo run prisma:generate ",
"prisma:generateSql": "turbo run prisma:generateSql ",
"prisma:push": "turbo run prisma:push",
"prisma:migrate": "turbo run prisma:migrate",
"typecheck": "turbo run typecheck"
},
"devDependencies": {
"@sapphire/framework": "^5.3.1",
"colorette": "^2.0.20",
"discord.js": "^14.16.3",
"prettier": "^3.2.5",
"turbo": "^2.2.3",
"typescript": "5.5.4"
},
"engines": {
"node": ">=18"
},
"packageManager": "bun@1.1.34",
"workspaces": [
"apps/*",
"packages/*"
],
"trustedDependencies": [
"@prisma/client",
"@prisma/engines",
"@sapphire/type",
"core-js-pure",
"esbuild",
"prisma"
],
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"binarytf": "^2.1.3"
}
}

View File

@ -0,0 +1,29 @@
{
"name": "@wyvern/config",
"version": "0.0.0",
"type": "module",
"private": true,
"license": "MIT",
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "tsc --watch",
"build": "tsc",
"typecheck": "tsc --noEmit --skipLibCheck"
},
"exports": {
".": {
"default": "./dist/src/index.js",
"types": "./src/index.ts"
},
"./debug": {
"default": "./dist/src/configs/debug.js",
"types": "./src/configs/debug.ts"
},
"./global": {
"default": "./dist/src/configs/global.js",
"types": "./src/configs/global.ts"
}
}
}

View File

@ -0,0 +1,11 @@
export const debugConfig: DebugConfig = {
displayDatabaseLogs: false,
logLevel: "",
};
type LogLevel = "";
interface DebugConfig {
displayDatabaseLogs: boolean;
logLevel: LogLevel;
}

View File

@ -0,0 +1,20 @@
export const globalConfig: GlobalConfig = {
guildIds: ["1072895701970858026", "739115569311383634"],
colors: {
success: "#f5c931",
warning: "#d7d1b5",
error: "#ffadad",
},
};
interface GlobalConfig {
colors: Colors;
guildIds: string[];
}
type HexCode = `#${string}`;
interface Colors {
success: HexCode;
warning: HexCode;
error: HexCode;
}

View File

@ -0,0 +1,2 @@
export * from "./configs/debug.js";
export * from "./configs/global.js";

View File

@ -0,0 +1,9 @@
{
"extends": "@wyvern/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

File diff suppressed because one or more lines are too long

3
packages/database/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
# Keep environment variables out of version control
.env

View File

@ -0,0 +1,37 @@
{
"name": "@wyvern/database",
"version": "0.0.0",
"type": "module",
"private": true,
"license": "MIT",
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "tsc --watch",
"build": "tsc",
"prisma:generate": "prisma generate",
"prisma:generateSql": "prisma generate --sql",
"prisma:push": "prisma db push",
"prisma:migrate": "prisma migrate dev",
"typecheck": "tsc --noEmit --skipLibCheck"
},
"exports": {
".": {
"default": "./dist/src/index.js",
"types": "./src/index.ts"
}
},
"dependencies": {
"@prisma/client": "^5.22.0",
"@wyvern/config": "*",
"@wyvern/plugin-custom-logger": "*"
},
"devDependencies": {
"client": "^0.0.1",
"prisma": "^5.22.0"
},
"trustedDependencies": [
"ws"
]
}

View File

@ -0,0 +1,78 @@
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Guild" (
"id" TEXT NOT NULL,
"guild_id" TEXT NOT NULL,
"auto_roles" TEXT[],
"report_channel" TEXT,
CONSTRAINT "Guild_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "guild_welcome_message" (
"id" TEXT NOT NULL,
"guild_id" TEXT NOT NULL,
"channel_id" TEXT NOT NULL,
"title" TEXT NOT NULL DEFAULT '🎉 Welcome to the server {user}!',
"description" TEXT NOT NULL DEFAULT '> Welcome to **{guild}** We hope you enjoy your stay here!',
"embed_color" TEXT NOT NULL DEFAULT '#5865F2',
"enabled" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "guild_welcome_message_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "guild_leave_message" (
"id" TEXT NOT NULL,
"guild_id" TEXT NOT NULL,
"channel_id" TEXT NOT NULL,
"title" TEXT NOT NULL DEFAULT '👋 Goodbye {user}!',
"description" TEXT NOT NULL DEFAULT '> We''re sorry to see you go!',
"embed_color" TEXT NOT NULL DEFAULT '#5865F2',
"enabled" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "guild_leave_message_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"content" TEXT NOT NULL,
"usages" INTEGER NOT NULL DEFAULT 0,
"image" TEXT,
"ownerId" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Guild_guild_id_key" ON "Guild"("guild_id");
-- CreateIndex
CREATE UNIQUE INDEX "guild_welcome_message_guild_id_key" ON "guild_welcome_message"("guild_id");
-- CreateIndex
CREATE UNIQUE INDEX "guild_leave_message_guild_id_key" ON "guild_leave_message"("guild_id");
-- AddForeignKey
ALTER TABLE "guild_welcome_message" ADD CONSTRAINT "guild_welcome_message_guild_id_fkey" FOREIGN KEY ("guild_id") REFERENCES "Guild"("guild_id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "guild_leave_message" ADD CONSTRAINT "guild_leave_message_guild_id_fkey" FOREIGN KEY ("guild_id") REFERENCES "Guild"("guild_id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "guild_leave_message" ALTER COLUMN "channel_id" DROP NOT NULL;
-- AlterTable
ALTER TABLE "guild_welcome_message" ALTER COLUMN "channel_id" DROP NOT NULL;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Guild" ADD COLUMN "auto_carbon" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@ -0,0 +1,83 @@
// 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-js"
previewFeatures = ["typedSql"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id
ownedTags Tag[]
@@map("user")
}
model Guild {
id String @id @default(cuid())
guildId String @unique @map("guild_id")
tags Tag[]
// Settings
autoRoles String[] @map("auto_roles")
reportChannel String? @map("report_channel")
guildWelcomeMessage GuildWelcomeMessage?
guildLeaveMessage GuildLeaveMessage?
autoCarbon Boolean @default(false) @map("auto_carbon")
}
// Guild welcome message
model GuildWelcomeMessage {
id String @id @default(cuid())
guildId String @unique @map(name: "guild_id")
channelId String? @map(name: "channel_id")
title String @default("🎉 Welcome to the server {user}!")
description String @default("> Welcome to **{guild}** We hope you enjoy your stay here!")
embedColor String @default("#5865F2") @map(name: "embed_color")
enabled Boolean @default(false)
createdAt DateTime @default(now()) @map(name: "created_at")
guild Guild @relation(fields: [guildId], references: [guildId], onDelete: Cascade)
@@map(name: "guild_welcome_message")
}
// Guild leave message
model GuildLeaveMessage {
id String @id @default(cuid())
guildId String @unique @map(name: "guild_id")
channelId String? @map(name: "channel_id")
title String @default("👋 Goodbye {user}!")
description String @default("> We're sorry to see you go!")
embedColor String @default("#5865F2") @map(name: "embed_color")
enabled Boolean @default(false)
createdAt DateTime @default(now()) @map(name: "created_at")
guild Guild @relation(fields: [guildId], references: [guildId], onDelete: Cascade)
@@map(name: "guild_leave_message")
}
model Tag {
id String @id @default(cuid())
owner User @relation(fields: [ownerId], references: [id])
guild Guild @relation(fields: [guildId], references: [id])
name String
content String
usages Int @default(0)
image String?
ownerId String
guildId String
}

View File

@ -0,0 +1,8 @@
-- @param {String} $1:role_id
-- @param {String} $2:guild_id
UPDATE
"public"."Guild"
SET
"AutoRoles" = array_append("AutoRoles", $1)
WHERE
"id" = $2;

View File

@ -0,0 +1,27 @@
-- @param {String} $1:content
-- @param {String} $2:image
-- @param {String} $3:name
-- @param {String} $4:guildId
-- @param {String} $5:ownerId
-- @param {String} $6:id
INSERT INTO
"Tag" (
"Content",
"Image",
"Name",
"guildId",
"ownerId",
"Id"
)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6
)

View File

@ -0,0 +1,11 @@
-- @param {String} $1:guildId
-- @param {String} $2:name
SELECT
*
FROM
"Tag"
WHERE
"guildId" = $1
AND "Name" = $2
LIMIT
1

View File

@ -0,0 +1,8 @@
-- @param {String} $1:role_id
-- @param {String} $2:guild_id
UPDATE
"public"."Guild"
SET
"AutoRoles" = array_remove("AutoRoles", $1)
WHERE
"id" = $2;

View File

@ -0,0 +1,10 @@
import { Guild, Prisma, prismaClient } from "../index.js";
import { Result } from "@sapphire/framework";
export async function getGuild(id: string) {
return Result.fromAsync<Guild, Prisma.PrismaClientKnownRequestError>(
prismaClient.guild.findFirstOrThrow({
where: { guildId: id },
})
);
}

View File

@ -0,0 +1 @@
export * from "./getGuild.js";

View File

@ -0,0 +1,53 @@
import { PrismaClient } from "@prisma/client";
import { wyvernLogger } from "@wyvern/plugin-custom-logger";
import { debugConfig } from "@wyvern/config/debug";
const log = wyvernLogger.child({ activity: "database" });
const pris = new PrismaClient({
log: [
{ emit: "event", level: "query" },
{ emit: "event", level: "warn" },
{ emit: "event", level: "error" },
],
});
const prismaClientWrapper = (): PrismaClient => {
if (debugConfig.displayDatabaseLogs) {
pris.$on("query", ({ duration, query }) => {
log.info("Query: %s", query);
log.info("Duration: %dms", duration);
});
pris.$on("warn", ({ message }) => {
wyvernLogger.warn(`Prisma Warning: %s`, message);
});
pris.$on("error", ({ message }) => {
wyvernLogger.warn(`Prisma Error: %s`, message);
});
}
return pris;
};
const prismaClientSingleton = () => {
return prismaClientWrapper();
};
declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;
// const prisma: PrismaClient = globalThis.prismaGlobal ?? prismaClientSingleton();
export const prismaClient = globalThis.prismaGlobal ?? prismaClientSingleton();
if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = pris;
// also export types
export * from "@prisma/client";
export * from "@prisma/client/sql";
// export helper funcs
export * from "./helpers/index.js";

View File

@ -0,0 +1,9 @@
{
"extends": "@wyvern/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,21 @@
{
"name": "@wyvern/error-handler",
"version": "0.0.0",
"type": "module",
"private": true,
"license": "MIT",
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "tsc --watch",
"build": "tsc",
"typecheck": "tsc --noEmit --skipLibCheck"
},
"exports": {
".": {
"default": "./dist/src/index.js",
"types": "./src/index.ts"
}
}
}

View File

@ -0,0 +1,53 @@
import * as Sentry from "@sentry/node";
export class WyvernErrorHandler {
constructor() {
const dsn = process.env.SENTRY_DSN;
if (dsn == undefined) {
throw new Error("process.env.SENTRY_DSN is undefined");
}
if (!Sentry.isInitialized()) {
Sentry.init({
dsn,
});
}
}
public async handleCommandError(options: CommandErrorOptions) {
if (process.env.NODE_ENV != "production") return;
Sentry.captureException(options.error, (scope) => {
scope.setContext("Command", {
commandName: options.commandName,
commandId: options.commandId,
});
scope.setContext("Guild", {
commandName: options.guildName,
commandId: options.guildId,
});
scope.setUser({
username: options.username,
id: options.userId,
});
return scope;
});
}
}
interface CommandErrorOptions {
error: Error;
commandName: string;
commandId: string;
username: string;
userId: string;
guildName: string;
guildId: string;
}

View File

@ -0,0 +1 @@
export * from "./WyvernErrorHandler.js";

View File

@ -0,0 +1,9 @@
{
"extends": "@wyvern/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,3 @@
# `@turbo/eslint-config`
Collection of internal eslint configurations.

View File

@ -0,0 +1,34 @@
const { resolve } = require("node:path");
const project = resolve(process.cwd(), "tsconfig.json");
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["eslint:recommended", "prettier", "turbo"],
plugins: ["only-warn"],
globals: {
React: true,
JSX: true,
},
env: {
node: true,
},
settings: {
"import/resolver": {
typescript: {
project,
},
},
},
ignorePatterns: [
// Ignore dotfiles
".*.js",
"node_modules/",
"dist/",
],
overrides: [
{
files: ["*.js?(x)", "*.ts?(x)"],
},
],
};

Some files were not shown because too many files have changed in this diff Show More