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}>*
+
+βο½‘Λ βοΈ ΛqβqΛβ½Λqβ
+${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}
+βο½‘Λ βοΈ ΛqβqΛβ½Λqβ`))
+ .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"]
+}