Compare commits

...

3 Commits

Author SHA1 Message Date
Jill ad48cd516e
more wip work yippeee 2024-03-18 22:19:14 +03:00
Jill 72b835f475
update deps 2024-03-18 16:23:42 +03:00
Jill d735d51676
apparently i need to update the discord.js for this so let me do that 2024-03-18 15:09:49 +03:00
8 changed files with 911 additions and 403 deletions

View File

@ -16,29 +16,31 @@
"@discordjs/rest": "^2.2.0", "@discordjs/rest": "^2.2.0",
"chalk": "^4.1.2", "chalk": "^4.1.2",
"d3-array": "^2.12.1", "d3-array": "^2.12.1",
"diff": "^5.2.0",
"discord.js": "^14.14.1", "discord.js": "^14.14.1",
"express": "^4.18.2", "express": "^4.18.3",
"express-handlebars": "^7.1.2", "express-handlebars": "^7.1.2",
"got": "^11.8.6", "got": "^11.8.6",
"knex": "^3.0.1", "knex": "^3.1.0",
"outdent": "^0.8.0", "outdent": "^0.8.0",
"parse-color": "^1.0.0", "parse-color": "^1.0.0",
"pretty-bytes": "^5.6.0", "pretty-bytes": "^5.6.0",
"random-seed": "^0.3.0", "random-seed": "^0.3.0",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.7",
"tough-cookie": "^4.1.3", "tough-cookie": "^4.1.3",
"uid-safe": "^2.1.5" "uid-safe": "^2.1.5"
}, },
"devDependencies": { "devDependencies": {
"@types/d3-array": "^3.2.1", "@types/d3-array": "^3.2.1",
"@types/diff": "^5.0.9",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/parse-color": "^1.0.3", "@types/parse-color": "^1.0.3",
"@types/tough-cookie": "^4.0.5", "@types/tough-cookie": "^4.0.5",
"@types/uid-safe": "^2.1.5", "@types/uid-safe": "^2.1.5",
"@typescript-eslint/eslint-plugin": "^6.11.0", "@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.11.0", "@typescript-eslint/parser": "^6.21.0",
"discord-api-types": "^0.37.63", "discord-api-types": "^0.37.74",
"eslint": "^8.53.0", "eslint": "^8.57.0",
"typescript": "5.2.2" "typescript": "5.2.2"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,123 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction, ComponentType, SlashCommandBuilder, StringSelectMenuBuilder } from 'discord.js';
import { Command } from '../types/index';
import { AuditLog, db } from '../lib/db';
import { EventType } from '../lib/events';
import { chunks } from '../lib/util';
export default {
data: new SlashCommandBuilder()
.setName('auditlog')
.setDescription('[ADMIN] Set up an audit logger, or edit an existing one')
.setDefaultMemberPermissions('0'),
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const channel = interaction.channelId;
if (!channel) return;
const log = await db<AuditLog>('auditLogs')
.select('eventTypes')
.where('channel', channel)
.first();
let types = log ? log.eventTypes.split(',') : [];
const options = Object.values(EventType)
.map(event => ({
label: event,
value: event,
}));
const chunked = [...chunks(options, 25)];
const selectRows =
chunked.map(
(opt, i) => new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder()
.addOptions(
...opt
)
.setMinValues(0)
.setMaxValues(opt.length)
.setCustomId(`auditlog-select-events-${i}`)
)
);
const components = [
...selectRows,
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('auditlog-select-events-done')
.setLabel('Done')
.setStyle(ButtonStyle.Primary)
.setDisabled(true)
),
];
const formatContent = () =>
`${log ? `Editing audit log in <#${channel}>` : `Creating audit log in <#${channel}>`}\n` +
'Select the types of events to be reported on' + ((types.length > 0) ? `\n**Current events**: ${types.map(s => '`' + s + '`').join(', ')}` : '');
const msg = await interaction.reply({
ephemeral: true,
content: formatContent(),
components,
});
const selectCollector = msg.createMessageComponentCollector({
componentType: ComponentType.StringSelect,
time: 60_000 * 5,
});
selectCollector.on('collect', async selectInteraction => {
const possibleTypes = selectInteraction.component.options.map(opt => opt.value);
const selectedTypes = selectInteraction.values;
types = types.filter(t => !possibleTypes.includes(t));
types = [...types, ...selectedTypes];
components[components.length - 1].components[0].setDisabled(types.length === 0);
await selectInteraction.reply({
content: 'Hit "Done" when finished',
ephemeral: true,
});
await msg.edit({
content: formatContent(),
components,
});
});
selectCollector.on('end', async () => {
await msg.edit({
content: formatContent(),
components: [],
});
});
const buttonInteraction = await msg.awaitMessageComponent({ componentType: ComponentType.Button, time: 60_000 * 5 });
selectCollector.stop();
if (log) {
await db<AuditLog>('auditLogs')
.where('channel', channel)
.update({
eventTypes: types.join(','),
});
} else {
await db<AuditLog>('auditLogs')
.insert({
guild: interaction.guildId!,
channel: channel,
eventTypes: types.join(','),
});
}
await buttonInteraction.reply({
content: 'Audit log successfully created.',
components: [],
ephemeral: true,
});
}
} satisfies Command;

