Skip to content

Commit 91033c0

Browse files
committed
feat(bot): add actual counting feature
1 parent f723759 commit 91033c0

File tree

12 files changed

+271
-8
lines changed

12 files changed

+271
-8
lines changed

api/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@
55
"scripts": {
66
"dev": "BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD=1 bun with-env bun --watch src/index.ts",
77
"db:push": "bun with-env drizzle-kit push",
8+
"db:studio": "bun with-env drizzle-kit studio",
89
"with-env": "dotenv -e ../.env --"
910
},
1011
"dependencies": {
1112
"@hono/trpc-server": "^0.3.2",
1213
"@trpc/server": "^10.45.2",
1314
"drizzle-orm": "^0.32.1",
1415
"hono": "^4.5.2",
15-
"postgres": "^3.4.4"
16+
"postgres": "^3.4.4",
17+
"zod": "^3.23.8"
1618
},
1719
"devDependencies": {
1820
"@types/bun": "latest",

api/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { trpcServer } from "@hono/trpc-server";
22
import { Hono } from "hono";
33
import { appRouter } from "./router";
4+
import { createContext } from "./utils/trpc";
45

56
const app = new Hono();
67

@@ -20,6 +21,7 @@ app.use(
2021
},
2122
trpcServer({
2223
router: appRouter,
24+
createContext,
2325
}),
2426
);
2527

api/src/router/channels.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { z } from "zod";
2+
import { procedure, router } from "../utils/trpc";
3+
import { channels, guilds } from "../utils/db/schema";
4+
import { and, eq } from "drizzle-orm";
5+
import { TRPCError } from "@trpc/server";
6+
7+
export const channelsRouter = router({
8+
getChannels: procedure
9+
.input(z.object({ guildId: z.string() }))
10+
.query(async ({ ctx, input }) => {
11+
if (
12+
!(await ctx.db.query.guilds.findFirst({
13+
where: eq(guilds.id, input.guildId),
14+
}))
15+
)
16+
throw new TRPCError({
17+
code: "NOT_FOUND",
18+
message: "Guild not found",
19+
});
20+
21+
return await ctx.db.query.channels.findMany({
22+
where: eq(channels.guildId, input.guildId),
23+
});
24+
}),
25+
addChannel: procedure
26+
.input(
27+
z.object({
28+
channelId: z.string(),
29+
guildId: z.string(),
30+
name: z.string(),
31+
}),
32+
)
33+
.mutation(async ({ ctx, input }) => {
34+
if (
35+
!(await ctx.db.query.guilds.findFirst({
36+
where: eq(guilds.id, input.guildId),
37+
}))
38+
)
39+
throw new TRPCError({
40+
code: "NOT_FOUND",
41+
message: "Guild not found",
42+
});
43+
if (
44+
await ctx.db.query.channels.findFirst({
45+
where: eq(channels.id, input.channelId),
46+
})
47+
)
48+
throw new TRPCError({
49+
code: "BAD_REQUEST",
50+
message: "Channel already exists",
51+
});
52+
53+
const createdChannels = await ctx.db
54+
.insert(channels)
55+
.values({
56+
id: input.channelId,
57+
guildId: input.guildId,
58+
name: input.name,
59+
count: 0,
60+
})
61+
.returning();
62+
return createdChannels[0];
63+
}),
64+
setCount: procedure
65+
.input(
66+
z.object({
67+
guildId: z.string(),
68+
channelId: z.string(),
69+
count: z.number(),
70+
}),
71+
)
72+
.mutation(async ({ ctx, input }) => {
73+
await ctx.db
74+
.update(channels)
75+
.set({ count: input.count })
76+
.where(
77+
and(
78+
(eq(channels.id, input.channelId),
79+
eq(channels.guildId, input.guildId)),
80+
),
81+
);
82+
return { success: true };
83+
}),
84+
updateLastUser: procedure
85+
.input(
86+
z.object({
87+
channelId: z.string(),
88+
guildId: z.string(),
89+
userId: z.string(),
90+
}),
91+
)
92+
.mutation(async ({ ctx, input }) => {
93+
await ctx.db
94+
.update(channels)
95+
.set({ lastUserId: input.userId })
96+
.where(
97+
and(
98+
(eq(channels.id, input.channelId),
99+
eq(channels.guildId, input.guildId)),
100+
),
101+
);
102+
return { success: true };
103+
}),
104+
});

api/src/router/guilds.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { z } from "zod";
2+
import { procedure, router } from "../utils/trpc";
3+
import { guilds } from "../utils/db/schema";
4+
5+
export const guildsRouter = router({
6+
createGuild: procedure
7+
.input(z.object({ id: z.string(), name: z.string() }))
8+
.mutation(async ({ ctx, input }) => {
9+
const createdGuilds = await ctx.db
10+
.insert(guilds)
11+
.values({
12+
id: input.id,
13+
name: input.name,
14+
})
15+
.returning();
16+
return createdGuilds[0];
17+
}),
18+
});

api/src/router/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { db } from "../utils/db";
21
import { procedure, router } from "../utils/trpc";
2+
import { channelsRouter } from "./channels";
3+
import { guildsRouter } from "./guilds";
34

45
export const appRouter = router({
56
greeting: procedure.query(() => "Hello from tRPC v10!"),
6-
getGuilds: procedure.query(async () => {
7-
return await db.query.guilds.findMany();
8-
}),
7+
channels: channelsRouter,
8+
guilds: guildsRouter,
99
});
1010

1111
// Export only the type of a router!

