Skip to content

Commit 37b072a

Browse files
feat: trigger service
1 parent 1918f5f commit 37b072a

File tree

4 files changed

+290
-9
lines changed

4 files changed

+290
-9
lines changed
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import { Inject } from "@framework/container/Inject";
2+
import { GatewayEventListener } from "@framework/events/GatewayEventListener";
3+
import { Name } from "@framework/services/Name";
4+
import { Service } from "@framework/services/Service";
5+
import { HasEventListeners } from "@framework/types/HasEventListeners";
6+
import { TriggerType } from "@main/schemas/TriggerSchema";
7+
import type ConfigurationManager from "@main/services/ConfigurationManager";
8+
import {
9+
ActionRowBuilder,
10+
ActivityType,
11+
ButtonBuilder,
12+
ButtonStyle,
13+
GuildMember,
14+
Message,
15+
Presence,
16+
Snowflake
17+
} from "discord.js";
18+
19+
const handlers = {
20+
sticky_message: "triggerMessageSticky",
21+
member_status_update: "triggerMemberStatusUpdate"
22+
} satisfies Record<TriggerType["type"], Extract<keyof TriggerService, `trigger${string}`>>;
23+
24+
const events = {
25+
sticky_message: ["messageCreate"],
26+
member_status_update: ["presenceUpdate"]
27+
} satisfies Record<TriggerType["type"], (keyof ClientEvents)[]>;
28+
29+
type TriggerHandlerContext<M extends boolean = false> = {
30+
message: M extends false ? Message | undefined : M extends true ? Message : never;
31+
member?: GuildMember;
32+
newPresence?: Presence;
33+
oldPresence?: Presence | null;
34+
};
35+
36+
@Name("triggerService")
37+
class TriggerService extends Service implements HasEventListeners {
38+
private readonly lastStickyMessages: Record<`${Snowflake}_${Snowflake}`, Message | undefined> =
39+
{};
40+
private readonly lastStickyMessageQueues: Record<`${Snowflake}_${Snowflake}`, boolean> = {};
41+
42+
@Inject("configManager")
43+
private readonly configManager!: ConfigurationManager;
44+
45+
private config(guildId: Snowflake) {
46+
return this.configManager.config[guildId]?.auto_triggers;
47+
}
48+
49+
@GatewayEventListener("presenceUpdate")
50+
public onPresenceUpdate(oldPresence: Presence | null, newPresence: Presence) {
51+
if (newPresence?.user?.bot) {
52+
return false;
53+
}
54+
55+
const config = this.config(newPresence?.guild?.id ?? "");
56+
57+
if (!config?.enabled) {
58+
return false;
59+
}
60+
61+
this.processTriggers(
62+
config.triggers,
63+
{
64+
roles: [...(newPresence?.member?.roles.cache.keys() ?? [])],
65+
userId: newPresence.user?.id,
66+
context: {
67+
message: undefined,
68+
oldPresence,
69+
newPresence
70+
}
71+
},
72+
["presenceUpdate"]
73+
);
74+
}
75+
76+
public onMessageCreate(message: Message<boolean>) {
77+
if (message.author.bot) {
78+
return false;
79+
}
80+
81+
const config = this.config(message.guildId!);
82+
83+
if (!config?.enabled || config?.global_disabled_channels?.includes(message.channelId!)) {
84+
return false;
85+
}
86+
87+
this.processMessageTriggers(message, config.triggers);
88+
return true;
89+
}
90+
91+
private processTriggers(
92+
triggers: TriggerType[],
93+
data: Parameters<typeof this.processTrigger<false>>[1],
94+
triggerEvents: (keyof ClientEvents)[] | undefined = undefined
95+
) {
96+
loop: for (const trigger of triggers) {
97+
if (triggerEvents !== undefined) {
98+
for (const triggerEvent of triggerEvents) {
99+
if (!(events[trigger.type] as string[]).includes(triggerEvent)) {
100+
continue loop;
101+
}
102+
}
103+
}
104+
105+
this.processTrigger<boolean>(trigger, data).catch(this.logger.error);
106+
}
107+
}
108+
109+
private processMessageTriggers(message: Message, triggers: TriggerType[]) {
110+
for (const trigger of triggers) {
111+
if (!(events[trigger.type] as string[]).includes("messageCreate")) {
112+
continue;
113+
}
114+
115+
this.processTrigger(trigger, {
116+
channelId: message.channelId!,
117+
roles: message.member!.roles.cache.keys(),
118+
userId: message.author.id,
119+
context: {
120+
message
121+
}
122+
}).catch(this.logger.error);
123+
}
124+
}
125+
126+
private async processTrigger<B extends true | false>(
127+
trigger: TriggerType,
128+
{
129+
channelId,
130+
roles,
131+
userId,
132+
context
133+
}: {
134+
channelId?: string;
135+
userId?: string;
136+
roles?: IterableIterator<Snowflake> | Snowflake[];
137+
context: TriggerHandlerContext<B>;
138+
}
139+
) {
140+
if (channelId && !trigger.enabled_channels.includes(channelId)) {
141+
return;
142+
}
143+
144+
if (userId && trigger.ignore_users.includes(userId)) {
145+
return;
146+
}
147+
148+
if (roles) {
149+
for (const roleId of roles) {
150+
if (trigger.ignore_roles.includes(roleId)) {
151+
return;
152+
}
153+
}
154+
}
155+
156+
const callback = this[handlers[trigger.type]].bind(this) as (
157+
trigger: TriggerType,
158+
context: TriggerHandlerContext<boolean>
159+
) => Promise<unknown>;
160+
161+
if (handlers[trigger.type].startsWith("triggerMessage")) {
162+
if (!context.message) {
163+
throw new Error(
164+
"Attempting to call a message trigger without specifying a message object inside the context. This is an internal error."
165+
);
166+
}
167+
}
168+
169+
if (handlers[trigger.type].startsWith("trigger")) {
170+
await callback(trigger, context);
171+
}
172+
}
173+
174+
public async triggerMemberStatusUpdate(
175+
trigger: Extract<TriggerType, { type: "member_status_update" }>,
176+
{ newPresence, oldPresence }: TriggerHandlerContext<false>
177+
) {
178+
if (!newPresence || !oldPresence || (!trigger.must_contain && !trigger.must_not_contain)) {
179+
return;
180+
}
181+
182+
const oldStatus =
183+
oldPresence?.activities.find(a => a.type === ActivityType.Custom)?.state ?? "";
184+
const newStatus =
185+
newPresence?.activities.find(a => a.type === ActivityType.Custom)?.state ?? "";
186+
187+
if (newPresence.status === "offline" || newPresence.status === "invisible") {
188+
return;
189+
}
190+
191+
if (oldStatus === newStatus) {
192+
return;
193+
}
194+
195+
if (trigger.must_contain) {
196+
for (const string of trigger.must_contain) {
197+
if (!newStatus.includes(string)) {
198+
return;
199+
}
200+
}
201+
}
202+
203+
if (trigger.must_not_contain) {
204+
for (const string of trigger.must_not_contain) {
205+
if (newStatus.includes(string)) {
206+
return;
207+
}
208+
}
209+
}
210+
211+
try {
212+
if (trigger.action === "assign_role") {
213+
await newPresence.member?.roles.add(trigger.roles);
214+
} else if (trigger.action === "take_away_role") {
215+
await newPresence.member?.roles.remove(trigger.roles);
216+
}
217+
} catch (error) {
218+
this.logger.error(error);
219+
}
220+
}
221+
222+
public async triggerMessageSticky(
223+
trigger: Extract<TriggerType, { type: "sticky_message" }>,
224+
{ message }: TriggerHandlerContext<true>
225+
) {
226+
if (!this.lastStickyMessageQueues[`${message.guildId!}_${message.channelId!}`]) {
227+
this.lastStickyMessageQueues[`${message.guildId!}_${message.channelId!}`] = true;
228+
229+
setTimeout(async () => {
230+
const lastStickyMessage =
231+
this.lastStickyMessages[`${message.guildId!}_${message.channelId!}`];
232+
233+
if (lastStickyMessage) {
234+
try {
235+
await lastStickyMessage.delete();
236+
this.lastStickyMessages[`${message.guildId!}_${message.channelId!}`] =
237+
undefined;
238+
} catch (error) {
239+
this.logger.error(error);
240+
return;
241+
}
242+
}
243+
244+
try {
245+
const sentMessage = await message.channel.send({
246+
content: trigger.message,
247+
components:
248+
trigger.buttons.length === 0
249+
? undefined
250+
: [
251+
new ActionRowBuilder<ButtonBuilder>().addComponents(
252+
...trigger.buttons.map(({ label, url }) =>
253+
new ButtonBuilder()
254+
.setStyle(ButtonStyle.Link)
255+
.setURL(url)
256+
.setLabel(label)
257+
)
258+
)
259+
]
260+
});
261+
262+
this.lastStickyMessages[`${message.guildId!}_${message.channelId!}`] =
263+
sentMessage;
264+
this.lastStickyMessageQueues[`${message.guildId!}_${message.channelId!}`] =
265+
false;
266+
} catch (error) {
267+
this.logger.error(error);
268+
}
269+
}, 2000);
270+
}
271+
}
272+
}
273+
274+
export default TriggerService;

