Skip to content

Commit 21e799a

Browse files
authored
Add quoted message IDs to DB (#571)
1 parent 0ccfe82 commit 21e799a

5 files changed

Lines changed: 175 additions & 77 deletions

File tree

src/handler/reaction/quoteHandler.ts

Lines changed: 75 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,24 @@ import {
33
type Message,
44
type MessageReaction,
55
type User,
6-
type TextBasedChannel,
7-
type GuildEmoji,
8-
type ReactionEmoji,
96
ChannelType,
107
type Channel,
118
type Snowflake,
129
type GuildTextBasedChannel,
13-
type ApplicationEmoji,
10+
hyperlink,
1411
} from "discord.js";
1512
import { Temporal } from "@js-temporal/polyfill";
1613

1714
import type { BotContext, QuoteConfig } from "#context.ts";
1815
import type { ReactionHandler } from "../ReactionHandler.ts";
1916
import log from "#log";
2017

18+
import * as quoteService from "#service/quote.ts";
19+
2120
const quoteMessage = "Ihr quoted echt jeden Scheiß, oder?";
2221

22+
// TODO: Move some of these functions to the service
23+
2324
const isChannelAnonymous = async (context: BotContext, channel: Channel) => {
2425
const anonChannels = context.commandConfig.quote.anonymousChannelIds;
2526

@@ -37,13 +38,6 @@ const isChannelAnonymous = async (context: BotContext, channel: Channel) => {
3738
return false;
3839
};
3940

40-
const isQuoteEmoji = (
41-
quoteConfig: QuoteConfig,
42-
emoji: GuildEmoji | ReactionEmoji | ApplicationEmoji,
43-
) => {
44-
return emoji.name === quoteConfig.emojiName;
45-
};
46-
4741
const getMessageQuoter = async (
4842
quoteConfig: QuoteConfig,
4943
message: Message,
@@ -53,8 +47,8 @@ const getMessageQuoter = async (
5347
throw new Error("Guild is null");
5448
}
5549
const fetchedMessage = await message.fetch(true);
56-
const messageReaction = fetchedMessage.reactions.cache.find(r =>
57-
isQuoteEmoji(quoteConfig, r.emoji),
50+
const messageReaction = fetchedMessage.reactions.cache.find(
51+
r => r.emoji.name === quoteConfig.emojiName,
5852
);
5953

6054
if (messageReaction === undefined) {
@@ -67,13 +61,6 @@ const getMessageQuoter = async (
6761
.filter((member): member is GuildMember => member !== null);
6862
};
6963

70-
const isMessageAlreadyQuoted = (
71-
messageQuoter: readonly GuildMember[],
72-
context: BotContext,
73-
): boolean => {
74-
return messageQuoter.some(u => u.id === context.client.user.id);
75-
};
76-
7764
const hasMessageEnoughQuotes = (
7865
context: BotContext,
7966
messageQuoter: readonly GuildMember[],
@@ -192,78 +179,72 @@ export default {
192179
return;
193180
}
194181

182+
if (invoker.id === context.client.user.id) {
183+
return;
184+
}
185+
195186
const quoteConfig = context.commandConfig.quote;
187+
if (event.emoji.name !== quoteConfig.emojiName) {
188+
return;
189+
}
196190

197-
if (
198-
!isQuoteEmoji(quoteConfig, event.emoji) ||
199-
event.message.guildId === null ||
200-
invoker.id === context.client.user.id
201-
) {
191+
const message = await event.message.fetch(true);
192+
if (!message.inGuild()) {
193+
return;
194+
}
195+
196+
const quotedMember = message.member;
197+
if (!quotedMember) {
198+
log.error(
199+
"`quotedMember` is null or undefined. Should not happen. Good luck finding the issue.",
200+
);
201+
return;
202+
}
203+
204+
const quoter = await context.guild.members.fetch(invoker.id);
205+
if (!quoter) {
206+
log.error(
207+
"`quoter` is null or undefined. Should not happen. Good luck finding the issue.",
208+
);
202209
return;
203210
}
204211

205-
const quoter = context.guild.members.cache.get(invoker.id);
212+
// We could use a proper transaction for that
213+
// But nobody's got time for that
214+
const alreadyQuoted = await quoteService.isMessageAlreadyQuoted(message);
215+
if (alreadyQuoted) {
216+
return;
217+
}
206218

207-
const sourceChannel = event.message.channel as TextBasedChannel;
208-
const quotedMessage = await sourceChannel.messages.fetch(event.message.id);
209-
const messageReference = quotedMessage.reference;
210-
const messageReferenceId = messageReference?.messageId;
219+
const messageReferenceId = message.reference?.messageId ?? undefined;
211220
const referencedMessage = messageReferenceId
212-
? await sourceChannel.messages.fetch(messageReferenceId)
221+
? await message.channel.messages.fetch(messageReferenceId)
213222
: undefined;
214223

215-
const quotedUser = quotedMessage.member;
216224
const referencedUser = referencedMessage?.member;
217-
const quotingMembers = await getMessageQuoter(quoteConfig, quotedMessage);
225+
const quotingMembers = await getMessageQuoter(quoteConfig, message);
218226

219227
const quotingMembersAllowed = quotingMembers.filter(context.roleGuard.isNerd);
220228

221-
if (!quotedUser || !quoter) {
222-
log.error(
223-
"Something bad happened, there is something missing that shouldn't be missing",
224-
);
225-
return;
226-
}
227-
228229
log.debug(
229-
`[Quote] User tried to ${quoter.displayName} (${quoter.id}) quote user ${quotedUser.displayName} (${quotedUser.id}) on message ${quotedMessage.id}`,
230+
`[Quote] User tried to ${quoter.displayName} (${quoter.id}) quote user ${quotedMember.displayName} (${quotedMember.id}) on message ${message.id}`,
230231
);
231232

232233
if (
233234
!context.roleGuard.isNerd(quoter) ||
234-
quoteConfig.blacklistedChannelIds.has(quotedMessage.channelId) ||
235-
isMessageAlreadyQuoted(quotingMembers, context)
235+
quoteConfig.blacklistedChannelIds.has(message.channelId)
236236
) {
237237
await event.users.remove(quoter);
238238
return;
239239
}
240240

241-
if (
242-
quotedMessage.author.id === context.client.user.id &&
243-
isQuoterQuotingQuoteMessage(quotedMessage)
244-
) {
241+
if (message.author.id === context.client.user.id && isQuoterQuotingQuoteMessage(message)) {
245242
await event.users.remove(quoter);
246243
return;
247244
}
248245

249-
if (isQuoterQuotingHimself(quoter, quotedUser)) {
250-
await context.textChannels.hauptchat.send({
251-
embeds: [
252-
{
253-
color: 0xe83e41,
254-
author: {
255-
name: quoter.displayName,
256-
icon_url: quoter.avatarURL({ forceStatic: true }) ?? undefined,
257-
},
258-
title: `${quoter.displayName} der Lellek hat gerade versucht sich, selbst zu quoten. Was für ein Opfer!`,
259-
description: `${quotedMessage.cleanContent}\n\n([link](${quotedMessage.url}))`,
260-
},
261-
],
262-
allowedMentions: {
263-
users: [quoter.id],
264-
},
265-
});
266-
246+
if (isQuoterQuotingHimself(quoter, quotedMember)) {
247+
await context.textChannels.hauptchat.send(createSelfQuoteReply(quoter, message));
267248
await event.users.remove(quoter);
268249
return;
269250
}
@@ -274,14 +255,14 @@ export default {
274255

275256
const { quote, reference } = await createQuote(
276257
context,
277-
quotedUser,
258+
quotedMember,
278259
quotingMembersAllowed.map(member => member.user),
279260
referencedUser,
280-
quotedMessage,
261+
message,
281262
referencedMessage,
282263
);
283264
const { id: targetChannelId, channel: targetChannel } = getTargetChannel(
284-
quotedMessage.channelId,
265+
message.channelId,
285266
context,
286267
);
287268

@@ -305,9 +286,9 @@ export default {
305286
// another quote event could sneak in and performing a quote itself.
306287
// Therefore we're checking again whether the message is already quoted BEFORE
307288
// sending the quote.
308-
// This is a really dirty fix - not even a fix at all - but I'm to lazy to
309-
// introduce some proper synchronization. Should work good enough for us.
310-
if (isMessageAlreadyQuoted(quotingMembers, context)) {
289+
const wasAdded = await quoteService.addQuoteIfNotPresent(message);
290+
if (!wasAdded) {
291+
// caught race condition
311292
return;
312293
}
313294

@@ -318,12 +299,29 @@ export default {
318299
await targetChannel.send(quote);
319300
}
320301

321-
await quotedMessage.react(event.emoji);
322-
if (
323-
quotedMessage.channel.isTextBased() &&
324-
quotedMessage.channel.type === ChannelType.GuildText
325-
) {
326-
await quotedMessage.reply(quoteMessage);
302+
await message.react(event.emoji);
303+
304+
if (message.channel.isTextBased() && message.channel.type === ChannelType.GuildText) {
305+
await message.reply(quoteMessage);
327306
}
328307
},
329308
} satisfies ReactionHandler;
309+
310+
function createSelfQuoteReply(quoter: GuildMember, message: Message<true>) {
311+
return {
312+
embeds: [
313+
{
314+
color: 0xe83e41,
315+
author: {
316+
name: quoter.displayName,
317+
icon_url: quoter.avatarURL({ forceStatic: true }) ?? undefined,
318+
},
319+
title: `${quoter.displayName} der Lellek hat gerade versucht sich, selbst zu quoten. Was für ein Opfer!`,
320+
description: `${message.cleanContent}\n\n(${hyperlink("link", message.url)})`,
321+
},
322+
],
323+
allowedMentions: {
324+
users: [quoter.id],
325+
},
326+
};
327+
}

src/service/quote.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { Message } from "discord.js";
2+
3+
import * as quoteStorage from "#storage/quote.ts";
4+
5+
export async function addQuoteIfNotPresent(message: Message<true>): Promise<boolean> {
6+
return await quoteStorage.addQuoteIfNotPresent({
7+
guildId: message.guildId,
8+
channelId: message.channelId,
9+
messageId: message.id,
10+
authorId: message.author.id,
11+
});
12+
}
13+
14+
export async function isMessageAlreadyQuoted(message: Message<true>): Promise<boolean> {
15+
return await quoteStorage.isMessageAlreadyQuoted(message.id);
16+
}

src/storage/db/model.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface Database {
4040
polls: PollsTable;
4141
pollOptions: PollOptionsTable;
4242
pollAnswers: PollAnswersTable;
43+
quotedMessages: QuotedMessagesTable;
4344
}
4445

4546
export type OneBasedMonth = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
@@ -449,3 +450,14 @@ export interface PollAnswersTable extends AuditedTable {
449450
optionId: PollOptionId;
450451
userId: Snowflake;
451452
}
453+
454+
export type QuotedMessagesId = number;
455+
export type QuotedMessage = Selectable<QuotedMessagesTable>;
456+
export interface QuotedMessagesTable extends AuditedTable {
457+
id: GeneratedAlways<QuotedMessagesId>;
458+
459+
guildId: Snowflake;
460+
channelId: Snowflake;
461+
messageId: Snowflake;
462+
authorId: Snowflake;
463+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { sql, type Kysely } from "kysely";
2+
3+
export async function up(db: Kysely<any>) {
4+
await db.schema
5+
.createTable("quotedMessages")
6+
.addColumn("id", "integer", c => c.primaryKey().autoIncrement())
7+
.addColumn("guildId", "text", c => c.notNull())
8+
.addColumn("channelId", "text", c => c.notNull())
9+
.addColumn("messageId", "text", c => c.notNull())
10+
.addColumn("authorId", "text", c => c.notNull())
11+
.addColumn("createdAt", "timestamp", c => c.notNull().defaultTo(sql`current_timestamp`))
12+
.addColumn("updatedAt", "timestamp", c => c.notNull().defaultTo(sql`current_timestamp`))
13+
.execute();
14+
15+
await db.schema
16+
.createIndex("quotedMessages_messageId")
17+
.on("quotedMessages")
18+
.column("messageId")
19+
.unique()
20+
.execute();
21+
22+
await createUpdatedAtTrigger(db, "quotedMessages");
23+
}
24+
25+
function createUpdatedAtTrigger(db: Kysely<any>, tableName: string) {
26+
return sql
27+
.raw(`
28+
create trigger ${tableName}_updatedAt
29+
after update on ${tableName} for each row
30+
begin
31+
update ${tableName}
32+
set updatedAt = current_timestamp
33+
where id = old.id;
34+
end;
35+
`)
36+
.execute(db);
37+
}
38+
39+
export async function down(_db: Kysely<any>) {
40+
throw new Error("Not supported lol");
41+
}

src/storage/quote.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { Snowflake } from "discord.js";
2+
import { sql, type Insertable } from "kysely";
3+
4+
import type { QuotedMessagesTable } from "#storage/db/model.ts";
5+
6+
import db from "#db";
7+
8+
/**
9+
* @returns `true` if it was added. `false` if it was already present.
10+
*/
11+
export async function addQuoteIfNotPresent(
12+
message: Insertable<QuotedMessagesTable>,
13+
ctx = db(),
14+
): Promise<boolean> {
15+
const res = await ctx
16+
.insertInto("quotedMessages")
17+
.values(message)
18+
.onConflict(c => c.column("messageId").doNothing())
19+
.returning(sql`1`.as("inserted"))
20+
.executeTakeFirst();
21+
return !!res?.inserted;
22+
}
23+
24+
export async function isMessageAlreadyQuoted(messageId: Snowflake, ctx = db()): Promise<boolean> {
25+
const message = await ctx
26+
.selectFrom("quotedMessages")
27+
.where("messageId", "=", messageId)
28+
.select("id")
29+
.executeTakeFirst();
30+
return !!message?.id;
31+
}

0 commit comments

Comments
 (0)