violation.ts 17 KB

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