Skip to content

Commit 8918332

Browse files
authored
Feat/grinder cronjob (#43)
* feat: tz on Asia/Jakarta on Docker * feat: `node-cron` * chore: typography * chore: typography on submitted checkin message * feat: bun watch when dev mode * feat: reset Grinder role job * feat: remove special grinder role on job * feat: good bye message to grind ashes channel * chore: typography * chore: fix schedule time * fix: ephemeral url attachment into fixed one * refactor: store the attachments first instead of update it last * refactor: checkin `attachment` to `attachments`
1 parent 378683c commit 8918332

19 files changed

Lines changed: 235 additions & 67 deletions

File tree

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docker-compose.prod.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ services:
66
- .env
77
environment:
88
- NODE_ENV=production
9+
- TZ=Asia/Jakarta
910
labels:
1011
- "com.centurylinklabs.watchtower.enable=true"
1112
depends_on:

docker-compose.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ services:
77
env_file:
88
- .env
99
environment:
10-
- NODE_ENV=production
10+
- NODE_ENV=local
11+
- TZ=Asia/Jakarta
1112
depends_on:
1213
db:
1314
condition: service_healthy

docker/entrypoint.sh

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,8 @@ echo "▶ Deploying commands..."
1919
bun commands
2020

2121
echo "▶ Starting application..."
22-
exec bun start
22+
if [ "$NODE_ENV" = "production" ]; then
23+
exec bun prod
24+
else
25+
exec bun start
26+
fi

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@
1818
"main": "index.js",
1919
"scripts": {
2020
"test": "echo \"Error: no test specified\" && exit 1",
21-
"start": "bun src/index.ts",
21+
"prod": "bun src/index.ts",
22+
"start": "bun --watch src/index.ts",
2223
"commands": "bun src/deploy-commands.ts",
2324
"prisma": "bunx prisma generate --schema db/schema.prisma"
2425
},
2526
"dependencies": {
2627
"@prisma/client": "6.13.0",
2728
"discord.js": "^14.25.1",
29+
"node-cron": "^4.2.1",
2830
"prisma": "^6.13.0"
2931
},
3032
"devDependencies": {

src/bot/commands/checkin/messages/checkin-status.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,16 @@ Wahai Tuan/Nona <@${userDiscordId}>,
1818
Nyala api Tuan/Nona belum dinyalakan hari ini.
1919
🗓 **Date**: ${getParsedNow()}
2020
🔥 **Current Streak**: ${checkinStreak?.streak ?? 0} day(s)
21-
🔎 **Status**: Belum melakukan check-in
22-
> *“Percikan hari ini belum ditorehkan. Lakukan check-in sebelum 23:59 WIB, agar api Tuan/Nona tak meredup.”*
21+
🔎 **Status**: Belum melakukan *check-in*
22+
> *“Percikan hari ini belum ditorehkan. Lakukan *check-in* sebelum 23:59 WIB, agar api Tuan/Nona tak meredup.”*
2323
`,
2424
WaitingCheckin: (userDiscordId: string, checkin: Checkin) => `
2525
🆔 **Check-In ID**:
2626
\`\`\`bash
2727
${checkin.public_id}
2828
\`\`\`
2929
🌟 **Grinder**: <@${userDiscordId}>
30-
📁 **Attachment:** ${checkin.attachment ? '✅' : '❌'}
30+
📁 **Attachment:** ${checkin.attachments?.length ? '✅' : '❌'}
3131
🗓 **Submitted At**: ${getParsedNow(getNow(checkin.created_at))}
3232
🔥 **Current Streak**: ${checkin.checkin_streak!.streak} day(s)
3333
🔎 **Status**: Menunggu peninjauan <@&${FLAMEWARDEN_ROLE}>
@@ -39,7 +39,7 @@ ${checkin.public_id}
3939
${checkin.public_id}
4040
\`\`\`
4141
🌟 **Grinder**: <@${userDiscordId}>
42-
📁 **Attachment:** ${checkin.attachment ? '✅' : '❌'}
42+
📁 **Attachment:** ${checkin.attachments?.length ? '✅' : '❌'}
4343
🔥 **Current Streak**: ${checkin.checkin_streak!.streak} day(s)
4444
🔎 **Status**: Disetujui; api Tuan/Nona kian terang
4545
🗓 **Approved At**: ${getParsedNow(getNow(checkin.updated_at!))}
@@ -53,7 +53,7 @@ ${checkin.public_id}
5353
${checkin.public_id}
5454
\`\`\`
5555
🌟 **Grinder**: <@${userDiscordId}>
56-
📁 **Attachment:** ${checkin.attachment ? '✅' : '❌'}
56+
📁 **Attachment:** ${checkin.attachments?.length ? '✅' : '❌'}
5757
🔥 **Current Streak**: ${checkin.checkin_streak!.streak} day(s)
5858
🔎 **Status**: Disetujui; api Tuan/Nona kian terang
5959
🗓 **Reviewed At**: ${getParsedNow(getNow(checkin.updated_at!))}

src/bot/commands/checkin/validators/checkin-status.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export class CheckinStatus extends CheckinStatusMessage {
8585
},
8686
}) as User
8787

88-
await Checkin.setAttachmentOnFirstCheckin(prisma, user?.checkins?.[0])
88+
await Checkin.setAttachments(prisma, user?.checkins?.[0])
8989

9090
return user
9191
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Event } from '@events/event'
2+
import type { Client } from 'discord.js'
3+
import process from 'node:process'
4+
import { GRIND_ASHES_CHANNEL } from '@config/discord'
5+
import { getChannel } from '@utils/discord'
6+
import { DiscordBaseError } from '@utils/discord/error'
7+
import { log } from '@utils/logger'
8+
import { Events } from 'discord.js'
9+
import cron from 'node-cron'
10+
import { ResetGrinderRoles } from '../validators/reset-grinder-roles'
11+
12+
export class ResetGrinderRolesError extends DiscordBaseError {
13+
constructor(message: string, options?: { cause?: unknown }) {
14+
super('ResetGrinderRolesError', message, options)
15+
}
16+
}
17+
18+
export default {
19+
name: Events.ClientReady,
20+
desc: `Reset Grinder roles for users that didn't do a check-in yesterday or the check-in didn't approved.`,
21+
once: true,
22+
exec(client: Client) {
23+
try {
24+
cron.schedule('0 0 * * *', async () => {
25+
log.check(ResetGrinderRoles.MSG.JobRunning)
26+
27+
const guild = await client.guilds.fetch(process.env.GUILD_ID!)
28+
const channel = await getChannel(guild, GRIND_ASHES_CHANNEL)
29+
ResetGrinderRoles.assertChannel(channel)
30+
const users = await ResetGrinderRoles.getUsersWithLatestCheckin(client.prisma)
31+
32+
await ResetGrinderRoles.validateUsers(guild, channel, users)
33+
34+
log.success(ResetGrinderRoles.MSG.JobSuccess)
35+
})
36+
}
37+
catch (err: any) {
38+
if (!(err instanceof DiscordBaseError))
39+
log.error(`Failed to handle ${ResetGrinderRoles.ERR.UnexpectedResetGrinderRoles}: ${err}`)
40+
}
41+
},
42+
} as Event
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { GuildMember } from 'discord.js'
2+
import { FLAMEWARDEN_ROLE, IGNITE_PATH_CHANNEL } from '@config/discord'
3+
import { DiscordAssert } from '@utils/discord'
4+
5+
export class ResetGrinderRolesMessage extends DiscordAssert {
6+
static override readonly ERR = {
7+
...DiscordAssert.ERR,
8+
UnexpectedResetGrinderRoles: '❌ Something went wrong while resetting grinder roles',
9+
}
10+
11+
static override readonly MSG = {
12+
...DiscordAssert.MSG,
13+
JobRunning: '[JOB] Running daily grinder reset...',
14+
JobSuccess: '[JOB] Grinder daily reset finished successfully',
15+
RemoveGrinderRoleFrom: (member: GuildMember) => `[JOB] Removed Grinder role from ${member.user.tag}`,
16+
GoodBye: (member: GuildMember) => `
17+
# 💔 Nyala Api Tuan/Nona <@${member.id}> Telah Gugur
18+
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.
19+
20+
Api bukanlah padam karena kelemahan, melainkan karena ia tak disirami pada waktunya.
21+
22+
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.
23+
24+
*Aksaria menanti mereka yang konsisten.*
25+
26+
> 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:
27+
> 1. Jangan terlebih dahulu memasuki ⁠<#${IGNITE_PATH_CHANNEL}>, demi menjaga ketertiban alur peninjauan.
28+
> 2. Silakan menjalankan perintah **\`/checkin-status\`** untuk menampilkan status *check-in* terakhir Tuan/Nona.
29+
> 3. Setelah pesan status tersebut muncul, berikan reaksi “❓” pada pesan tersebut.
30+
> 4. Dari reaksi tersebut, sebuah *thread* akan tercipta secara otomatis sebagai ruang klarifikasi dan komunikasi dengan <@&${FLAMEWARDEN_ROLE}>.
31+
`,
32+
}
33+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { PrismaClient } from '@generatedDB/client'
2+
import type { Guild, GuildMember, TextChannel } from 'discord.js'
3+
import { getGrindRoles, GRINDER_ROLE } from '@config/discord'
4+
import { isDateToday, isDateYesterday } from '@utils/date'
5+
import { sendAsBot } from '@utils/discord'
6+
import { log } from '@utils/logger'
7+
import { ResetGrinderRolesMessage } from '../messages/reset-grinder-roles'
8+
9+
interface UserWithLatestCheckin {
10+
discord_id: string
11+
checkins: {
12+
status: string
13+
created_at: Date
14+
}[]
15+
}
16+
17+
export class ResetGrinderRoles extends ResetGrinderRolesMessage {
18+
static hasValidCheckin(checkin?: { created_at: Date, status: string }): boolean {
19+
if (!checkin)
20+
return false
21+
22+
const { created_at, status } = checkin
23+
if (isDateToday(created_at))
24+
return true
25+
if (isDateYesterday(created_at) && status === 'APPROVED')
26+
return true
27+
28+
return false
29+
}
30+
31+
static async removeGrinderRoles(member: GuildMember) {
32+
await member.roles.remove(GRINDER_ROLE)
33+
34+
const grindRoles = getGrindRoles()
35+
for (const grindRole of grindRoles) {
36+
if (member.roles.cache.has(grindRole.id)) {
37+
await member.roles.remove(grindRole.id)
38+
}
39+
}
40+
}
41+
42+
static async validateUsers(guild: Guild, channel: TextChannel, users: UserWithLatestCheckin[]) {
43+
for (const user of users) {
44+
const lastCheckin = user.checkins?.[0]
45+
if (this.hasValidCheckin(lastCheckin))
46+
continue
47+
48+
const member = await guild.members.fetch(user.discord_id)
49+
await this.removeGrinderRoles(member)
50+
51+
await sendAsBot(
52+
null,
53+
channel,
54+
{ content: ResetGrinderRoles.MSG.GoodBye(member), allowedMentions: { users: [member.id], roles: [] } },
55+
)
56+
57+
log.info(this.MSG.RemoveGrinderRoleFrom(member))
58+
}
59+
}
60+
61+
static async getUsersWithLatestCheckin(prisma: PrismaClient): Promise<UserWithLatestCheckin[]> {
62+
const users = await prisma.user.findMany({
63+
select: {
64+
discord_id: true,
65+
checkins: {
66+
select: {
67+
status: true,
68+
created_at: true,
69+
},
70+
orderBy: { created_at: 'desc' },
71+
take: 1,
72+
},
73+
},
74+
}) as UserWithLatestCheckin[]
75+
76+
return users
77+
}
78+
}

0 commit comments

Comments
 (0)