460 lines
17 KiB
TypeScript
460 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 (!Object.keys(COMMANDS).includes(args[0]?.toLowerCase())) return;
|
|
|
|
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 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 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);
|
|
}
|
|
});
|