diff --git a/README.md b/README.md index c4e3764..8847610 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ make migrate-reset ```sh bun commands -bun start +bun start-discord # run discord bot ```

🐳 Develop w/ Docker

diff --git a/bun.lock b/bun.lock index cd4e435..e2c7014 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,28 @@ "@antfu/eslint-config": "^5.3.0", }, }, + "packages/aksaria-core": { + "name": "aksaria-core", + "version": "0.1.0", + "dependencies": { + "@prisma/client": "6.13.0", + }, + "devDependencies": { + "typescript": "^5.0.0", + }, + }, + "packages/aksaria-discord": { + "name": "aksaria-discord", + "version": "0.1.0", + "dependencies": { + "aksaria-core": "*", + "discord.js": "^14.25.1", + "node-cron": "^4.2.1", + }, + "devDependencies": { + "typescript": "^5.0.0", + }, + }, }, "packages": { "@antfu/eslint-config": ["@antfu/eslint-config@5.3.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@clack/prompts": "^0.11.0", "@eslint-community/eslint-plugin-eslint-comments": "^4.5.0", "@eslint/markdown": "^7.2.0", "@stylistic/eslint-plugin": "^5.3.1", "@typescript-eslint/eslint-plugin": "^8.42.0", "@typescript-eslint/parser": "^8.42.0", "@vitest/eslint-plugin": "^1.3.9", "ansis": "^4.1.0", "cac": "^6.7.14", "eslint-config-flat-gitignore": "^2.1.0", "eslint-flat-config-utils": "^2.1.1", "eslint-merge-processors": "^2.0.0", "eslint-plugin-antfu": "^3.1.1", "eslint-plugin-command": "^3.3.1", "eslint-plugin-import-lite": "^0.3.0", "eslint-plugin-jsdoc": "^54.5.0", "eslint-plugin-jsonc": "^2.20.1", "eslint-plugin-n": "^17.21.3", "eslint-plugin-no-only-tests": "^3.3.0", "eslint-plugin-perfectionist": "^4.15.0", "eslint-plugin-pnpm": "^1.1.1", "eslint-plugin-regexp": "^2.10.0", "eslint-plugin-toml": "^0.12.0", "eslint-plugin-unicorn": "^61.0.2", "eslint-plugin-unused-imports": "^4.2.0", "eslint-plugin-vue": "^10.4.0", "eslint-plugin-yml": "^1.18.0", "eslint-processor-vue-blocks": "^2.0.0", "globals": "^16.3.0", "jsonc-eslint-parser": "^2.4.0", "local-pkg": "^1.1.2", "parse-gitignore": "^2.0.0", "toml-eslint-parser": "^0.10.0", "vue-eslint-parser": "^10.2.0", "yaml-eslint-parser": "^1.3.0" }, "peerDependencies": { "@eslint-react/eslint-plugin": "^1.38.4", "@next/eslint-plugin-next": "^15.4.0-canary.115", "@prettier/plugin-xml": "^3.4.1", "@unocss/eslint-plugin": ">=0.50.0", "astro-eslint-parser": "^1.0.2", "eslint": "^9.10.0", "eslint-plugin-astro": "^1.2.0", "eslint-plugin-format": ">=0.1.0", "eslint-plugin-jsx-a11y": ">=6.10.2", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-solid": "^0.14.3", "eslint-plugin-svelte": ">=2.35.1", "eslint-plugin-vuejs-accessibility": "^2.4.1", "prettier-plugin-astro": "^0.14.0", "prettier-plugin-slidev": "^1.0.5", "svelte-eslint-parser": ">=0.37.0" }, "optionalPeers": ["@eslint-react/eslint-plugin", "@next/eslint-plugin-next", "@prettier/plugin-xml", "@unocss/eslint-plugin", "astro-eslint-parser", "eslint-plugin-astro", "eslint-plugin-format", "eslint-plugin-jsx-a11y", "eslint-plugin-react-hooks", "eslint-plugin-react-refresh", "eslint-plugin-solid", "eslint-plugin-svelte", "eslint-plugin-vuejs-accessibility", "prettier-plugin-astro", "prettier-plugin-slidev", "svelte-eslint-parser"], "bin": { "eslint-config": "bin/index.js" } }, "sha512-VzBemSi453rd06lF6gG6VkpP3HH7XKTf+sK6frSrGm7uMFkN57jry1XB074tQRKB3qOjhpsx3kKpWtOv9e5FnQ=="], @@ -167,6 +189,10 @@ "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "aksaria-core": ["aksaria-core@workspace:packages/aksaria-core"], + + "aksaria-discord": ["aksaria-discord@workspace:packages/aksaria-discord"], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansis": ["ansis@4.1.0", "", {}, "sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w=="], diff --git a/docker/Dockerfile b/docker/Dockerfile index 5e0e582..6dd5a61 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -12,11 +12,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-cert # Dev deps (for Prisma generate) RUN mkdir -p /temp/dev COPY package.json bun.lock /temp/dev/ +COPY packages/aksaria-core/package.json /temp/dev/packages/aksaria-core/ +COPY packages/aksaria-discord/package.json /temp/dev/packages/aksaria-discord/ RUN cd /temp/dev && bun install # install with --production (exclude devDependencies) RUN mkdir -p /temp/prod COPY package.json bun.lock /temp/prod/ +COPY packages/aksaria-core/package.json /temp/prod/packages/aksaria-core/ +COPY packages/aksaria-discord/package.json /temp/prod/packages/aksaria-discord/ COPY db /temp/prod/db COPY prisma.config.ts /temp/prod/ RUN cd /temp/prod && bun install --production && bun prisma @@ -27,7 +31,11 @@ ENV NODE_ENV=production COPY --chmod=755 docker/entrypoint.sh /entrypoint.sh COPY --from=install /temp/prod/node_modules node_modules COPY --from=install /temp/prod/db ./db -COPY src ./src + +# Copy monorepo packages +COPY packages/aksaria-core ./packages/aksaria-core +COPY packages/aksaria-discord ./packages/aksaria-discord + COPY prisma.config.ts . COPY package.json . COPY tsconfig.json . diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index c7ff71b..5319f68 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -22,5 +22,5 @@ echo "β–Ά Starting application..." if [ "$NODE_ENV" = "production" ]; then exec bun prod else - exec bun start + exec bun start-discord fi diff --git a/package.json b/package.json index 8cc4040..b9fe2f9 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,16 @@ "keywords": [ "bot" ], - "main": "index.js", + "workspaces": [ + "packages/*" + ], "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "prod": "bun src/index.ts", - "start": "bun --watch src/index.ts", - "commands": "bun src/deploy-commands.ts", - "prisma": "bunx prisma generate --schema db/schema.prisma" + "prod": "bun packages/aksaria-discord/src/index.ts", + "start-discord": "bun --watch packages/aksaria-discord/src/index.ts", + "commands": "bun packages/aksaria-discord/src/deploy-commands.ts", + "prisma": "bunx prisma generate --schema db/schema.prisma", + "build": "npm run build -w aksaria-core && npm run build -w aksaria-discord" }, "dependencies": { "@prisma/client": "6.13.0", diff --git a/packages/aksaria-core/package.json b/packages/aksaria-core/package.json new file mode 100644 index 0000000..a9b0646 --- /dev/null +++ b/packages/aksaria-core/package.json @@ -0,0 +1,17 @@ +{ + "name": "aksaria-core", + "version": "0.1.0", + "description": "Core business logic for Aksaria platform", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "@prisma/client": "6.13.0" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/aksaria-core/src/db/client.ts b/packages/aksaria-core/src/db/client.ts new file mode 100644 index 0000000..3f5ef8c --- /dev/null +++ b/packages/aksaria-core/src/db/client.ts @@ -0,0 +1,28 @@ +import { PrismaClient } from '@generatedDB/client' + +// Singleton Prisma client instance +let prismaInstance: PrismaClient | null = null + +/** + * Get the Prisma client singleton instance + */ +export function getPrismaClient(): PrismaClient { + if (!prismaInstance) { + prismaInstance = new PrismaClient() + } + return prismaInstance +} + +/** + * Disconnect the Prisma client (useful for cleanup) + */ +export async function disconnectPrisma(): Promise { + if (prismaInstance) { + await prismaInstance.$disconnect() + prismaInstance = null + } +} + +// Re-export PrismaClient type for convenience +export type { PrismaClient } +export type { Prisma } from '@generatedDB/client' diff --git a/packages/aksaria-core/src/db/index.ts b/packages/aksaria-core/src/db/index.ts new file mode 100644 index 0000000..83dae76 --- /dev/null +++ b/packages/aksaria-core/src/db/index.ts @@ -0,0 +1 @@ +export * from './client' diff --git a/packages/aksaria-core/src/index.ts b/packages/aksaria-core/src/index.ts new file mode 100644 index 0000000..29adf18 --- /dev/null +++ b/packages/aksaria-core/src/index.ts @@ -0,0 +1,20 @@ +/** + * Aksaria Core + * Platform-agnostic business logic for the Aksaria daily checkin system + */ + +// Types +export * from './types' + +// Interfaces +export * from './interfaces' + +// Services +export * from './services' + +// Utilities +export * from './utils' + +// Database +export { getPrismaClient, disconnectPrisma } from './db' +export type { PrismaClient, Prisma } from './db' diff --git a/packages/aksaria-core/src/interfaces/index.ts b/packages/aksaria-core/src/interfaces/index.ts new file mode 100644 index 0000000..39abfbb --- /dev/null +++ b/packages/aksaria-core/src/interfaces/index.ts @@ -0,0 +1 @@ +export * from './platform-adapter' diff --git a/packages/aksaria-core/src/interfaces/platform-adapter.ts b/packages/aksaria-core/src/interfaces/platform-adapter.ts new file mode 100644 index 0000000..0a3b1d3 --- /dev/null +++ b/packages/aksaria-core/src/interfaces/platform-adapter.ts @@ -0,0 +1,59 @@ +import type { ICheckin } from '../types' + +/** + * Embed data structure for platform-agnostic message formatting + */ +export interface EmbedData { + title: string + description: string + color?: string + footer?: string + fields?: EmbedField[] +} + +export interface EmbedField { + name: string + value: string + inline?: boolean +} + +/** + * Platform adapter interface + * Implement this interface for each platform (Discord, Telegram, REST) + */ +export interface IPlatformAdapter { + /** + * Platform identifier + */ + readonly platform: string + + /** + * Send a plain text message to a user + */ + sendMessage(userId: string, content: string): Promise + + /** + * Send a rich embed message to a user + */ + sendEmbed(userId: string, embed: EmbedData): Promise + + /** + * Notify user that their checkin was approved + */ + notifyCheckinApproved(userId: string, checkin: ICheckin, reviewerName: string): Promise + + /** + * Notify user that their checkin was rejected + */ + notifyCheckinRejected(userId: string, checkin: ICheckin, reviewerName: string, reason?: string): Promise + + /** + * Notify user of successful checkin submission + */ + notifyCheckinSubmitted(userId: string, checkin: ICheckin): Promise + + /** + * Get display name for a user + */ + getUserDisplayName(userId: string): Promise +} diff --git a/packages/aksaria-core/src/services/attachment.service.ts b/packages/aksaria-core/src/services/attachment.service.ts new file mode 100644 index 0000000..5c8c6a9 --- /dev/null +++ b/packages/aksaria-core/src/services/attachment.service.ts @@ -0,0 +1,69 @@ +import type { PrismaClient } from '../db/client' +import type { AttachmentInput, IAttachment } from '../types' + +/** + * Attachment service - handles attachment-related operations + */ +export class AttachmentService { + constructor(private prisma: PrismaClient) {} + + /** + * Create attachments for a checkin + */ + async createAttachments(checkinId: number, attachments: AttachmentInput[]): Promise { + if (attachments.length === 0) return [] + + await this.prisma.attachment.createMany({ + data: attachments.map(a => ({ + name: a.name, + url: a.url, + type: a.type, + size: a.size, + module_id: checkinId, + module_type: 'CHECKIN', + })), + }) + + return this.getAttachmentsByCheckin(checkinId) + } + + /** + * Get all attachments for a checkin + */ + async getAttachmentsByCheckin(checkinId: number): Promise { + const attachments = await this.prisma.attachment.findMany({ + where: { + module_id: checkinId, + module_type: 'CHECKIN', + }, + orderBy: { created_at: 'asc' }, + }) + + return attachments.map(this.mapToIAttachment) + } + + /** + * Delete all attachments for a checkin + */ + async deleteAttachmentsByCheckin(checkinId: number): Promise { + await this.prisma.attachment.deleteMany({ + where: { + module_id: checkinId, + module_type: 'CHECKIN', + }, + }) + } + + private mapToIAttachment(attachment: any): IAttachment { + return { + id: attachment.id, + name: attachment.name, + url: attachment.url, + type: attachment.type, + size: attachment.size, + createdAt: attachment.created_at, + moduleId: attachment.module_id, + moduleType: attachment.module_type, + } + } +} diff --git a/packages/aksaria-core/src/services/checkin.service.ts b/packages/aksaria-core/src/services/checkin.service.ts new file mode 100644 index 0000000..887d6ec --- /dev/null +++ b/packages/aksaria-core/src/services/checkin.service.ts @@ -0,0 +1,293 @@ +import crypto from 'node:crypto' +import type { Prisma, PrismaClient } from '../db/client' +import type { + CheckinResult, + CheckinStatus, + ICheckin, + ICheckinStreak, + UpdateCheckinStatusInput, +} from '../types' +import { isDateToday, isStreakContinuing } from '../utils/date' + +const PUBLIC_ID_PREFIX = 'CHK-' + +/** + * Checkin service - handles checkin and streak business logic + */ +export class CheckinService { + constructor(private prisma: PrismaClient) {} + + /** + * Create a new checkin for a user + * Handles streak logic automatically + */ + async createCheckin(userId: number, description: string): Promise { + // Get the user's latest streak + const latestStreak = await this.prisma.checkinStreak.findFirst({ + where: { user_id: userId }, + orderBy: { first_date: 'desc' }, + include: { + checkins: { + orderBy: { created_at: 'desc' }, + take: 1, + }, + }, + }) + + const decision = this.determineStreakDecision(latestStreak) + + return this.prisma.$transaction(async (tx) => { + const checkinStreak = await this.upsertStreak(tx, userId, latestStreak, decision) + const checkin = await this.createCheckinRecord(tx, userId, checkinStreak.id, description) + const prevCheckin = decision === 'next' + ? await this.getPreviousCheckin(tx, userId, checkinStreak.id, checkin.id) + : null + + return { + checkin: this.mapToICheckin(checkin), + checkinStreak: this.mapToICheckinStreak(checkinStreak), + prevCheckin: prevCheckin ? this.mapToICheckin(prevCheckin) : null, + isNewStreak: decision === 'new', + } + }) + } + + /** + * Get a checkin by its ID + */ + async getCheckinById(id: number): Promise { + const checkin = await this.prisma.checkin.findUnique({ + where: { id }, + include: { user: true, checkin_streak: true }, + }) + + return checkin ? this.mapToICheckin(checkin) : null + } + + /** + * Get a checkin by its public ID + */ + async getCheckinByPublicId(publicId: string): Promise { + const checkin = await this.prisma.checkin.findUnique({ + where: { public_id: publicId }, + include: { user: true, checkin_streak: true }, + }) + + return checkin ? this.mapToICheckin(checkin) : null + } + + /** + * Get a waiting checkin by ID + */ + async getWaitingCheckin(checkinId: number): Promise { + const checkin = await this.prisma.checkin.findFirst({ + where: { + id: checkinId, + status: 'WAITING', + reviewed_by: null, + }, + include: { user: true, checkin_streak: true }, + }) + + return checkin ? this.mapToICheckin(checkin) : null + } + + /** + * Get a waiting checkin for a user + */ + async getWaitingCheckinForUser(userId: number): Promise { + const checkin = await this.prisma.checkin.findFirst({ + where: { + user_id: userId, + status: 'WAITING', + reviewed_by: null, + }, + include: { user: true, checkin_streak: true }, + }) + + return checkin ? this.mapToICheckin(checkin) : null + } + + /** + * Update checkin status (approve/reject) + */ + async updateCheckinStatus(input: UpdateCheckinStatusInput): Promise { + const { checkinId, status, reviewerId, comment, isLateCheckin } = input + + const checkin = await this.prisma.checkin.findUnique({ + where: { id: checkinId }, + }) + + if (!checkin) { + throw new Error('Checkin not found') + } + + const updatedDate = isLateCheckin ? checkin.created_at : new Date() + + const updatedCheckin = await this.prisma.checkin.update({ + where: { id: checkinId }, + data: { + status, + reviewed_by: reviewerId, + comment, + updated_at: updatedDate, + checkin_streak: { + update: { + streak: { + increment: status === 'APPROVED' ? 1 : 0, + }, + last_date: updatedDate, + updated_at: updatedDate, + }, + }, + }, + include: { checkin_streak: true, user: true }, + }) + + return this.mapToICheckin(updatedCheckin) + } + + /** + * Update checkin message link + */ + async updateCheckinLink(checkinId: number, link: string): Promise { + const updatedCheckin = await this.prisma.checkin.update({ + where: { id: checkinId }, + data: { link }, + include: { checkin_streak: true }, + }) + + return this.mapToICheckin(updatedCheckin) + } + + /** + * Check if a user has already checked in today + */ + hasCheckedInToday(streak: ICheckinStreak | undefined | null, checkin: ICheckin | undefined | null): boolean { + const streakWasToday = streak?.lastDate ? isDateToday(streak.lastDate) : false + const checkinWasToday = checkin?.createdAt ? isDateToday(checkin.createdAt) : false + + return streakWasToday || checkinWasToday + } + + /** + * Check if a checkin is not rejected + */ + isNotRejectedCheckin(checkin: ICheckin | undefined | null): boolean { + return !!checkin?.status && checkin.status !== 'REJECTED' + } + + /** + * Determine if streak should continue or start new + */ + determineStreakDecision(lastStreak: any): 'new' | 'next' { + if (!lastStreak) return 'new' + if (!lastStreak.last_date) return 'new' + + const lastCheckin = lastStreak.checkins?.[0] + if (lastCheckin?.status === 'WAITING') return 'new' + + return isStreakContinuing(lastStreak.last_date) ? 'next' : 'new' + } + + /** + * Generate a unique public ID for a checkin + */ + async generatePublicId(tx: Prisma.TransactionClient = this.prisma as any): Promise { + while (true) { + const random = crypto.randomBytes(3).toString('hex').toUpperCase() + const id = `${PUBLIC_ID_PREFIX}${random}` + const exists = await tx.checkin.findUnique({ where: { public_id: id } }) + + if (!exists) return id + } + } + + // Private helpers + + private async upsertStreak( + tx: Prisma.TransactionClient, + userId: number, + lastStreak: any, + decision: 'new' | 'next', + ): Promise { + if (decision === 'new') { + return tx.checkinStreak.create({ + data: { user_id: userId }, + }) + } + + return tx.checkinStreak.update({ + where: { id: lastStreak!.id }, + data: { last_date: new Date() }, + }) + } + + private async createCheckinRecord( + tx: Prisma.TransactionClient, + userId: number, + checkinStreakId: number, + description: string, + ): Promise { + return tx.checkin.create({ + data: { + public_id: await this.generatePublicId(tx), + user_id: userId, + checkin_streak_id: checkinStreakId, + description, + status: 'WAITING', + }, + }) + } + + private async getPreviousCheckin( + tx: Prisma.TransactionClient, + userId: number, + streakId: number, + currentCheckinId: number, + ): Promise { + return tx.checkin.findFirst({ + where: { + user_id: userId, + checkin_streak_id: streakId, + id: { not: currentCheckinId }, + }, + orderBy: { created_at: 'desc' }, + }) + } + + private mapToICheckin(checkin: any): ICheckin { + return { + id: checkin.id, + publicId: checkin.public_id, + userId: checkin.user_id, + checkinStreakId: checkin.checkin_streak_id, + description: checkin.description, + link: checkin.link, + status: checkin.status as CheckinStatus, + reviewedBy: checkin.reviewed_by, + comment: checkin.comment, + createdAt: checkin.created_at, + updatedAt: checkin.updated_at, + user: checkin.user ? { + id: checkin.user.id, + externalId: checkin.user.discord_id, + platform: 'discord', + createdAt: checkin.user.created_at, + updatedAt: checkin.user.updated_at, + } : undefined, + checkinStreak: checkin.checkin_streak ? this.mapToICheckinStreak(checkin.checkin_streak) : undefined, + } + } + + private mapToICheckinStreak(streak: any): ICheckinStreak { + return { + id: streak.id, + userId: streak.user_id, + firstDate: streak.first_date, + lastDate: streak.last_date, + streak: streak.streak, + updatedAt: streak.updated_at, + } + } +} diff --git a/packages/aksaria-core/src/services/index.ts b/packages/aksaria-core/src/services/index.ts new file mode 100644 index 0000000..11046cc --- /dev/null +++ b/packages/aksaria-core/src/services/index.ts @@ -0,0 +1,3 @@ +export * from './user.service' +export * from './checkin.service' +export * from './attachment.service' diff --git a/packages/aksaria-core/src/services/user.service.ts b/packages/aksaria-core/src/services/user.service.ts new file mode 100644 index 0000000..feadcf2 --- /dev/null +++ b/packages/aksaria-core/src/services/user.service.ts @@ -0,0 +1,122 @@ +import type { PrismaClient } from '../db/client' +import type { IUser, Platform, UserWithLatestCheckin } from '../types' + +/** + * User service - handles user-related operations + */ +export class UserService { + constructor(private prisma: PrismaClient) {} + + /** + * Find a user by their external ID and platform + */ + async findByExternalId(externalId: string, platform: Platform = 'discord'): Promise { + const user = await this.prisma.user.findFirst({ + where: { + // Note: In the current schema, external_id is still discord_id + // This will be updated after migration + discord_id: externalId, + }, + }) + + if (!user) return null + + return this.mapToIUser(user, platform) + } + + /** + * Find or create a user + */ + async findOrCreate(externalId: string, platform: Platform = 'discord'): Promise { + const user = await this.prisma.user.upsert({ + where: { discord_id: externalId }, + create: { discord_id: externalId }, + update: {}, + }) + + return this.mapToIUser(user, platform) + } + + /** + * Find a user with their latest checkin and streak info + */ + async findWithLatestCheckin(externalId: string, platform: Platform = 'discord'): Promise { + const user = await this.prisma.user.findFirst({ + where: { discord_id: externalId }, + include: { + checkin_streaks: { + orderBy: { first_date: 'desc' }, + take: 1, + include: { + checkins: { + orderBy: { created_at: 'desc' }, + take: 1, + }, + }, + }, + checkins: { + orderBy: { created_at: 'desc' }, + take: 1, + include: { + checkin_streak: true, + }, + }, + }, + }) + + if (!user) return null + + const latestStreak = user.checkin_streaks[0] + const latestCheckin = user.checkins[0] + + return { + id: user.id, + externalId: user.discord_id, + platform, + createdAt: user.created_at, + updatedAt: user.updated_at, + latestCheckin: latestCheckin ? { + id: latestCheckin.id, + publicId: latestCheckin.public_id, + userId: latestCheckin.user_id, + checkinStreakId: latestCheckin.checkin_streak_id, + description: latestCheckin.description, + link: latestCheckin.link, + status: latestCheckin.status as 'WAITING' | 'APPROVED' | 'REJECTED', + reviewedBy: latestCheckin.reviewed_by, + comment: latestCheckin.comment, + createdAt: latestCheckin.created_at, + updatedAt: latestCheckin.updated_at, + checkinStreak: latestCheckin.checkin_streak ? { + id: latestCheckin.checkin_streak.id, + userId: latestCheckin.checkin_streak.user_id, + firstDate: latestCheckin.checkin_streak.first_date, + lastDate: latestCheckin.checkin_streak.last_date, + streak: latestCheckin.checkin_streak.streak, + updatedAt: latestCheckin.checkin_streak.updated_at, + } : undefined, + } : null, + latestStreak: latestStreak ? { + id: latestStreak.id, + userId: latestStreak.user_id, + firstDate: latestStreak.first_date, + lastDate: latestStreak.last_date, + streak: latestStreak.streak, + updatedAt: latestStreak.updated_at, + } : null, + } + } + + /** + * Map database user to IUser interface + */ + private mapToIUser(user: any, platform: Platform): IUser { + return { + id: user.id, + externalId: user.discord_id, + platform, + createdAt: user.created_at, + updatedAt: user.updated_at, + } + } +} diff --git a/packages/aksaria-core/src/types/index.ts b/packages/aksaria-core/src/types/index.ts new file mode 100644 index 0000000..84ed32a --- /dev/null +++ b/packages/aksaria-core/src/types/index.ts @@ -0,0 +1,91 @@ +/** + * Platform-agnostic types for Aksaria + */ + +// Platform types +export type Platform = 'discord' | 'telegram' | 'rest' +export type CheckinStatus = 'WAITING' | 'APPROVED' | 'REJECTED' + +// Base entity interfaces +export interface IUser { + id: number + externalId: string + platform: Platform + createdAt: Date + updatedAt?: Date | null + checkinStreaks?: ICheckinStreak[] + checkins?: ICheckin[] +} + +export interface ICheckinStreak { + id: number + userId: number + firstDate: Date + lastDate?: Date | null + streak: number + updatedAt?: Date | null + user?: IUser + checkins?: ICheckin[] +} + +export interface ICheckin { + id: number + publicId: string + userId: number + checkinStreakId: number + description: string + link?: string | null + status: CheckinStatus + reviewedBy?: string | null + comment?: string | null + createdAt: Date + updatedAt?: Date | null + user?: IUser + checkinStreak?: ICheckinStreak + attachments?: IAttachment[] +} + +export interface IAttachment { + id: number + name: string + url: string + type: string + size: number + createdAt: Date + moduleId: number + moduleType: string +} + +// Input types for creating entities +export interface CreateCheckinInput { + userId: number + description: string +} + +export interface UpdateCheckinStatusInput { + checkinId: number + status: CheckinStatus + reviewerId: string + comment?: string | null + isLateCheckin?: boolean +} + +export interface AttachmentInput { + name: string + url: string + type: string + size: number +} + +// Result types +export interface CheckinResult { + checkin: ICheckin + checkinStreak: ICheckinStreak + prevCheckin?: ICheckin | null + isNewStreak: boolean +} + +export interface UserWithLatestCheckin extends IUser { + latestCheckin?: ICheckin | null + latestStreak?: ICheckinStreak | null +} diff --git a/packages/aksaria-core/src/utils/date.ts b/packages/aksaria-core/src/utils/date.ts new file mode 100644 index 0000000..91906d7 --- /dev/null +++ b/packages/aksaria-core/src/utils/date.ts @@ -0,0 +1,69 @@ +/** + * Date utilities for Aksaria + * Note: Uses UTC+7 (WIB - Western Indonesian Time) as the base timezone + */ + +const TIMEZONE_OFFSET_HOURS = 7 + +/** + * Get the current date/time adjusted to the configured timezone + */ +export function getNow(date: Date = new Date()): Date { + return new Date(date.getTime() + TIMEZONE_OFFSET_HOURS * 60 * 60 * 1000) +} + +/** + * Get a formatted date string (DD/MM/YYYY, HH.MM.SS) + */ +export function getParsedNow(now: Date = getNow()): string { + const day = String(now.getUTCDate()).padStart(2, '0') + const month = String(now.getUTCMonth() + 1).padStart(2, '0') + const year = now.getUTCFullYear() + const hours = String(now.getUTCHours()).padStart(2, '0') + const minutes = String(now.getUTCMinutes()).padStart(2, '0') + const seconds = String(now.getUTCSeconds()).padStart(2, '0') + + return `${day}/${month}/${year}, ${hours}.${minutes}.${seconds}` +} + +/** + * Check if a date is today (in the configured timezone) + */ +export function isDateToday(date: Date): boolean { + const today = getNow() + const newDate = getNow(date) + + return newDate.getUTCFullYear() === today.getUTCFullYear() + && newDate.getUTCMonth() + 1 === today.getUTCMonth() + 1 + && newDate.getUTCDate() === today.getUTCDate() +} + +/** + * Check if a date is yesterday (in the configured timezone) + */ +export function isDateYesterday(date: Date): boolean { + const today = getNow() + const newDate = getNow(date) + const yesterday = getNow(today) + yesterday.setUTCDate(today.getUTCDate() - 1) + + return ( + newDate.getUTCFullYear() === yesterday.getUTCFullYear() + && newDate.getUTCMonth() === yesterday.getUTCMonth() + && newDate.getUTCDate() === yesterday.getUTCDate() + ) +} + +/** + * Get an ISO timestamp string + */ +export function timestamp(): string { + return getNow().toISOString() +} + +/** + * Check if a streak is continuing (last date was today or yesterday) + */ +export function isStreakContinuing(lastDate: Date): boolean { + return isDateToday(lastDate) || isDateYesterday(lastDate) +} diff --git a/packages/aksaria-core/src/utils/index.ts b/packages/aksaria-core/src/utils/index.ts new file mode 100644 index 0000000..edf1e3c --- /dev/null +++ b/packages/aksaria-core/src/utils/index.ts @@ -0,0 +1 @@ +export * from './date' diff --git a/packages/aksaria-core/tsconfig.json b/packages/aksaria-core/tsconfig.json new file mode 100644 index 0000000..90ec8ee --- /dev/null +++ b/packages/aksaria-core/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "paths": { + "@generatedDB/*": ["../../db/generated/prisma/*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/aksaria-discord/package.json b/packages/aksaria-discord/package.json new file mode 100644 index 0000000..6c1e4bb --- /dev/null +++ b/packages/aksaria-discord/package.json @@ -0,0 +1,19 @@ +{ + "name": "aksaria-discord", + "version": "0.1.0", + "description": "Discord bot implementation for Aksaria", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "bun src/index.ts" + }, + "dependencies": { + "aksaria-core": "*", + "discord.js": "^14.25.1", + "node-cron": "^4.2.1" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/aksaria-discord/src/adapters/discord-adapter.ts b/packages/aksaria-discord/src/adapters/discord-adapter.ts new file mode 100644 index 0000000..ccd5ae1 --- /dev/null +++ b/packages/aksaria-discord/src/adapters/discord-adapter.ts @@ -0,0 +1,112 @@ +import type { Client, EmbedBuilder } from 'discord.js' +import { createEmbed } from '@utils/component' + +// Types for platform adapter +export interface EmbedData { + title: string + description: string + color?: string + footer?: string +} + +export interface ICheckin { + id: number + publicId: string + userId: number + checkinStreakId: number + description: string + link?: string | null + status: 'WAITING' | 'APPROVED' | 'REJECTED' + reviewedBy?: string | null + comment?: string | null + createdAt: Date + updatedAt?: Date | null + checkinStreak?: { + id: number + userId: number + firstDate: Date + lastDate?: Date | null + streak: number + updatedAt?: Date | null + } +} + +export interface IPlatformAdapter { + readonly platform: string + sendMessage(userId: string, content: string): Promise + sendEmbed(userId: string, embed: EmbedData): Promise + notifyCheckinApproved(userId: string, checkin: ICheckin, reviewerName: string): Promise + notifyCheckinRejected(userId: string, checkin: ICheckin, reviewerName: string, reason?: string): Promise + notifyCheckinSubmitted(userId: string, checkin: ICheckin): Promise + getUserDisplayName(userId: string): Promise +} + +/** + * Discord implementation of IPlatformAdapter + */ +export class DiscordAdapter implements IPlatformAdapter { + readonly platform = 'discord' + + constructor(private client: Client) {} + + async sendMessage(userId: string, content: string): Promise { + const user = await this.client.users.fetch(userId) + await user.send(content) + } + + async sendEmbed(userId: string, embed: EmbedData): Promise { + const user = await this.client.users.fetch(userId) + const discordEmbed = createEmbed( + embed.title, + embed.description, + embed.color ?? '#5865F2', + embed.footer ? { text: embed.footer } : undefined, + ) + await user.send({ embeds: [discordEmbed] }) + } + + async notifyCheckinApproved(userId: string, checkin: ICheckin, reviewerName: string): Promise { + const user = await this.client.users.fetch(userId) + const embed = createEmbed( + 'πŸ”₯ *Check-In* Disetujui', + `Check-in **#${checkin.publicId}** Anda telah disetujui oleh **${reviewerName}**!\n\n` + + `πŸ”₯ **Current Streak:** ${checkin.checkinStreak?.streak ?? 0} day(s)\n` + + `πŸ“ **Deskripsi:** ${checkin.description}`, + '#4CAF50', + { text: '✨ Aksaria Daily Check-In' }, + ) + await user.send({ embeds: [embed] }) + } + + async notifyCheckinRejected(userId: string, checkin: ICheckin, reviewerName: string, reason?: string): Promise { + const user = await this.client.users.fetch(userId) + const embed = createEmbed( + '⚠️ *Check-In* Ditolak', + `Check-in **#${checkin.publicId}** Anda ditolak oleh **${reviewerName}**.\n\n` + + (reason ? `πŸ“‹ **Alasan:** ${reason}\n\n` : '') + + `πŸ“ **Deskripsi:** ${checkin.description}\n\n` + + `Silakan perbaiki dan coba lagi.`, + '#D9534F', + { text: '✨ Aksaria Daily Check-In' }, + ) + await user.send({ embeds: [embed] }) + } + + async notifyCheckinSubmitted(userId: string, checkin: ICheckin): Promise { + const user = await this.client.users.fetch(userId) + const embed = createEmbed( + 'πŸŽ‰ *Check-In* Berhasil', + `Check-in **#${checkin.publicId}** telah berhasil dikirim!\n\n` + + `πŸ“ **Deskripsi:** ${checkin.description}\n\n` + + `⏳ Menunggu review dari Flamewarden...`, + '#5865F2', + { text: '✨ Aksaria Daily Check-In' }, + ) + await user.send({ embeds: [embed] }) + } + + async getUserDisplayName(userId: string): Promise { + const user = await this.client.users.fetch(userId) + return user.displayName ?? user.username + } +} diff --git a/packages/aksaria-discord/src/adapters/index.ts b/packages/aksaria-discord/src/adapters/index.ts new file mode 100644 index 0000000..a5cee80 --- /dev/null +++ b/packages/aksaria-discord/src/adapters/index.ts @@ -0,0 +1 @@ +export * from './discord-adapter' diff --git a/packages/aksaria-discord/src/bot/commands/checkin/handlers/checkin-audit.ts b/packages/aksaria-discord/src/bot/commands/checkin/handlers/checkin-audit.ts new file mode 100644 index 0000000..d3ff30a --- /dev/null +++ b/packages/aksaria-discord/src/bot/commands/checkin/handlers/checkin-audit.ts @@ -0,0 +1,61 @@ +import type { ChatInputCommandInteraction, Client } from 'discord.js' +import { registerCommand } from '@commands/registry' +import { AUDIT_FLAME_CHANNEL, FLAMEWARDEN_ROLE } from '@config/discord' +import { CHECKIN_AUDIT_ID } from '@events/interaction-create/checkin/handlers/audit-modal' +import { createCheckinReviewModal, encodeSnowflake, getCustomId } from '@utils/component' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { log } from '@utils/logger' +import { SlashCommandBuilder } from 'discord.js' +import { CheckinAudit } from '../../../events/interaction-create/checkin/validators/audit' + +export class CheckinAuditError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('CheckinAuditError', message, options) + } +} + +registerCommand({ + data: new SlashCommandBuilder() + .setName('checkin-audit') + .setDescription('Review an old check-in using its public ID.') + .addStringOption(opt => + opt.setName('checkin-id') + .setDescription('Check-In ID (e.g., CHK-A1B2C3)') + .setRequired(true), + ), + + async execute(client: Client, interaction: ChatInputCommandInteraction) { + try { + if (!interaction.inCachedGuild()) + throw new CheckinAuditError(CheckinAudit.ERR.NotGuild) + + const channel = await CheckinAudit.assertAllowedChannel(interaction.guild, interaction.channelId, AUDIT_FLAME_CHANNEL) + CheckinAudit.assertMissPerms(interaction.client.user, channel) + const flamewarden = await interaction.guild.members.fetch(interaction.member.id) + CheckinAudit.assertMember(flamewarden) + CheckinAudit.assertMemberHasRole(flamewarden, FLAMEWARDEN_ROLE) + + const checkinId = interaction.options.getString('checkin-id', true) + const checkin = await CheckinAudit.assertExistCheckinId(client.prisma, checkinId) + CheckinAudit.assertCheckinNotToday(checkin) + const checkins = await CheckinAudit.getOldestWaitingCheckins(client.prisma, checkin.checkin_streak_id) + CheckinAudit.assertCheckinWithOldestWaiting(checkin, checkins) + + const modalCustomId = getCustomId([ + CHECKIN_AUDIT_ID, + encodeSnowflake(interaction.guildId), + checkinId, + checkin.created_at.getTime().toString(), + ]) + const modal = createCheckinReviewModal(modalCustomId, checkin, false) + + await interaction.showModal(modal) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message, true) + else log.error(`Failed to handle: ${CheckinAudit.ERR.UnexpectedCheckinAudit}: ${err}`) + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/commands/checkin/handlers/checkin-status.ts b/packages/aksaria-discord/src/bot/commands/checkin/handlers/checkin-status.ts new file mode 100644 index 0000000..909a5c2 --- /dev/null +++ b/packages/aksaria-discord/src/bot/commands/checkin/handlers/checkin-status.ts @@ -0,0 +1,50 @@ +import type { ChatInputCommandInteraction, Client, GuildMember } from 'discord.js' +import { registerCommand } from '@commands/registry' +import { AUDIT_FLAME_CHANNEL, FLAMEWARDEN_ROLE, GRINDER_ROLE } from '@config/discord' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { log } from '@utils/logger' +import { SlashCommandBuilder } from 'discord.js' +import { CheckinStatus } from '../validators/checkin-status' + +export class CheckinStatusError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('CheckinStatusError', message, options) + } +} + +registerCommand({ + data: new SlashCommandBuilder() + .setName('checkin-status') + .setDescription('Check your current daily check-in and streak status.'), + + async execute(client: Client, interaction: ChatInputCommandInteraction) { + try { + if (!interaction.inCachedGuild()) + throw new CheckinStatusError(CheckinStatus.ERR.NotGuild) + + const channel = await CheckinStatus.assertAllowedChannel(interaction.guild, interaction.channelId, AUDIT_FLAME_CHANNEL) + CheckinStatus.assertMissPerms(interaction.client.user, channel) + + const userDiscordId: string = interaction.user.id + const member = interaction.member as GuildMember + const user = await CheckinStatus.getUser(client.prisma, userDiscordId) + + CheckinStatus.assertMember(member) + CheckinStatus.assertMemberHasRole(member, GRINDER_ROLE) + + const { content, embed } = await CheckinStatus.getEmbedStatusContent( + interaction.guild, + user?.discord_id ?? member.id, + user?.checkins?.[0], + ) + + await sendReply(interaction, content, false, { embeds: [embed], allowedMentions: { roles: [FLAMEWARDEN_ROLE] } }) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else log.error(`Failed to handle: ${CheckinStatus.ERR.UnexpectedCheckinStatus}: ${err}`) + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/commands/checkin/handlers/checkin.ts b/packages/aksaria-discord/src/bot/commands/checkin/handlers/checkin.ts new file mode 100644 index 0000000..859949f --- /dev/null +++ b/packages/aksaria-discord/src/bot/commands/checkin/handlers/checkin.ts @@ -0,0 +1,70 @@ +import type { ChatInputCommandInteraction } from 'discord.js' +import { registerCommand } from '@commands/registry' +import { CHECKIN_CHANNEL } from '@config/discord' +import { CHECKIN_ID } from '@events/interaction-create/checkin/handlers/modal' +import { Checkin } from '@events/interaction-create/checkin/validators' +import { encodeSnowflake, getCustomId } from '@utils/component' +import { getAttachments, sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { log } from '@utils/logger' +import { DUMMY } from '@utils/placeholder' +import { LabelBuilder, ModalBuilder, SlashCommandBuilder, TextInputBuilder, TextInputStyle } from 'discord.js' + +export class CheckinError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('CheckinError', message, options) + } +} + +registerCommand({ + data: new SlashCommandBuilder() + .setName('checkin') + .setDescription('Daily grind check-in.') + .addAttachmentOption(opt => + opt.setName(`attachment-1`) + .setDescription(`Attachment (optional)`) + .setRequired(false), + ), + + async execute(_, interaction: ChatInputCommandInteraction) { + try { + if (!interaction.inCachedGuild()) + throw new CheckinError(Checkin.ERR.NotGuild) + + const channel = await Checkin.assertAllowedChannel(interaction.guild, interaction.channelId, CHECKIN_CHANNEL) + Checkin.assertMissPerms(interaction.client.user, channel) + + const attachments = getAttachments(interaction, Checkin.ATTACHMENT_COUNT) + const tempToken = Checkin.setTempItem(attachments) + + const modalCustomId = getCustomId([ + CHECKIN_ID, + encodeSnowflake(interaction.guildId), + tempToken, + ]) + const modal = new ModalBuilder() + .setCustomId(modalCustomId) + .setTitle('Daily Check-In') + .addLabelComponents( + new LabelBuilder() + .setLabel('Description') + .setDescription('Mohon sampaikan catatan pencapaian kecil Tuan/Nona hari ini, walaupun sederhana') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('todo') + .setPlaceholder(DUMMY.DESC) + .setStyle(TextInputStyle.Paragraph) + .setRequired(true), + ), + ) + .addTextDisplayComponents(textDisplay => textDisplay.setContent(DUMMY.MARKDOWN)) + + await interaction.showModal(modal) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else log.error(`Failed to handle: ${Checkin.ERR.UnexpectedCheckin}: ${err}`) + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/commands/checkin/messages/checkin-status.ts b/packages/aksaria-discord/src/bot/commands/checkin/messages/checkin-status.ts new file mode 100644 index 0000000..790245d --- /dev/null +++ b/packages/aksaria-discord/src/bot/commands/checkin/messages/checkin-status.ts @@ -0,0 +1,65 @@ +import type { Checkin } from '@type/checkin' +import type { CheckinStreak } from '@type/checkin-streak' +import type { GuildMember } from 'discord.js' +import { FLAMEWARDEN_ROLE } from '@config/discord' +import { getNow, getParsedNow } from '@utils/date' +import { DiscordAssert } from '@utils/discord' + +export class CheckinStatusMessage extends DiscordAssert { + static override readonly ERR = { + ...DiscordAssert.ERR, + UnexpectedCheckinStatus: '❌ The status for this check-in is unknown or unexpected', + } + + static override readonly MSG = { + ...DiscordAssert.MSG, + NoCheckin: (userDiscordId: string, checkinStreak: CheckinStreak | undefined) => ` +Wahai Tuan/Nona <@${userDiscordId}>, +Nyala api Tuan/Nona belum dinyalakan hari ini. +πŸ—“ **Date**: ${getParsedNow()} +πŸ”₯ **Current Streak**: ${checkinStreak?.streak ?? 0} day(s) +πŸ”Ž **Status**: Belum melakukan *check-in* +> *"Percikan hari ini belum ditorehkan. Lakukan check-in sebelum 23:59 WIB, agar api Tuan/Nona tak meredup."* + `, + WaitingCheckin: (userDiscordId: string, checkin: Checkin) => ` +πŸ†” **Check-In ID**: +\`\`\`bash +${checkin.public_id} +\`\`\` +🌟 **Grinder**: <@${userDiscordId}> +πŸ“ **Attachment:** ${checkin.attachments?.length ? 'βœ…' : '❌'} +πŸ—“ **Submitted At**: ${getParsedNow(getNow(checkin.created_at))} +πŸ”₯ **Current Streak**: ${checkin.checkin_streak!.streak} day(s) +πŸ”Ž **Status**: Menunggu peninjauan <@&${FLAMEWARDEN_ROLE}> +> *"Percikan telah Tuan/Nona <@${userDiscordId}> titipkan. Mohon menanti sesaat, <@&${FLAMEWARDEN_ROLE}> tengah menakar apakah [nyala tersebut](${checkin.link}) layak menjadi bagian dari perjalanan Tuan/Nona."* + `, + ApprovedCheckin: (userDiscordId: string, flamewarden: GuildMember, checkin: Checkin) => ` +πŸ†” **Check-In ID**: +\`\`\`bash +${checkin.public_id} +\`\`\` +🌟 **Grinder**: <@${userDiscordId}> +πŸ“ **Attachment:** ${checkin.attachments?.length ? 'βœ…' : '❌'} +πŸ”₯ **Current Streak**: ${checkin.checkin_streak!.streak} day(s) +πŸ”Ž **Status**: Disetujui; api Tuan/Nona kian terang +πŸ—“ **Approved At**: ${getParsedNow(getNow(checkin.updated_at!))} +πŸ‘€ **Approved By**: ${flamewarden.displayName} (@${flamewarden.user.username}) +✍🏻 **${flamewarden.displayName}'(s) Comment**: ${checkin.comment ?? '-'} +> *"[Nyala hari ini](${checkin.link}) diterima. Teruslah menenun aksara disiplin, satu hari demi satu hari."* + `, + RejectedCheckin: (userDiscordId: string, flamewarden: GuildMember, checkin: Checkin) => ` +πŸ†” **Check-In ID**: +\`\`\`bash +${checkin.public_id} +\`\`\` +🌟 **Grinder**: <@${userDiscordId}> +πŸ“ **Attachment:** ${checkin.attachments?.length ? 'βœ…' : '❌'} +πŸ”₯ **Current Streak**: ${checkin.checkin_streak!.streak} day(s) +πŸ”Ž **Status**: Disetujui; api Tuan/Nona kian terang +πŸ—“ **Reviewed At**: ${getParsedNow(getNow(checkin.updated_at!))} +πŸ‘€ **Reviewed By**: ${flamewarden.displayName} (@${flamewarden.user.username}) +✍🏻 **${flamewarden.displayName}'(s) Comment**: ${checkin.comment ?? '-'} +> *"[Api Tuan/Nona](${checkin.link}) <@${userDiscordId}> meredup hari ini, namun belum padam sepenuhnya. Perbaiki, dan nyalakan kembali percikan yang benar."* + `, + } +} diff --git a/packages/aksaria-discord/src/bot/commands/checkin/validators/checkin-status.ts b/packages/aksaria-discord/src/bot/commands/checkin/validators/checkin-status.ts new file mode 100644 index 0000000..f77da52 --- /dev/null +++ b/packages/aksaria-discord/src/bot/commands/checkin/validators/checkin-status.ts @@ -0,0 +1,92 @@ +import type { PrismaClient } from '@generatedDB/client' +import type { Checkin as CheckinType } from '@type/checkin' +import type { User } from '@type/user' +import type { EmbedBuilder, Guild } from 'discord.js' +import { FLAMEWARDEN_ROLE } from '@config/discord' +import { Checkin } from '@events/interaction-create/checkin/validators' +import { createEmbed } from '@utils/component' +import { DiscordAssert } from '@utils/discord' +import { DUMMY } from '@utils/placeholder' +import { PermissionsBitField } from 'discord.js' +import { CheckinStatusMessage } from '../messages/checkin-status' + +export class CheckinStatus extends CheckinStatusMessage { + static override BASE_PERMS = [ + ...DiscordAssert.BASE_PERMS, + PermissionsBitField.Flags.UseApplicationCommands, + ] + + static async getEmbedStatusContent(guild: Guild, userDiscordId: string, checkin: CheckinType | undefined) { + let content = '' + let embed: EmbedBuilder + const checkinStreak = checkin?.checkin_streak + + const hasCheckedInToday = Checkin.hasCheckinToday(checkinStreak, checkin) + if (hasCheckedInToday && checkin) { + const flamewarden = await guild.members.fetch(checkin.reviewed_by!) + + switch (checkin.status) { + case 'WAITING': + content = `<@&${FLAMEWARDEN_ROLE}>` + embed = createEmbed( + `🧭 Check-In #${checkin.public_id}`, + CheckinStatus.MSG.WaitingCheckin(userDiscordId, checkin), + DUMMY.COLOR, + { text: DUMMY.FOOTER }, + ) + break + + case 'APPROVED': + embed = createEmbed( + `πŸ”₯ Check-In #${checkin.public_id}`, + CheckinStatus.MSG.ApprovedCheckin(userDiscordId, flamewarden, checkin), + DUMMY.COLOR, + { text: DUMMY.FOOTER }, + ) + break + + default: + embed = createEmbed( + `❌ Check-In #${checkin.public_id}`, + CheckinStatus.MSG.RejectedCheckin(userDiscordId, flamewarden, checkin), + DUMMY.COLOR, + { text: DUMMY.FOOTER }, + ) + break + } + } + else { + embed = createEmbed( + `🧐 Check-In`, + CheckinStatus.MSG.NoCheckin(userDiscordId, checkinStreak), + DUMMY.COLOR, + { text: DUMMY.FOOTER }, + ) + } + + return { content, embed } + } + + static async getUser(prisma: PrismaClient, userDiscordId: string): Promise { + const user = await prisma.user.findFirst({ + where: { + discord_id: userDiscordId, + }, + select: { + id: true, + discord_id: true, + created_at: true, + updated_at: true, + checkins: { + orderBy: { created_at: 'desc' }, + take: 1, + include: { checkin_streak: true }, + }, + }, + }) as User + + await Checkin.setAttachments(prisma, user?.checkins?.[0]) + + return user + } +} diff --git a/packages/aksaria-discord/src/bot/commands/command.d.ts b/packages/aksaria-discord/src/bot/commands/command.d.ts new file mode 100644 index 0000000..504bbc2 --- /dev/null +++ b/packages/aksaria-discord/src/bot/commands/command.d.ts @@ -0,0 +1,6 @@ +import type { ChatInputCommandInteraction, Client, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder } from 'discord.js' + +export interface Command { + data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder + execute: (client: Client, interaction: ChatInputCommandInteraction) => Promise +} diff --git a/packages/aksaria-discord/src/bot/commands/embed/handlers/role-grant-create.ts b/packages/aksaria-discord/src/bot/commands/embed/handlers/role-grant-create.ts new file mode 100644 index 0000000..4551ae2 --- /dev/null +++ b/packages/aksaria-discord/src/bot/commands/embed/handlers/role-grant-create.ts @@ -0,0 +1,106 @@ +import type { ChatInputCommandInteraction, TextChannel } from 'discord.js' +import { registerCommand } from '@commands/registry' +import { LabelBuilder, ModalBuilder, TextInputBuilder } from '@discordjs/builders' +import { EMBED_ROLE_GRANT_CREATE_MODAL_ID } from '@events/interaction-create/embed/handlers/role-grant-create-modal' +import { RoleGrantCreate } from '@events/interaction-create/embed/validators/role-grant-create' +import { encodeSnowflake, getCustomId } from '@utils/component' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { log } from '@utils/logger' +import { DUMMY } from '@utils/placeholder' +import { PermissionFlagsBits, SlashCommandBuilder, TextInputStyle } from 'discord.js' + +export class EmbedRoleGrantError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('EmbedRoleGrantError', message, options) + } +} + +registerCommand({ + data: new SlashCommandBuilder() + .setName('create-embed-role-grant') + .setDescription('Create an embed in a channel w/ a role-grant button.') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addRoleOption(opt => opt.setName('role').setDescription('Role to grant.').setRequired(true)) + .addStringOption(opt => opt.setName('button-name').setDescription('Text to display on the button-make it catchy.').setRequired(true)), + + async execute(_, interaction: ChatInputCommandInteraction) { + try { + if (!interaction.inCachedGuild()) + throw new EmbedRoleGrantError(RoleGrantCreate.ERR.NotGuild) + + const channel = interaction.channel as TextChannel + RoleGrantCreate.assertMissPerms(interaction.client.user, channel) + + const buttonName = interaction.options.getString('button-name', true) + const role = interaction.options.getRole('role', true) + + const modalCustomId = getCustomId([ + EMBED_ROLE_GRANT_CREATE_MODAL_ID, + encodeSnowflake(interaction.guildId!), + encodeSnowflake(channel.id), + encodeSnowflake(role.id), + encodeURIComponent(buttonName), + ]) + const modal = new ModalBuilder() + .setCustomId(modalCustomId) + .setTitle('Create Embed with Role-Grant Button') + .addLabelComponents( + new LabelBuilder() + .setLabel('Title') + .setDescription('The title of embed') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('title') + .setPlaceholder(DUMMY.TITLE) + .setStyle(TextInputStyle.Short) + .setMaxLength(256) + .setRequired(true), + ), + + new LabelBuilder() + .setLabel('Description') + .setDescription('Main embed content') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('description') + .setPlaceholder(DUMMY.DESC) + .setStyle(TextInputStyle.Paragraph) + .setRequired(true), + ), + + new LabelBuilder() + .setLabel('Color') + .setDescription('Optional embed accent color (HEX)') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('color') + .setPlaceholder(DUMMY.COLOR) + .setValue(DUMMY.COLOR) + .setStyle(TextInputStyle.Short) + .setRequired(false), + ), + + new LabelBuilder() + .setLabel('Footer') + .setDescription('Optional embed footer text') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('footer') + .setPlaceholder(DUMMY.FOOTER) + .setValue(DUMMY.FOOTER) + .setStyle(TextInputStyle.Short) + .setRequired(false), + ), + ) + .addTextDisplayComponents(textDisplay => textDisplay.setContent(DUMMY.MARKDOWN)) + + await interaction.showModal(modal) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else log.error(`Failed to handle ${EMBED_ROLE_GRANT_CREATE_MODAL_ID}: ${RoleGrantCreate.ERR.UnexpectedRoleGrantCreate}: ${err}`) + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/commands/index.ts b/packages/aksaria-discord/src/bot/commands/index.ts new file mode 100644 index 0000000..1a1209e --- /dev/null +++ b/packages/aksaria-discord/src/bot/commands/index.ts @@ -0,0 +1,25 @@ +import type { Client } from 'discord.js' +import path from 'node:path' +import { log } from '@utils/logger' +import { commandRegistry, loadCommands } from './registry' + +export class CommandError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options) + this.name = 'CommandError' + Object.setPrototypeOf(this, new.target.prototype) + } +} + +export const COMMAND_PATH = path.join(__dirname) + +export async function registerCommands(client: Client) { + try { + await loadCommands() + client.commands = commandRegistry + } + catch (err: any) { + const msg = err instanceof CommandError ? err.message : '❌ Something went wrong when importing the command' + log.error(`Failed to register an command: ${msg}: ${err.message}`) + } +} diff --git a/packages/aksaria-discord/src/bot/commands/message/handlers/send.ts b/packages/aksaria-discord/src/bot/commands/message/handlers/send.ts new file mode 100644 index 0000000..87d1189 --- /dev/null +++ b/packages/aksaria-discord/src/bot/commands/message/handlers/send.ts @@ -0,0 +1,76 @@ +import type { ChatInputCommandInteraction, TextChannel } from 'discord.js' +import { registerCommand } from '@commands/registry' +import { MESSAGE_SEND_ID } from '@events/interaction-create/message/handlers/send-modal' +import { Send } from '@events/interaction-create/message/validators/send' +import { encodeSnowflake, getCustomId } from '@utils/component' +import { getAttachments, sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { log } from '@utils/logger' +import { DUMMY } from '@utils/placeholder' +import { LabelBuilder, ModalBuilder, PermissionFlagsBits, SlashCommandBuilder, TextInputBuilder, TextInputStyle } from 'discord.js' + +export class SendError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('SendError', message, options) + } +} + +const data = new SlashCommandBuilder() + .setName('send-message') + .setDescription('Send a message (optionally with attachments) as the bot.') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + +for (let i = 1; i <= Send.ATTACHMENT_COUNT; i++) { + data.addAttachmentOption(opt => + opt + .setName(`attachment-${i}`) + .setDescription(`Attachment ${i} (optional)`) + .setRequired(false), + ) +} + +registerCommand({ + data, + async execute(_, interaction: ChatInputCommandInteraction) { + try { + if (!interaction.inCachedGuild()) + throw new SendError(Send.ERR.NotGuild) + + const channel = interaction.channel as TextChannel + Send.assertMissPerms(interaction.client.user, channel) + + const attachments = getAttachments(interaction, Send.ATTACHMENT_COUNT) + const tempToken = Send.setTempItem(attachments) + + const modalCustomId = getCustomId([ + MESSAGE_SEND_ID, + encodeSnowflake(interaction.guildId), + encodeSnowflake(channel.id), + tempToken, + ]) + const modal = new ModalBuilder() + .setCustomId(modalCustomId) + .setTitle('Send a Message as Bot') + .addLabelComponents( + new LabelBuilder() + .setLabel('Message') + .setDescription('Pesan ini akan disampaikan sebagai bot') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('message') + .setPlaceholder(DUMMY.DESC) + .setStyle(TextInputStyle.Paragraph) + .setRequired(false), + ), + ) + .addTextDisplayComponents(textDisplay => textDisplay.setContent(DUMMY.MARKDOWN)) + + await interaction.showModal(modal) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else log.error(`Failed to handle: ${Send.ERR.UnexpectedSend}: ${err}`) + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/commands/registry.ts b/packages/aksaria-discord/src/bot/commands/registry.ts new file mode 100644 index 0000000..49625f8 --- /dev/null +++ b/packages/aksaria-discord/src/bot/commands/registry.ts @@ -0,0 +1,34 @@ +import type { Command } from '@commands/command' +import path from 'node:path' +import { readFiles } from '@utils/io' +import { log } from '@utils/logger' +import { Collection } from 'discord.js' +import { COMMAND_PATH } from '.' + +export const commandRegistry = new Collection() + +export function registerCommand(command: Command) { + if (commandRegistry.has(command.data.name)) { + throw new Error(`Duplicate command name: ${command.data.name}`) + } + + commandRegistry.set(command.data.name, command) +} + +export async function loadCommands() { + const files = readFiles(COMMAND_PATH).filter(file => !file.endsWith('/index.ts')) + + await Promise.all( + files.map(async (file) => { + const fileName = path.basename(file, path.extname(file)) + + try { + await import(file) + log.info(`Loaded command file '${fileName}'`) + } + catch (err) { + log.error(`Failed to load command file ${file}: ${err}`) + } + }), + ) +} diff --git a/packages/aksaria-discord/src/bot/commands/utility/handlers/ping.ts b/packages/aksaria-discord/src/bot/commands/utility/handlers/ping.ts new file mode 100644 index 0000000..089d94d --- /dev/null +++ b/packages/aksaria-discord/src/bot/commands/utility/handlers/ping.ts @@ -0,0 +1,36 @@ +import type { ChatInputCommandInteraction, TextChannel } from 'discord.js' +import { registerCommand } from '@commands/registry' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { log } from '@utils/logger' +import { SlashCommandBuilder } from 'discord.js' +import { Ping } from '../validators/ping' + +export class PingError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('PingError', message, options) + } +} + +registerCommand({ + data: new SlashCommandBuilder() + .setName('ping') + .setDescription('Replies with pong!'), + + async execute(_, interaction: ChatInputCommandInteraction) { + try { + if (!interaction.inCachedGuild()) + throw new PingError(Ping.ERR.NotGuild) + + const channel = interaction.channel as TextChannel + Ping.assertMissPerms(interaction.client.user, channel) + + await sendReply(interaction, 'Pong!') + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else log.error(`Failed to handle: ${Ping.ERR.UnexpectedPing}: ${err}`) + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/commands/utility/messages/ping.ts b/packages/aksaria-discord/src/bot/commands/utility/messages/ping.ts new file mode 100644 index 0000000..991f1a0 --- /dev/null +++ b/packages/aksaria-discord/src/bot/commands/utility/messages/ping.ts @@ -0,0 +1,8 @@ +import { DiscordAssert } from '@utils/discord' + +export class PingMessage extends DiscordAssert { + static override readonly ERR = { + ...DiscordAssert.ERR, + UnexpectedPing: '❌ Something went wrong while handling the ping command', + } +} diff --git a/packages/aksaria-discord/src/bot/commands/utility/validators/ping.ts b/packages/aksaria-discord/src/bot/commands/utility/validators/ping.ts new file mode 100644 index 0000000..1cb57bb --- /dev/null +++ b/packages/aksaria-discord/src/bot/commands/utility/validators/ping.ts @@ -0,0 +1,8 @@ +import { DiscordAssert } from '@utils/discord' +import { PingMessage } from '../messages/ping' + +export class Ping extends PingMessage { + static override BASE_PERMS = [ + ...DiscordAssert.BASE_PERMS, + ] +} diff --git a/packages/aksaria-discord/src/bot/events/client-ready/entry.ts b/packages/aksaria-discord/src/bot/events/client-ready/entry.ts new file mode 100644 index 0000000..8e22597 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/client-ready/entry.ts @@ -0,0 +1,21 @@ +import type { Event } from '@events/event' +import type { Client } from 'discord.js' +import { log } from '@utils/logger' +import { Events } from 'discord.js' +import { clientReadyHandlers } from './registry' + +export default { + name: Events.ClientReady, + once: true, + desc: 'Runs all registered ClientReady handlers.', + async exec(client: Client) { + for (const handler of clientReadyHandlers) { + try { + await handler.exec(client) + } + catch (err) { + log.error(`ClientReady handler failed ${handler.errorTag()}: ${err}`) + } + } + }, +} as Event diff --git a/packages/aksaria-discord/src/bot/events/client-ready/jobs/handlers/reset-grinder-roles.ts b/packages/aksaria-discord/src/bot/events/client-ready/jobs/handlers/reset-grinder-roles.ts new file mode 100644 index 0000000..2e14aa4 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/client-ready/jobs/handlers/reset-grinder-roles.ts @@ -0,0 +1,44 @@ +import type { Client } from 'discord.js' +import process from 'node:process' +import { GRIND_ASHES_CHANNEL } from '@config/discord' +import { registerClientReadyHandler } from '@events/client-ready/registry' +import { EVENT_PATH } from '@events/index' +import { getChannel } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { log } from '@utils/logger' +import cron from 'node-cron' +import { ResetGrinderRoles } from '../validators/reset-grinder-roles' + +export class ResetGrinderRolesError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('ResetGrinderRolesError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) + +registerClientReadyHandler({ + desc: `Reset Grinder roles for users that didn't do a check-in yesterday or the check-in didn't approved.`, + errorTag: () => `${moduleName}: ${ResetGrinderRoles.ERR.UnexpectedResetGrinderRoles}`, + exec(client: Client) { + try { + cron.schedule('0 0 * * *', async () => { + log.check(ResetGrinderRoles.MSG.JobRunning) + + const guild = await client.guilds.fetch(process.env.GUILD_ID!) + const channel = await getChannel(guild, GRIND_ASHES_CHANNEL) + ResetGrinderRoles.assertChannel(channel) + const users = await ResetGrinderRoles.getUsersWithLatestStreak(client.prisma) + + await ResetGrinderRoles.validateUsers(client.prisma, guild, channel, users) + + log.success(ResetGrinderRoles.MSG.JobSuccess) + }) + } + catch (err) { + if (!(err instanceof DiscordBaseError)) + throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/client-ready/jobs/messages/reset-grinder-roles.ts b/packages/aksaria-discord/src/bot/events/client-ready/jobs/messages/reset-grinder-roles.ts new file mode 100644 index 0000000..4ec6821 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/client-ready/jobs/messages/reset-grinder-roles.ts @@ -0,0 +1,35 @@ +import type { GuildMember } from 'discord.js' +import { AUDIT_FLAME_CHANNEL, FLAMEWARDEN_ROLE, IGNITE_PATH_CHANNEL } from '@config/discord' +import { DiscordAssert } from '@utils/discord' + +export class ResetGrinderRolesMessage extends DiscordAssert { + static override readonly ERR = { + ...DiscordAssert.ERR, + UnexpectedResetGrinderRoles: '❌ Something went wrong while resetting grinder roles', + } + + static override readonly MSG = { + ...DiscordAssert.MSG, + JobRunning: '[JOB] Running daily grinder reset...', + JobSuccess: '[JOB] Grinder daily reset finished successfully', + RemoveGrinderRoleFrom: (member: GuildMember) => `[JOB] Removed Grinder role from ${member.user.tag}`, + GoodBye: (member: GuildMember) => ` +# πŸ’” Nyala Api Tuan/Nona <@${member.id}> Telah Gugur +Tatkala hari telah berganti dan lonceng waktu menunjukkan pergantian malam, tercatat bahwa tiada *check-in* yang sah diterima pada hari yang telah berlalu. Maka, sesuai hukum Aksaria, peran Grinder untuk saat ini harus dilepaskan. + +Api bukanlah padam karena kelemahan, melainkan karena ia tak disirami pada waktunya. + +Namun jangan berduka, jalan ini selalu terbuka bagi mereka yang bersedia memulai kembali. Apabila Tuan/Nona berkehendak menyalakan api kembali, silakan kembali ke <#${IGNITE_PATH_CHANNEL}> dan bangkitlah dari awal. + +*Aksaria menanti mereka yang konsisten.* + `, + GoodByeNotes: ` +> Apabila *check-in* Tuan/Nona masih berada dalam status menunggu peninjauan (*waiting*) dan belum memperoleh keputusan hingga mendekati pergantian hari, maka dengan ini disampaikan ketentuan berikut: +> 1. Jangan terlebih dahulu memasuki ⁠<#${IGNITE_PATH_CHANNEL}>, demi menjaga ketertiban alur peninjauan. +> 2. Silakan menjalankan perintah **\`/checkin-status\`** pada <#${AUDIT_FLAME_CHANNEL}> untuk menampilkan status *check-in* terakhir Tuan/Nona. +> 3. Setelah pesan status tersebut muncul, berikan reaksi "❓" pada pesan tersebut. +> 4. Dari reaksi tersebut, sebuah *thread* akan tercipta secara otomatis sebagai ruang klarifikasi dan komunikasi dengan <@&${FLAMEWARDEN_ROLE}>. +> ⏳ Batas waktu penantian atas status *WAITING* adalah maksimal 1Γ—24 jam sejak *check-in* diajukan. + `, + } +} diff --git a/packages/aksaria-discord/src/bot/events/client-ready/jobs/validators/reset-grinder-roles.ts b/packages/aksaria-discord/src/bot/events/client-ready/jobs/validators/reset-grinder-roles.ts new file mode 100644 index 0000000..def0b3d --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/client-ready/jobs/validators/reset-grinder-roles.ts @@ -0,0 +1,121 @@ +import type { PrismaClient } from '@generatedDB/client' +import type { CheckinStreak } from '@type/checkin-streak' +import type { User } from '@type/user' +import type { Guild, GuildMember, Interaction, TextChannel } from 'discord.js' +import { getGrindRoles, GRINDER_ROLE } from '@config/discord' +import { GOODBYE_NOTE_BUTTON_ID, ResetGrinderRolesButtonError } from '@events/interaction-create/jobs/handlers/reset-grinder-roles-button' +import { decodeSnowflakes, encodeSnowflake, getCustomId } from '@utils/component' +import { isDateToday, isDateYesterday } from '@utils/date' +import { DiscordAssert, sendAsBot } from '@utils/discord' +import { log } from '@utils/logger' +import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js' +import { ResetGrinderRolesMessage } from '../messages/reset-grinder-roles' + +export class ResetGrinderRoles extends ResetGrinderRolesMessage { + static override BASE_PERMS = [ + ...DiscordAssert.BASE_PERMS, + ] + + static getButtonId(interaction: Interaction, customId: string) { + const [prefix, guildId] = decodeSnowflakes(customId) + + if (!guildId) + throw new ResetGrinderRolesButtonError(this.ERR.GuildMissing) + if (interaction.guildId !== guildId) + throw new ResetGrinderRolesButtonError(this.ERR.NotGuild) + + return { prefix, guildId } + } + + static generateButton(guildId: string): ActionRowBuilder { + const noteButtonId = getCustomId([GOODBYE_NOTE_BUTTON_ID, encodeSnowflake(guildId)]) + const noteButton = new ButtonBuilder() + .setCustomId(noteButtonId) + .setLabel('πŸ“œ Ketentuan Peninjauan Api') + .setStyle(ButtonStyle.Primary) + + return new ActionRowBuilder().addComponents(noteButton) + } + + static hasValidCheckin(checkin?: { created_at: Date, status: string }): boolean { + if (!checkin) + return false + + const { created_at, status } = checkin + if (isDateToday(created_at)) + return true + if (isDateYesterday(created_at) && status === 'APPROVED') + return true + + return false + } + + static async removeGrinderRoles(member: GuildMember) { + await member.roles.remove(GRINDER_ROLE) + + const grindRoles = getGrindRoles() + for (const grindRole of grindRoles) { + if (this.isMemberHasRole(member, grindRole.id)) { + await member.roles.remove(grindRole.id) + } + } + } + + static async validateUsers(prisma: PrismaClient, guild: Guild, channel: TextChannel, users: User[]) { + for (const user of users) { + const checkinStreak = user.checkin_streaks?.[0] + if (!checkinStreak) + continue + + const lastCheckin = checkinStreak.checkins?.[0] + if (this.hasValidCheckin(lastCheckin)) + continue + + const member = await guild.members.fetch(user.discord_id) + await this.removeGrinderRoles(member) + await this.breakCheckinStreakAt(prisma, checkinStreak) + const button = this.generateButton(guild.id) + + await sendAsBot( + null, + channel, + { content: ResetGrinderRoles.MSG.GoodBye(member), components: [button], allowedMentions: { users: [member.id], roles: [] } }, + ) + + log.info(this.MSG.RemoveGrinderRoleFrom(member)) + } + } + + static async getUsersWithLatestStreak(prisma: PrismaClient): Promise { + const users = await prisma.user.findMany({ + select: { + discord_id: true, + checkin_streaks: { + orderBy: { first_date: 'desc' }, + take: 1, + where: { + streak_broken_at: null, + }, + include: { + checkins: { + orderBy: { created_at: 'desc' }, + take: 1, + }, + }, + }, + }, + }) as User[] + + return users + } + + static async breakCheckinStreakAt(prisma: PrismaClient, checkinStreak: CheckinStreak) { + await prisma.checkinStreak.update({ + where: { id: checkinStreak.id }, + data: { + streak_broken_at: new Date(), + updated_at: new Date(), + }, + }) + } +} diff --git a/packages/aksaria-discord/src/bot/events/client-ready/registry.ts b/packages/aksaria-discord/src/bot/events/client-ready/registry.ts new file mode 100644 index 0000000..0a95e65 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/client-ready/registry.ts @@ -0,0 +1,13 @@ +import type { Client } from 'discord.js' + +export interface ClientReadyHandler { + desc: string + errorTag: () => string + exec: (client: Client) => Promise | void +} + +export const clientReadyHandlers: ClientReadyHandler[] = [] + +export function registerClientReadyHandler(handler: ClientReadyHandler) { + clientReadyHandlers.push(handler) +} diff --git a/packages/aksaria-discord/src/bot/events/client-ready/say-hello/handlers/index.ts b/packages/aksaria-discord/src/bot/events/client-ready/say-hello/handlers/index.ts new file mode 100644 index 0000000..48a638e --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/client-ready/say-hello/handlers/index.ts @@ -0,0 +1,22 @@ +import type { Client } from 'discord.js' +import { registerClientReadyHandler } from '@events/client-ready/registry' +import { EVENT_PATH } from '@events/index' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { SayHello } from '../validators' + +export class SayHelloError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('SayHelloError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) + +registerClientReadyHandler({ + desc: 'Say こんにけは for the first load.', + errorTag: () => `${moduleName}: ${SayHello.ERR.UnexpectedSayHello}`, + exec(client: Client) { + console.warn(`こんにけは、${client.user?.tag}`) + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/client-ready/say-hello/messages/index.ts b/packages/aksaria-discord/src/bot/events/client-ready/say-hello/messages/index.ts new file mode 100644 index 0000000..6ae2e87 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/client-ready/say-hello/messages/index.ts @@ -0,0 +1,8 @@ +import { DiscordAssert } from '@utils/discord' + +export class SayHelloMessage extends DiscordAssert { + static override readonly ERR = { + ...DiscordAssert.ERR, + UnexpectedSayHello: '❌ Something went wrong while handling the say hello event', + } +} diff --git a/packages/aksaria-discord/src/bot/events/client-ready/say-hello/validators/index.ts b/packages/aksaria-discord/src/bot/events/client-ready/say-hello/validators/index.ts new file mode 100644 index 0000000..d4d8ce2 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/client-ready/say-hello/validators/index.ts @@ -0,0 +1,4 @@ +import { SayHelloMessage } from '../messages' + +export class SayHello extends SayHelloMessage { +} diff --git a/packages/aksaria-discord/src/bot/events/event.d.ts b/packages/aksaria-discord/src/bot/events/event.d.ts new file mode 100644 index 0000000..6985525 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/event.d.ts @@ -0,0 +1,8 @@ +import type { Client, ClientEvents } from 'discord.js' + +export interface Event { + name: K + desc: string + once?: boolean + exec: (client: Client, ...args: any[]) => void +} diff --git a/packages/aksaria-discord/src/bot/events/guild-member-update/entry.ts b/packages/aksaria-discord/src/bot/events/guild-member-update/entry.ts new file mode 100644 index 0000000..feed010 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/guild-member-update/entry.ts @@ -0,0 +1,22 @@ +import type { Event } from '@events/event' +import type { GuildMember } from 'discord.js' +import { log } from '@utils/logger' +import { Events } from 'discord.js' +import { guildMemberUpdateHandlers } from './registry' + +export default { + name: Events.GuildMemberUpdate, + desc: 'Dispatch all registered GuildMemberUpdate handlers.', + async exec(client, oldMember: GuildMember, newMember: GuildMember) { + for (const handler of guildMemberUpdateHandlers) { + try { + if (handler.match && !handler.match(oldMember, newMember)) + continue + await handler.exec(client, oldMember, newMember) + } + catch (err) { + log.error(`GuildMemberUpdate handler failed ${handler.errorTag()}: ${err}`) + } + } + }, +} as Event diff --git a/packages/aksaria-discord/src/bot/events/guild-member-update/grinder-role/handlers/index.ts b/packages/aksaria-discord/src/bot/events/guild-member-update/grinder-role/handlers/index.ts new file mode 100644 index 0000000..15e6bef --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/guild-member-update/grinder-role/handlers/index.ts @@ -0,0 +1,45 @@ +import { GRIND_ASHES_CHANNEL, GRINDER_ROLE } from '@config/discord' +import { registerGuildMemberUpdateHandler } from '@events/guild-member-update/registry' +import { EVENT_PATH } from '@events/index' +import { getChannel, sendAsBot } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { GrinderRole } from '../validators' + +export class GrinderRoleError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('GrinderRoleError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) + +registerGuildMemberUpdateHandler({ + desc: 'Watches grinder role assignment/removal for members on guild member update.', + errorTag: () => `${moduleName}: ${GrinderRole.ERR.UnexpectedGrinderRole}`, + match: (_, newMember) => GrinderRole.isMemberHasRole(newMember, GRINDER_ROLE), + async exec(_, oldMember, newMember) { + try { + if (!newMember.guild) + throw new GrinderRoleError(GrinderRole.ERR.NotGuild) + + const newHasGrinderRole = GrinderRole.isMemberHasRole(newMember, GRINDER_ROLE) + const oldHasGrinderRole = GrinderRole.isMemberHasRole(oldMember, GRINDER_ROLE) + if (newHasGrinderRole && !oldHasGrinderRole) { + const channel = await getChannel(newMember.guild, GRIND_ASHES_CHANNEL) + GrinderRole.assertChannel(channel) + const button = GrinderRole.generateButton(newMember.guild.id) + + await sendAsBot( + null, + channel, + { content: GrinderRole.MSG.Greetings(newMember), components: [button], allowedMentions: { users: [newMember.id], roles: [] } }, + ) + } + } + catch (err: any) { + if (!(err instanceof DiscordBaseError)) + throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/guild-member-update/grinder-role/messages/index.ts b/packages/aksaria-discord/src/bot/events/guild-member-update/grinder-role/messages/index.ts new file mode 100644 index 0000000..7febbc9 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/guild-member-update/grinder-role/messages/index.ts @@ -0,0 +1,31 @@ +import type { GuildMember } from 'discord.js' +import { CHECKIN_CHANNEL, FLAMEWARDEN_ROLE } from '@config/discord' +import { DiscordAssert } from '@utils/discord' + +export class GrinderRoleMessage extends DiscordAssert { + static override readonly ERR = { + ...DiscordAssert.ERR, + UnexpectedGrinderRole: '❌ Something went wrong while managing the grinder role', + } + + static override readonly MSG = { + ...DiscordAssert.MSG, + Greetings: (member: GuildMember): string => ` +# πŸ”₯ Seorang Grinder Baru Telah Memasuki Perkemahan! +Selamat datang, Tuan/Nona <@${member.id}>✨ +Nyala api kamu telah dinyalakan, dan dengan itu Tuan/Nona resmi menapaki Path of Grinder. + +Sebagai langkah permulaan, perkenankan kami menuntun Tuan/Nona: +β… . Kunjungilah ⁠<#${CHECKIN_CHANNEL}> untuk menorehkan grind harian pertama kamu. +β…‘. Tuliskan apa yang tengah Tuan/Nona tempuh hari ini, entah itu reading, coding, crafting, designing, exercise, ataupun belajar hal baru. +β…’. Nantikan peninjauan dari seorang <@&${FLAMEWARDEN_ROLE}>, yang akan menilai dan mengesahkan *check-in* Tuan/Nona. + `, + WelcomeNotes: ` +> Harap diingat dengan saksama: +> Streak Tuan/Nona hanya bermula setelah *check-in* pertama disahkan. +> Apabila hingga pukul 23:59 WIB Tuan/Nona lalai menorehkan *check-in*, maka nyala api akan meredup, dan perjalanan harus dimulai kembali dari awal. +> Selamat menempuh jalan ini. +> Biarlah disiplin menjadi percikan, dan konsistensi menjelma nyala yang tak mudah padamπŸ”₯. + `, + } +} diff --git a/packages/aksaria-discord/src/bot/events/guild-member-update/grinder-role/validators/index.ts b/packages/aksaria-discord/src/bot/events/guild-member-update/grinder-role/validators/index.ts new file mode 100644 index 0000000..a7241ad --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/guild-member-update/grinder-role/validators/index.ts @@ -0,0 +1,21 @@ +import { WELCOME_NOTE_BUTTON_ID } from '@events/interaction-create/grinder-role/handlers/button' +import { encodeSnowflake, getCustomId } from '@utils/component' +import { DiscordAssert } from '@utils/discord' +import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js' +import { GrinderRoleMessage } from '../messages' + +export class GrinderRole extends GrinderRoleMessage { + static override BASE_PERMS = [ + ...DiscordAssert.BASE_PERMS, + ] + + static generateButton(guildId: string): ActionRowBuilder { + const noteButtonId = getCustomId([WELCOME_NOTE_BUTTON_ID, encodeSnowflake(guildId)]) + const noteButton = new ButtonBuilder() + .setCustomId(noteButtonId) + .setLabel('πŸ“œ Titah Perjalanan') + .setStyle(ButtonStyle.Primary) + + return new ActionRowBuilder().addComponents(noteButton) + } +} diff --git a/packages/aksaria-discord/src/bot/events/guild-member-update/registry.ts b/packages/aksaria-discord/src/bot/events/guild-member-update/registry.ts new file mode 100644 index 0000000..076609f --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/guild-member-update/registry.ts @@ -0,0 +1,14 @@ +import type { Client, GuildMember } from 'discord.js' + +export interface GuildMemberUpdateHandler { + desc: string + errorTag: () => string + match?: (oldMember: GuildMember, newMember: GuildMember) => boolean + exec: (client: Client, oldMember: GuildMember, newMember: GuildMember) => Promise | void +} + +export const guildMemberUpdateHandlers: GuildMemberUpdateHandler[] = [] + +export function registerGuildMemberUpdateHandler(handler: GuildMemberUpdateHandler) { + guildMemberUpdateHandlers.push(handler) +} diff --git a/packages/aksaria-discord/src/bot/events/index.ts b/packages/aksaria-discord/src/bot/events/index.ts new file mode 100644 index 0000000..3d90b68 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/index.ts @@ -0,0 +1,43 @@ +import type { Event } from '@events/event' +import type { Client } from 'discord.js' +import path from 'node:path' +import { getModuleName, readFiles } from '@utils/io' +import { log } from '@utils/logger' + +export class EventError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options) + this.name = 'EventError' + Object.setPrototypeOf(this, new.target.prototype) + } +} + +export const EVENT_PATH = path.basename(__dirname) +export async function registerEvents(client: Client) { + const files = readFiles(__dirname) + + for (const file of files) { + const fileName = getModuleName(EVENT_PATH, file) + const { default: event } = await import(file) as { default: Event } + if (!event) + continue + + try { + if (event) { + log.info(`Registering event ${fileName}...`) + if (event.once) { + client.once(event.name, (...args) => event.exec(client, ...args)) + } + else { + client.on(event.name, (...args) => { + event.exec(client, ...args) + }) + } + } + } + catch (err: any) { + const msg = err instanceof EventError ? err.message : '❌ Something went wrong when importing the event' + log.error(`Failed to register an event: ${msg}: ${err.message}`) + } + } +} diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/approve-button.ts b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/approve-button.ts new file mode 100644 index 0000000..728ea5a --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/approve-button.ts @@ -0,0 +1,57 @@ +import type { TextChannel } from 'discord.js' +import { FLAMEWARDEN_ROLE } from '@config/discord' +import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' +import { generateCustomId } from '@utils/component' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { Checkin } from '../validators' + +export class CheckinApproveButtonError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('CheckinApproveButtonError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) +export const CHECKIN_APPROVE_BUTTON_ID = `${generateCustomId(EVENT_PATH, __filename)}` + +registerInteractionHandler({ + desc: 'Approves a user check-in from the approve button.', + id: CHECKIN_APPROVE_BUTTON_ID, + errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedButton}`, + async exec(client, interaction) { + if (!interaction.isButton()) + return + + try { + await interaction.deferUpdate() + + if (!interaction.inCachedGuild()) + throw new CheckinApproveButtonError(Checkin.ERR.NotGuild) + + const { checkinId, checkinCreatedAt } = Checkin.getButtonId(interaction, interaction.customId) + + const channel = interaction.channel as TextChannel + Checkin.assertMissPerms(interaction.client.user, channel) + const flamewarden = await interaction.guild.members.fetch(interaction.member.id) + Checkin.assertMember(flamewarden) + Checkin.assertMemberHasRole(flamewarden, FLAMEWARDEN_ROLE) + + await Checkin.validateCheckin( + client, + interaction.guild, + flamewarden, + { key: 'id', value: checkinId }, + checkinCreatedAt, + 'APPROVED', + ) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts new file mode 100644 index 0000000..b1437ab --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts @@ -0,0 +1,66 @@ +import type { CheckinStatusType } from '@type/checkin' +import type { TextChannel } from 'discord.js' +import { FLAMEWARDEN_ROLE } from '@config/discord' +import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' +import { generateCustomId } from '@utils/component' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { Checkin } from '../validators' +import { CheckinAudit } from '../validators/audit' + +export class CheckinAuditModalError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('CheckinAuditModalError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) +export const CHECKIN_AUDIT_ID = `${generateCustomId(EVENT_PATH, __filename)}` + +registerInteractionHandler({ + desc: 'Handles modal submissions for check-in audit modal forms.', + id: CHECKIN_AUDIT_ID, + errorTag: () => `${moduleName}: ${CheckinAudit.ERR.UnexpectedModal}`, + async exec(client, interaction) { + if (!interaction.isModalSubmit()) + return + + try { + await interaction.deferUpdate() + + if (!interaction.inCachedGuild()) + throw new CheckinAuditModalError(CheckinAudit.ERR.NotGuild) + + const { checkinId, checkinCreatedAt } = CheckinAudit.getModalReviewId(interaction, interaction.customId) + + const channel = interaction.channel as TextChannel + CheckinAudit.assertMissPerms(interaction.client.user, channel) + const flamewarden = await interaction.guild.members.fetch(interaction.member.id) + CheckinAudit.assertMember(flamewarden) + CheckinAudit.assertMemberHasRole(flamewarden, FLAMEWARDEN_ROLE) + + const status: CheckinStatusType = 'APPROVED' + const comment = interaction.fields.getTextInputValue('comment') + + const updatedCheckin = await Checkin.validateCheckin( + client, + interaction.guild, + flamewarden, + { key: 'public_id', value: checkinId }, + checkinCreatedAt, + status, + comment, + true, + ) + + await sendReply(interaction, CheckinAudit.MSG.AuditSuccess(updatedCheckin.link!, updatedCheckin.user!.discord_id)) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/custom-button-modal.ts b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/custom-button-modal.ts new file mode 100644 index 0000000..b239bf8 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/custom-button-modal.ts @@ -0,0 +1,62 @@ +import type { CheckinStatusType } from '@type/checkin' +import type { TextChannel } from 'discord.js' +import { FLAMEWARDEN_ROLE } from '@config/discord' +import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' +import { generateCustomId } from '@utils/component' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { Checkin } from '../validators' + +export class CheckinCustomButtonModalError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('CheckinCustomButtonModalError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) +export const CHECKIN_CUSTOM_BUTTON_MODAL_ID = `${generateCustomId(EVENT_PATH, __filename)}` + +registerInteractionHandler({ + desc: 'Handles modal submissions for the custom check-in review modal.', + id: CHECKIN_CUSTOM_BUTTON_MODAL_ID, + errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedModal}`, + async exec(client, interaction) { + if (!interaction.isModalSubmit()) + return + + try { + await interaction.deferUpdate() + + if (!interaction.inCachedGuild()) + throw new CheckinCustomButtonModalError(Checkin.ERR.NotGuild) + + const { checkinId, checkinCreatedAt } = Checkin.getModalReviewId(interaction, interaction.customId) + + const channel = interaction.channel as TextChannel + Checkin.assertMissPerms(interaction.client.user, channel) + const flamewarden = await interaction.guild.members.fetch(interaction.member.id) + Checkin.assertMember(flamewarden) + Checkin.assertMemberHasRole(flamewarden, FLAMEWARDEN_ROLE) + + const status = interaction.fields.getStringSelectValues('status')[0] as CheckinStatusType + const comment = interaction.fields.getTextInputValue('comment') + + await Checkin.validateCheckin( + client, + interaction.guild, + flamewarden, + { key: 'id', value: checkinId }, + checkinCreatedAt, + status, + comment, + ) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/custom-button.ts b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/custom-button.ts new file mode 100644 index 0000000..62cdfba --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/custom-button.ts @@ -0,0 +1,57 @@ +import type { TextChannel } from 'discord.js' +import { FLAMEWARDEN_ROLE } from '@config/discord' +import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' +import { createCheckinReviewModal, encodeSnowflake, generateCustomId, getCustomId } from '@utils/component' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { Checkin } from '../validators' +import { CHECKIN_CUSTOM_BUTTON_MODAL_ID } from './custom-button-modal' + +export class CheckinCustomButtonError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('CheckinCustomButtonError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) +export const CHECKIN_CUSTOM_BUTTON_ID = `${generateCustomId(EVENT_PATH, __filename)}` + +registerInteractionHandler({ + desc: 'Opens review modal for a check-in', + id: CHECKIN_CUSTOM_BUTTON_ID, + errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedButton}`, + async exec(client, interaction) { + if (!interaction.isButton()) + return + + try { + if (!interaction.inCachedGuild()) + throw new CheckinCustomButtonError(Checkin.ERR.NotGuild) + + const channel = interaction.channel as TextChannel + Checkin.assertMissPerms(interaction.client.user, channel) + const flamewarden = await interaction.guild.members.fetch(interaction.member.id) + Checkin.assertMember(flamewarden) + Checkin.assertMemberHasRole(flamewarden, FLAMEWARDEN_ROLE) + + const { checkinId, checkinCreatedAt } = Checkin.getButtonId(interaction, interaction.customId) + const checkin = await Checkin.getWaitingCheckin(client.prisma, 'id', checkinId) + const modalCustomId = getCustomId([ + CHECKIN_CUSTOM_BUTTON_MODAL_ID, + encodeSnowflake(interaction.guildId), + encodeSnowflake(checkinId.toString()), + checkinCreatedAt.getTime().toString(), + ]) + const modal = createCheckinReviewModal(modalCustomId, checkin) + + await interaction.showModal(modal) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/detail-button.ts b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/detail-button.ts new file mode 100644 index 0000000..1cb2bc8 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/detail-button.ts @@ -0,0 +1,50 @@ +import type { GuildMember, TextChannel } from 'discord.js' +import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' +import { generateCustomId } from '@utils/component' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { Checkin } from '../validators' + +export class CheckinDetailButtonError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('CheckinDetailButtonError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) +export const CHECKIN_DETAIL_BUTTON_ID = `${generateCustomId(EVENT_PATH, __filename)}` + +registerInteractionHandler({ + desc: 'Handles displaying more details for a user check-in when the detail button is pressed.', + id: CHECKIN_DETAIL_BUTTON_ID, + errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedButton}`, + async exec(client, interaction) { + if (!interaction.isButton()) + return + + try { + if (!interaction.inCachedGuild()) + throw new CheckinDetailButtonError(Checkin.ERR.NotGuild) + + const { checkinId } = Checkin.getButtonId(interaction, interaction.customId) + + const channel = interaction.channel as TextChannel + const member = interaction.member as GuildMember + Checkin.assertMissPerms(interaction.client.user, channel) + Checkin.assertMember(member) + Checkin.assertMemberGrindRoles(member) + + const checkin = await Checkin.getCheckin(client.prisma, checkinId) + const prevCheckin = await Checkin.getPrevCheckin(client.prisma, checkin.user!.id, checkin.checkin_streak!, checkin) + + await sendReply(interaction, Checkin.MSG.GrinderDetails(checkin, prevCheckin)) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/modal.ts b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/modal.ts new file mode 100644 index 0000000..ff52429 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/modal.ts @@ -0,0 +1,73 @@ +import type { Attachment, GuildMember, Message } from 'discord.js' +import { FLAMEWARDEN_ROLE } from '@config/discord' +import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' +import { generateCustomId, tempStore } from '@utils/component' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { Checkin } from '../validators' + +export class CheckinModalError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('CheckinModalError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) +export const CHECKIN_ID = `${generateCustomId(EVENT_PATH, __filename)}` + +registerInteractionHandler({ + desc: 'Handles modal submissions for check-in modal forms.', + id: CHECKIN_ID, + errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedModal}`, + async exec(client, interaction) { + if (!interaction.isModalSubmit()) + return + + try { + if (!interaction.inCachedGuild()) + throw new CheckinModalError(Checkin.ERR.NotGuild) + + const { tempToken } = Checkin.getModalId(interaction, interaction.customId) + const attachments = tempStore.get(tempToken) as Attachment[] + Checkin.delTempItem(attachments, tempToken) + + const todo = interaction.fields.getTextInputValue('todo') + const userDiscordId: string = interaction.user.id + const member = interaction.member as GuildMember + const user = await Checkin.getOrCreateUser(client.prisma, userDiscordId) + + Checkin.assertMember(member) + Checkin.assertMemberGrindRoles(member) + Checkin.assertCheckinToday(user) + + const { checkin } = await Checkin.validateCheckinStreak(client.prisma, user.id, user.checkin_streaks?.[0], todo) + const buttons = Checkin.generateButtons(interaction.guildId, checkin.id.toString(), checkin.created_at) + + const msg = await sendReply( + interaction, + Checkin.MSG.CheckinSuccess(todo), + false, + { + files: attachments.length ? attachments : undefined, + components: [buttons], + allowedMentions: { users: [member.id], roles: [FLAMEWARDEN_ROLE] }, + }, + true, + ) as Message + + if (msg.attachments.size > 0) { + await Checkin.createAttachments(client.prisma, checkin, Array.from(msg.attachments.values())) + } + + const updatedCheckin = await Checkin.updateCheckinMsgLink(interaction, client.prisma, checkin, msg) + await Checkin.sendSuccessCheckinToMember(member, updatedCheckin) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/reject-button.ts b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/reject-button.ts new file mode 100644 index 0000000..a5db077 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/handlers/reject-button.ts @@ -0,0 +1,57 @@ +import type { TextChannel } from 'discord.js' +import { FLAMEWARDEN_ROLE } from '@config/discord' +import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' +import { generateCustomId } from '@utils/component' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { Checkin } from '../validators' + +export class CheckinRejectButtonError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('CheckinRejectButtonError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) +export const CHECKIN_REJECT_BUTTON_ID = `${generateCustomId(EVENT_PATH, __filename)}` + +registerInteractionHandler({ + desc: 'Handles check-in reject button interactions and rejects user check-in.', + id: CHECKIN_REJECT_BUTTON_ID, + errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedButton}`, + async exec(client, interaction) { + if (!interaction.isButton()) + return + + try { + await interaction.deferUpdate() + + if (!interaction.inCachedGuild()) + throw new CheckinRejectButtonError(Checkin.ERR.NotGuild) + + const { checkinId, checkinCreatedAt } = Checkin.getButtonId(interaction, interaction.customId) + + const channel = interaction.channel as TextChannel + Checkin.assertMissPerms(interaction.client.user, channel) + const flamewarden = await interaction.guild.members.fetch(interaction.member.id) + Checkin.assertMember(flamewarden) + Checkin.assertMemberHasRole(flamewarden, FLAMEWARDEN_ROLE) + + await Checkin.validateCheckin( + client, + interaction.guild, + flamewarden, + { key: 'id', value: checkinId }, + checkinCreatedAt, + 'REJECTED', + ) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/checkin/messages/audit.ts b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/messages/audit.ts new file mode 100644 index 0000000..eecca18 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/messages/audit.ts @@ -0,0 +1,19 @@ +import type { Checkin } from '@type/checkin' +import { DiscordAssert } from '@utils/discord' + +export class CheckinAuditMessage extends DiscordAssert { + static override readonly ERR = { + ...DiscordAssert.ERR, + CheckinShouldNotToday: (checkinMsgLink: string) => `❌ You cannot review [this check-in](${checkinMsgLink}). Please only audit check-ins from previous days`, + CheckinNotDiffWithinDay: (checkin: Checkin, waitingCheckinList: string) => ` +❌ Check-ins must be within 1 day of each other. Please validate [this check-in](${checkin.link!}) first: +${waitingCheckinList} + `, + UnexpectedCheckinAudit: '❌ Something went wrong during the check-in audit', + } + + static override readonly MSG = { + ...DiscordAssert.MSG, + AuditSuccess: (msgLink: string, userDiscordId: string) => `βœ… Successfully [audited check-in](${msgLink}) for <@${userDiscordId}>.`, + } +} diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/checkin/messages/index.ts b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/messages/index.ts new file mode 100644 index 0000000..d21f745 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/messages/index.ts @@ -0,0 +1,74 @@ +import type { Checkin } from '@type/checkin' +import type { GuildMember } from 'discord.js' +import { FLAMEWARDEN_ROLE } from '@config/discord' +import { getNow, getParsedNow } from '@utils/date' +import { DiscordAssert } from '@utils/discord' +import { DUMMY } from '@utils/placeholder' + +export class CheckinMessage extends DiscordAssert { + static override readonly ERR = { + ...DiscordAssert.ERR, + AlreadyCheckinToday: (checkinMsgLink: string) => `❌ You already have a [check-in for today](${checkinMsgLink}). Please come back tomorrow`, + SubmittedCheckinNotToday: (checkinMsgLink: string) => `❌ This [submitted check-in](${checkinMsgLink})'s date should equals as today. You can't review this anymore`, + UnknownCheckinStatus: '❌ The status for this check-in is unknown or unexpected', + UnexpectedSubmittedCheckinMessage: '❌ Something went wrong while submitting your check-in', + UnexpectedCheckin: '❌ Something went wrong during check-in', + } + + static override readonly MSG = { + ...DiscordAssert.MSG, + CheckinSuccess: (todo: string) => ` +# βœ… Check-In Baru Terdeteksi! +*Kindly take a look and do a review for this one, <@&${FLAMEWARDEN_ROLE}>* + +β‹†ο½‘Λš ☁︎ Λšο½‘β‹†ο½‘Λšβ˜½Λšο½‘β‹† +${todo} + +> ${DUMMY.FOOTER}`, + + GrinderDetails: (checkin: Checkin, lastCheckin?: Checkin) => ` +βœ¨β”€β”€β”€β”€β”€βœ¨/βœ¨β”β”β”β”βœ¨ +🌟 **Grinder:** <@${checkin.user!.discord_id}> +πŸ“ **Attachment:** ${checkin.attachments && checkin.attachments.length > 0 ? 'βœ…' : '❌'} +πŸ•“ **Date:** ${getParsedNow(getNow(checkin.created_at))} +πŸ”₯ **Current Streak:** ${checkin.checkin_streak!.streak} day(s) +πŸ—“ **Last Check-In:** ${lastCheckin ? `[${getParsedNow(getNow(lastCheckin.created_at))}](${lastCheckin.link})` : '-'} + `, + + CheckinSuccessToMember: (checkin: Checkin) => ` +Sebuah [check-in](${checkin.link}) baru telah Tuan/Nona serahkan dan kini menunggu pemeriksaan dari Flamewarden. +πŸ†” **Check-In ID**: +\`\`\`bash +${checkin.public_id} +\`\`\` +πŸ—“ **Submitted At**: ${getParsedNow(getNow(checkin.created_at))} + +> πŸ”Ž Sedang menunggu peninjauan Flamewarden; mohon Tuan/Nona bersabar`, + + CheckinApproved: (flamewarden: GuildMember, checkin: Checkin) => ` +[Nyala api](${checkin.link}) Tuan/Nona berkobar lebih terang pada hari ini. +πŸ†” **Check-In ID**: +\`\`\`bash +${checkin.public_id} +\`\`\` +πŸ”₯ **Current Streak**: ${checkin.checkin_streak!.streak} +πŸ—“ **Approved At**: ${getParsedNow(getNow(checkin.updated_at!))} +πŸ‘€ **Approved By**: ${flamewarden.displayName} (@${flamewarden.user.username}) +✍🏻 **${flamewarden.displayName}'(s) Comment**: ${checkin.comment ?? '-'} + +> πŸ”₯ Konsistensi ialah bahan bakar nyala api; teruskan langkah Tuan/Nona`, + + CheckinRejected: (flamewarden: GuildMember, checkin: Checkin) => ` +[Check-in ini](${checkin.link}) tidak memenuhi syarat dan dengan demikian telah ditolak. +πŸ†” **Check-In ID**: +\`\`\`bash +${checkin.public_id} +\`\`\` +πŸ”₯ **Current Streak**: ${checkin.checkin_streak!.streak} +πŸ—“ **Reviewed At**: ${getParsedNow(getNow(checkin.updated_at!))} +πŸ‘€ **Reviewed By**: ${flamewarden.displayName} (@${flamewarden.user.username}) +✍🏻 **${flamewarden.displayName}'(s) Comment**: ${checkin.comment ?? '-'} + +> 🧯 Nyala api Tuan/Nona meredup, namun belum padam; silakan mencuba kembali`, + } +} diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/checkin/validators/audit.ts b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/validators/audit.ts new file mode 100644 index 0000000..713916a --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/validators/audit.ts @@ -0,0 +1,107 @@ +import type { PrismaClient } from '@generatedDB/client' +import type { Checkin as CheckinType } from '@type/checkin' +import type { CheckinStreak } from '@type/checkin-streak' +import type { Interaction } from 'discord.js' +import { CheckinAuditError } from '@commands/checkin/handlers/checkin-audit' +import { decodeSnowflakes } from '@utils/component' +import { isDateToday } from '@utils/date' +import { DiscordAssert } from '@utils/discord' +import { PermissionsBitField } from 'discord.js' +import { Checkin } from '.' +import { CheckinAuditModalError } from '../handlers/audit-modal' +import { CheckinAuditMessage } from '../messages/audit' + +export class CheckinAudit extends CheckinAuditMessage { + static override BASE_PERMS = [ + ...DiscordAssert.BASE_PERMS, + PermissionsBitField.Flags.UseApplicationCommands, + ] + + static getModalReviewId(interaction: Interaction, customId: string) { + const [prefix, guildId, checkinId, checkinTs] = decodeSnowflakes(customId) + + if (!guildId) + throw new CheckinAuditModalError(this.ERR.GuildMissing) + if (interaction.guildId !== guildId) + throw new CheckinAuditModalError(this.ERR.NotGuild) + if (!checkinId) + throw new CheckinAuditModalError(this.ERR.CheckinIdMissing) + if (!checkinTs) + throw new CheckinAuditModalError(this.ERR.CheckinDateMissing) + + const checkinCreatedAt = new Date(Number(checkinTs)) + if (!checkinCreatedAt) + throw new CheckinAuditModalError(this.ERR.CheckinDateInvalid) + + return { prefix, guildId, checkinId, checkinCreatedAt } + } + + static assertCheckinNotToday(checkin: CheckinType) { + if (isDateToday(checkin.created_at)) { + throw new CheckinAuditError(CheckinAudit.ERR.CheckinShouldNotToday(checkin.link!)) + } + } + + static assertCheckinWithOldestWaiting(currCheckin: CheckinType, checkins: CheckinType[]) { + const oldestWaitingCheckin = checkins[0] + + const diffMs = Math.abs(currCheckin.created_at.getTime() - oldestWaitingCheckin.created_at.getTime()) + const diffDays = diffMs / (1000 * 60 * 60 * 24) + + if (diffDays > 0) { + let waitingCheckinList = `` + for (const [idx, checkin] of checkins.entries()) { + if (idx === 0) { + waitingCheckinList += ` +[#1](${checkin.link}) *<- validate this check-in first* +\`\`\`bash +${checkin.public_id} +\`\`\`` + } + else { + waitingCheckinList += ` +[#${idx + 1}](${checkin.link}) +\`\`\`bash +${checkin.public_id} +\`\`\`` + } + } + throw new CheckinAuditError(CheckinAudit.ERR.CheckinNotDiffWithinDay(oldestWaitingCheckin, waitingCheckinList)) + } + } + + static async assertExistCheckinId(prisma: PrismaClient, checkinId: string) { + const checkin = await prisma.checkin.findUnique({ + where: { public_id: checkinId }, + include: { user: true, checkin_streak: true }, + }) + if (!checkin) { + throw new CheckinAuditError(this.ERR.CheckinIdInvalid) + } + + await Checkin.setAttachments(prisma, checkin) + + return checkin + } + + static async getOldestWaitingCheckins(prisma: PrismaClient, checkinStreakId: number): Promise { + const checkinStreak = await prisma.checkinStreak.findFirst({ + where: { + id: checkinStreakId, + }, + select: { + id: true, + updated_at: true, + checkins: { + where: { + status: 'WAITING', + reviewed_by: null, + }, + orderBy: { created_at: 'asc' }, + }, + }, + }) as CheckinStreak + + return checkinStreak.checkins! + } +} diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/checkin/validators/index.ts b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/validators/index.ts new file mode 100644 index 0000000..25d85b1 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/checkin/validators/index.ts @@ -0,0 +1,541 @@ +import type { GrindRole } from '@config/discord' +import type { Prisma, PrismaClient } from '@generatedDB/client' +import type { Attachment as AttachmentType } from '@type/attachment' +import type { CheckinAllowedEmojiType, CheckinColumn, CheckinStatusType, Checkin as CheckinType } from '@type/checkin' +import type { CheckinStreak } from '@type/checkin-streak' +import type { User } from '@type/user' +import type { Attachment, Client, EmbedBuilder, Guild, GuildMember, Interaction, Message, TextChannel } from 'discord.js' +import crypto from 'node:crypto' +import { CheckinError } from '@commands/checkin/handlers/checkin' +import { AURA_FARMING_CHANNEL, CHECKIN_CHANNEL, GRINDER_ROLE } from '@config/discord' +import { SubmittedCheckinError } from '@events/message-reaction-add/checkin/handlers/submitted' +import { createEmbed, decodeSnowflakes, encodeSnowflake, getCustomId } from '@utils/component' +import { isDateToday, isDateYesterday } from '@utils/date' +import { DiscordAssert, getChannel, sendAsBot } from '@utils/discord' +import { attachNewGrindRole, getGrindRoleByStreakCount } from '@utils/discord/roles' +import { DUMMY } from '@utils/placeholder' +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, messageLink, PermissionsBitField } from 'discord.js' +import { CHECKIN_APPROVE_BUTTON_ID } from '../handlers/approve-button' +import { CHECKIN_CUSTOM_BUTTON_ID } from '../handlers/custom-button' +import { CheckinCustomButtonModalError } from '../handlers/custom-button-modal' +import { CHECKIN_DETAIL_BUTTON_ID } from '../handlers/detail-button' +import { CheckinModalError } from '../handlers/modal' +import { CHECKIN_REJECT_BUTTON_ID } from '../handlers/reject-button' +import { CheckinMessage } from '../messages' + +export class Checkin extends CheckinMessage { + static override BASE_PERMS = [ + ...DiscordAssert.BASE_PERMS, + PermissionsBitField.Flags.UseApplicationCommands, + ] + + static override ATTACHMENT_COUNT: number = 1 + + static PUBLIC_ID_PREFIX = 'CHK-' + + static EMOJI_STATUS: Record = { + '❌': 'REJECTED', + 'πŸ”₯': 'APPROVED', + } + + static REVERSED_EMOJI_STATUS = Object.fromEntries( + Object.entries(this.EMOJI_STATUS).map(([emoji, status]) => [status, emoji]), + ) as Record + + static getModalId(interaction: Interaction, customId: string) { + const [prefix, guildId, tempToken] = decodeSnowflakes(customId) + + if (!guildId) + throw new CheckinModalError(this.ERR.GuildMissing) + if (interaction.guildId !== guildId) + throw new CheckinModalError(this.ERR.NotGuild) + + return { prefix, guildId, tempToken } + } + + static getModalReviewId(interaction: Interaction, customId: string) { + const [prefix, guildId, checkinId, checkinTs] = decodeSnowflakes(customId) + + if (!guildId) + throw new CheckinCustomButtonModalError(this.ERR.GuildMissing) + if (interaction.guildId !== guildId) + throw new CheckinCustomButtonModalError(this.ERR.NotGuild) + if (!checkinId) + throw new CheckinCustomButtonModalError(this.ERR.CheckinIdMissing) + if (!checkinTs) + throw new CheckinCustomButtonModalError(this.ERR.CheckinDateMissing) + + const checkinIdNum = Number(checkinId) + if (Number.isNaN(checkinIdNum)) + throw new CheckinError(this.ERR.CheckinIdInvalid) + + const checkinCreatedAt = new Date(Number(checkinTs)) + if (!checkinCreatedAt) + throw new CheckinCustomButtonModalError(this.ERR.CheckinDateInvalid) + + return { prefix, guildId, checkinId: checkinIdNum, checkinCreatedAt } + } + + static getButtonId(interaction: Interaction, customId: string) { + const [prefix, guildId, checkinId, checkinTs] = decodeSnowflakes(customId) + + if (!guildId) + throw new CheckinError(this.ERR.GuildMissing) + if (interaction.guildId !== guildId) + throw new CheckinError(this.ERR.NotGuild) + if (!checkinId) + throw new CheckinError(this.ERR.CheckinIdMissing) + if (!checkinTs) + throw new CheckinError(this.ERR.CheckinDateMissing) + + const checkinIdNum = Number(checkinId) + if (Number.isNaN(checkinIdNum)) + throw new CheckinError(this.ERR.CheckinIdInvalid) + + const checkinCreatedAt = new Date(Number(checkinTs)) + if (!checkinCreatedAt) + throw new CheckinError(this.ERR.CheckinDateInvalid) + + return { prefix, guildId, checkinId: checkinIdNum, checkinCreatedAt } + } + + static generatePublicId(): string { + const random = crypto.randomBytes(3).toString('hex').toUpperCase() + return `${this.PUBLIC_ID_PREFIX}${random}` + } + + static async getPublicId(tx: Prisma.TransactionClient): Promise { + while (true) { + const id = this.generatePublicId() + const exists = await tx.checkin.findUnique({ where: { public_id: id } }) + + if (!exists) + return id + } + } + + static generateButtons(guildId: string, checkinId: string, checkinCreatedAt: Date): ActionRowBuilder { + const detailButtonId = getCustomId([CHECKIN_DETAIL_BUTTON_ID, encodeSnowflake(guildId), encodeSnowflake(checkinId), checkinCreatedAt.getTime().toString()]) + const detailButton = new ButtonBuilder() + .setCustomId(detailButtonId) + .setLabel('πŸ” Detail') + .setStyle(ButtonStyle.Primary) + + const approveButtonId = getCustomId([CHECKIN_APPROVE_BUTTON_ID, encodeSnowflake(guildId), encodeSnowflake(checkinId), checkinCreatedAt.getTime().toString()]) + const approveButton = new ButtonBuilder() + .setCustomId(approveButtonId) + .setLabel('πŸ”₯ Approve') + .setStyle(ButtonStyle.Success) + + const rejectButtonId = getCustomId([CHECKIN_REJECT_BUTTON_ID, encodeSnowflake(guildId), encodeSnowflake(checkinId), checkinCreatedAt.getTime().toString()]) + const rejectButton = new ButtonBuilder() + .setCustomId(rejectButtonId) + .setLabel('πŸ™… Reject') + .setStyle(ButtonStyle.Danger) + + const customButtonId = getCustomId([CHECKIN_CUSTOM_BUTTON_ID, encodeSnowflake(guildId), encodeSnowflake(checkinId), checkinCreatedAt.getTime().toString()]) + const customButton = new ButtonBuilder() + .setCustomId(customButtonId) + .setLabel('βš™οΈ Review') + .setStyle(ButtonStyle.Secondary) + + return new ActionRowBuilder().addComponents(detailButton, approveButton, rejectButton, customButton) + } + + static getNewGrindRole(guild: Guild, streakCount: number) { + return getGrindRoleByStreakCount(guild.roles, streakCount) + } + + static async setMemberNewGrindRole( + guild: Guild, + member: GuildMember, + newRole?: GrindRole, + ) { + if (!newRole) + return + + const hasGrindRole = this.isMemberHasRole(member, newRole.id) + const channel = await getChannel(guild, AURA_FARMING_CHANNEL) + this.assertChannel(channel) + + if (!hasGrindRole) { + await attachNewGrindRole(member, newRole) + await sendAsBot(null, channel, { + content: `**Congratulations, <@${member.id}>** ${this.MSG.ReachNewGrindRole(newRole)}`, + allowedMentions: { users: [member.id], roles: [] }, + }) + } + else { + const checkinChannel = await getChannel(guild, CHECKIN_CHANNEL) + await sendAsBot(null, checkinChannel, { + content: `Hey, <@${member.id}>. You already have <@&${newRole.id}>`, + allowedMentions: { users: [member.id], roles: [] }, + }, true) + } + } + + static assertCheckinToday(user: User) { + const latestStreak = user.checkin_streaks?.[0] + const latestCheckin = latestStreak?.checkins?.[0] + + const hasCheckedInToday = this.hasCheckinToday(latestStreak, latestCheckin) + const checkinIsNonRejected = this.isNotRejectedCheckin(latestCheckin) + + if (hasCheckedInToday && checkinIsNonRejected) + throw new CheckinModalError(this.ERR.AlreadyCheckinToday(latestCheckin!.link!)) + } + + static async assertSubmittedCheckinToday(prisma: PrismaClient, opt: CheckinColumn) { + const checkin = await this.getWaitingCheckin(prisma, opt.key, opt.value) + + const isCheckinToday = this.hasCheckinToday(checkin.checkin_streak, checkin) + if (!isCheckinToday) + throw new SubmittedCheckinError(this.ERR.SubmittedCheckinNotToday(checkin.link!)) + } + + static assertMemberGrindRoles(member: GuildMember) { + const hasGrinderRole = this.isMemberHasRole(member, GRINDER_ROLE) + + if (!hasGrinderRole) + throw new CheckinModalError(this.ERR.RoleMissing(GRINDER_ROLE)) + } + + static assertEmojis(emoji: string | null | undefined) { + if (!emoji || !(emoji in this.EMOJI_STATUS)) { + throw new SubmittedCheckinError(this.ERR.UnexpectedEmoji) + } + + return emoji as CheckinAllowedEmojiType + } + + static async getOrCreateUser(prisma: PrismaClient, userDiscordId: string): Promise { + const select = { + id: true, + discord_id: true, + created_at: true, + updated_at: true, + checkin_streaks: { + orderBy: { first_date: 'desc' }, + take: 1, + include: { + checkins: { + orderBy: { created_at: 'desc' }, + take: 1, + }, + }, + }, + } satisfies Prisma.UserSelect + + return prisma.user.upsert({ + where: { discord_id: userDiscordId }, + create: { discord_id: userDiscordId }, + update: {}, + select, + }) + } + + static async getWaitingCheckin( + prisma: PrismaClient, + key: T, + value: Prisma.CheckinWhereInput[T], + ) { + const checkin = await prisma.checkin.findFirst({ + where: { + [key]: value, + status: 'WAITING', + reviewed_by: null, + }, + include: { user: true, checkin_streak: true }, + }) as CheckinType + + if (!checkin) + throw new SubmittedCheckinError(this.ERR.PlainMessage) + + await this.setAttachments(prisma, checkin) + + return checkin + } + + static determineStreak(lastStreak: CheckinStreak | undefined) { + if (!lastStreak) + return 'new' + + if (!lastStreak.last_date) + return 'new' + + if (lastStreak.checkins?.[0]?.status === 'WAITING') + return 'new' + + return this.isStreakContinuing(lastStreak.last_date) ? 'next' : 'new' + } + + static isStreakContinuing(date: Date): boolean { + return isDateToday(date) || isDateYesterday(date) + } + + static isNotRejectedCheckin(checkin: CheckinType | undefined) { + return checkin?.status && checkin.status !== 'REJECTED' + } + + static hasCheckinToday(checkinStreak: CheckinStreak | undefined, checkin: CheckinType | undefined) { + const streakWasToday = checkinStreak?.last_date + ? isDateToday(checkinStreak.last_date) + : false + const checkinWasToday = checkin?.created_at + ? isDateToday(checkin.created_at) + : false + + return streakWasToday || checkinWasToday + } + + static async upsertStreak( + tx: Prisma.TransactionClient, + userId: number, + lastStreak: CheckinStreak | undefined, + decision: 'new' | 'next', + ): Promise { + if (decision === 'new') { + return await tx.checkinStreak.create({ + data: { + user: { + connect: { + id: userId, + }, + }, + }, + }) + } + else { + return await tx.checkinStreak.update({ + where: { id: lastStreak!.id }, + data: { last_date: new Date() }, + }) + } + } + + static async createCheckin( + tx: Prisma.TransactionClient, + userId: number, + streak: CheckinStreak, + description: string, + ): Promise { + return await tx.checkin.create({ + data: { + public_id: await this.getPublicId(tx), + user_id: userId, + checkin_streak_id: streak.id, + description, + status: 'WAITING', + }, + }) + } + + static async getPrevCheckin( + tx: Prisma.TransactionClient, + userId: number, + streak: CheckinStreak, + checkin: CheckinType, + ): Promise { + return await tx.checkin.findFirst({ + where: { + user_id: userId, + checkin_streak_id: streak.id, + id: { not: checkin.id }, + }, + orderBy: { created_at: 'desc' }, + }) as CheckinType + } + + static async getCheckin( + prisma: PrismaClient, + checkinId: number, + ): Promise { + const checkin = await prisma.checkin.findUnique({ + where: { id: checkinId }, + include: { + checkin_streak: true, + user: true, + }, + }) as CheckinType + + if (!checkin) + throw new SubmittedCheckinError(this.ERR.PlainMessage) + + await this.setAttachments(prisma, checkin) + + return checkin + } + + static async createAttachments( + prisma: PrismaClient, + checkin: CheckinType, + attachments: Attachment[], + ) { + await prisma.$transaction(async (tx) => { + await tx.attachment.createMany({ + data: attachments.map(a => ({ + name: a.name, + url: a.url, + type: a.contentType ?? '', + size: a.size, + module_id: checkin.id, + module_type: 'CHECKIN', + })), + }) + }) + } + + static async setAttachments(prisma: PrismaClient, checkin: CheckinType | undefined) { + if (!checkin) + return + + const attachments = await prisma.attachment.findMany({ + where: { + module_id: checkin.id, + module_type: 'CHECKIN', + }, + orderBy: { created_at: 'asc' }, + }) as AttachmentType[] + + checkin.attachments = attachments + } + + static async validateCheckinStreak( + prisma: PrismaClient, + userId: number, + lastCheckinStreak: CheckinStreak | undefined, + description: string, + ) { + const decision = this.determineStreak(lastCheckinStreak) + + return prisma.$transaction(async (tx) => { + const checkinStreak = await this.upsertStreak(tx, userId, lastCheckinStreak, decision) + const checkin = await this.createCheckin(tx, userId, checkinStreak, description) + + return { checkinStreak, checkin } + }) + } + + static async validateCheckin( + client: Client, + guild: Guild, + flamewarden: GuildMember, + opt: CheckinColumn, + checkinCreatedAt: Date, + checkinStatus: CheckinStatusType, + comment?: string | null, + isAudit: boolean = false, + ): Promise { + if (!isAudit) + await this.assertSubmittedCheckinToday(client.prisma, opt) + const updatedCheckin = await this.updateCheckinStatus(client.prisma, flamewarden, opt, checkinCreatedAt, checkinStatus, comment, isAudit) as CheckinType + + const checkinChannel = await client.channels.fetch(CHECKIN_CHANNEL) as TextChannel + const { messageId } = this.getMessageFromLink(updatedCheckin.link!) + const message = await checkinChannel.messages.fetch(messageId) + + await this.validateCheckinHandleToUser(guild, flamewarden, updatedCheckin.user!.discord_id, updatedCheckin) + await message.react(this.REVERSED_EMOJI_STATUS[checkinStatus]) + + return updatedCheckin + } + + static async validateCheckinHandleToUser(guild: Guild, flamewarden: GuildMember, userDiscordId: string, updatedCheckin: CheckinType) { + const member = await guild.members.fetch(userDiscordId) + this.assertMember(member) + + const hasGrinderRole = this.isMemberHasRole(member, GRINDER_ROLE) + if (!hasGrinderRole) + await member.roles.add(GRINDER_ROLE) + + const newGrindRole = this.getNewGrindRole(guild, updatedCheckin.checkin_streak!.streak) + await this.setMemberNewGrindRole(guild, member, newGrindRole) + await this.sendCheckinStatusToMember(flamewarden, member, updatedCheckin) + } + + static async updateCheckinMsgLink(interaction: Interaction, prisma: PrismaClient, checkin: CheckinType, msg: Message): Promise { + const msgLink = messageLink(interaction.channelId!, msg.id, interaction.guildId!) + + return prisma.checkin.update({ + where: { id: checkin.id }, + data: { link: msgLink }, + }) + } + + static async updateCheckinStatus( + prisma: PrismaClient, + member: GuildMember, + opt: CheckinColumn, + checkinCreatedAt: Date, + checkinStatus: CheckinStatusType, + comment: string | null = null, + isAudit: boolean = false, + ): Promise { + const updatedDate = isAudit ? checkinCreatedAt : new Date() + + const updatedCheckin = await prisma.checkin.update({ + where: { + [opt.key!]: opt.value!, + } as Prisma.CheckinWhereUniqueInput, + data: { + status: checkinStatus, + reviewed_by: member.id, + comment, + updated_at: updatedDate, + checkin_streak: { + update: { + streak: { + increment: checkinStatus === 'APPROVED' ? 1 : 0, + }, + last_date: updatedDate, + updated_at: updatedDate, + streak_broken_at: null, + }, + }, + }, + include: { user: true, checkin_streak: true }, + }) + + return updatedCheckin + } + + static async sendSuccessCheckinToMember(member: GuildMember, checkin: CheckinType) { + const embed = createEmbed( + `πŸŽ‰ *Check-In* Berhasil`, + this.MSG.CheckinSuccessToMember(checkin), + DUMMY.COLOR, + { text: DUMMY.FOOTER }, + ) + + await member.send({ embeds: [embed] }) + } + + static async sendCheckinStatusToMember(flamewarden: GuildMember, member: GuildMember, checkin: CheckinType) { + let embed: EmbedBuilder + + switch (checkin.status) { + case 'REJECTED': + embed = createEmbed( + `⚠️ *Check-In* Ditolak`, + this.MSG.CheckinRejected(flamewarden, checkin), + '#D9534F', + { text: DUMMY.FOOTER }, + ) + break + + case 'APPROVED': + embed = createEmbed( + `πŸ”₯ *Check-In* Disetujui`, + this.MSG.CheckinApproved(flamewarden, checkin), + '#4CAF50', + { text: DUMMY.FOOTER }, + ) + break + + default: + throw new SubmittedCheckinError(this.ERR.UnknownCheckinStatus) + } + + await member.send({ embeds: [embed] }) + } +} diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/embed/handlers/role-grant-create-button.ts b/packages/aksaria-discord/src/bot/events/interaction-create/embed/handlers/role-grant-create-button.ts new file mode 100644 index 0000000..bed00cd --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/embed/handlers/role-grant-create-button.ts @@ -0,0 +1,52 @@ +import type { GuildMember, TextChannel } from 'discord.js' +import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' +import { generateCustomId } from '@utils/component' +import { getRole, sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { RoleGrantCreate } from '../validators/role-grant-create' + +export class EmbedRoleGrantButtonError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('EmbedRoleGrantButtonError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) +export const EMBED_ROLE_GRANT_CREATE_BUTTON_ID = `${generateCustomId(EVENT_PATH, __filename)}` + +registerInteractionHandler({ + desc: 'Handles role assignment button interactions and adds a role for users.', + id: EMBED_ROLE_GRANT_CREATE_BUTTON_ID, + errorTag: () => `${moduleName}: ${RoleGrantCreate.ERR.UnexpectedButton}`, + async exec(_, interaction) { + if (!interaction.isButton()) + return + + try { + if (!interaction.inCachedGuild()) + throw new EmbedRoleGrantButtonError(RoleGrantCreate.ERR.NotGuild) + + const channel = interaction.channel as TextChannel + RoleGrantCreate.assertMissPerms(interaction.client.user, channel) + + const { roleId } = RoleGrantCreate.getButtonId(interaction, interaction.customId) + const member = interaction.member as GuildMember + const role = await getRole(interaction.guild, roleId) + + RoleGrantCreate.assertRole(role) + RoleGrantCreate.assertMember(member) + RoleGrantCreate.assertMemberAlreadyHasRole(member, role.id) + + await member.roles.add(role) + await sendReply(interaction, ` + ${RoleGrantCreate.MSG.RoleGranted(role.id)}`) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/embed/handlers/role-grant-create-modal.ts b/packages/aksaria-discord/src/bot/events/interaction-create/embed/handlers/role-grant-create-modal.ts new file mode 100644 index 0000000..6dd72d8 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/embed/handlers/role-grant-create-modal.ts @@ -0,0 +1,71 @@ +import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' +import { createEmbed, encodeSnowflake, generateCustomId, getCustomId } from '@utils/component' +import { getChannel, getRole, sendAsBot, sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js' +import { RoleGrantCreate } from '../validators/role-grant-create' +import { EMBED_ROLE_GRANT_CREATE_BUTTON_ID } from './role-grant-create-button' + +export class EmbedRoleGrantModalError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('EmbedRoleGrantModalError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) +export const EMBED_ROLE_GRANT_CREATE_MODAL_ID = `${generateCustomId(EVENT_PATH, __filename)}` + +registerInteractionHandler({ + desc: 'Handles modal submissions for creating an embed with a role-grant button.', + id: EMBED_ROLE_GRANT_CREATE_MODAL_ID, + errorTag: () => `${moduleName}: ${RoleGrantCreate.ERR.UnexpectedModal}`, + async exec(_, interaction) { + if (!interaction.isModalSubmit()) + return + + try { + if (!interaction.inCachedGuild()) + throw new EmbedRoleGrantModalError(RoleGrantCreate.ERR.NotGuild) + + const { channelId, roleId, buttonName } = RoleGrantCreate.getModalId(interaction, interaction.customId) + const channel = await getChannel(interaction.guild, channelId) + RoleGrantCreate.assertChannel(channel) + RoleGrantCreate.assertMissPerms(interaction.client.user, channel) + const role = await getRole(interaction.guild, roleId) + RoleGrantCreate.assertRole(role) + + const title = interaction.fields.getTextInputValue('title') + const description = interaction.fields.getTextInputValue('description') + const color = interaction.fields.getTextInputValue('color') + const footer = interaction.fields.getTextInputValue('footer') + + const embed = createEmbed( + title, + description, + color, + footer ? { text: footer } : null, + ) + + const buttonCustomId = getCustomId([ + EMBED_ROLE_GRANT_CREATE_BUTTON_ID, + encodeSnowflake(interaction.guildId), + encodeSnowflake(role.id), + ]) + const button = new ButtonBuilder() + .setCustomId(buttonCustomId) + .setLabel(buttonName) + .setStyle(ButtonStyle.Primary) + const row = new ActionRowBuilder().addComponents(button) + + await sendAsBot(interaction, channel, { embeds: [embed], components: [row] }) + await sendReply(interaction, `βœ… Posted! Clicking will add <@&${role.id}> role~`) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/embed/messages/role-grant-create.ts b/packages/aksaria-discord/src/bot/events/interaction-create/embed/messages/role-grant-create.ts new file mode 100644 index 0000000..50946ea --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/embed/messages/role-grant-create.ts @@ -0,0 +1,10 @@ +import { DiscordAssert } from '@utils/discord' + +export class RoleGrantCreateMessage extends DiscordAssert { + static override readonly ERR = { + ...DiscordAssert.ERR, + NotModal: '❌ Not a modal submit interaction', + NotButton: '❌ Not a button interaction', + UnexpectedRoleGrantCreate: '❌ Something went wrong while creating the embed role-grant message', + } +} diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/embed/validators/role-grant-create.ts b/packages/aksaria-discord/src/bot/events/interaction-create/embed/validators/role-grant-create.ts new file mode 100644 index 0000000..2ccd227 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/embed/validators/role-grant-create.ts @@ -0,0 +1,43 @@ +import type { Interaction } from 'discord.js' +import { decodeSnowflakes } from '@utils/component' +import { DiscordAssert } from '@utils/discord' +import { PermissionsBitField } from 'discord.js' +import { EmbedRoleGrantButtonError } from '../handlers/role-grant-create-button' +import { EmbedRoleGrantModalError } from '../handlers/role-grant-create-modal' +import { RoleGrantCreateMessage } from '../messages/role-grant-create' + +export class RoleGrantCreate extends RoleGrantCreateMessage { + static override BASE_PERMS = [ + ...DiscordAssert.BASE_PERMS, + PermissionsBitField.Flags.UseApplicationCommands, + ] + + static getModalId(interaction: Interaction, customId: string) { + const [prefix, guildId, channelId, roleId, buttonNameEnc] = decodeSnowflakes(customId) + const buttonName = decodeURIComponent(buttonNameEnc) + + if (!guildId) + throw new EmbedRoleGrantModalError(this.ERR.GuildMissing) + if (interaction.guildId !== guildId) + throw new EmbedRoleGrantModalError(this.ERR.NotGuild) + if (!channelId) + throw new EmbedRoleGrantModalError(this.ERR.ChannelNotFound) + if (!roleId) + throw new EmbedRoleGrantModalError(this.ERR.RoleMissing(roleId)) + + return { prefix, guildId, channelId, roleId, buttonName } + } + + static getButtonId(interaction: Interaction, customId: string) { + const [prefix, guildId, roleId] = decodeSnowflakes(customId) + + if (!guildId) + throw new EmbedRoleGrantButtonError(this.ERR.GuildMissing) + if (interaction.guildId !== guildId) + throw new EmbedRoleGrantButtonError(this.ERR.NotGuild) + if (!roleId) + throw new EmbedRoleGrantButtonError(this.ERR.RoleMissing(roleId)) + + return { prefix, guildId, roleId } + } +} diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/entry.ts b/packages/aksaria-discord/src/bot/events/interaction-create/entry.ts new file mode 100644 index 0000000..fa4848c --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/entry.ts @@ -0,0 +1,37 @@ +import type { Event } from '@events/event' +import type { Interaction } from 'discord.js' +import { ARCHFYRE_ROLE } from '@config/discord' +import { decodeSnowflakes } from '@utils/component' +import { sendReply } from '@utils/discord' +import { log } from '@utils/logger' +import { Events } from 'discord.js' +import { interactionHandlerMap, interactionHandlers } from './registry' + +export default { + name: Events.InteractionCreate, + desc: 'Handles Discord InteractionCreate events and delegates them to registered handlers.', + async exec(client, interaction: Interaction) { + if ('customId' in interaction && interaction.customId) { + const [prefix] = decodeSnowflakes(interaction.customId) + + const handler = interactionHandlerMap.get(prefix) + if (handler) { + try { + await handler.exec(client, interaction) + return + } + catch (err) { + await sendReply(interaction, `❓ Something weird happen... kindly contact <@&${ARCHFYRE_ROLE}> :)`) + log.error(`InteractionCreate handler failed ${handler.errorTag()}: ${err}`) + } + } + } + + for (const handler of interactionHandlers) { + if (handler.match && !handler.match(interaction)) + continue + + await handler.exec(client, interaction) + } + }, +} as Event diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/execute/handlers/command.ts b/packages/aksaria-discord/src/bot/events/interaction-create/execute/handlers/command.ts new file mode 100644 index 0000000..20eea85 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/execute/handlers/command.ts @@ -0,0 +1,34 @@ +import type { Command } from '@commands/command' +import { EVENT_PATH } from '@events/index' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { registerInteractionHandler } from '../../registry' +import { ExecuteCommand } from '../validators/command' + +export class ExecuteCommandError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('ExecuteCommandError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) + +registerInteractionHandler({ + desc: 'Executing a command when an interaction is created.', + errorTag: () => `${moduleName}: ${ExecuteCommand.ERR.UnexpectedExecuteCommand}`, + async exec(client, interaction) { + if (!interaction.isChatInputCommand()) + return + + try { + const command: Command = ExecuteCommand.getCommand(interaction) + await command.execute(client, interaction) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/execute/messages/command.ts b/packages/aksaria-discord/src/bot/events/interaction-create/execute/messages/command.ts new file mode 100644 index 0000000..4bc5cfe --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/execute/messages/command.ts @@ -0,0 +1,9 @@ +import { DiscordAssert } from '@utils/discord' + +export class ExecuteCommandMessage extends DiscordAssert { + static override readonly ERR = { + ...DiscordAssert.ERR, + NoMatchingCommand: (commandName: string) => `❌ No command matching ${commandName} was found`, + UnexpectedExecuteCommand: '❌ Something went wrong during execute command', + } +} diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/execute/validators/command.ts b/packages/aksaria-discord/src/bot/events/interaction-create/execute/validators/command.ts new file mode 100644 index 0000000..c63a32d --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/execute/validators/command.ts @@ -0,0 +1,22 @@ +import type { Command } from '@commands/command' +import type { ChatInputCommandInteraction } from 'discord.js' +import { ExecuteCommandError } from '@events/interaction-create/execute/handlers/command' +import { DiscordAssert } from '@utils/discord' +import { PermissionsBitField } from 'discord.js' +import { ExecuteCommandMessage } from '../messages/command' + +export class ExecuteCommand extends ExecuteCommandMessage { + static override BASE_PERMS = [ + ...DiscordAssert.BASE_PERMS, + PermissionsBitField.Flags.UseApplicationCommands, + ] + + static getCommand(interaction: ChatInputCommandInteraction) { + const command: Command | undefined = interaction.client.commands.get(interaction.commandName) + if (!command) { + throw new ExecuteCommandError(this.ERR.NoMatchingCommand(interaction.commandName)) + } + + return command + } +} diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/grinder-role/handlers/button.ts b/packages/aksaria-discord/src/bot/events/interaction-create/grinder-role/handlers/button.ts new file mode 100644 index 0000000..98b6293 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/grinder-role/handlers/button.ts @@ -0,0 +1,42 @@ +import type { TextChannel } from 'discord.js' +import { GrinderRole } from '@events/guild-member-update/grinder-role/validators' +import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' +import { generateCustomId } from '@utils/component' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' + +export class GrinderRoleButtonError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('GrinderRoleButtonError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) +export const WELCOME_NOTE_BUTTON_ID = `${generateCustomId(EVENT_PATH, __filename)}` + +registerInteractionHandler({ + desc: 'Opens welcome note modal for users receiving Grinder roles.', + id: WELCOME_NOTE_BUTTON_ID, + errorTag: () => `${moduleName}: ${GrinderRole.ERR.UnexpectedButton}`, + async exec(_, interaction) { + if (!interaction.isButton()) + return + + try { + if (!interaction.inCachedGuild()) + throw new GrinderRoleButtonError(GrinderRole.ERR.NotGuild) + + const channel = interaction.channel as TextChannel + GrinderRole.assertMissPerms(interaction.client.user, channel) + + await sendReply(interaction, GrinderRole.MSG.WelcomeNotes) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/jobs/handlers/reset-grinder-roles-button.ts b/packages/aksaria-discord/src/bot/events/interaction-create/jobs/handlers/reset-grinder-roles-button.ts new file mode 100644 index 0000000..5bfa45c --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/jobs/handlers/reset-grinder-roles-button.ts @@ -0,0 +1,42 @@ +import type { TextChannel } from 'discord.js' +import { ResetGrinderRoles } from '@events/client-ready/jobs/validators/reset-grinder-roles' +import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' +import { generateCustomId } from '@utils/component' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' + +export class ResetGrinderRolesButtonError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('ResetGrinderRolesButtonError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) +export const GOODBYE_NOTE_BUTTON_ID = `${generateCustomId(EVENT_PATH, __filename)}` + +registerInteractionHandler({ + desc: 'Opens goodbye note modal for users losing Grinder roles.', + id: GOODBYE_NOTE_BUTTON_ID, + errorTag: () => `${moduleName}: ${ResetGrinderRoles.ERR.UnexpectedButton}`, + async exec(_, interaction) { + if (!interaction.isButton()) + return + + try { + if (!interaction.inCachedGuild()) + throw new ResetGrinderRolesButtonError(ResetGrinderRoles.ERR.NotGuild) + + const channel = interaction.channel as TextChannel + ResetGrinderRoles.assertMissPerms(interaction.client.user, channel) + + await sendReply(interaction, ResetGrinderRoles.MSG.GoodByeNotes) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/message/handlers/send-modal.ts b/packages/aksaria-discord/src/bot/events/interaction-create/message/handlers/send-modal.ts new file mode 100644 index 0000000..8fe3f81 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/message/handlers/send-modal.ts @@ -0,0 +1,54 @@ +import type { Attachment } from 'discord.js' +import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' +import { generateCustomId, tempStore } from '@utils/component' +import { getChannel, sendAsBot, sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { Send } from '../validators/send' + +export class SendModalError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('SendModalError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) +export const MESSAGE_SEND_ID = `${generateCustomId(EVENT_PATH, __filename)}` + +registerInteractionHandler({ + desc: 'Handles message send modal submissions, posting messages (text/attachments) as the bot in the selected channel.', + id: MESSAGE_SEND_ID, + errorTag: () => `${moduleName}: ${Send.ERR.UnexpectedModal}`, + async exec(_, interaction) { + if (!interaction.isModalSubmit()) + return + + try { + if (!interaction.inCachedGuild()) + throw new SendModalError(Send.ERR.NotGuild) + + const { channelId, tempToken } = Send.getModalId(interaction, interaction.customId) + const channel = await getChannel(interaction.guild, channelId) + Send.assertChannel(channel) + Send.assertMissPerms(interaction.client.user, channel) + const attachments = tempStore.get(tempToken) as Attachment[] + Send.delTempItem(attachments, tempToken) + + const message = interaction.fields.getTextInputValue('message') + Send.assertNotEmpty(attachments, message) + + await sendAsBot(interaction, channel, { + content: message.length ? message : undefined, + files: attachments.length ? attachments : undefined, + allowedMentions: { parse: [] }, + }, false, true, true) + await sendReply(interaction, 'βœ… Message sent~') + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/message/messages/send.ts b/packages/aksaria-discord/src/bot/events/interaction-create/message/messages/send.ts new file mode 100644 index 0000000..a9f1735 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/message/messages/send.ts @@ -0,0 +1,9 @@ +import { DiscordAssert } from '@utils/discord' + +export class SendMessage extends DiscordAssert { + static override readonly ERR = { + ...DiscordAssert.ERR, + EmptyMessage: '❌ Cannot send an empty message', + UnexpectedSend: '❌ Something went wrong while sending the message', + } +} diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/message/validators/send.ts b/packages/aksaria-discord/src/bot/events/interaction-create/message/validators/send.ts new file mode 100644 index 0000000..914df70 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/message/validators/send.ts @@ -0,0 +1,35 @@ +import type { Attachment, Interaction } from 'discord.js' +import { decodeSnowflakes } from '@utils/component' +import { DiscordAssert } from '@utils/discord' +import { PermissionsBitField } from 'discord.js' +import { SendModalError } from '../handlers/send-modal' +import { SendMessage } from '../messages/send' + +export class Send extends SendMessage { + static override BASE_PERMS = [ + ...DiscordAssert.BASE_PERMS, + PermissionsBitField.Flags.AttachFiles, + PermissionsBitField.Flags.UseApplicationCommands, + ] + + static override ATTACHMENT_COUNT: number = 5 + + static getModalId(interaction: Interaction, customId: string) { + const [prefix, guildId, channelId, tempToken] = decodeSnowflakes(customId) + + if (!guildId) + throw new SendModalError(this.ERR.GuildMissing) + if (interaction.guildId !== guildId) + throw new SendModalError(this.ERR.NotGuild) + if (!channelId) + throw new SendModalError(this.ERR.ChannelNotFound) + + return { prefix, guildId, channelId, tempToken } + } + + static assertNotEmpty(attachments: Attachment[] | undefined, message: string | undefined) { + if ((!attachments || attachments.length === 0) && (!message || message.trim().length === 0)) { + throw new SendModalError(this.ERR.EmptyMessage) + } + } +} diff --git a/packages/aksaria-discord/src/bot/events/interaction-create/registry.ts b/packages/aksaria-discord/src/bot/events/interaction-create/registry.ts new file mode 100644 index 0000000..8f35d44 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/interaction-create/registry.ts @@ -0,0 +1,19 @@ +import type { Client, Interaction } from 'discord.js' + +export interface InteractionHandler { + desc: string + id?: string + errorTag: () => string + match?: (interaction: Interaction) => boolean + exec: (client: Client, interaction: Interaction) => Promise | void +} + +export const interactionHandlerMap = new Map() +export const interactionHandlers: InteractionHandler[] = [] + +export function registerInteractionHandler(handler: InteractionHandler) { + if (handler.id) + interactionHandlerMap.set(handler.id, handler) + else + interactionHandlers.push(handler) +} diff --git a/packages/aksaria-discord/src/bot/events/message-create/channel/handlers/check-in.ts b/packages/aksaria-discord/src/bot/events/message-create/channel/handlers/check-in.ts new file mode 100644 index 0000000..b2330ae --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/message-create/channel/handlers/check-in.ts @@ -0,0 +1,44 @@ +import type { TextChannel } from 'discord.js' +import { CHECKIN_CHANNEL } from '@config/discord' +import { EVENT_PATH } from '@events/index' +import { registerMessageHandler } from '@events/message-create/registry' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { log } from '@utils/logger' +import { ChannelType } from 'discord.js' +import { CheckIn } from '../validators/check-in' + +export class CheckInError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('CheckInError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) + +registerMessageHandler({ + desc: 'Handle messages in channel for Check In event.', + errorTag: () => `${moduleName}: ${CheckIn.ERR.UnexpectedCheckIn}`, + match: msg => msg.channel.id === CHECKIN_CHANNEL, + async exec(_, msg) { + try { + if (!msg.guild) + throw new CheckInError(CheckIn.ERR.NotGuild) + + const channel = msg.channel as TextChannel + CheckIn.assertMissPerms(msg.guild.members.me!, channel) + + if (channel.type !== ChannelType.GuildText) + return + if (msg.author.bot) + return + + await msg.delete() + log.warn(`${channel.name}: deleted unauthorized message from '${msg.author.tag}'`) + } + catch (err: any) { + if (!(err instanceof DiscordBaseError)) + throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/message-create/channel/messages/check-in.ts b/packages/aksaria-discord/src/bot/events/message-create/channel/messages/check-in.ts new file mode 100644 index 0000000..ff80f1f --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/message-create/channel/messages/check-in.ts @@ -0,0 +1,8 @@ +import { DiscordAssert } from '@utils/discord' + +export class CheckInMessage extends DiscordAssert { + static override readonly ERR = { + ...DiscordAssert.ERR, + UnexpectedCheckIn: '❌ Something went wrong while handling the Check-In message', + } +} diff --git a/packages/aksaria-discord/src/bot/events/message-create/channel/validators/check-in.ts b/packages/aksaria-discord/src/bot/events/message-create/channel/validators/check-in.ts new file mode 100644 index 0000000..e399f0f --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/message-create/channel/validators/check-in.ts @@ -0,0 +1,10 @@ +import { DiscordAssert } from '@utils/discord' +import { PermissionsBitField } from 'discord.js' +import { CheckInMessage } from '../messages/check-in' + +export class CheckIn extends CheckInMessage { + static override BASE_PERMS = [ + ...DiscordAssert.BASE_PERMS, + PermissionsBitField.Flags.ManageMessages, + ] +} diff --git a/packages/aksaria-discord/src/bot/events/message-create/entry.ts b/packages/aksaria-discord/src/bot/events/message-create/entry.ts new file mode 100644 index 0000000..606ff48 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/message-create/entry.ts @@ -0,0 +1,22 @@ +import type { Event } from '@events/event' +import type { Message } from 'discord.js' +import { log } from '@utils/logger' +import { Events } from 'discord.js' +import { messageHandlers } from './registry' + +export default { + name: Events.MessageCreate, + desc: 'Dispatch all registered MessageCreate handlers.', + async exec(client, msg: Message) { + for (const handler of messageHandlers) { + try { + if (handler.match && !handler.match(msg)) + continue + await handler.exec(client, msg) + } + catch (err) { + log.error(`Message handler failed ${handler.errorTag()}: ${err}`) + } + } + }, +} as Event diff --git a/packages/aksaria-discord/src/bot/events/message-create/im-fine/handlers/index.ts b/packages/aksaria-discord/src/bot/events/message-create/im-fine/handlers/index.ts new file mode 100644 index 0000000..54f21d9 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/message-create/im-fine/handlers/index.ts @@ -0,0 +1,22 @@ +import { EVENT_PATH } from '@events/index' +import { registerMessageHandler } from '@events/message-create/registry' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { ImFine } from '../validators' + +export class ImFineError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('ImFineError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) + +registerMessageHandler({ + desc: 'Replying to a user when the user\'s chat contains \'fine\' word.', + errorTag: () => `${moduleName}: ${ImFine.ERR.UnexpectedImFine}`, + match: msg => !msg.author.bot && msg.content.includes('fine'), + async exec(_, msg) { + await msg.reply('gua I\'m fineπŸ˜…') + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/message-create/im-fine/messages/index.ts b/packages/aksaria-discord/src/bot/events/message-create/im-fine/messages/index.ts new file mode 100644 index 0000000..ccedb56 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/message-create/im-fine/messages/index.ts @@ -0,0 +1,8 @@ +import { DiscordAssert } from '@utils/discord' + +export class ImFineMessage extends DiscordAssert { + static override readonly ERR = { + ...DiscordAssert.ERR, + UnexpectedImFine: '❌ Something went wrong while handling the Im Fine message', + } +} diff --git a/packages/aksaria-discord/src/bot/events/message-create/im-fine/validators/index.ts b/packages/aksaria-discord/src/bot/events/message-create/im-fine/validators/index.ts new file mode 100644 index 0000000..66454d5 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/message-create/im-fine/validators/index.ts @@ -0,0 +1,4 @@ +import { ImFineMessage } from '../messages' + +export class ImFine extends ImFineMessage { +} diff --git a/packages/aksaria-discord/src/bot/events/message-create/registry.ts b/packages/aksaria-discord/src/bot/events/message-create/registry.ts new file mode 100644 index 0000000..5672005 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/message-create/registry.ts @@ -0,0 +1,14 @@ +import type { Client, Message } from 'discord.js' + +export interface MessageHandler { + desc: string + errorTag: () => string + match?: (msg: Message) => boolean + exec: (client: Client, msg: Message) => Promise | void +} + +export const messageHandlers: MessageHandler[] = [] + +export function registerMessageHandler(handler: MessageHandler) { + messageHandlers.push(handler) +} diff --git a/packages/aksaria-discord/src/bot/events/message-reaction-add/checkin/handlers/submitted.ts b/packages/aksaria-discord/src/bot/events/message-reaction-add/checkin/handlers/submitted.ts new file mode 100644 index 0000000..6215cac --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/message-reaction-add/checkin/handlers/submitted.ts @@ -0,0 +1,52 @@ +import { CHECKIN_CHANNEL, FLAMEWARDEN_ROLE } from '@config/discord' +import { EVENT_PATH } from '@events/index' +import { Checkin } from '@events/interaction-create/checkin/validators' +import { registerReactionHandler } from '@events/message-reaction-add/registry' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' + +export class SubmittedCheckinError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('SubmittedCheckinError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) + +registerReactionHandler({ + desc: 'Handles user-submitted checkin submissions with reacted by Flamewarden whether approved or rejected.', + errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedSubmittedCheckinMessage}`, + match: (_, user) => !user.bot, + async exec(client, reaction, user) { + const message = reaction.message + const guild = message.guild + if (!guild || !message.inGuild()) + return + + if (reaction.partial) + await reaction.fetch() + if (message.partial) + await message.fetch() + + try { + const flamewarden = await guild.members.fetch(user.id) + const emoji = Checkin.assertEmojis(reaction.emoji.name) + Checkin.assertMember(flamewarden) + Checkin.assertMemberHasRole(flamewarden, FLAMEWARDEN_ROLE) + await Checkin.assertAllowedChannel(guild, message.channel.id, CHECKIN_CHANNEL) + + await Checkin.validateCheckin( + client, + guild, + flamewarden, + { key: 'link', value: message.url }, + message.createdAt, + Checkin.EMOJI_STATUS[emoji], + ) + } + catch (err: any) { + if (!(err instanceof DiscordBaseError)) + throw err + } + }, +}) diff --git a/packages/aksaria-discord/src/bot/events/message-reaction-add/entry.ts b/packages/aksaria-discord/src/bot/events/message-reaction-add/entry.ts new file mode 100644 index 0000000..5278896 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/message-reaction-add/entry.ts @@ -0,0 +1,22 @@ +import type { Event } from '@events/event' +import type { MessageReaction, User } from 'discord.js' +import { log } from '@utils/logger' +import { Events } from 'discord.js' +import { reactionHandlers } from './registry' + +export default { + name: Events.MessageReactionAdd, + desc: 'Dispatch all registered MessageReactionAdd handlers.', + async exec(client, reaction: MessageReaction, user: User) { + for (const handler of reactionHandlers) { + try { + if (handler.match && !handler.match(reaction, user)) + continue + await handler.exec(client, reaction, user) + } + catch (err) { + log.error(`Reaction handler failed ${handler.errorTag()}: ${err}`) + } + } + }, +} as Event diff --git a/packages/aksaria-discord/src/bot/events/message-reaction-add/registry.ts b/packages/aksaria-discord/src/bot/events/message-reaction-add/registry.ts new file mode 100644 index 0000000..c112b95 --- /dev/null +++ b/packages/aksaria-discord/src/bot/events/message-reaction-add/registry.ts @@ -0,0 +1,14 @@ +import type { Client, MessageReaction, User } from 'discord.js' + +export interface ReactionHandler { + desc: string + errorTag: () => string + match?: (reaction: MessageReaction, user: User) => boolean + exec: (client: Client, reaction: MessageReaction, user: User) => Promise | void +} + +export const reactionHandlers: ReactionHandler[] = [] + +export function registerReactionHandler(handler: ReactionHandler) { + reactionHandlers.push(handler) +} diff --git a/packages/aksaria-discord/src/config/discord.ts b/packages/aksaria-discord/src/config/discord.ts new file mode 100644 index 0000000..8e031c1 --- /dev/null +++ b/packages/aksaria-discord/src/config/discord.ts @@ -0,0 +1,62 @@ +import type { RoleManager } from 'discord.js' + +export const CHECKIN_CHANNEL = '1405165987288059944' +export const AURA_FARMING_CHANNEL = '1405162471496351794' +export const GRIND_ASHES_CHANNEL = '1405165926600409148' +export const WARDEN_DUTY_CHANNEL = '1404086708051509318' +export const AUDIT_FLAME_CHANNEL = '1447816805329539162' +export const IGNITE_PATH_CHANNEL = '1405159743596793997' + +export interface GrindRole { + name?: string + id: string + threshold: number +} + +export const ARCHFYRE_ROLE = '1402625885684891658' +export const FLAMEWARDEN_ROLE = '1403022712938561668' +export const GRINDER_ROLE = '1403320523756146768' + +const GRIND_ROLES: GrindRole[] = [ + { + id: '1403320928519327755', + threshold: 1, + }, + { + id: '1403320958202282047', + threshold: 7, + }, + { + id: '1403321016406638674', + threshold: 30, + }, + { + id: '1403322168841994340', + threshold: 90, + }, + { + id: '1403321040448258138', + threshold: 180, + }, + { + id: '1403321099688607774', + threshold: 270, + }, + { + id: '1403321123331899402', + threshold: 364, + }, +] + +export function getGrindRoles(roleManager?: RoleManager) { + if (!GRIND_ROLES[0].name && roleManager) { + GRIND_ROLES.forEach((r) => { + const role = roleManager.cache.find(role => role.id === r.id) + if (role) { + r.name = role.name + } + }) + } + + return GRIND_ROLES +} diff --git a/packages/aksaria-discord/src/config/index.ts b/packages/aksaria-discord/src/config/index.ts new file mode 100644 index 0000000..0f4707c --- /dev/null +++ b/packages/aksaria-discord/src/config/index.ts @@ -0,0 +1 @@ +export * from './discord' diff --git a/packages/aksaria-discord/src/constants.ts b/packages/aksaria-discord/src/constants.ts new file mode 100644 index 0000000..329c54d --- /dev/null +++ b/packages/aksaria-discord/src/constants.ts @@ -0,0 +1,13 @@ +export const CUSTOM_ID_SEPARATOR = ':' +export const SNOWFLAKE_MARKER = 'S#' +export const ALPHABETS = '0123456789abcdefghijklmnopqrstuvwxyz' +export const ANSI_COLORS = { + reset: '\x1B[0m', + red: '\x1B[31m', + green: '\x1B[32m', + yellow: '\x1B[33m', + blue: '\x1B[34m', + magenta: '\x1B[35m', + cyan: '\x1B[36m', + white: '\x1B[37m', +} diff --git a/packages/aksaria-discord/src/deploy-commands.ts b/packages/aksaria-discord/src/deploy-commands.ts new file mode 100644 index 0000000..86b8c47 --- /dev/null +++ b/packages/aksaria-discord/src/deploy-commands.ts @@ -0,0 +1,31 @@ +import process from 'node:process' +import { commandRegistry, loadCommands } from '@commands/registry' +import { log } from '@utils/logger' +import { REST, Routes } from 'discord.js' + +async function main() { + log.base('πŸš€ Deploying commands...') + + try { + await loadCommands() + const commands = [...commandRegistry.values()].map(cmd => cmd.data.toJSON()) + const rest = new REST().setToken(process.env.APP_TOKEN!) + + log.check(`Started refreshing ${commands.length} application (/) commands...`) + + const data = await rest.put( + Routes.applicationGuildCommands(process.env.APP_ID!, process.env.GUILD_ID!), + { body: commands }, + ) + + log.success(`Successfully reloaded ${(data as unknown[]).length} application (/) commands~`) + log.base('πŸš€ Commands deployed!') + } + catch (error) { + log.error(`Error while deploying commands: ${error}`) + } +} + +main().catch((e) => { + log.error(`Unhandled error: ${e}`) +}) diff --git a/packages/aksaria-discord/src/index.ts b/packages/aksaria-discord/src/index.ts new file mode 100644 index 0000000..239a803 --- /dev/null +++ b/packages/aksaria-discord/src/index.ts @@ -0,0 +1,52 @@ +import process from 'node:process' +import { PrismaClient } from '@generatedDB/client' +import { registerCommands } from './bot/commands' +import { registerEvents } from './bot/events' +import { log } from '@utils/logger' +import { DiscordAdapter } from './adapters/discord-adapter' +import { Client, GatewayIntentBits, Partials } from 'discord.js' + +// Create Prisma client singleton +const prisma = new PrismaClient() + +async function main() { + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildMessageReactions, + ], + partials: [ + Partials.Message, + Partials.Reaction, + Partials.Channel, + ], + }) + + // Attach Prisma client + client.prisma = prisma + + // Create Discord adapter for notifications + const discordAdapter = new DiscordAdapter(client) + + log.base('πŸš€ Starting bot...') + + log.check('Loading events...') + await registerEvents(client) + log.success('Events loaded~') + + log.check('Loading commands...') + await registerCommands(client) + log.success('Commands loaded~') + + await client.login(process.env.APP_TOKEN) + + log.base('πŸš€ Bot is running!') +} + +main().catch((err) => { + log.error('❌ Failed to start bot:') + console.error(err) +}) diff --git a/packages/aksaria-discord/src/types/attachment.d.ts b/packages/aksaria-discord/src/types/attachment.d.ts new file mode 100644 index 0000000..103eb9e --- /dev/null +++ b/packages/aksaria-discord/src/types/attachment.d.ts @@ -0,0 +1,12 @@ +export type AttachmentModuleType = 'CHECKIN' + +export interface Attachment { + id: number + name: string + url: string + type: string + size: number + module_id: number + module_type: AttachmentModuleType + created_at: Date +} diff --git a/packages/aksaria-discord/src/types/checkin-streak.d.ts b/packages/aksaria-discord/src/types/checkin-streak.d.ts new file mode 100644 index 0000000..bb06cd4 --- /dev/null +++ b/packages/aksaria-discord/src/types/checkin-streak.d.ts @@ -0,0 +1,15 @@ +import type { Checkin } from './checkin' +import type { User } from './user' + +export interface CheckinStreak { + id: number + user_id: number + first_date: Date + last_date?: Date | null + streak: number + streak_broken_at?: Date | null + updated_at?: Date | null + + user?: User + checkins?: Checkin[] +} diff --git a/packages/aksaria-discord/src/types/checkin.d.ts b/packages/aksaria-discord/src/types/checkin.d.ts new file mode 100644 index 0000000..521ef8d --- /dev/null +++ b/packages/aksaria-discord/src/types/checkin.d.ts @@ -0,0 +1,30 @@ +import type { Prisma } from '@generatedDB/client' +import type { Attachment } from './attachment' +import type { CheckinStreak } from './checkin-streak' +import type { User } from './user' + +export type CheckinStatusType = 'WAITING' | 'APPROVED' | 'REJECTED' +export type CheckinAllowedEmojiType = '❌' | 'πŸ”₯' + +export interface Checkin { + id: number + public_id: string + user_id: number + checkin_streak_id: number + description: string + link?: string | null + status: CheckinStatusType | string + reviewed_by?: string | null + comment?: string | null + created_at: Date + updated_at?: Date | null + + user?: User + checkin_streak?: CheckinStreak + attachments?: Attachment[] | null +} + +export interface CheckinColumn { + key: T + value: Prisma.CheckinWhereInput[T] | Prisma.CheckinWhereUniqueInput[K] +} diff --git a/packages/aksaria-discord/src/types/discord-component.d.ts b/packages/aksaria-discord/src/types/discord-component.d.ts new file mode 100644 index 0000000..336a0c1 --- /dev/null +++ b/packages/aksaria-discord/src/types/discord-component.d.ts @@ -0,0 +1 @@ +export type DiscordCustomIdMetadata = (string | number | undefined | null)[] diff --git a/packages/aksaria-discord/src/types/discord.d.ts b/packages/aksaria-discord/src/types/discord.d.ts new file mode 100644 index 0000000..c8fb188 --- /dev/null +++ b/packages/aksaria-discord/src/types/discord.d.ts @@ -0,0 +1,10 @@ +import type { Command } from '@commands' +import type { PrismaClient } from '@generatedDB/client' +import type { Collection } from 'discord.js' + +declare module 'discord.js' { + interface Client { + prisma: PrismaClient + commands: Collection + } +} diff --git a/packages/aksaria-discord/src/types/log.d.ts b/packages/aksaria-discord/src/types/log.d.ts new file mode 100644 index 0000000..faad840 --- /dev/null +++ b/packages/aksaria-discord/src/types/log.d.ts @@ -0,0 +1,8 @@ +export interface Logger { + base: (message: string) => void + info: (message: string) => void + success: (message: string) => void + check: (message: string) => void + warn: (message: string) => void + error: (message: string) => void +} diff --git a/packages/aksaria-discord/src/types/placeholder.d.ts b/packages/aksaria-discord/src/types/placeholder.d.ts new file mode 100644 index 0000000..315b8d7 --- /dev/null +++ b/packages/aksaria-discord/src/types/placeholder.d.ts @@ -0,0 +1,7 @@ +export interface PlaceholderDummy { + TITLE: string + DESC: string + COLOR: string + FOOTER: string + MARKDOWN: string +} diff --git a/packages/aksaria-discord/src/types/user.d.ts b/packages/aksaria-discord/src/types/user.d.ts new file mode 100644 index 0000000..a40a934 --- /dev/null +++ b/packages/aksaria-discord/src/types/user.d.ts @@ -0,0 +1,12 @@ +import type { Checkin } from './checkin' +import type { CheckinStreak } from './checkin-streak' + +export interface User { + id: number + discord_id: string + created_at: Date + updated_at?: Date | null + + checkin_streaks?: CheckinStreak[] + checkins?: Checkin[] +} diff --git a/packages/aksaria-discord/src/utils/color.ts b/packages/aksaria-discord/src/utils/color.ts new file mode 100644 index 0000000..db06ad7 --- /dev/null +++ b/packages/aksaria-discord/src/utils/color.ts @@ -0,0 +1,15 @@ +export function parseHexColor(input?: string | null): number | undefined { + if (!input) + return undefined + + const color = input.trim() + if (/^#?[0-9a-f]{6}$/i.test(color)) { + const hex = color.startsWith('#') ? color.slice(1) : color + return Number.parseInt(hex, 16) + } + + if (/^\d+$/.test(color)) + return Number(color) + + return undefined +} diff --git a/packages/aksaria-discord/src/utils/component.ts b/packages/aksaria-discord/src/utils/component.ts new file mode 100644 index 0000000..d6924c8 --- /dev/null +++ b/packages/aksaria-discord/src/utils/component.ts @@ -0,0 +1,150 @@ +import type { Checkin } from '@type/checkin' +import type { EmbedFooterOptions } from 'discord.js' +import { ALPHABETS, CUSTOM_ID_SEPARATOR, SNOWFLAKE_MARKER } from '../constants' +import { EmbedBuilder, LabelBuilder, ModalBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextInputBuilder, TextInputStyle } from 'discord.js' +import { parseHexColor } from './color' +import { getNow, getParsedNow } from './date' +import { getModuleName } from './io' +import { DUMMY } from './placeholder' + +export type DiscordCustomIdMetadata = string[] + +const isOnlyDigitSnowflake = (id: string): boolean => /^\d+$/.test(id) + +const trimSnowflakeMarker = (chars: string): string => chars.split(SNOWFLAKE_MARKER.toLowerCase()).pop()! + +export function generateCustomId(rootName: string, file: string): string { + return getModuleName(rootName, file) + .split('/') + .filter(Boolean) + .map(item => + item + .split('-') + .map(word => word[0]?.toUpperCase() ?? '') + .join(''), + ) + .join('-') +} + +export const getCustomId = (obj: DiscordCustomIdMetadata) => Object.values(obj).join(CUSTOM_ID_SEPARATOR) + +export const encodeSnowflake = (numbers: string): string => `${SNOWFLAKE_MARKER}${BigInt(numbers).toString(36)}` + +export function decodeSnowflakes(customId: string): string[] { + return customId + .split(CUSTOM_ID_SEPARATOR) + .map((item: string): string => { + const text = item.toLowerCase() + if (!item.includes(SNOWFLAKE_MARKER)) + return item + + const id = decodeSnowflake(text) + const encodedText = encodeSnowflake(id).toLocaleLowerCase() + if (isOnlyDigitSnowflake(id) && encodedText === text) + return id + + return item + }) +} + +export function decodeSnowflake(data: string): string { + const chars = trimSnowflakeMarker(data) + + let result = 0n + for (const char of chars) { + result = result * 36n + BigInt(ALPHABETS.indexOf(char)) + } + + return result.toString() +} + +export function parseMessageLink(link: string) { + const regex = /discord\.com\/channels\/(\d+)\/(\d+)\/(\d+)/ + const match = link.match(regex) + + if (!match) + return null + + const [, guildId, channelId, messageId] = match + return { guildId, channelId, messageId } +} + +export const getTempToken = () => Math.random().toString(36).slice(2, 8) + +export const tempStore = new Map() + +export function createEmbed( + title?: string | null | undefined, + desc?: string | null | undefined, + color?: string | null, + footer?: EmbedFooterOptions | null | undefined, + date: boolean = true, +): EmbedBuilder { + const embed = new EmbedBuilder() + + const parsedColor = parseHexColor(color) + + if (title) + embed.setTitle(title) + if (desc) + embed.setDescription(desc) + if (parsedColor) + embed.setColor(parsedColor) + if (date) + embed.setTimestamp(new Date()) + if (footer) + embed.setFooter(footer) + + return embed +} + +export function createCheckinReviewModal(customId: string, checkin: Checkin, setStatusLabel: boolean = true) { + const statusLabel = new LabelBuilder() + .setLabel('Review Status') + .setDescription('Setujui atau tolak check-in ini') + .setStringSelectMenuComponent( + new StringSelectMenuBuilder() + .setCustomId('status') + .addOptions( + new StringSelectMenuOptionBuilder().setLabel('❌ Reject').setValue('REJECTED').setDefault(true), + new StringSelectMenuOptionBuilder().setLabel('πŸ”₯ Approve').setValue('APPROVED'), + ), + ) + + const noteLabel = new LabelBuilder() + .setLabel('Review Note') + .setDescription('Berikan pendapat kamu') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('comment') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true), + ) + + const modal = new ModalBuilder() + .setCustomId(customId) + .setTitle('Review Check-in') + + if (setStatusLabel) { + modal.addLabelComponents(statusLabel) + } + + modal.addLabelComponents(noteLabel) + modal + .addTextDisplayComponents(textDisplay => textDisplay.setContent(` +# Informasi Grinder +πŸ†” **Check-In ID**: +\`\`\`bash +${checkin.public_id} +\`\`\` +🌟 **Grinder**: <@${checkin.user!.discord_id}> +πŸ“ **Attachment:** ${checkin.attachments?.length ? 'βœ…' : '❌'} +πŸ—“ **Submitted At**: ${getParsedNow(getNow(checkin.created_at))} +πŸ”₯ **Current Streak**: ${checkin.checkin_streak!.streak} day(s) +## Notulen Grinder +${checkin.description} +β‹†ο½‘Λš ☁︎ Λšο½‘β‹†ο½‘Λšβ˜½Λšο½‘β‹†`)) + .addTextDisplayComponents(textDisplay => textDisplay.setContent(DUMMY.MARKDOWN)) + + return modal +} diff --git a/packages/aksaria-discord/src/utils/date.ts b/packages/aksaria-discord/src/utils/date.ts new file mode 100644 index 0000000..4d18fd4 --- /dev/null +++ b/packages/aksaria-discord/src/utils/date.ts @@ -0,0 +1,50 @@ +// Re-export date utilities from aksaria-core for backward compatibility +// Note: In bun runtime, 'aksaria-core' resolves through workspace linking +// For TypeScript checking, we inline the implementations + +const TIMEZONE_OFFSET_HOURS = 7 + +export function getNow(date: Date = new Date()): Date { + return new Date(date.getTime() + TIMEZONE_OFFSET_HOURS * 60 * 60 * 1000) +} + +export function getParsedNow(now: Date = getNow()): string { + const day = String(now.getUTCDate()).padStart(2, '0') + const month = String(now.getUTCMonth() + 1).padStart(2, '0') + const year = now.getUTCFullYear() + const hours = String(now.getUTCHours()).padStart(2, '0') + const minutes = String(now.getUTCMinutes()).padStart(2, '0') + const seconds = String(now.getUTCSeconds()).padStart(2, '0') + + return `${day}/${month}/${year}, ${hours}.${minutes}.${seconds}` +} + +export function isDateToday(date: Date): boolean { + const today = getNow() + const newDate = getNow(date) + + return newDate.getUTCFullYear() === today.getUTCFullYear() + && newDate.getUTCMonth() + 1 === today.getUTCMonth() + 1 + && newDate.getUTCDate() === today.getUTCDate() +} + +export function isDateYesterday(date: Date): boolean { + const today = getNow() + const newDate = getNow(date) + const yesterday = getNow(today) + yesterday.setUTCDate(today.getUTCDate() - 1) + + return ( + newDate.getUTCFullYear() === yesterday.getUTCFullYear() + && newDate.getUTCMonth() === yesterday.getUTCMonth() + && newDate.getUTCDate() === yesterday.getUTCDate() + ) +} + +export function timestamp(): string { + return getNow().toISOString() +} + +export function isStreakContinuing(lastDate: Date): boolean { + return isDateToday(lastDate) || isDateYesterday(lastDate) +} diff --git a/packages/aksaria-discord/src/utils/discord/assert.ts b/packages/aksaria-discord/src/utils/discord/assert.ts new file mode 100644 index 0000000..52c811e --- /dev/null +++ b/packages/aksaria-discord/src/utils/discord/assert.ts @@ -0,0 +1,115 @@ +import type { ClientUser, Guild, GuildMember, Role, TextChannel } from 'discord.js' +import { getTempToken, parseMessageLink, tempStore } from '../component' +import { ChannelType, PermissionsBitField } from 'discord.js' +import { getBotPerms, getChannel, getMissPerms } from '.' +import { DiscordBaseError } from './error' +import { DiscordMessage } from './message' + +class DiscordAssertError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('DiscordAssertError', message, options) + } +} + +export class DiscordAssert extends DiscordMessage { + static BASE_PERMS: bigint[] = [ + PermissionsBitField.Flags.SendMessages, + PermissionsBitField.Flags.ViewChannel, + PermissionsBitField.Flags.ReadMessageHistory, + PermissionsBitField.Flags.EmbedLinks, + ] + + static PERM_LABELS = new Map( + Object.entries(PermissionsBitField.Flags).map(([key, value]) => [ + value, + key.replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/_/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()), + ]), + ) + + static ATTACHMENT_COUNT = 10 + + static getMessageFromLink(link: string) { + const data = parseMessageLink(link) + + if (!data) + throw new DiscordAssertError(this.ERR.MessageLinkInvalid) + + return { ...data } + } + + static setTempItem(items: any): string { + const token = getTempToken() + tempStore.set(token, items) + + return token + } + + static delTempItem(items: any, token: string) { + if (items) + tempStore.delete(token) + } + + static assertMember(member: GuildMember) { + if (!member || !('roles' in member)) + throw new DiscordAssertError(this.ERR.NoMember) + } + + static assertRoleManageable(guild: Guild, bot: GuildMember, role: Role) { + if (!role.editable) + throw new DiscordAssertError(this.ERR.RoleUneditable) + if (role.managed || role.id === guild.roles.everyone.id) + throw new DiscordAssertError(this.ERR.RoleUneditable) + if (bot.roles.highest.comparePositionTo(role) <= 0) + throw new DiscordAssertError(this.ERR.MemberAboveMe) + } + + static assertChannel(channel: TextChannel) { + if (!channel || channel.type !== ChannelType.GuildText) + throw new DiscordAssertError(this.ERR.ChannelNotFound) + } + + static assertRole(role: Role) { + if (!role) + throw new DiscordAssertError(this.ERR.RoleNotFound) + } + + static assertMemberAlreadyHasRole(member: GuildMember, roleId: string) { + if (this.isMemberHasRole(member, roleId)) + throw new DiscordAssertError(this.MSG.RoleRevoked(roleId)) + } + + static assertMemberHasRole(member: GuildMember, roleId: string) { + const hasThisRole = this.isMemberHasRole(member, roleId) + + if (!hasThisRole) + throw new DiscordAssertError(this.ERR.RoleMissing(roleId)) + } + + static async assertAllowedChannel(guild: Guild, currentChannelId: string, channelId: string) { + if (currentChannelId !== channelId) { + throw new DiscordAssertError(this.ERR.AllowedChannel(channelId)) + } + + const channel = await getChannel(guild, channelId) + this.assertChannel(channel) + + return channel + } + + static assertMissPerms(user: ClientUser | GuildMember, channel: TextChannel) { + const channelPerms = getBotPerms(user, channel) + const missedPerms = getMissPerms(channelPerms, this.BASE_PERMS) + + if (missedPerms.length) { + const missingNames = missedPerms.map(p => this.PERM_LABELS.get(p) ?? 'Unknown Permission') + + throw new DiscordAssertError(this.ERR.RoleMissing(missingNames)) + } + } + + static isMemberHasRole(member: GuildMember, roleId: string): boolean { + return member.roles.cache.has(roleId) + } +} diff --git a/packages/aksaria-discord/src/utils/discord/error.ts b/packages/aksaria-discord/src/utils/discord/error.ts new file mode 100644 index 0000000..5589e14 --- /dev/null +++ b/packages/aksaria-discord/src/utils/discord/error.ts @@ -0,0 +1,15 @@ +import { log } from '../logger' + +export class DiscordBaseError extends Error { + constructor(name: string, message: string, options?: { cause?: unknown }) { + super(message, options) + this.name = name + Object.setPrototypeOf(this, new.target.prototype) + + log.warn(`${this.name}: ${this.message}`) + } + + toJSON() { + return { name: this.name, message: this.message, stack: this.stack } + } +} diff --git a/packages/aksaria-discord/src/utils/discord/index.ts b/packages/aksaria-discord/src/utils/discord/index.ts new file mode 100644 index 0000000..0f27af6 --- /dev/null +++ b/packages/aksaria-discord/src/utils/discord/index.ts @@ -0,0 +1,97 @@ +import type { Attachment, ChatInputCommandInteraction, ClientUser, Guild, GuildMember, InteractionDeferReplyOptions, InteractionReplyOptions, MessageCreateOptions, PermissionsBitField, Role, TextChannel, Interaction } from 'discord.js' +import { MessageFlags } from 'discord.js' + +export async function getChannel(guild: Guild, id: string): Promise { + return guild!.channels.cache.get(id) as TextChannel ?? await guild!.channels.fetch(id).then(channel => channel as TextChannel) +} + +export async function getRole(guild: Guild, id: string): Promise { + return guild!.roles.cache.get(id) as Role ?? await guild!.roles.fetch(id) +} + +export const getMissPerms = (channelPerms: Readonly, requiredPerms: bigint[]): bigint[] => requiredPerms.filter(p => !channelPerms.has(p)) + +export async function getBot(guild: Guild): Promise { + return guild!.members.me as GuildMember ?? await guild!.members.fetchMe() +} + +export const getBotPerms = (user: ClientUser | GuildMember, channel: TextChannel): Readonly => channel.permissionsFor(user!)! + +export function getAttachments(interaction: ChatInputCommandInteraction, fileCount: number): Attachment[] { + const files: Attachment[] = [] + + for (let i = 0; i <= fileCount; i++) { + const file = interaction.options.getAttachment(`attachment-${i}`) + if (file) + files.push(file) + } + + return files +} + +export async function sendReply( + interaction: Interaction, + content: string, + ephemeral = true, + payloads?: InteractionReplyOptions, + isDeferred = false, + isDeferEphemeral = false, +) { + if (!interaction.isRepliable()) + return null + + const opts: InteractionReplyOptions = { ...payloads, content } + const deferOpts: InteractionDeferReplyOptions = {} + + if (ephemeral) + opts.flags = MessageFlags.Ephemeral + if (isDeferEphemeral) + deferOpts.flags = MessageFlags.Ephemeral + + if (isDeferred) { + await interaction.deferReply(deferOpts) + } + + if (interaction.replied || interaction.deferred) { + return await interaction.followUp(opts) + } + else { + await interaction.reply(opts) + } + + if (ephemeral) + return null +} + +export async function sendAsBot( + interaction: Interaction | null, + channel: TextChannel, + payloads: InteractionReplyOptions, + isTempMessage: boolean = false, + isDeferred: boolean = false, + isNextMessageEphemeral: boolean = false, +) { + const { allowedMentions, components, content, embeds, files, poll, tts } = payloads + const opts: MessageCreateOptions = { allowedMentions, components, content, embeds, files, poll, tts } + const deferOpts: InteractionDeferReplyOptions = {} + + if (isNextMessageEphemeral) + deferOpts.flags = MessageFlags.Ephemeral + + if (interaction) { + if (!interaction.isRepliable()) + return + + if (isDeferred) + await interaction.deferReply(deferOpts) + } + + const msg = await channel.send(opts) + if (isTempMessage) + setTimeout(() => msg?.delete().catch(() => {}), 5000) +} + +export * from './assert' +export * from './message' +export * from './error' +export * from './roles' diff --git a/packages/aksaria-discord/src/utils/discord/message.ts b/packages/aksaria-discord/src/utils/discord/message.ts new file mode 100644 index 0000000..44ecfc0 --- /dev/null +++ b/packages/aksaria-discord/src/utils/discord/message.ts @@ -0,0 +1,47 @@ +import type { GrindRole } from '../../config/discord' +import { formatList } from '../text' + +export class DiscordMessage { + static readonly ERR = { + NoMember: "❌ Couldn't resolve your member record", + NotGuild: '❌ This action must be used in a server', + ChannelNotFound: '❌ Channel not found', + RoleUneditable: "❌ I can't manage that role (check role hierarchy/managed role/@everyone)", + MemberAboveMe: "❌ I can't change roles for this member (their highest role is at/above mine)", + RoleNotFound: '❌ The role no longer exists', + RoleMissing(role: string | string[]): string { + if (typeof role === 'string') { + return `❌ Missing role: <@&${role}>` + } + + return `❌ I'm missing **${formatList(role)}** in this channel.` + }, + AllowedChannel: (channelId: string) => `❌ You can't do anything on this channel. You need to go to <#${channelId}>`, + GuildMissing: '❌ The guild could not be found', + CannotPost: "❌ I can't post in that channel", + MessageIdMissing: '❌ Message ID is missing or invalid', + MessageLinkInvalid: '❌ The provided message link is invalid', + + PlainMessage: '❌ There is nothing to do with this plain message', + CheckinIdMissing: '❌ Check-in ID is missing or invalid', + CheckinIdInvalid: '❌ The provided check-in ID is invalid', + CheckinDateMissing: '❌ Check-in date is missing or invalid', + CheckinDateInvalid: '❌ The check-in date is invalid', + + UnexpectedModal: '❌ Something went wrong while handling the modal component', + UnexpectedButton: '❌ Something went wrong while handling the button component', + UnexpectedEmoji: '❌ You used an invalid emoji for this action', + } + + static readonly MSG = { + ReachNewGrindRole(role: GrindRole) { + return `πŸŽ‰ You have reached a new grind role: <@&${role.id}>~` + }, + RoleGranted(roleId: string): string { + return `βœ… Granted <@&${roleId}> to you` + }, + RoleRevoked(roleId: string): string { + return `❌ You already have the <@&${roleId}> role` + }, + } +} diff --git a/packages/aksaria-discord/src/utils/discord/roles.ts b/packages/aksaria-discord/src/utils/discord/roles.ts new file mode 100644 index 0000000..4c8eb86 --- /dev/null +++ b/packages/aksaria-discord/src/utils/discord/roles.ts @@ -0,0 +1,13 @@ +import type { GrindRole } from '../../config/discord' +import type { GuildMember, RoleManager } from 'discord.js' +import { getGrindRoles } from '../../config/discord' + +export function getGrindRoleByStreakCount(roleManager: RoleManager, streak_count: number) { + const role = getGrindRoles(roleManager).find(role => streak_count === role.threshold) + return role +} + +export async function attachNewGrindRole(member: GuildMember, grindRole: GrindRole) { + await member.roles.remove(getGrindRoles().map(r => r.id)) + await member.roles.add(grindRole.id) +} diff --git a/packages/aksaria-discord/src/utils/index.ts b/packages/aksaria-discord/src/utils/index.ts new file mode 100644 index 0000000..9d0f2d3 --- /dev/null +++ b/packages/aksaria-discord/src/utils/index.ts @@ -0,0 +1,8 @@ +export * from './color' +export * from './component' +export * from './date' +export * from './io' +export * from './logger' +export * from './placeholder' +export * from './text' +export * from './discord' diff --git a/packages/aksaria-discord/src/utils/io.ts b/packages/aksaria-discord/src/utils/io.ts new file mode 100644 index 0000000..62a0d26 --- /dev/null +++ b/packages/aksaria-discord/src/utils/io.ts @@ -0,0 +1,30 @@ +import fs from 'node:fs' +import { resolve } from 'node:path' + +const EXCLUDED_FILES = new Set(['registry.ts', 'messages.ts', 'validators.ts']) +const EXCLUDED_FOLDERS = new Set(['validators', 'messages']) +const EXCLUDED_PATTERNS: RegExp[] = [/\.d\.ts$/] + +const isExcluded = (fileName: string) => EXCLUDED_FILES.has(fileName) || EXCLUDED_PATTERNS.some(rx => rx.test(fileName)) + +export function readFiles(dirPath: string, files: string[] = []) { + const paths = fs.readdirSync(dirPath, { withFileTypes: true }) + + for (const path of paths) { + const basePath = resolve(dirPath, path.name) + + if (path.isDirectory()) { + if (EXCLUDED_FOLDERS.has(path.name)) + continue + + readFiles(basePath, files) + } + else if (!isExcluded(path.name)) { + files.push(basePath) + } + } + + return files +} + +export const getModuleName = (rootName: string, file: string): string => file.split(rootName).pop()!.split('.').shift() || file diff --git a/packages/aksaria-discord/src/utils/logger.ts b/packages/aksaria-discord/src/utils/logger.ts new file mode 100644 index 0000000..7e0b547 --- /dev/null +++ b/packages/aksaria-discord/src/utils/logger.ts @@ -0,0 +1,31 @@ +import { timestamp } from './date' +import { ANSI_COLORS } from '../constants' + +export interface Logger { + base: (msg: string) => void + info: (msg: string) => void + success: (msg: string) => void + check: (msg: string) => void + warn: (msg: string) => void + error: (msg: string) => void +} + +export const log: Logger = { + base: (msg: string) => + console.log(`${ANSI_COLORS.white}[LOG ${timestamp()}]${ANSI_COLORS.reset} ${msg}`), + + info: (msg: string) => + console.log(`${ANSI_COLORS.cyan}[INFO ${timestamp()}] ✨ ${ANSI_COLORS.reset} ${msg}`), + + success: (msg: string) => + console.log(`${ANSI_COLORS.green}[SUCCESS ${timestamp()}] βœ… ${ANSI_COLORS.reset} ${msg}`), + + check: (msg: string) => + console.log(`${ANSI_COLORS.blue}[CHECKING ${timestamp()}] πŸ” ${ANSI_COLORS.reset} ${msg}`), + + warn: (msg: string) => + console.log(`${ANSI_COLORS.yellow}[WARNING ${timestamp()}] ⚠️ ${ANSI_COLORS.reset} ${msg}`), + + error: (msg: string) => + console.log(`${ANSI_COLORS.red}[ERROR ${timestamp()}] ❌ ${ANSI_COLORS.reset} ${msg}`), +} diff --git a/packages/aksaria-discord/src/utils/placeholder.ts b/packages/aksaria-discord/src/utils/placeholder.ts new file mode 100644 index 0000000..cf03704 --- /dev/null +++ b/packages/aksaria-discord/src/utils/placeholder.ts @@ -0,0 +1,15 @@ +export interface PlaceholderDummy { + TITLE: string + DESC: string + COLOR: string + FOOTER: string + MARKDOWN: string +} + +export const DUMMY: PlaceholderDummy = { + TITLE: 'Pengumuman Penting', + DESC: 'Halo, teman-teman! Hari ini...', + COLOR: '#FF7518', + FOOTER: 'Aksaria β€’ Where discipline meets destiny', + MARKDOWN: `Kamu dapat menggunakan Discord formatting untuk memperindah atau memperjelas rangkaian kata yang telah kamu buat. Untuk panduan lengkap, silakan merujuk ke [Markdown Text 101](https://support.discord.com/hc/en-us/articles/210298617-Markdown-Text-101-Chat-Formatting-Bold-Italic-Underline).`, +} diff --git a/packages/aksaria-discord/src/utils/text.ts b/packages/aksaria-discord/src/utils/text.ts new file mode 100644 index 0000000..4e0c951 --- /dev/null +++ b/packages/aksaria-discord/src/utils/text.ts @@ -0,0 +1,8 @@ +export function formatList(items: string[]): string { + if (items.length === 0) return '' + if (items.length === 1) return items[0] + if (items.length === 2) return `${items[0]} and ${items[1]}` + + const lastItem = items.pop() + return `${items.join(', ')}, and ${lastItem}` +} diff --git a/packages/aksaria-discord/tsconfig.json b/packages/aksaria-discord/tsconfig.json new file mode 100644 index 0000000..ae6011b --- /dev/null +++ b/packages/aksaria-discord/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "noEmit": true, + "baseUrl": "./src", + "paths": { + "@generatedDB/client": ["../../../db/generated/prisma/index"], + "@generatedDB/*": ["../../../db/generated/prisma/*"], + "@config/*": ["./config/*"], + "@commands/*": ["./bot/commands/*"], + "@events/*": ["./bot/events/*"], + "@utils/*": ["./utils/*"], + "@type/*": ["./types/*"], + "@adapters/*": ["./adapters/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"], + "exclude": ["node_modules", "dist"] +}