violation.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. import { Plugin, ICommandData, Command, Event, BotEventData } from "src/model/plugin";
  2. import { parseArgs, tryDo, parseDuration, UNIT_MEASURES, Option } from "src/util";
  3. import { GuildMember, Guild, MessageEmbed, Message, TextChannel, PartialGuildMember, User } from "discord.js";
  4. import { logger } from "src/logging";
  5. import { client } from "src/client";
  6. import humanizeDuration from "humanize-duration";
  7. import { getRepository, ObjectType, FindConditions, DeepPartial } from "typeorm";
  8. import { GuildViolationSettings } from "@shared/db/entity/GuildViolationSettings";
  9. import { Mute, Violation } from "@shared/db/entity/Violation";
  10. import { scheduleJob, Job, rescheduleJob } from "node-schedule";
  11. import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
  12. const MENTION_PATTERN = /<@!?(\d+)>/;
  13. interface ViolationInfo {
  14. member: GuildMember;
  15. endDate: Date;
  16. duration: number;
  17. guild: Guild;
  18. reason: string;
  19. settings: GuildViolationSettings;
  20. dryRun: boolean;
  21. noAnnounce: boolean;
  22. }
  23. type TimedViolation = Violation & { endsAt: Date };
  24. type StartViolationFunction = (member: GuildMember | PartialGuildMember, settings: GuildViolationSettings) => Promise<void>;
  25. type StopViolationFunction = (guild: Guild, userId: string, settings: GuildViolationSettings) => Promise<void>;
  26. interface TimedViolationStopHandler {
  27. type: ObjectType<TimedViolation>;
  28. start: StartViolationFunction;
  29. stop: StopViolationFunction;
  30. command: string;
  31. }
  32. @Plugin
  33. export class ViolationPlugin {
  34. jobs: Record<number, Job> = {};
  35. timedViolationHandlers: TimedViolationStopHandler[] = [
  36. {
  37. command: "mute",
  38. type: Mute,
  39. start: async (member: GuildMember | PartialGuildMember, settings: GuildViolationSettings): Promise<void> => {
  40. const muteRoleResolve = await tryDo(member.guild.roles.fetch(settings.muteRoleId));
  41. if (!muteRoleResolve.ok || !muteRoleResolve.result) {
  42. logger.error(
  43. "mute: Tried to mute user %s#%s (%s) but mute role ID %s is invalid!",
  44. member.user?.username,
  45. member.user?.discriminator,
  46. member.user?.id,
  47. settings.muteRoleId);
  48. return;
  49. }
  50. await member.roles.add(muteRoleResolve.result);
  51. },
  52. stop: async (guild: Guild, userId: string, settings: GuildViolationSettings): Promise<void> => {
  53. const muteRoleResolve = await tryDo(guild.roles.fetch(settings.muteRoleId));
  54. if (!muteRoleResolve.ok || !muteRoleResolve.result) {
  55. logger.warn("mute: couldn't find mute role id %s (removed from server?)", settings.muteRoleId);
  56. return;
  57. }
  58. const muteRole = muteRoleResolve.result;
  59. const memberResolve = await tryDo(guild.members.fetch(userId));
  60. if (!memberResolve.ok) {
  61. logger.warn("mute: user %s is not on the server anymore", userId);
  62. return;
  63. }
  64. await memberResolve.result.roles.remove(muteRole);
  65. }
  66. }
  67. ];
  68. async start(): Promise<void> {
  69. for (const handler of this.timedViolationHandlers) {
  70. const repo = getRepository(handler.type);
  71. const validViolations = await repo.find({
  72. where: { valid: true }
  73. });
  74. for (const violation of validViolations) {
  75. const stopJob = this.scheduleRemoveViolation(handler.type, violation.guildId, violation.userId, handler.stop, handler.command);
  76. if (violation.endsAt <= new Date())
  77. await stopJob();
  78. else
  79. this.jobs[violation.id] = scheduleJob(violation.endsAt, stopJob);
  80. }
  81. }
  82. }
  83. @Event("guildMemberAdd")
  84. async onUserJoin(data: BotEventData, member: GuildMember | PartialGuildMember): Promise<void> {
  85. const settingsRepo = getRepository(GuildViolationSettings);
  86. const settings = await settingsRepo.findOne(member.guild.id);
  87. if (!settings)
  88. return;
  89. const hasActiveViolations = await getRepository(Violation).findOne({
  90. where: {
  91. guildId: member.guild.id,
  92. userId: member.id,
  93. valid: true
  94. }
  95. });
  96. if (!hasActiveViolations)
  97. return;
  98. for (const handler of this.timedViolationHandlers) {
  99. const repo = getRepository(handler.type);
  100. const activeViolations = await repo.find({
  101. where: {
  102. guildId: member.guild.id,
  103. userId: member.id,
  104. valid: true
  105. }
  106. });
  107. if (activeViolations.length == 0)
  108. continue;
  109. for (const violation of activeViolations) {
  110. if (violation.endsAt < new Date())
  111. await repo.update({ id: violation.id }, { valid: false });
  112. else
  113. await handler.start(member, settings);
  114. }
  115. }
  116. }
  117. @Command({
  118. type: "prefix",
  119. pattern: "mute",
  120. auth: true,
  121. documentation: {
  122. example: "mute[?!] <user> [<duration>] [<reason>]",
  123. description: "Mutes for a given duration and reason. ? = dry run, ! = no announcement"
  124. }
  125. })
  126. async muteUser({ message }: ICommandData): Promise<void> {
  127. const info = await this.parseCommand(message);
  128. if (!info.ok)
  129. return;
  130. const handler = this.getViolationHandler(Mute);
  131. if (!handler) {
  132. logger.error("Couldn't find handler for Mute");
  133. return;
  134. }
  135. await this.applyTimedViolation(Mute, info, "mute", handler.start, handler.stop);
  136. await this.sendViolationMessage(message, info, "User has been muted for server violation");
  137. }
  138. @Command({
  139. type: "prefix",
  140. pattern: "unmute",
  141. auth: true,
  142. documentation: {
  143. example: "unmute <user>",
  144. description: "Unmutes user"
  145. }
  146. })
  147. async unmuteUser({ message }: ICommandData): Promise<void> {
  148. await this.removeTimedViolation(Mute, message, "mute");
  149. }
  150. private getViolationHandler(type: ObjectType<TimedViolation>): TimedViolationStopHandler {
  151. for (const handler of this.timedViolationHandlers) {
  152. if (handler.type == type)
  153. return handler;
  154. }
  155. throw new Error("Couldn't find handler for violation type!");
  156. }
  157. private async removeTimedViolation<T extends TimedViolation>(type: ObjectType<T>, message: Message, command = "violation") {
  158. if (!message.guild) {
  159. await message.reply("cannot do in DMs!");
  160. return;
  161. }
  162. const settingsRepo = getRepository(GuildViolationSettings);
  163. const settings = await settingsRepo.findOne(message.guild.id);
  164. if (!settings) {
  165. message.reply("this guild doesn't have violation settings set up!");
  166. return;
  167. }
  168. const [, userId] = parseArgs(message.content);
  169. if (!userId) {
  170. await message.reply("no user specified!");
  171. return;
  172. }
  173. if (userId == message.author.id) {
  174. await message.reply(`cannot ${command} yourself!`);
  175. return;
  176. }
  177. const user = await this.resolveUser(userId);
  178. if (!user) {
  179. await message.reply("couldn't find the given user!");
  180. logger.error("Tried to un-%s user %s but couldn't find them by id!", command, userId);
  181. return;
  182. }
  183. const violationRepo = getRepository(type);
  184. const existingViolation = await violationRepo.findOne({
  185. where: {
  186. guildId: message.guild.id,
  187. userId: user.id,
  188. valid: true
  189. }
  190. });
  191. if (!existingViolation) {
  192. await message.reply(`user has no existing active ${command}s in the DB!`);
  193. return;
  194. }
  195. await violationRepo.update({ id: existingViolation.id } as unknown as FindConditions<T>, { valid: false } as unknown as QueryDeepPartialEntity<T>);
  196. delete this.jobs[existingViolation.id];
  197. const handler = this.getViolationHandler(type);
  198. await handler.stop(message.guild, user.id, settings);
  199. await message.reply(`removed ${command} on user!`);
  200. }
  201. private async applyTimedViolation<T extends TimedViolation>(type: ObjectType<T>, info: ViolationInfo, command = "violation", apply: StartViolationFunction, remove: StopViolationFunction) {
  202. if (info.dryRun)
  203. return;
  204. const violationRepo = getRepository(type);
  205. const existingViolation = await violationRepo.findOne({
  206. where: {
  207. userId: info.member.id,
  208. guildId: info.guild.id,
  209. valid: true
  210. }
  211. });
  212. if (existingViolation) {
  213. logger.warn("%s: trying to reapply on user %s#%s (%s)", command, info.member.user.username, info.member.user.discriminator, info.member.id);
  214. await violationRepo.update({ id: existingViolation.id } as unknown as FindConditions<T>, { endsAt: info.endDate } as unknown as QueryDeepPartialEntity<T>);
  215. const job = this.jobs[existingViolation.id];
  216. rescheduleJob(job, info.endDate);
  217. } else {
  218. const newViolation = await violationRepo.save({
  219. guildId: info.guild.id,
  220. userId: info.member.id,
  221. reason: info.reason,
  222. endsAt: info.endDate,
  223. valid: true,
  224. } as unknown as DeepPartial<T>);
  225. this.jobs[newViolation.id] = scheduleJob(info.endDate, this.scheduleRemoveViolation(type, info.guild.id, info.member.id, remove, command));
  226. }
  227. await apply(info.member, info.settings);
  228. }
  229. private scheduleRemoveViolation<T extends TimedViolation>(type: ObjectType<T>, guildId: string, userId: string, handle: StopViolationFunction, command = "violation") {
  230. return async () => {
  231. const settingsRepo = getRepository(GuildViolationSettings);
  232. const settings = await settingsRepo.findOne(guildId);
  233. if (!settings) {
  234. logger.warn("un-%s: no violation settings found for guild %s", command, guildId);
  235. return;
  236. }
  237. const repo = getRepository(type);
  238. const violation = await repo.findOne({
  239. where: {
  240. guildId: guildId,
  241. userId: userId,
  242. valid: true
  243. }
  244. });
  245. if (!violation) {
  246. logger.warn("un-%s: no violation found for user ID %s in guild %s", command, userId, guildId);
  247. return;
  248. }
  249. await repo.update({ id: violation.id } as unknown as FindConditions<T>, { valid: false } as unknown as QueryDeepPartialEntity<T>);
  250. delete this.jobs[violation.id];
  251. const guild = client.bot.guilds.resolve(guildId);
  252. if (!guild) {
  253. logger.warn("un-%s: couldn't find guild %s", command, guildId);
  254. return;
  255. }
  256. await handle(guild, userId, settings);
  257. };
  258. }
  259. private async resolveUser(id: string): Promise<User | undefined> {
  260. const result = MENTION_PATTERN.exec(id);
  261. if (result) {
  262. const userId = result[1];
  263. const fetchResult = await tryDo(client.bot.users.fetch(userId));
  264. if (fetchResult.ok)
  265. return fetchResult.result;
  266. }
  267. const fetchResult = await tryDo(client.bot.users.fetch(id));
  268. if (!fetchResult.ok)
  269. return undefined;
  270. return fetchResult.result;
  271. }
  272. private async parseCommand(message: Message, command = "violation"): Promise<Option<ViolationInfo>> {
  273. if (!message.guild) {
  274. await message.reply("cannot do in DMs!");
  275. return { ok: false };
  276. }
  277. const violationSettingsRepo = getRepository(GuildViolationSettings);
  278. const settings = await violationSettingsRepo.findOne(message.guild.id);
  279. if (!settings) {
  280. await message.reply("sorry, this server doesn't have violation settings set up.");
  281. logger.error(
  282. "%s was called in guild %s (%s) on user %s which doesn't have config set up!",
  283. command,
  284. message.guild.name,
  285. message.guild.id,
  286. message.author.id);
  287. return { ok: false };
  288. }
  289. const [directive, userId, duration, ...rest] = parseArgs(message.content);
  290. const dryRun = directive.endsWith("?");
  291. const noAnnounce = directive.endsWith("!");
  292. if (!userId) {
  293. await message.reply("no user specified!");
  294. return { ok: false };
  295. }
  296. if (userId == message.author.id) {
  297. await message.reply(`cannot ${command} yourself!`);
  298. return { ok: false };
  299. }
  300. const user = await this.resolveUser(userId);
  301. if (!user) {
  302. await message.reply("couldn't find the given user!");
  303. logger.error("Tried to %s user %s but couldn't find them by id!", command, userId);
  304. return { ok: false };
  305. }
  306. const memberResolve = await tryDo(message.guild.members.fetch(user));
  307. if (!memberResolve.ok) {
  308. await message.reply("user is not member of the server anymore!");
  309. logger.error("Tried to %s user %s but they are not on the server anymore!", command, userId);
  310. return { ok: false };
  311. }
  312. let durationMs = parseDuration(duration);
  313. let reasonArray = rest;
  314. if (!durationMs) {
  315. durationMs = UNIT_MEASURES.d as number;
  316. reasonArray = [duration, ...reasonArray];
  317. }
  318. const endDate = new Date(Date.now() + durationMs);
  319. let reason = reasonArray.join(" ");
  320. if (!reason)
  321. reason = "None given";
  322. return {
  323. ok: true,
  324. duration: durationMs,
  325. endDate: endDate,
  326. guild: message.guild,
  327. member: memberResolve.result,
  328. reason: reason,
  329. settings: settings,
  330. dryRun: dryRun,
  331. noAnnounce: noAnnounce
  332. };
  333. }
  334. private async sendViolationMessage(message: Message, info: ViolationInfo, title: string) {
  335. let announceChannel: TextChannel | null = null;
  336. if ((info.noAnnounce || info.dryRun) && message.channel.type == "text") {
  337. announceChannel = message.channel;
  338. }
  339. else if (info.settings.violationInfoChannelId) {
  340. const ch = info.guild.channels.resolve(info.settings.violationInfoChannelId);
  341. if (ch && ch.type == "text")
  342. announceChannel = ch as TextChannel;
  343. else if (message.channel.type == "text") {
  344. announceChannel = message.channel;
  345. }
  346. }
  347. await announceChannel?.send(new MessageEmbed({
  348. title: `${info.dryRun ? "[DRY RUN] " : ""}${title}`,
  349. color: 4944347,
  350. timestamp: new Date(),
  351. footer: {
  352. text: client.botUser.username
  353. },
  354. author: {
  355. name: client.botUser.username,
  356. iconURL: client.botUser.avatarURL() ?? undefined
  357. },
  358. fields: [
  359. {
  360. name: "Username",
  361. value: info.member.toString()
  362. },
  363. {
  364. name: "Duration",
  365. value: humanizeDuration(info.duration, { unitMeasures: UNIT_MEASURES })
  366. },
  367. {
  368. name: "Reason",
  369. value: info.reason
  370. }
  371. ]
  372. }));
  373. }
  374. }