violation.ts 19 KB

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