api/src/utils/db/schema.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1-
import { pgTable, text } from "drizzle-orm/pg-core";
1+
import { integer, pgTable, text } from "drizzle-orm/pg-core";
22

33
export const guilds = pgTable("guilds", {
44
id: text("id").primaryKey(),
55
name: text("name"),
66
});
7+
8+
export const channels = pgTable("channels", {
9+
id: text("id").primaryKey(),
10+
name: text("name"),
11+
guildId: text("guild_id").references(() => guilds.id),
12+
count: integer("count").default(0),
13+
lastUserId: text("last_user_id"),
14+
});

api/src/utils/trpc.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { initTRPC } from "@trpc/server";
2+
import { db } from "./db";
23

3-
const t = initTRPC.create();
4+
export const createContext = async () => {
5+
return { db };
6+
};
7+
8+
const t = initTRPC.context<typeof createContext>().create();
49

510
export const router = t.router;
611
export const procedure = t.procedure;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { ChannelType, SlashCommandBuilder } from "discord.js";
2+
import type { Command } from "../../structures/command";
3+
import { api } from "../../utils/trpc";
4+
import { TRPCClientError } from "@trpc/client";
5+
6+
export default {
7+
data: new SlashCommandBuilder()
8+
.setName("channels")
9+
.setDescription("Manage counting channels for this server.")
10+
.addSubcommand((subcommand) =>
11+
subcommand
12+
.setName("add")
13+
.setDescription("Add a counting channel.")
14+
.addChannelOption((option) =>
15+
option
16+
.setName("channel")
17+
.setDescription("The channel to add.")
18+
.addChannelTypes(ChannelType.GuildText)
19+
.setRequired(true),
20+
),
21+
),
22+
run: async ({ interaction }) => {
23+
await interaction.deferReply({
24+
ephemeral: true,
25+
});
26+
27+
const subcommand = interaction.options.getSubcommand();
28+
switch (subcommand) {
29+
case "add": {
30+
const channel = interaction.options.getChannel("channel", true);
31+
try {
32+
await api.channels.addChannel.mutate({
33+
channelId: channel.id,
34+
name: channel.name,
35+
guildId: interaction.guild.id,
36+
});
37+
return interaction.followUp(
38+
`Added ${channel} as a counting channel.`,
39+
);
40+
} catch (err) {
41+
if (err instanceof TRPCClientError) {
42+
if (err.message === "Guild not found") {
43+
await api.guilds.createGuild.mutate({
44+
id: interaction.guild.id,
45+
name: interaction.guild.name,
46+
});
47+
await api.channels.addChannel.mutate({
48+
channelId: channel.id,
49+
name: channel.name,
50+
guildId: interaction.guild.id,
51+
});
52+
return interaction.followUp(
53+
`Added ${channel} as a counting channel.`,
54+
);
55+
} else {
56+
return interaction.followUp(err.message);
57+
}
58+
}
59+
}
60+
}
61+
}
62+
},
63+
} satisfies Command;

bot/src/features/counting.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { TRPCClientError } from "@trpc/client";
2+
import type { BotClient } from "../structures/client";
3+
import { api } from "../utils/trpc";
4+
5+
const isNumber = (str: string) => /^\d+$/.test(str);
6+
7+
function stripCommas(str: string) {
8+
return str.replace(/,/g, "");
9+
}
10+
11+
export default (client: BotClient) => {
12+
client.on("messageCreate", async (message) => {
13+
if (message.author.bot || !message.guild) return;
14+
15+
try {
16+
// TODO: cache all api calls to avoid spamming the api
17+
const channels = await api.channels.getChannels.query({
18+
guildId: message.guild.id,
19+
});
20+
const currentChannel = channels.find(
21+
(channel) => channel.id === message.channel.id,
22+
);
23+
if (!channels.length || !currentChannel) return;
24+
25+
const messageSplit = message.content.split(/[ :\n]+/);
26+
const messageNumberString = stripCommas(messageSplit[0]);
27+
if (!isNumber(messageNumberString)) return message.delete();
28+
29+
const messageNumber = parseInt(messageNumberString, 10);
30+
const nextCount = (currentChannel.count ?? 0) + 1;
31+
if (nextCount !== messageNumber) return message.delete();
32+
33+
await api.channels.setCount.mutate({
34+
channelId: currentChannel.id,
35+
guildId: message.guild.id,
36+
count: nextCount,
37+
});
38+
await api.channels.updateLastUser.mutate({
39+
channelId: currentChannel.id,
40+
guildId: message.guild.id,
41+
userId: message.author.id,
42+
});
43+
} catch (err) {
44+
if (err instanceof TRPCClientError && err.message === "Guild not found")
45+
return;
46+
console.error(err);
47+
}
48+
});
49+
};

bot/src/structures/client.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export class BotClient<Ready extends boolean = boolean> extends Client<Ready> {
2222
register() {
2323
this._registerCommands();
2424
this._registerEvents();
25+
this._registerFeatures();
2526
}
2627

2728
private async _registerCommands() {
@@ -54,4 +55,14 @@ export class BotClient<Ready extends boolean = boolean> extends Client<Ready> {
5455
this.on(event.name, event.run.bind(null, this));
5556
}
5657
}
58+
59+
private async _registerFeatures() {
60+
const featureFiles = await readdir(join(process.cwd(), "src/features"));
61+
for (const file of featureFiles) {
62+
const feature = await import(`../features/${file}`).then(
63+
(x) => x.default,
64+
);
65+
feature(this);
66+
}
67+
}
5768
}

0 commit comments

Comments
 (0)