src/main/typescript/core/DiscordKernel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ class DiscordKernel extends Kernel {
8585
"@services/ReactionRoleService",
8686
"@services/AFKService",
8787
"@services/AutoRoleService",
88+
"@automod/TriggerService",
8889
"@services/DirectiveParsingService",
8990
"@services/WelcomerService",
9091
"@services/AuthService",

src/main/typescript/events/message/MessageCreateEventListener.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { Inject } from "@framework/container/Inject";
2121
import EventListener from "@framework/events/EventListener";
2222
import type { Logger } from "@framework/log/Logger";
2323
import { Events } from "@framework/types/ClientEvents";
24+
import type TriggerService from "@main/automod/TriggerService";
2425
import type AFKService from "@main/services/AFKService";
2526
import { Message, MessageType } from "discord.js";
2627
import type RuleModerationService from "../../automod/RuleModerationService";
@@ -44,6 +45,9 @@ class MessageCreateEventListener extends EventListener<Events.MessageCreate> {
4445
@Inject("spamModerationService")
4546
private readonly spamModerationService!: SpamModerationService;
4647

48+
@Inject("triggerService")
49+
private readonly triggerService!: TriggerService;
50+
4751
@Inject("afkService")
4852
private readonly afkService!: AFKService;
4953

@@ -52,7 +56,8 @@ class MessageCreateEventListener extends EventListener<Events.MessageCreate> {
5256
public override async onInitialize() {
5357
this.listeners.push(
5458
this.ruleModerationService.onMessageCreate.bind(this.ruleModerationService),
55-
this.spamModerationService.onMessageCreate.bind(this.spamModerationService)
59+
this.spamModerationService.onMessageCreate.bind(this.spamModerationService),
60+
this.triggerService.onMessageCreate.bind(this.triggerService)
5661
);
5762
}
5863

src/main/typescript/schemas/GuildConfigSchema.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import { LoggingSchema } from "@main/schemas/LoggingSchema";
2121
import { SurveySystemConfigSchema } from "@main/schemas/SurveySystemConfigSchema";
22+
import { TriggerSchema } from "@main/schemas/TriggerSchema";
2223
import { z } from "zod";
2324
import { MessageRuleSchema } from "./MessageRuleSchema";
2425
import { ModerationActionSchema } from "./ModerationActionSchema";
@@ -272,7 +273,14 @@ export const GuildConfigSchema = z.object({
272273
force_embeds: z.boolean().default(true),
273274
forced_embed_color: z.number().int().optional()
274275
})
275-
.optional()
276+
.optional(),
277+
auto_triggers: z
278+
.object({
279+
enabled: z.boolean().default(false),
280+
triggers: z.array(TriggerSchema).default([]),
281+
global_disabled_channels: z.array(zSnowflake).default([])
282+
})
283+
.optional(),
276284
/*
277285
message_reporting: z
278286
.object({
@@ -301,13 +309,6 @@ export const GuildConfigSchema = z.object({
301309
.default({})
302310
})
303311
.optional(),
304-
auto_triggers: z
305-
.object({
306-
enabled: z.boolean().default(false),
307-
triggers: z.array(TriggerSchema).default([]),
308-
global_disabled_channels: z.array(zSnowflake).default([])
309-
})
310-
.optional(),
311312
bump_reminder: z
312313
.object({
313314
enabled: z.boolean().optional(),

0 commit comments

Comments
 (0)