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