kibby/src/index.ts
2023-03-20 21:30:58 +01:00

462 lines
17 KiB
TypeScript

import { Channel, Client, Member, Message, User } from "revolt.js";
import { config } from 'dotenv';
import { SendableEmbed } from 'revolt-api';
import { Low, JSONFile } from 'lowdb';
import { decodeTime } from 'ulid';
config();
type Db = {
probation: string[];
blocked: string[];
}
const DB_FILE = process.env.DB_FILE || './db.json';
const RE_USER_MENTION = /^<@[0-9A-HJ-KM-NP-TV-Z]{26}>$/i;
const PUBLIC_COMMANDS = ['suicide', 'status', 'help'];
const COMMANDS = {
'help': 'List available commands',
'approve': 'Release users from probation',
'unapprove': 'Send users to probation',
'status': 'Edit the bot\'s status',
'suicide': 'This will make you commit suicide',
'block': 'Troll a user',
'unblock': 'Untroll a user',
}
if (!process.env.TOKEN) throw "$TOKEN not set";
if (!process.env.SERVER) throw "$SERVER not set";
if (!process.env.ROLE) throw "$ROLE not set";
if (!process.env.LOGS) throw "$LOGS not set";
const db = new Low<Db>(new JSONFile(DB_FILE));
const client = new Client({ });
client.loginBot(process.env.TOKEN);
db.read().then(() => {
db.data ||= { probation: [], blocked: [] };
db.data.probation ||= [];
db.data.blocked ||= [];
db.write();
});
client.once('ready', async () => {
console.log('Ready');
const server = await client.servers.get(process.env.SERVER!);
await server?.fetchMembers();
console.log('Got server members');
});
function embed(content: string, title?: string, type?: 'INFO'|'SUCCESS'|'WARN'|'ERROR'): SendableEmbed {
const colors = {
'SUCCESS': 'var(--success)',
'INFO': 'var(--status-streaming)',
'WARN': 'var(--warning)',
'ERROR': 'var(--error)',
}
return {
colour: colors[type || 'INFO'],
description: content,
title: title,
}
}
async function fetchMessage(id: string, channel: Channel): Promise<Message> {
if (client.messages.get(id)) return client.messages.get(id)!;
return await channel.fetchMessage(id);
}
async function extractUsers(message: Message, args: string[]): Promise<User[]|undefined> {
const users: User[] = [];
if (!args.length) {
if (!message.reply_ids) {
await message.reply({
embeds: [embed('You need to either reply to a message or provide an user ID.', 'No users provided', 'WARN')]
});
return;
}
let messages = await Promise.all(message.reply_ids?.map(id => fetchMessage(id, message.channel!)));
for (const m of messages) if (m.author) users.push(m.author);
} else {
const parsed = await Promise.allSettled(args.map(u => parseUser(u)));
const failed = parsed.filter(p => p.status == "rejected" || !p.value);
if (failed.length) {
await message.reply({ embeds: [
embed(
`One or more of the provided users could not be found:\n- ${
failed.map(p => args[parsed.indexOf(p)]).join('\n- ')
}`,
"Failed to parse users",
"WARN",
)
] })
return;
}
parsed.forEach(p => p.status == "fulfilled" && users.push(p.value!));
}
return users;
}
const getDmChannel = async (user: string | { _id: string } | User) => {
if (typeof user == "string")
user = client.users.get(user) || (await client.users.fetch(user));
if (!(user instanceof User))
user =
client.users.get(user._id) || (await client.users.fetch(user._id));
return (
Array.from(client.channels).find(
(c) =>
c[1].channel_type == "DirectMessage" &&
c[1].recipient?._id == (user as User)._id
)?.[1] || (await (user as User).openDM())
);
};
/**
* Parses user input and returns an user object.
* Supports: `userID`, `<@userID>` (mention), `username`, `@username` (if user is cached).
* @param text
* @returns null if not found, otherwise user object
*/
async function parseUser(text: string): Promise<User | null> {
if (!text) return null;
let uid: string | null = null;
if (RE_USER_MENTION.test(text)) {
uid = text.replace(/<@|>/g, "").toUpperCase();
} else if (/^[0-9A-HJ-KM-NP-TV-Z]{26}$/gi.test(text)) {
uid = text.toUpperCase();
} else {
if (text.startsWith("@")) text = text.substring(1);
// Why is there no .find() or .filter()
let user: User | null = null;
client.users.forEach((u) => {
if (u.username?.toLowerCase() == text.toLowerCase()) {
user = u;
}
});
if (user) return user;
}
try {
if (uid) return (await client.users.fetch(uid)) || null;
else return null;
} catch (e) {
return null;
}
}
// Get all cached members of a server. Whoever put STRINGIFIED JSON as map keys is now on my hit list.
const getMembers = (id: string) =>
Array.from(client.members.entries())
.filter((item) => item[0].includes(`"${id}"`))
.map((entry) => entry[1]);
// Call db.write() afterwards
const setProbation = async (member: Member, probation: boolean) => {
const logs = client.channels.get(process.env.LOGS!);
if (probation) {
if (!member?.roles?.includes(process.env.ROLE!)) {
await member?.edit({ roles: [...(member.roles || []), process.env.ROLE!] });
}
if (!db.data?.probation.includes(member!._id.user)) db.data?.probation.push(member!._id.user);
await logs?.sendMessage({
content: `$%log action=probation_add user=${member._id.user}%$`,
embeds: [
embed(
`User @${member.user?.username} (<@${member._id.user}> ${member._id.user}) has been put on probation. Press ↩️ to undo.`,
'Probation added',
'INFO',
),
],
interactions: {
restrict_reactions: true,
reactions: [ '↩️' ]
},
});
} else {
if (member?.roles?.includes(process.env.ROLE!)) {
await member?.edit({ roles: member.roles.filter(r => r != process.env.ROLE) });
}
if (db.data?.probation.includes(member!._id.user)) db.data.probation = db.data.probation.filter(id => id != member?._id.user);
await logs?.sendMessage({
content: `$%log action=probation_remove user=${member._id.user}%$`,
embeds: [
embed(
`User @${member.user?.username} (<@${member._id.user}> ${member._id.user}) has been removed from probation. Press ↩️ to undo.`,
'Probation removed',
'INFO',
),
],
interactions: {
restrict_reactions: true,
reactions: [ '↩️' ]
},
});
}
}
client.on('message', async (message) => {
try {
if (!message.content || typeof message.content != 'string') return;
const args = message.content.replace(/ +/g, ' ').split(' ');
if (args.shift()?.toLowerCase() != '/kibby') return;
const logs = client.channels.get(process.env.LOGS!);
const privileged = message.member?.hasPermission(message.channel?.server!, 'ManageMessages');
if (!privileged && db.data?.blocked.includes(message.author_id)) {
console.log('Ignoring nerd');
try {
await message.react('01G7PX5GVMPQD35FQE15H2T08S');
} catch(e) { console.error(e) }
return;
}
if (!privileged && db.data?.probation.includes(message.author_id)) {
console.log('Ignoring user on probation');
return;
}
if (!PUBLIC_COMMANDS.includes(args[0]?.toLowerCase()) && !privileged) {
console.log('User has no permission');
return;
}
console.log('Got command: ' + args.join(' '));
if (message.channel?.server_id != process.env.SERVER) return console.log('Command received in wrong server');
switch(args.shift()?.toLowerCase()) {
case 'approve': {
const users = await extractUsers(message, args);
if (!users?.length) return console.log('No users received');
const members = getMembers(message.channel!.server_id);
for (const user of users) {
const member = members.find(m => m._id.user == user._id);
await setProbation(member!, false);
}
await db.write();
await message.reply({ embeds: [
embed(`Selected user${users.length != 1 ? 's have' : ' has'} been approved.`, undefined, "SUCCESS"),
] });
break;
}
case 'unapprove':
case 'probation':
case 'detention': {
const users = await extractUsers(message, args);
if (!users?.length) return console.log('No users received');
const members = getMembers(message.channel!.server_id);
for (const user of users) {
const member = members.find(m => m._id.user == user._id);
await setProbation(member!, true);
}
await db.write();
await message.reply({ embeds: [
embed(`Selected user${users.length != 1 ? 's have' : ' has'} been sent to probation.`, undefined, "SUCCESS"),
] });
break;
}
case 'status': {
let status = args.join(' ');
if (!status.length) {
await client.users.edit({
remove: ['StatusText']
});
await logs?.sendMessage({ embeds: [
embed(`<@${message.author_id}> (${message.author_id}) cleared the status`, 'Status cleared by user', 'INFO'),
] });
await message.reply({ embeds: [
embed('Status cleared!', undefined, 'SUCCESS'),
] });
}
else {
await client.users.edit({
status: {
...client.user?.status,
text: status,
},
});
await logs?.sendMessage({ embeds: [
embed(`Status set by <@${message.author_id}> (${message.author_id}):\n\n>${status.replace(/\n/g, '>')}`, 'Status changed by user', 'INFO'),
] });
await message.reply({ embeds: [
embed('Status set!', undefined, 'SUCCESS'),
] });
}
break;
}
case 'suicide': {
const target = message.member;
const roles = target?.roles?.filter(r => r != process.env.ROLE);
if (!target || roles?.length || !target.kickable) {
await message.reply({ embeds: [embed(`Don't give in to the voices, champ!`)] });
return;
}
const channel = await getDmChannel(target._id.user);
await channel.sendMessage('https://rvlt.gg/M8CxaQRK :01G7KWY7NS7KNG5BQ9N064YK23:');
await target.kick();
await message.reply('[](https://autumn.revolt.chat/attachments/KosQrwl31AMxA0nLtwL1DFIvvZiPE2pYtr7DwAGJNF/928247511842431016.gif)');
await logs?.sendMessage({ embeds: [
embed(`<@${message.author_id}> (${message.author_id}) committed suicide`, 'Self kick', 'INFO'),
] });
break;
}
case 'block': {
const users = await extractUsers(message, args);
if (!users?.length) return await message.reply('User(s) not found or no users provided');
for (const user of users) {
if (!db.data?.blocked.includes(user._id)) db.data?.blocked.push(user._id);
}
await db.write();
await message.reply({ embeds: [
embed("Users trolled successfully", undefined, "SUCCESS"),
] });
break;
}
case 'unblock': {
const users = await extractUsers(message, args);
if (!users?.length) return await message.reply('User(s) not found or no users provided');
for (const user of users) {
if (db.data?.blocked.includes(user._id)) db.data.blocked = db.data.blocked.filter(u => u != user._id);
}
await db.write();
await message.reply({ embeds: [
embed("Users untrolled successfully", undefined, "SUCCESS"),
] });
break;
}
case 'help': {
const commands = Object.entries(COMMANDS).filter(c => privileged || PUBLIC_COMMANDS.includes(c[0]));
return await message.reply({ embeds: [
embed("Kibby is this server's personal maid!"),
embed(`### Commands\n${commands.map(c => `- **/kibby ${c[0]}:** ${c[1]}`).join('\n')}`),
] });
}
default: break;
}
} catch(e) {
console.error(e);
message.reply({ embeds: [
embed(`\`\`\`js\n${e}\n\`\`\``, 'Aw, balls! Something broke!', 'ERROR'),
] })?.catch(e => console.error(e))
}
});
client.on('member/join', async (member) => {
const GAMER_WORDS = /nigg{er,a}|hitt?ler|trann(y|ie)|troon|faggot/i;
const CHILD_WORDS = /roblox|fran(ç|c)ais/i;
console.log('User joined');
try {
if (db.data?.probation.includes(member._id.user)) {
console.log('User on probation joined back');
await setProbation(member, true);
} else {
const profile = await member.user?.fetchProfile();
// Age in hours
const age = (Date.now() - decodeTime(member._id.user)) / 1000 / 60 / 60;
console.log('Account age in hours: ' + age)
if (GAMER_WORDS.test(profile?.content ?? '') || GAMER_WORDS.test(member.user?.username ?? '')) {
await setProbation(member, true);
}
else if (CHILD_WORDS.test(profile?.content ?? '') || CHILD_WORDS.test(member.user?.username ?? '')) {
if (age < 48) await setProbation(member, true);
}
else if (!profile?.background && !member.user?.avatar) {
if (age < 2) await setProbation(member, true);
}
else {
// if (age < 1) await setProbation(member, true);
}
}
} catch(e) {
console.error(e);
}
});
client.on('packet', async (packet) => {
if (packet.type != 'MessageReact') return;
if (packet.emoji_id != '↩️') return;
try {
const channel = client.channels.get(packet.channel_id);
const message = client.messages.get(packet.id) || await channel!.fetchMessage(packet.id);
if (!message || message.author_id != client.user?._id) return console.log('Ignoring react: Author mismatch');
const members = getMembers(channel!.server_id!);
const member = members.find(m => m._id.user == packet.user_id);
if (!member) return console.log('Ignoring react: Could not find reacting user');
const privileged = member.hasPermission(channel!.server!, 'ManageMessages');
if (!privileged) return console.log('Ignoring react: User is unprivileged');
let info = message.content?.match(/^\$%log action=\S+ user=[A-Z0-9]+%\$$/)?.[0];
if (!info) return console.log('Ignoring react: Could not extract message metadata');
let action = info.match(/action=\S+/)?.[0].substring(7),
user = info.match(/user=[A-Z0-9]+/)?.[0].substring(5);
const target = members.find(m => m._id.user == user);
if (!target) return await channel?.sendMessage({ embeds: [
embed(`Failed to find user \`${user}\`.`, 'Message interaction failed', 'ERROR'),
] });
switch(action) {
case 'probation_add': {// Log message was for added probation, clicking button will remove it
await setProbation(target, false);
const embed = message.embeds?.[0] as SendableEmbed;
embed.description = embed.description?.replace(' Press ↩️ to undo.', '');
embed.colour = 'var(--status-invisible)';
await message.edit({ content: '#', embeds: [ embed ] });
break;
}
case 'probation_remove': {
await setProbation(target, true);
const embed = message.embeds?.[0] as SendableEmbed;
embed.description = embed.description?.replace(' Press ↩️ to undo.', '');
embed.colour = 'var(--status-invisible)';
await message.edit({ content: '#', embeds: [ embed ] });
break;
}
}
if (!target) return;
} catch(e) {
console.error(e);
}
});