View File

@ -10,6 +10,7 @@ import { Command } from './types/index';
import { startServer } from './web/web'; import { startServer } from './web/web';
import { init as initPVP } from './lib/rpg/pvp'; import { init as initPVP } from './lib/rpg/pvp';
import { autocomplete } from './lib/autocomplete'; import { autocomplete } from './lib/autocomplete';
import { setupListeners } from './lib/events';
const bot = new Client({ const bot = new Client({
intents: [ intents: [
@ -69,6 +70,8 @@ bot.on(Events.ClientReady, async () => {
log.nonsense(`| ${prettyBytes(memory.rss)} memory usage, ${prettyBytes(memory.heapUsed)} / ${prettyBytes(memory.heapTotal)} heap usage`); log.nonsense(`| ${prettyBytes(memory.rss)} memory usage, ${prettyBytes(memory.heapUsed)} / ${prettyBytes(memory.heapTotal)} heap usage`);
}); });
setupListeners(bot);
function stringifyArg(arg: CommandInteractionOption): string { function stringifyArg(arg: CommandInteractionOption): string {
switch (arg.type) { switch (arg.type) {
case ApplicationCommandOptionType.Boolean: case ApplicationCommandOptionType.Boolean:

View File

@ -102,4 +102,10 @@ export interface RoleSeperator {
guild: string, guild: string,
role: string, role: string,
type: RoleSeperatorType, type: RoleSeperatorType,
}
export interface AuditLog {
guild: string,
channel: string,
// comma-seperated
eventTypes: string,
} }

View File

@ -1,158 +1,292 @@
import { Sticker, ThreadChannel, Message, User, Invite, GuildMember, GuildScheduledEvent, GuildEmoji, Channel } from 'discord.js'; import { Sticker, ThreadChannel, Message, User, Invite, GuildMember, GuildScheduledEvent, GuildEmoji, Client, Events, AuditLogEvent, ChannelType, PartialMessage, EmbedBuilder, MessageType } from 'discord.js';
import { AuditLog, db } from './db';
import * as log from './log';
import { formatMessageAsEmbed, getUploadLimitForGuild, shortenStr } from './util';
import { Change, diffWords } from 'diff';
export enum EventType { export enum EventType {
ChannelCreate, EmojiCreate = 'EMOJI_CREATE',
ChannelRename, EmojiRename = 'EMOJI_RENAME',
ChannelDelete, EmojiDelete = 'EMOJI_DELETE',
EmojiCreate, EventCreate = 'EVENT_CREATE',
EmojiRename, EventEdit = 'EVENT_EDIT',
EmojiDelete, EventDelete = 'EVENT_DELETE',
EventCreate, InviteCreate = 'INVITE_CREATE',
EventEdit, InviteDelete = 'INVITE_DELETE',
EventDelete,
InviteCreate, MemberBan = 'MEMBER_BAN',
InviteUpdate, MemberUnban = 'MEMBER_UNBAN',
InviteDelete, MemberKick = 'MEMBER_KICK',
MemberDisconnect = 'MEMBER_DISCONNECT',
MemberBan, MemberNickname = 'MEMBER_NICKNAME',
MemberUnban, MemberJoin = 'MEMBER_JOIN',
MemberKick, MemberLeave = 'MEMBER_LEAVE',
MemberDisconnect,
MemberNickname,
MemberChangeRoles,
MessageDelete, MessageDelete = 'MESSAGE_DELETE',
MessageEdit, MessageEdit = 'MESSAGE_EDIT',
StickerCreate, StickerCreate = 'STICKER_CREATE',
StickerRename, StickerRename = 'STICKER_RENAME',
StickerDelete, StickerDelete = 'STICKER_DELETE',
ThreadCreate, ThreadCreate = 'THREAD_CREATE',
ThreadEdit, ThreadEdit = 'THREAD_EDIT',
ThreadDelete, ThreadDelete = 'THREAD_DELETE',
} }
export type Event = { export type Event = {
type: EventType.ChannelCreate,
causer: User,
channel: Channel,
} | {
type: EventType.ChannelRename,
causer: User,
channel: Channel,
oldName: string, newName: string,
} | {
type: EventType.ChannelDelete,
causer: User,
channel: Channel,
} | {
type: EventType.EmojiCreate, type: EventType.EmojiCreate,
causer: User, causer: User | null,
emoji: GuildEmoji, emoji: GuildEmoji,
} | { } | {
type: EventType.EmojiRename, type: EventType.EmojiRename,
causer: User, causer: User | null,
emoji: GuildEmoji, emoji: GuildEmoji,
oldName: string, newName: string, oldName: string, newName: string,
} | { } | {
type: EventType.EmojiDelete, type: EventType.EmojiDelete,
causer: User, causer: User | null,
emoji: GuildEmoji, emoji: GuildEmoji,
} | { } | {
type: EventType.EventCreate, type: EventType.EventCreate,
causer: User, causer: User | null,
event: GuildScheduledEvent, event: GuildScheduledEvent,
} | { } | {
type: EventType.EventEdit, type: EventType.EventEdit,
causer: User, causer: User | null,
oldEvent: GuildScheduledEvent, oldEvent: GuildScheduledEvent,
event: GuildScheduledEvent, event: GuildScheduledEvent,
} | { } | {
type: EventType.EventDelete, type: EventType.EventDelete,
causer: User, causer: User | null,
event: GuildScheduledEvent, event: GuildScheduledEvent,
} | { } | {
type: EventType.InviteCreate, type: EventType.InviteCreate,
causer: User, causer: User | null,
invite: Invite,
} | {
type: EventType.InviteUpdate,
causer: User,
oldInvite: Invite,
invite: Invite, invite: Invite,
} | { } | {
type: EventType.InviteDelete, type: EventType.InviteDelete,
causer: User, causer: User | null,
invite: Invite, invite: Invite,
} | { } | {
type: EventType.MemberBan, type: EventType.MemberBan,
causer: User, causer: User | null,
member: GuildMember, member: GuildMember,
reason: string, reason: string,
} | { } | {
type: EventType.MemberUnban, type: EventType.MemberUnban,
causer: User, causer: User | null,
member: GuildMember, member: GuildMember,
reason: string, reason: string,
} | { } | {
type: EventType.MemberKick, type: EventType.MemberKick,
causer: User, causer: User | null,
member: GuildMember, member: GuildMember,
reason: string, reason: string,
} | { } | {
type: EventType.MemberDisconnect, type: EventType.MemberDisconnect,
causer: User, causer: User | null,
member: GuildMember, member: GuildMember,
} | { } | {
type: EventType.MemberNickname, type: EventType.MemberNickname,
causer: User, causer: User | null,
member: GuildMember, member: GuildMember,
oldNickname: string, newNickname: string, oldNickname: string, newNickname: string,
} | {
type: EventType.MemberChangeRoles,
causer: User,
member: GuildMember,
// TODO: huh
} | { } | {
type: EventType.MessageDelete, type: EventType.MessageDelete,
causer: User, causer: User | null,
message: Message, message: Message | PartialMessage,
} | { } | {
type: EventType.MessageEdit, type: EventType.MessageEdit,
causer: User, causer: User | null,
oldMessage: Message, oldMessage: Message,
message: Message, message: Message,
} | { } | {
type: EventType.StickerCreate, type: EventType.StickerCreate,
causer: User, causer: User | null,
sticker: Sticker, sticker: Sticker,
} | { } | {
type: EventType.StickerRename, type: EventType.StickerRename,
causer: User, causer: User | null,
oldName: string, newName: string, oldName: string, newName: string,
} | { } | {
type: EventType.StickerDelete, type: EventType.StickerDelete,
causer: User, causer: User | null,
sticker: Sticker, sticker: Sticker,
} | { } | {
type: EventType.ThreadCreate, type: EventType.ThreadCreate,
causer: User, causer: User | null,
thread: ThreadChannel, thread: ThreadChannel,
} | { } | {
type: EventType.ThreadEdit, type: EventType.ThreadEdit,
causer: User, causer: User | null,
oldThread: ThreadChannel, oldThread: ThreadChannel,
thread: ThreadChannel, thread: ThreadChannel,
} | { } | {
type: EventType.ThreadDelete, type: EventType.ThreadDelete,
causer: User, causer: User | null,
thread: ThreadChannel, thread: ThreadChannel,
}; };
export async function triggerEvent(event: Event) { export async function triggerEvent(bot: Client, guildId: string, event: Event) {
const type = event.type;
log.info(`got event ${event.type}`);
const logs = await db<AuditLog>('auditLogs')
.select('guild', 'channel')
.where('guild', guildId)
// @ts-expect-error this LITERALLY works
.whereLike(db.raw('UPPER(eventTypes)'), `%${type.toUpperCase()}%`);
for (const auditLog of logs) {
let channel;
try {
channel = await bot.channels.fetch(auditLog.channel);
} catch (err) {
log.warn(err);
}
if (channel && (channel.type === ChannelType.GuildText)) {
switch(event.type) {
case EventType.MessageDelete: {
const limit = getUploadLimitForGuild(channel.guild);
const { embed, attach } = formatMessageAsEmbed(event.message, limit);
const logEmbed = new EmbedBuilder()
.setDescription(`Message sent by ${event.message.author} in <#${event.message.channelId}> deleted by **${event.causer}**:`)
.setTimestamp();
channel.send({
embeds: [ logEmbed, embed ],
files: attach ? [{
attachment: attach.url,
name: attach.name,
description: attach.url,
}] : [],
});
break;
}
case EventType.MessageEdit: {
const logEmbed = new EmbedBuilder()
.setDescription(`Message sent by ${event.message.author} in <#${event.message.channelId}> edited:`)
.setTimestamp();
const diff = diffWords(event.oldMessage.content.replace(/`/g, ''), event.message.content.replace(/`/g, ''));
let output = '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff.forEach((part: Change) => {
if (part.added) output += `\x1B[2;32m\x1B[1;32m${part.value}\x1B[0m`;
if (part.removed) output += `\x1B[2;41m\x1B[1;2m${part.value}\x1B[0m`;
if (!part.added && !part.removed) output += part.value;
});
const diffMsg = `\`\`\`ansi\n${shortenStr(output, 4000)}\`\`\``;
const editEmbed = new EmbedBuilder()
.setAuthor({ name: event.message.author.tag, iconURL: event.message.author.displayAvatarURL() })
.setDescription(diffMsg);
channel.send({
embeds: [ logEmbed, editEmbed ],
});
break;
}
case EventType.InviteCreate: {
const logEmbed = new EmbedBuilder()
.setDescription(`Invite ${event.invite.code} created by ${event.causer} in <#${event.invite.channelId}>`)
.setTimestamp();
channel.send({
embeds: [ logEmbed ],
});
break;
}
case EventType.InviteDelete: {
const logEmbed = new EmbedBuilder()
.setDescription(`Invite ${event.invite.code} made by ${event.invite.inviter} in <#${event.invite.channelId}> deleted by ${event.causer}`)
.setTimestamp();
channel.send({
embeds: [ logEmbed ],
});
break;
}
}
} else {
log.warn(`Channel ${auditLog.channel} from guild ${auditLog.guild} not found! Deleting audit log`);
await db<AuditLog>('auditLogs')
.where('guild', auditLog.guild)
.where('channel', auditLog.channel)
.delete();
}
}
}
const DELETE_FETCH_TIMEOUT = 2_000;
export function setupListeners(bot: Client) {
bot.on(Events.MessageDelete, async message => {
if (!message.guild) return;
if (!(message.type === MessageType.Default || message.type === MessageType.Reply || message.type === MessageType.ThreadStarterMessage)) return;
let causer: User | null = message.author;
const audit = await message.guild.fetchAuditLogs({
type: AuditLogEvent.MessageDelete,
limit: 1,
});
const entry = audit.entries.first();
if (entry && ((Date.now() - entry.createdTimestamp) < DELETE_FETCH_TIMEOUT || (entry.extra.count > 1)) && entry.targetId === message.author?.id) {
causer = entry.executor;
}
triggerEvent(bot, message.guild.id, {
type: EventType.MessageDelete,
causer, message,
});
});
bot.on(Events.MessageUpdate, async (old, message) => {
if (!message.guild) return;
if (message.content === old.content) return;
if (message.partial || old.partial) return;
triggerEvent(bot, message.guild.id, {
type: EventType.MessageEdit,
causer: message.author,
oldMessage: old, message: message,
});
});
bot.on(Events.InviteCreate, async invite => {
if (!invite.guild || !invite.inviter) return;
triggerEvent(bot, invite.guild.id, {
type: EventType.InviteCreate,
causer: invite.inviter, invite,
});
});
bot.on(Events.GuildAuditLogEntryCreate, async (entry, guild) => {
if (entry.action === AuditLogEvent.InviteDelete) {
if (!entry.target) return;
triggerEvent(bot, guild.id, {
type: EventType.InviteDelete,
causer: entry.executor,
// @ts-expect-error shut up
invite: entry.target
});
} else if (entry.action === AuditLogEvent.EmojiCreate) {
}
});
} }

View File

@ -1,5 +1,6 @@
import { Interaction, Message, TextBasedChannel, User } from 'discord.js'; import { Interaction, Message, TextBasedChannel, User } from 'discord.js';
import * as log from '../lib/log'; import * as log from '../lib/log';
import { chunks } from './util';
export const RANDOM_WORDS = [ export const RANDOM_WORDS = [
'tarsorado', 'aboba', 'radiation', 'extreme', 'glogging', 'glogged', 'penis', 'easy', 'glue', 'contaminated water', 'tarsorado', 'aboba', 'radiation', 'extreme', 'glogging', 'glogged', 'penis', 'easy', 'glue', 'contaminated water',
@ -140,12 +141,6 @@ export async function getTextResponsePrettyPlease(user: User, prompt: string, fi
return randomWord(); return randomWord();
} }
function* chunks(arr: unknown[], n: number) {
for (let i = 0; i < arr.length; i += n) {
yield arr.slice(i, i + n);
}
}
export async function sendSegments(segments: string[], channel: TextBasedChannel) { export async function sendSegments(segments: string[], channel: TextBasedChannel) {
const content = []; const content = [];
let contentBuffer = ''; let contentBuffer = '';

View File

@ -2,6 +2,7 @@ import * as fsp from 'fs/promises';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { Attachment, EmbedBuilder, Guild, GuildPremiumTier, Message, PartialMessage } from 'discord.js';
export async function exists(file: string) { export async function exists(file: string) {
try { try {
@ -41,3 +42,91 @@ export class Right<R> {
constructor(private readonly value: R) {} constructor(private readonly value: R) {}
public getValue() { return this.value; } public getValue() { return this.value; }
} }
export function* chunks<T>(arr: T[], n: number) {
for (let i = 0; i < arr.length; i += n) {
yield arr.slice(i, i + n);
}
}
export function shortenStr(str: string, chars: number) {
if (str.length > chars)
return str.slice(0, chars - 1) + '…';
return str;
}
export const MAX_MESSAGE_SIZE = 2000;
export const defaultUploadLimit = 25 * 1024 * 1024;
export const getUploadLimitForGuild = (guild: Guild) => {
switch (guild.premiumTier) {
case GuildPremiumTier.Tier3: return 100 * 1024 * 1024;
case GuildPremiumTier.Tier2: return 50 * 1024 * 1024;
default: return defaultUploadLimit;
}
};
export function formatMessageAsEmbed(message: Message | PartialMessage, uploadLimit: number): { embed: EmbedBuilder, attach: Attachment | null } {
const embed = new EmbedBuilder()
.setTimestamp(message.createdTimestamp);
if (message.author) {
embed.setAuthor({
name: message.author.tag,
iconURL: message.author.displayAvatarURL(),
});
}
let attach = null;
message.embeds.forEach(em => {
if (em.image) {
embed.setImage(em.image.url);
} else if (em.thumbnail) {
embed.setImage(em.thumbnail.url);
}
if (em.provider) {
embed.addFields({
name: shortenStr(`${em.provider.name}:`, 256),
value: shortenStr(`${em.author}: **${em.title}**\n${em.description || ''}`, 2048),
});
} else if (!em.image) {
embed.addFields({
name: 'Embed:',
value: shortenStr(em.description || em.fields[0]?.value || em.title || 'Unknown content', 2048),
});
}
});
let msgEnd = null;
const first = message.attachments.first();
if (
message.attachments.size === 1 && first && first.contentType
&& !first.spoiler
&& (first.contentType?.startsWith('image/')
|| first.contentType?.startsWith('video/'))
&& (first.size <= uploadLimit)
) {
attach = first;
} else {
for (const attach of message.attachments.values()) {
msgEnd = (msgEnd || '\n') + `\n[${attach.name}](${attach.url})`;
}
}
if (message.content) {
if (!msgEnd) {
embed.setDescription(shortenStr(message.content, 4096));
} else {
embed.setDescription(shortenStr(message.content, 4096 - msgEnd.length) + msgEnd);
}
} else if (msgEnd) {
embed.setDescription('_Content unknown_' + msgEnd);
}
return { embed, attach };
}