3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-05-10 20:35:02 +00:00

Reorganize project. Add folder for shared code between backend/dashboard. Switch from jest to ava for tests.

This commit is contained in:
Dragory 2019-11-02 22:11:26 +02:00
parent 80a82fe348
commit 16111bbe84
162 changed files with 11056 additions and 9900 deletions

View file

@ -0,0 +1,45 @@
import { AllowedGuild } from "./entities/AllowedGuild";
import {
getConnection,
getRepository,
Repository,
Transaction,
TransactionManager,
TransactionRepository,
} from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { BaseRepository } from "./BaseRepository";
export class AllowedGuilds extends BaseRepository {
private allowedGuilds: Repository<AllowedGuild>;
constructor() {
super();
this.allowedGuilds = getRepository(AllowedGuild);
}
async isAllowed(guildId) {
const count = await this.allowedGuilds.count({
where: {
id: guildId,
},
});
return count !== 0;
}
getForApiUser(userId) {
return this.allowedGuilds
.createQueryBuilder("allowed_guilds")
.innerJoin(
"api_permissions",
"api_permissions",
"api_permissions.guild_id = allowed_guilds.id AND api_permissions.user_id = :userId",
{ userId },
)
.getMany();
}
updateInfo(id, name, icon, ownerId) {
return this.allowedGuilds.update({ id }, { name, icon, owner_id: ownerId });
}
}

View file

@ -0,0 +1,90 @@
import { getRepository, Repository } from "typeorm";
import { ApiLogin } from "./entities/ApiLogin";
import { BaseRepository } from "./BaseRepository";
import crypto from "crypto";
import moment from "moment-timezone";
// tslint:disable-next-line:no-submodule-imports
import uuidv4 from "uuid/v4";
import { DBDateFormat } from "../utils";
import { log } from "util";
export class ApiLogins extends BaseRepository {
private apiLogins: Repository<ApiLogin>;
constructor() {
super();
this.apiLogins = getRepository(ApiLogin);
}
async getUserIdByApiKey(apiKey: string): Promise<string | null> {
const [loginId, token] = apiKey.split(".");
if (!loginId || !token) {
return null;
}
const login = await this.apiLogins
.createQueryBuilder()
.where("id = :id", { id: loginId })
.andWhere("expires_at > NOW()")
.getOne();
if (!login) {
return null;
}
const hash = crypto.createHash("sha256");
hash.update(loginId + token); // Remember to use loginId as the salt
const hashedToken = hash.digest("hex");
if (hashedToken !== login.token) {
return null;
}
return login.user_id;
}
async addLogin(userId: string): Promise<string> {
// Generate random login id
let loginId;
while (true) {
loginId = uuidv4();
const existing = await this.apiLogins.findOne({
where: {
id: loginId,
},
});
if (!existing) break;
}
// Generate token
const token = uuidv4();
const hash = crypto.createHash("sha256");
hash.update(loginId + token); // Use loginId as a salt
const hashedToken = hash.digest("hex");
// Save this to the DB
await this.apiLogins.insert({
id: loginId,
token: hashedToken,
user_id: userId,
logged_in_at: moment().format(DBDateFormat),
expires_at: moment()
.add(1, "day")
.format(DBDateFormat),
});
return `${loginId}.${token}`;
}
expireApiKey(apiKey) {
const [loginId, token] = apiKey.split(".");
if (!loginId || !token) return;
return this.apiLogins.update(
{ id: loginId },
{
expires_at: moment().format(DBDateFormat),
},
);
}
}

View file

@ -0,0 +1,29 @@
import { getRepository, Repository } from "typeorm";
import { ApiPermission } from "./entities/ApiPermission";
import { BaseRepository } from "./BaseRepository";
export class ApiPermissions extends BaseRepository {
private apiPermissions: Repository<ApiPermission>;
constructor() {
super();
this.apiPermissions = getRepository(ApiPermission);
}
getByUserId(userId) {
return this.apiPermissions.find({
where: {
user_id: userId,
},
});
}
getByGuildAndUserId(guildId, userId) {
return this.apiPermissions.findOne({
where: {
guild_id: guildId,
user_id: userId,
},
});
}
}

View file

@ -0,0 +1,6 @@
export enum ApiRoles {
Viewer = 1,
Editor,
Manager,
ServerOwner,
}

View file

@ -0,0 +1,38 @@
import { getRepository, Repository } from "typeorm";
import { ApiUserInfo as ApiUserInfoEntity, ApiUserInfoData } from "./entities/ApiUserInfo";
import { BaseRepository } from "./BaseRepository";
import { connection } from "./db";
import moment from "moment-timezone";
import { DBDateFormat } from "../utils";
export class ApiUserInfo extends BaseRepository {
private apiUserInfo: Repository<ApiUserInfoEntity>;
constructor() {
super();
this.apiUserInfo = getRepository(ApiUserInfoEntity);
}
get(id) {
return this.apiUserInfo.findOne({
where: {
id,
},
});
}
update(id, data: ApiUserInfoData) {
return connection.transaction(async entityManager => {
const repo = entityManager.getRepository(ApiUserInfoEntity);
const existingInfo = await repo.findOne({ where: { id } });
const updatedAt = moment().format(DBDateFormat);
if (existingInfo) {
await repo.update({ id }, { data, updated_at: updatedAt });
} else {
await repo.insert({ id, data, updated_at: updatedAt });
}
});
}
}

View file

@ -0,0 +1,28 @@
import { BaseRepository } from "./BaseRepository";
export class BaseGuildRepository extends BaseRepository {
private static guildInstances: Map<string, any>;
protected guildId: string;
constructor(guildId: string) {
super();
this.guildId = guildId;
}
/**
* Returns a cached instance of the inheriting class for the specified guildId,
* or creates a new instance if one doesn't exist yet
*/
public static getGuildInstance<T extends typeof BaseGuildRepository>(this: T, guildId: string): InstanceType<T> {
if (!this.guildInstances) {
this.guildInstances = new Map();
}
if (!this.guildInstances.has(guildId)) {
this.guildInstances.set(guildId, new this(guildId));
}
return this.guildInstances.get(guildId) as InstanceType<T>;
}
}

View file

@ -0,0 +1,30 @@
export class BaseRepository {
private nextRelations: string[];
constructor() {
this.nextRelations = [];
}
/**
* Primes the specified relation(s) to be used in the next database operation.
* Can be chained.
*/
public with(relations: string | string[]): this {
if (Array.isArray(relations)) {
this.nextRelations.push(...relations);
} else {
this.nextRelations.push(relations);
}
return this;
}
/**
* Gets and resets the relations primed using with()
*/
protected getRelations(): string[] {
const relations = this.nextRelations || [];
this.nextRelations = [];
return relations;
}
}

View file

@ -0,0 +1,12 @@
import { CaseTypes } from "./CaseTypes";
export const CaseTypeColors = {
[CaseTypes.Note]: 0x3498db,
[CaseTypes.Warn]: 0xdae622,
[CaseTypes.Mute]: 0xe6b122,
[CaseTypes.Unmute]: 0xa175b3,
[CaseTypes.Kick]: 0xe67e22,
[CaseTypes.Softban]: 0xe67e22,
[CaseTypes.Ban]: 0xcb4314,
[CaseTypes.Unban]: 0x9b59b6,
};

View file

@ -0,0 +1,11 @@
export enum CaseTypes {
Ban = 1,
Unban,
Note,
Warn,
Kick,
Mute,
Unmute,
Expunged,
Softban,
}

View file

@ -0,0 +1,74 @@
import { Config } from "./entities/Config";
import {
getConnection,
getRepository,
Repository,
Transaction,
TransactionManager,
TransactionRepository,
} from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { connection } from "./db";
import { BaseRepository } from "./BaseRepository";
export class Configs extends BaseRepository {
private configs: Repository<Config>;
constructor() {
super();
this.configs = getRepository(Config);
}
getActiveByKey(key) {
return this.configs.findOne({
where: {
key,
is_active: true,
},
});
}
async getHighestId(): Promise<number> {
const rows = await connection.query("SELECT MAX(id) AS highest_id FROM configs");
return (rows.length && rows[0].highest_id) || 0;
}
getActiveLargerThanId(id) {
return this.configs
.createQueryBuilder()
.where("id > :id", { id })
.andWhere("is_active = 1")
.getMany();
}
async hasConfig(key) {
return (await this.getActiveByKey(key)) != null;
}
getRevisions(key, num = 10) {
return this.configs.find({
relations: this.getRelations(),
where: { key },
select: ["id", "key", "is_active", "edited_by", "edited_at"],
order: {
edited_at: "DESC",
},
take: num,
});
}
async saveNewRevision(key, config, editedBy) {
return connection.transaction(async entityManager => {
const repo = entityManager.getRepository(Config);
// Mark all old revisions inactive
await repo.update({ key }, { is_active: false });
// Add new, active revision
await repo.insert({
key,
config,
is_active: true,
edited_by: editedBy,
});
});
}
}

View file

@ -0,0 +1,62 @@
{
"MEMBER_WARN": "⚠️ {userMention(member)} was warned by {userMention(mod)}",
"MEMBER_MUTE": "🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}",
"MEMBER_TIMED_MUTE": "🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}",
"MEMBER_UNMUTE": "🔊 {userMention(user)} was unmuted by {userMention(mod)}",
"MEMBER_TIMED_UNMUTE": "🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}",
"MEMBER_MUTE_EXPIRED": "🔊 {userMention(member)}'s mute expired",
"MEMBER_KICK": "👢 {userMention(user)} was kicked by {userMention(mod)}",
"MEMBER_BAN": "🔨 {userMention(user)} was banned by {userMention(mod)}",
"MEMBER_UNBAN": "🔓 User (`{userId}`) was unbanned by {userMention(mod)}",
"MEMBER_FORCEBAN": "🔨 User (`{userId}`) was forcebanned by {userMention(mod)}",
"MEMBER_SOFTBAN": "🔨 {userMention(member)} was softbanned by {userMention(mod)}",
"MEMBER_JOIN": "📥 {userMention(member)} joined{new} (created {account_age} ago)",
"MEMBER_LEAVE": "📤 {userMention(member)} left the server",
"MEMBER_ROLE_ADD": "🔑 {userMention(member)}: role(s) **{roles}** added by {userMention(mod)}",
"MEMBER_ROLE_REMOVE": "🔑 {userMention(member)}: role(s) **{roles}** removed by {userMention(mod)}",
"MEMBER_ROLE_CHANGES": "🔑 {userMention(member)}: roles changed: added **{addedRoles}**, removed **{removedRoles}** by {userMention(mod)}",
"MEMBER_NICK_CHANGE": "✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**",
"MEMBER_USERNAME_CHANGE": "✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**",
"MEMBER_RESTORE": "💿 Restored {restoredData} for {userMention(member)} on rejoin",
"CHANNEL_CREATE": "🖊 Channel {channelMention(channel)} was created",
"CHANNEL_DELETE": "🗑 Channel {channelMention(channel)} was deleted",
"CHANNEL_EDIT": "✏ Channel {channelMention(channel)} was edited",
"ROLE_CREATE": "🖊 Role **{role.name}** (`{role.id}`) was created",
"ROLE_DELETE": "🖊 Role **{role.name}** (`{role.id}`) was deleted",
"ROLE_EDIT": "🖊 Role **{role.name}** (`{role.id}`) was edited",
"MESSAGE_EDIT": "✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}",
"MESSAGE_DELETE": "🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}",
"MESSAGE_DELETE_BULK": "🗑 **{count}** messages deleted in {channelMention(channel)} ({archiveUrl})",
"MESSAGE_DELETE_BARE": "🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)",
"VOICE_CHANNEL_JOIN": "🎙 🔵 {userMention(member)} joined **{channel.name}**",
"VOICE_CHANNEL_MOVE": "🎙 ↔ {userMention(member)} moved from **{oldChannel.name}** to **{newChannel.name}**",
"VOICE_CHANNEL_LEAVE": "🎙 🔴 {userMention(member)} left **{channel.name}**",
"VOICE_CHANNEL_FORCE_MOVE": "\uD83C\uDF99 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}",
"COMMAND": "🤖 {userMention(member)} used command in {channelMention(channel)}:\n`{command}`",
"MESSAGE_SPAM_DETECTED": "🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\n{archiveUrl}",
"OTHER_SPAM_DETECTED": "🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)",
"CENSOR": "🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\n```{messageText}```",
"CLEAN": "🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\n{archiveUrl}",
"CASE_CREATE": "✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})",
"MASSBAN": "⚒ {userMention(mod)} massbanned {count} users",
"MEMBER_JOIN_WITH_PRIOR_RECORDS": "⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}",
"CASE_UPDATE": "✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\n```{note}```",
"MEMBER_MUTE_REJOIN": "⚠ Reapplied active mute for {userMention(member)} on rejoin",
"SCHEDULED_MESSAGE": "⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {date} at {time} (UTC)",
"POSTED_SCHEDULED_MESSAGE": "\uD83D\uDCE8 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}",
"BOT_ALERT": "⚠ {tmplEval(body)}",
"AUTOMOD_ACTION": "\uD83E\uDD16 Automod rule **{rule}** triggered by {userMention(user)}. Actions taken: **{actionsTaken}**\n{matchSummary}"
}

View file

@ -0,0 +1,100 @@
import uuid from "uuid/v4"; // tslint:disable-line
import moment from "moment-timezone";
import { ArchiveEntry } from "./entities/ArchiveEntry";
import { getRepository, Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { trimLines } from "../utils";
import { SavedMessage } from "./entities/SavedMessage";
import { Channel, Guild, User } from "eris";
import { renderTemplate } from "../templateFormatter";
const DEFAULT_EXPIRY_DAYS = 30;
const MESSAGE_ARCHIVE_HEADER_FORMAT = trimLines(`
Server: {guild.name} ({guild.id})
`);
const MESSAGE_ARCHIVE_MESSAGE_FORMAT =
"[#{channel.name}] [{user.id}] [{timestamp}] {user.username}#{user.discriminator}: {content}{attachments}";
export class GuildArchives extends BaseGuildRepository {
protected archives: Repository<ArchiveEntry>;
constructor(guildId) {
super(guildId);
this.archives = getRepository(ArchiveEntry);
// Clean expired archives at start and then every hour
this.deleteExpiredArchives();
setInterval(() => this.deleteExpiredArchives(), 1000 * 60 * 60);
}
private deleteExpiredArchives() {
this.archives
.createQueryBuilder()
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("expires_at IS NOT NULL")
.andWhere("expires_at <= NOW()")
.delete()
.execute();
}
async find(id: string): Promise<ArchiveEntry> {
return this.archives.findOne({
where: { id },
relations: this.getRelations(),
});
}
async makePermanent(id: string): Promise<void> {
await this.archives.update(
{ id },
{
expires_at: null,
},
);
}
/**
* @returns ID of the created entry
*/
async create(body: string, expiresAt: moment.Moment = null): Promise<string> {
if (!expiresAt) {
expiresAt = moment().add(DEFAULT_EXPIRY_DAYS, "days");
}
const result = await this.archives.insert({
guild_id: this.guildId,
body,
expires_at: expiresAt.format("YYYY-MM-DD HH:mm:ss"),
});
return result.identifiers[0].id;
}
async createFromSavedMessages(savedMessages: SavedMessage[], guild: Guild, expiresAt = null) {
if (expiresAt == null) expiresAt = moment().add(DEFAULT_EXPIRY_DAYS, "days");
const headerStr = await renderTemplate(MESSAGE_ARCHIVE_HEADER_FORMAT, { guild });
const msgLines = [];
for (const msg of savedMessages) {
const channel = guild.channels.get(msg.channel_id);
const user = { ...msg.data.author, id: msg.user_id };
const line = await renderTemplate(MESSAGE_ARCHIVE_MESSAGE_FORMAT, {
id: msg.id,
timestamp: moment(msg.posted_at).format("YYYY-MM-DD HH:mm:ss"),
content: msg.data.content,
user,
channel,
});
msgLines.push(line);
}
const messagesStr = msgLines.join("\n");
return this.create([headerStr, messagesStr].join("\n\n"), expiresAt);
}
getUrl(baseUrl, archiveId) {
return baseUrl ? `${baseUrl}/archives/${archiveId}` : `Archive ID: ${archiveId}`;
}
}

View file

@ -0,0 +1,57 @@
import { BaseGuildRepository } from "./BaseGuildRepository";
import { getRepository, Repository } from "typeorm";
import { AutoReaction } from "./entities/AutoReaction";
export class GuildAutoReactions extends BaseGuildRepository {
private autoReactions: Repository<AutoReaction>;
constructor(guildId) {
super(guildId);
this.autoReactions = getRepository(AutoReaction);
}
async all(): Promise<AutoReaction[]> {
return this.autoReactions.find({
where: {
guild_id: this.guildId,
},
});
}
async getForChannel(channelId: string): Promise<AutoReaction> {
return this.autoReactions.findOne({
where: {
guild_id: this.guildId,
channel_id: channelId,
},
});
}
async removeFromChannel(channelId: string) {
await this.autoReactions.delete({
guild_id: this.guildId,
channel_id: channelId,
});
}
async set(channelId: string, reactions: string[]) {
const existingRecord = await this.getForChannel(channelId);
if (existingRecord) {
this.autoReactions.update(
{
guild_id: this.guildId,
channel_id: channelId,
},
{
reactions,
},
);
} else {
await this.autoReactions.insert({
guild_id: this.guildId,
channel_id: channelId,
reactions,
});
}
}
}

View file

@ -0,0 +1,155 @@
import { Case } from "./entities/Case";
import { CaseNote } from "./entities/CaseNote";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { getRepository, In, Repository } from "typeorm";
import { disableLinkPreviews } from "../utils";
import { CaseTypes } from "./CaseTypes";
import moment = require("moment-timezone");
const CASE_SUMMARY_REASON_MAX_LENGTH = 300;
export class GuildCases extends BaseGuildRepository {
private cases: Repository<Case>;
private caseNotes: Repository<CaseNote>;
constructor(guildId) {
super(guildId);
this.cases = getRepository(Case);
this.caseNotes = getRepository(CaseNote);
}
async get(ids: number[]): Promise<Case[]> {
return this.cases.find({
relations: this.getRelations(),
where: {
guild_id: this.guildId,
id: In(ids),
},
});
}
async find(id: number): Promise<Case> {
return this.cases.findOne({
relations: this.getRelations(),
where: {
guild_id: this.guildId,
id,
},
});
}
async findByCaseNumber(caseNumber: number): Promise<Case> {
return this.cases.findOne({
relations: this.getRelations(),
where: {
guild_id: this.guildId,
case_number: caseNumber,
},
});
}
async findLatestByModId(modId: string): Promise<Case> {
return this.cases.findOne({
relations: this.getRelations(),
where: {
guild_id: this.guildId,
mod_id: modId,
},
order: {
case_number: "DESC",
},
});
}
async findByAuditLogId(auditLogId: string): Promise<Case> {
return this.cases.findOne({
relations: this.getRelations(),
where: {
guild_id: this.guildId,
audit_log_id: auditLogId,
},
});
}
async getByUserId(userId: string): Promise<Case[]> {
return this.cases.find({
relations: this.getRelations(),
where: {
guild_id: this.guildId,
user_id: userId,
},
});
}
async getRecentByModId(modId: string, count: number): Promise<Case[]> {
return this.cases.find({
relations: this.getRelations(),
where: {
guild_id: this.guildId,
mod_id: modId,
is_hidden: 0,
},
take: count,
order: {
case_number: "DESC",
},
});
}
async setHidden(id: number, hidden: boolean): Promise<void> {
await this.cases.update(
{ id },
{
is_hidden: hidden,
},
);
}
async create(data): Promise<Case> {
const result = await this.cases.insert({
...data,
guild_id: this.guildId,
case_number: () => `(SELECT IFNULL(MAX(case_number)+1, 1) FROM cases AS ma2 WHERE guild_id = ${this.guildId})`,
});
return this.find(result.identifiers[0].id);
}
update(id, data) {
return this.cases.update(id, data);
}
async createNote(caseId: number, data: any): Promise<void> {
await this.caseNotes.insert({
...data,
case_id: caseId,
});
}
getSummaryText(theCase: Case) {
const firstNote = theCase.notes[0];
let reason = firstNote ? firstNote.body : "";
if (reason.length > CASE_SUMMARY_REASON_MAX_LENGTH) {
const match = reason.slice(CASE_SUMMARY_REASON_MAX_LENGTH, 100).match(/(?:[.,!?\s]|$)/);
const nextWhitespaceIndex = match ? CASE_SUMMARY_REASON_MAX_LENGTH + match.index : CASE_SUMMARY_REASON_MAX_LENGTH;
if (nextWhitespaceIndex < reason.length) {
reason = reason.slice(0, nextWhitespaceIndex - 1) + "...";
}
}
reason = disableLinkPreviews(reason);
const timestamp = moment(theCase.created_at).format("YYYY-MM-DD");
let line = `\`[${timestamp}]\` \`Case #${theCase.case_number}\` __${CaseTypes[theCase.type]}__ ${reason}`;
if (theCase.notes.length > 1) {
line += ` *(+${theCase.notes.length - 1} ${theCase.notes.length === 2 ? "note" : "notes"})*`;
}
if (theCase.is_hidden) {
line += " *(hidden)*";
}
return line;
}
}

View file

@ -0,0 +1,42 @@
import { BaseGuildRepository } from "./BaseGuildRepository";
import { QueuedEventEmitter } from "../QueuedEventEmitter";
export class GuildEvents extends BaseGuildRepository {
private queuedEventEmitter: QueuedEventEmitter;
private pluginListeners: Map<string, Map<string, any[]>>;
constructor(guildId) {
super(guildId);
this.queuedEventEmitter = new QueuedEventEmitter();
}
public on(pluginName: string, eventName: string, fn) {
this.queuedEventEmitter.on(eventName, fn);
if (!this.pluginListeners.has(pluginName)) {
this.pluginListeners.set(pluginName, new Map());
}
const pluginListeners = this.pluginListeners.get(pluginName);
if (!pluginListeners.has(eventName)) {
pluginListeners.set(eventName, []);
}
const pluginEventListeners = pluginListeners.get(eventName);
pluginEventListeners.push(fn);
}
public offPlugin(pluginName: string) {
const pluginListeners = this.pluginListeners.get(pluginName) || new Map();
for (const [eventName, listeners] of Array.from(pluginListeners.entries())) {
for (const listener of listeners) {
this.queuedEventEmitter.off(eventName, listener);
}
}
this.pluginListeners.delete(pluginName);
}
public emit(eventName: string, args: any[] = []) {
return this.queuedEventEmitter.emit(eventName, args);
}
}

View file

@ -0,0 +1,55 @@
import EventEmitter from "events";
import { LogType } from "./LogType";
// Use the same instance for the same guild, even if a new instance is created
const guildInstances: Map<string, GuildLogs> = new Map();
interface IIgnoredLog {
type: LogType;
ignoreId: any;
}
export class GuildLogs extends EventEmitter {
protected guildId: string;
protected ignoredLogs: IIgnoredLog[];
constructor(guildId) {
if (guildInstances.has(guildId)) {
// Return existing instance for this guild if one exists
return guildInstances.get(guildId);
}
super();
this.guildId = guildId;
this.ignoredLogs = [];
// Store the instance for this guild so it can be returned later if a new instance for this guild is requested
guildInstances.set(guildId, this);
}
log(type: LogType, data: any, ignoreId = null) {
if (ignoreId && this.isLogIgnored(type, ignoreId)) {
this.clearIgnoredLog(type, ignoreId);
return;
}
this.emit("log", { type, data });
}
ignoreLog(type: LogType, ignoreId: any, timeout: number = null) {
this.ignoredLogs.push({ type, ignoreId });
// Clear after expiry (15sec by default)
setTimeout(() => {
this.clearIgnoredLog(type, ignoreId);
}, timeout || 1000 * 15);
}
isLogIgnored(type: LogType, ignoreId: any) {
return this.ignoredLogs.some(info => type === info.type && ignoreId === info.ignoreId);
}
clearIgnoredLog(type: LogType, ignoreId: any) {
this.ignoredLogs.splice(this.ignoredLogs.findIndex(info => type === info.type && ignoreId === info.ignoreId), 1);
}
}

View file

@ -0,0 +1,101 @@
import moment from "moment-timezone";
import { Mute } from "./entities/Mute";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { getRepository, Repository, Brackets } from "typeorm";
export class GuildMutes extends BaseGuildRepository {
private mutes: Repository<Mute>;
constructor(guildId) {
super(guildId);
this.mutes = getRepository(Mute);
}
async getExpiredMutes(): Promise<Mute[]> {
return this.mutes
.createQueryBuilder("mutes")
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("expires_at IS NOT NULL")
.andWhere("expires_at <= NOW()")
.getMany();
}
async findExistingMuteForUserId(userId: string): Promise<Mute> {
return this.mutes.findOne({
where: {
guild_id: this.guildId,
user_id: userId,
},
});
}
async isMuted(userId: string): Promise<boolean> {
const mute = await this.findExistingMuteForUserId(userId);
return mute != null;
}
async addMute(userId, expiryTime): Promise<Mute> {
const expiresAt = expiryTime
? moment()
.add(expiryTime, "ms")
.format("YYYY-MM-DD HH:mm:ss")
: null;
const result = await this.mutes.insert({
guild_id: this.guildId,
user_id: userId,
expires_at: expiresAt,
});
return this.mutes.findOne({ where: result.identifiers[0] });
}
async updateExpiryTime(userId, newExpiryTime) {
const expiresAt = newExpiryTime
? moment()
.add(newExpiryTime, "ms")
.format("YYYY-MM-DD HH:mm:ss")
: null;
return this.mutes.update(
{
guild_id: this.guildId,
user_id: userId,
},
{
expires_at: expiresAt,
},
);
}
async getActiveMutes(): Promise<Mute[]> {
return this.mutes
.createQueryBuilder("mutes")
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere(
new Brackets(qb => {
qb.where("expires_at > NOW()").orWhere("expires_at IS NULL");
}),
)
.getMany();
}
async setCaseId(userId: string, caseId: number) {
await this.mutes.update(
{
guild_id: this.guildId,
user_id: userId,
},
{
case_id: caseId,
},
);
}
async clear(userId) {
await this.mutes.delete({
guild_id: this.guildId,
user_id: userId,
});
}
}

View file

@ -0,0 +1,68 @@
import { BaseGuildRepository } from "./BaseGuildRepository";
import { getRepository, Repository } from "typeorm";
import { NicknameHistoryEntry } from "./entities/NicknameHistoryEntry";
import { sorter } from "../utils";
export const MAX_NICKNAME_ENTRIES_PER_USER = 10;
export class GuildNicknameHistory extends BaseGuildRepository {
private nicknameHistory: Repository<NicknameHistoryEntry>;
constructor(guildId) {
super(guildId);
this.nicknameHistory = getRepository(NicknameHistoryEntry);
}
async getByUserId(userId): Promise<NicknameHistoryEntry[]> {
return this.nicknameHistory.find({
where: {
guild_id: this.guildId,
user_id: userId,
},
order: {
id: "DESC",
},
});
}
getLastEntry(userId): Promise<NicknameHistoryEntry> {
return this.nicknameHistory.findOne({
where: {
guild_id: this.guildId,
user_id: userId,
},
order: {
id: "DESC",
},
});
}
async addEntry(userId, nickname) {
await this.nicknameHistory.insert({
guild_id: this.guildId,
user_id: userId,
nickname,
});
// Cleanup (leave only the last MAX_NICKNAME_ENTRIES_PER_USER entries)
const lastEntries = await this.getByUserId(userId);
if (lastEntries.length > MAX_NICKNAME_ENTRIES_PER_USER) {
const earliestEntry = lastEntries
.sort(sorter("timestamp", "DESC"))
.slice(0, 10)
.reduce((earliest, entry) => {
if (earliest == null) return entry;
if (entry.id < earliest.id) return entry;
return earliest;
}, null);
this.nicknameHistory
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.andWhere("user_id = :userId", { userId })
.andWhere("id < :id", { id: earliestEntry.id })
.delete()
.execute();
}
}
}

View file

@ -0,0 +1,58 @@
import { PersistedData } from "./entities/PersistedData";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { getRepository, Repository } from "typeorm";
export interface IPartialPersistData {
roles?: string[];
nickname?: string;
is_voice_muted?: boolean;
}
export class GuildPersistedData extends BaseGuildRepository {
private persistedData: Repository<PersistedData>;
constructor(guildId) {
super(guildId);
this.persistedData = getRepository(PersistedData);
}
async find(userId: string) {
return this.persistedData.findOne({
where: {
guild_id: this.guildId,
user_id: userId,
},
});
}
async set(userId: string, data: IPartialPersistData = {}) {
const finalData: any = {};
if (data.roles) finalData.roles = data.roles.join(",");
if (data.nickname) finalData.nickname = data.nickname;
if (data.is_voice_muted != null) finalData.is_voice_muted = data.is_voice_muted ? 1 : 0;
const existing = await this.find(userId);
if (existing) {
await this.persistedData.update(
{
guild_id: this.guildId,
user_id: userId,
},
finalData,
);
} else {
await this.persistedData.insert({
...finalData,
guild_id: this.guildId,
user_id: userId,
});
}
}
async clear(userId: string) {
await this.persistedData.delete({
guild_id: this.guildId,
user_id: userId,
});
}
}

View file

@ -0,0 +1,55 @@
import { BaseGuildRepository } from "./BaseGuildRepository";
import { getRepository, Repository } from "typeorm";
import { PingableRole } from "./entities/PingableRole";
export class GuildPingableRoles extends BaseGuildRepository {
private pingableRoles: Repository<PingableRole>;
constructor(guildId) {
super(guildId);
this.pingableRoles = getRepository(PingableRole);
}
async all(): Promise<PingableRole[]> {
return this.pingableRoles.find({
where: {
guild_id: this.guildId,
},
});
}
async getForChannel(channelId: string): Promise<PingableRole[]> {
return this.pingableRoles.find({
where: {
guild_id: this.guildId,
channel_id: channelId,
},
});
}
async getByChannelAndRoleId(channelId: string, roleId: string): Promise<PingableRole> {
return this.pingableRoles.findOne({
where: {
guild_id: this.guildId,
channel_id: channelId,
role_id: roleId,
},
});
}
async delete(channelId: string, roleId: string) {
await this.pingableRoles.delete({
guild_id: this.guildId,
channel_id: channelId,
role_id: roleId,
});
}
async add(channelId: string, roleId: string) {
await this.pingableRoles.insert({
guild_id: this.guildId,
channel_id: channelId,
role_id: roleId,
});
}
}

View file

@ -0,0 +1,62 @@
import { ReactionRole } from "./entities/ReactionRole";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { getRepository, Repository } from "typeorm";
export class GuildReactionRoles extends BaseGuildRepository {
private reactionRoles: Repository<ReactionRole>;
constructor(guildId) {
super(guildId);
this.reactionRoles = getRepository(ReactionRole);
}
async all(): Promise<ReactionRole[]> {
return this.reactionRoles.find({
where: {
guild_id: this.guildId,
},
});
}
async getForMessage(messageId: string): Promise<ReactionRole[]> {
return this.reactionRoles.find({
where: {
guild_id: this.guildId,
message_id: messageId,
},
});
}
async getByMessageAndEmoji(messageId: string, emoji: string): Promise<ReactionRole> {
return this.reactionRoles.findOne({
where: {
guild_id: this.guildId,
message_id: messageId,
emoji,
},
});
}
async removeFromMessage(messageId: string, emoji: string = null) {
const criteria: any = {
guild_id: this.guildId,
message_id: messageId,
};
if (emoji) {
criteria.emoji = emoji;
}
await this.reactionRoles.delete(criteria);
}
async add(channelId: string, messageId: string, emoji: string, roleId: string) {
await this.reactionRoles.insert({
guild_id: this.guildId,
channel_id: channelId,
message_id: messageId,
emoji,
role_id: roleId,
});
}
}

View file

@ -0,0 +1,46 @@
import { BaseGuildRepository } from "./BaseGuildRepository";
import { getRepository, Repository } from "typeorm";
import { Reminder } from "./entities/Reminder";
export class GuildReminders extends BaseGuildRepository {
private reminders: Repository<Reminder>;
constructor(guildId) {
super(guildId);
this.reminders = getRepository(Reminder);
}
async getDueReminders(): Promise<Reminder[]> {
return this.reminders
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.andWhere("remind_at <= NOW()")
.getMany();
}
async getRemindersByUserId(userId: string): Promise<Reminder[]> {
return this.reminders.find({
where: {
guild_id: this.guildId,
user_id: userId,
},
});
}
async delete(id) {
await this.reminders.delete({
guild_id: this.guildId,
id,
});
}
async add(userId: string, channelId: string, remindAt: string, body: string) {
await this.reminders.insert({
guild_id: this.guildId,
user_id: userId,
channel_id: channelId,
remind_at: remindAt,
body,
});
}
}

View file

@ -0,0 +1,285 @@
import { Brackets, getRepository, Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { ISavedMessageData, SavedMessage } from "./entities/SavedMessage";
import { QueuedEventEmitter } from "../QueuedEventEmitter";
import { GuildChannel, Message } from "eris";
import moment from "moment-timezone";
const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 min
const RETENTION_PERIOD = 5 * 24 * 60 * 60 * 1000; // 5 days
async function cleanup() {
const repository = getRepository(SavedMessage);
await repository
.createQueryBuilder("messages")
.where(
// Clear deleted messages
new Brackets(qb => {
qb.where("deleted_at IS NOT NULL");
qb.andWhere(`deleted_at <= (NOW() - INTERVAL ${CLEANUP_INTERVAL}000 MICROSECOND)`);
}),
)
.orWhere(
// Clear old messages
new Brackets(qb => {
qb.where("is_permanent = 0");
qb.andWhere(`posted_at <= (NOW() - INTERVAL ${RETENTION_PERIOD}000 MICROSECOND)`);
}),
)
.delete()
.execute();
setTimeout(cleanup, CLEANUP_INTERVAL);
}
// Start first cleanup 30 seconds after startup
setTimeout(cleanup, 30 * 1000);
export class GuildSavedMessages extends BaseGuildRepository {
private messages: Repository<SavedMessage>;
protected toBePermanent: Set<string>;
public events: QueuedEventEmitter;
constructor(guildId) {
super(guildId);
this.messages = getRepository(SavedMessage);
this.events = new QueuedEventEmitter();
this.toBePermanent = new Set();
}
public msgToSavedMessageData(msg: Message): ISavedMessageData {
const data: ISavedMessageData = {
author: {
username: msg.author.username,
discriminator: msg.author.discriminator,
},
content: msg.content,
timestamp: msg.timestamp,
};
if (msg.attachments.length) data.attachments = msg.attachments;
if (msg.embeds.length) data.embeds = msg.embeds;
return data;
}
find(id) {
return this.messages
.createQueryBuilder()
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("id = :id", { id })
.andWhere("deleted_at IS NULL")
.getOne();
}
getLatestBotMessagesByChannel(channelId, limit) {
return this.messages
.createQueryBuilder()
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("channel_id = :channel_id", { channel_id: channelId })
.andWhere("is_bot = 1")
.andWhere("deleted_at IS NULL")
.orderBy("id", "DESC")
.limit(limit)
.getMany();
}
getLatestByChannelBeforeId(channelId, beforeId, limit) {
return this.messages
.createQueryBuilder()
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("channel_id = :channel_id", { channel_id: channelId })
.andWhere("id < :beforeId", { beforeId })
.andWhere("deleted_at IS NULL")
.orderBy("id", "DESC")
.limit(limit)
.getMany();
}
getLatestByChannelAndUser(channelId, userId, limit) {
return this.messages
.createQueryBuilder()
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("channel_id = :channel_id", { channel_id: channelId })
.andWhere("user_id = :user_id", { user_id: userId })
.andWhere("deleted_at IS NULL")
.orderBy("id", "DESC")
.limit(limit)
.getMany();
}
getUserMessagesByChannelAfterId(userId, channelId, afterId, limit = null) {
let query = this.messages
.createQueryBuilder()
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("user_id = :user_id", { user_id: userId })
.andWhere("channel_id = :channel_id", { channel_id: channelId })
.andWhere("id > :afterId", { afterId })
.andWhere("deleted_at IS NULL");
if (limit != null) {
query = query.limit(limit);
}
return query.getMany();
}
getMultiple(messageIds: string[]): Promise<SavedMessage[]> {
return this.messages
.createQueryBuilder()
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("id IN (:messageIds)", { messageIds })
.getMany();
}
async create(data) {
const isPermanent = this.toBePermanent.has(data.id);
if (isPermanent) {
data.is_permanent = true;
this.toBePermanent.delete(data.id);
}
try {
await this.messages.insert(data);
} catch (e) {
console.warn(e);
return;
}
const inserted = await this.messages.findOne(data.id);
this.events.emit("create", [inserted]);
this.events.emit(`create:${data.id}`, [inserted]);
}
async createFromMsg(msg: Message, overrides = {}) {
const existingSavedMsg = await this.find(msg.id);
if (existingSavedMsg) return;
const savedMessageData = this.msgToSavedMessageData(msg);
const postedAt = moment.utc(msg.timestamp, "x").format("YYYY-MM-DD HH:mm:ss.SSS");
const data = {
id: msg.id,
guild_id: (msg.channel as GuildChannel).guild.id,
channel_id: msg.channel.id,
user_id: msg.author.id,
is_bot: msg.author.bot,
data: savedMessageData,
posted_at: postedAt,
};
return this.create({ ...data, ...overrides });
}
async markAsDeleted(id) {
await this.messages
.createQueryBuilder("messages")
.update()
.set({
deleted_at: () => "NOW(3)",
})
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("id = :id", { id })
.execute();
const deleted = await this.messages.findOne(id);
if (deleted) {
this.events.emit("delete", [deleted]);
this.events.emit(`delete:${id}`, [deleted]);
}
}
/**
* Marks the specified messages as deleted in the database (if they weren't already marked before).
* If any messages were marked as deleted, also emits the deleteBulk event.
*/
async markBulkAsDeleted(ids) {
const deletedAt = moment().format("YYYY-MM-DD HH:mm:ss.SSS");
await this.messages
.createQueryBuilder()
.update()
.set({ deleted_at: deletedAt })
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("id IN (:ids)", { ids })
.andWhere("deleted_at IS NULL")
.execute();
const deleted = await this.messages
.createQueryBuilder()
.where("id IN (:ids)", { ids })
.andWhere("deleted_at = :deletedAt", { deletedAt })
.getMany();
if (deleted.length) {
this.events.emit("deleteBulk", [deleted]);
}
}
async saveEdit(id, newData: ISavedMessageData) {
const oldMessage = await this.messages.findOne(id);
if (!oldMessage) return;
const newMessage = { ...oldMessage, data: newData };
await this.messages.update(
{ id },
{
data: newData,
},
);
this.events.emit("update", [newMessage, oldMessage]);
this.events.emit(`update:${id}`, [newMessage, oldMessage]);
}
async saveEditFromMsg(msg: Message) {
const newData = this.msgToSavedMessageData(msg);
return this.saveEdit(msg.id, newData);
}
async setPermanent(id: string) {
const savedMsg = await this.find(id);
if (savedMsg) {
await this.messages.update(
{ id },
{
is_permanent: true,
},
);
} else {
this.toBePermanent.add(id);
}
}
async onceMessageAvailable(id: string, handler: (msg: SavedMessage) => any, timeout: number = 60 * 1000) {
let called = false;
let onceEventListener;
let timeoutFn;
const callHandler = async (msg: SavedMessage) => {
this.events.off(`create:${id}`, onceEventListener);
clearTimeout(timeoutFn);
if (called) return;
called = true;
await handler(msg);
};
onceEventListener = this.events.once(`create:${id}`, callHandler);
timeoutFn = setTimeout(() => {
called = true;
callHandler(null);
}, timeout);
const messageInDB = await this.find(id);
if (messageInDB) {
callHandler(messageInDB);
}
}
}

View file

@ -0,0 +1,41 @@
import { BaseGuildRepository } from "./BaseGuildRepository";
import { getRepository, Repository } from "typeorm";
import { ScheduledPost } from "./entities/ScheduledPost";
export class GuildScheduledPosts extends BaseGuildRepository {
private scheduledPosts: Repository<ScheduledPost>;
constructor(guildId) {
super(guildId);
this.scheduledPosts = getRepository(ScheduledPost);
}
all(): Promise<ScheduledPost[]> {
return this.scheduledPosts
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.getMany();
}
getDueScheduledPosts(): Promise<ScheduledPost[]> {
return this.scheduledPosts
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.andWhere("post_at <= NOW()")
.getMany();
}
async delete(id) {
await this.scheduledPosts.delete({
guild_id: this.guildId,
id,
});
}
async create(data: Partial<ScheduledPost>) {
await this.scheduledPosts.insert({
...data,
guild_id: this.guildId,
});
}
}

View file

@ -0,0 +1,38 @@
import { BaseGuildRepository } from "./BaseGuildRepository";
import { getRepository, Repository } from "typeorm";
import { SelfGrantableRole } from "./entities/SelfGrantableRole";
export class GuildSelfGrantableRoles extends BaseGuildRepository {
private selfGrantableRoles: Repository<SelfGrantableRole>;
constructor(guildId) {
super(guildId);
this.selfGrantableRoles = getRepository(SelfGrantableRole);
}
async getForChannel(channelId: string): Promise<SelfGrantableRole[]> {
return this.selfGrantableRoles.find({
where: {
guild_id: this.guildId,
channel_id: channelId,
},
});
}
async delete(channelId: string, roleId: string) {
await this.selfGrantableRoles.delete({
guild_id: this.guildId,
channel_id: channelId,
role_id: roleId,
});
}
async add(channelId: string, roleId: string, aliases: string[]) {
await this.selfGrantableRoles.insert({
guild_id: this.guildId,
channel_id: channelId,
role_id: roleId,
aliases,
});
}
}

View file

@ -0,0 +1,121 @@
import { BaseGuildRepository } from "./BaseGuildRepository";
import { getRepository, Repository } from "typeorm";
import { SlowmodeChannel } from "./entities/SlowmodeChannel";
import { SlowmodeUser } from "./entities/SlowmodeUser";
import moment from "moment-timezone";
export class GuildSlowmodes extends BaseGuildRepository {
private slowmodeChannels: Repository<SlowmodeChannel>;
private slowmodeUsers: Repository<SlowmodeUser>;
constructor(guildId) {
super(guildId);
this.slowmodeChannels = getRepository(SlowmodeChannel);
this.slowmodeUsers = getRepository(SlowmodeUser);
}
async getChannelSlowmode(channelId): Promise<SlowmodeChannel> {
return this.slowmodeChannels.findOne({
where: {
guild_id: this.guildId,
channel_id: channelId,
},
});
}
async setChannelSlowmode(channelId, seconds): Promise<void> {
const existingSlowmode = await this.getChannelSlowmode(channelId);
if (existingSlowmode) {
await this.slowmodeChannels.update(
{
guild_id: this.guildId,
channel_id: channelId,
},
{
slowmode_seconds: seconds,
},
);
} else {
await this.slowmodeChannels.insert({
guild_id: this.guildId,
channel_id: channelId,
slowmode_seconds: seconds,
});
}
}
async deleteChannelSlowmode(channelId): Promise<void> {
await this.slowmodeChannels.delete({
guild_id: this.guildId,
channel_id: channelId,
});
}
async getChannelSlowmodeUser(channelId, userId): Promise<SlowmodeUser> {
return this.slowmodeUsers.findOne({
guild_id: this.guildId,
channel_id: channelId,
user_id: userId,
});
}
async userHasSlowmode(channelId, userId): Promise<boolean> {
return (await this.getChannelSlowmodeUser(channelId, userId)) != null;
}
async addSlowmodeUser(channelId, userId): Promise<void> {
const slowmode = await this.getChannelSlowmode(channelId);
if (!slowmode) return;
const expiresAt = moment()
.add(slowmode.slowmode_seconds, "seconds")
.format("YYYY-MM-DD HH:mm:ss");
if (await this.userHasSlowmode(channelId, userId)) {
// Update existing
await this.slowmodeUsers.update(
{
guild_id: this.guildId,
channel_id: channelId,
user_id: userId,
},
{
expires_at: expiresAt,
},
);
} else {
// Add new
await this.slowmodeUsers.insert({
guild_id: this.guildId,
channel_id: channelId,
user_id: userId,
expires_at: expiresAt,
});
}
}
async clearSlowmodeUser(channelId, userId): Promise<void> {
await this.slowmodeUsers.delete({
guild_id: this.guildId,
channel_id: channelId,
user_id: userId,
});
}
async getChannelSlowmodeUsers(channelId): Promise<SlowmodeUser[]> {
return this.slowmodeUsers.find({
where: {
guild_id: this.guildId,
channel_id: channelId,
},
});
}
async getExpiredSlowmodeUsers(): Promise<SlowmodeUser[]> {
return this.slowmodeUsers
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.andWhere("expires_at <= NOW()")
.getMany();
}
}

View file

@ -0,0 +1,84 @@
import { BaseGuildRepository } from "./BaseGuildRepository";
import { getRepository, Repository } from "typeorm";
import { Starboard } from "./entities/Starboard";
import { StarboardMessage } from "./entities/StarboardMessage";
export class GuildStarboards extends BaseGuildRepository {
private starboards: Repository<Starboard>;
private starboardMessages: Repository<StarboardMessage>;
constructor(guildId) {
super(guildId);
this.starboards = getRepository(Starboard);
this.starboardMessages = getRepository(StarboardMessage);
}
getStarboardByChannelId(channelId): Promise<Starboard> {
return this.starboards.findOne({
where: {
guild_id: this.guildId,
channel_id: channelId,
},
});
}
getStarboardsByEmoji(emoji): Promise<Starboard[]> {
return this.starboards.find({
where: {
guild_id: this.guildId,
emoji,
},
});
}
getStarboardMessageByStarboardIdAndMessageId(starboardId, messageId): Promise<StarboardMessage> {
return this.starboardMessages.findOne({
relations: this.getRelations(),
where: {
starboard_id: starboardId,
message_id: messageId,
},
});
}
getStarboardMessagesByMessageId(id): Promise<StarboardMessage[]> {
return this.starboardMessages.find({
relations: this.getRelations(),
where: {
message_id: id,
},
});
}
async createStarboardMessage(starboardId, messageId, starboardMessageId): Promise<void> {
await this.starboardMessages.insert({
starboard_id: starboardId,
message_id: messageId,
starboard_message_id: starboardMessageId,
});
}
async deleteStarboardMessage(starboardId, messageId): Promise<void> {
await this.starboardMessages.delete({
starboard_id: starboardId,
message_id: messageId,
});
}
async create(channelId: string, channelWhitelist: string[], emoji: string, reactionsRequired: number): Promise<void> {
await this.starboards.insert({
guild_id: this.guildId,
channel_id: channelId,
channel_whitelist: channelWhitelist ? channelWhitelist.join(",") : null,
emoji,
reactions_required: reactionsRequired,
});
}
async delete(channelId: string): Promise<void> {
await this.starboards.delete({
guild_id: this.guildId,
channel_id: channelId,
});
}
}

View file

@ -0,0 +1,89 @@
import { Tag } from "./entities/Tag";
import { getRepository, Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { TagResponse } from "./entities/TagResponse";
export class GuildTags extends BaseGuildRepository {
private tags: Repository<Tag>;
private tagResponses: Repository<TagResponse>;
constructor(guildId) {
super(guildId);
this.tags = getRepository(Tag);
this.tagResponses = getRepository(TagResponse);
}
async all(): Promise<Tag[]> {
return this.tags.find({
where: {
guild_id: this.guildId,
},
});
}
async find(tag): Promise<Tag> {
return this.tags.findOne({
where: {
guild_id: this.guildId,
tag,
},
});
}
async createOrUpdate(tag, body, userId) {
const existingTag = await this.find(tag);
if (existingTag) {
await this.tags
.createQueryBuilder()
.update()
.set({
body,
user_id: userId,
created_at: () => "NOW()",
})
.where("guild_id = :guildId", { guildId: this.guildId })
.andWhere("tag = :tag", { tag })
.execute();
} else {
await this.tags.insert({
guild_id: this.guildId,
user_id: userId,
tag,
body,
});
}
}
async delete(tag) {
await this.tags.delete({
guild_id: this.guildId,
tag,
});
}
async findResponseByCommandMessageId(messageId: string): Promise<TagResponse> {
return this.tagResponses.findOne({
where: {
guild_id: this.guildId,
command_message_id: messageId,
},
});
}
async findResponseByResponseMessageId(messageId: string): Promise<TagResponse> {
return this.tagResponses.findOne({
where: {
guild_id: this.guildId,
response_message_id: messageId,
},
});
}
async addResponse(cmdMessageId, responseMessageId) {
await this.tagResponses.insert({
guild_id: this.guildId,
command_message_id: cmdMessageId,
response_message_id: responseMessageId,
});
}
}

View file

@ -0,0 +1,63 @@
import { BaseGuildRepository } from "./BaseGuildRepository";
import { getRepository, Repository } from "typeorm";
import { VCAlert } from "./entities/VCAlert";
export class GuildVCAlerts extends BaseGuildRepository {
private allAlerts: Repository<VCAlert>;
constructor(guildId) {
super(guildId);
this.allAlerts = getRepository(VCAlert);
}
async getOutdatedAlerts(): Promise<VCAlert[]> {
return this.allAlerts
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.andWhere("expires_at <= NOW()")
.getMany();
}
async getAllGuildAlerts(): Promise<VCAlert[]> {
return this.allAlerts
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.getMany();
}
async getAlertsByUserId(userId: string): Promise<VCAlert[]> {
return this.allAlerts.find({
where: {
guild_id: this.guildId,
user_id: userId,
},
});
}
async getAlertsByRequestorId(requestorId: string): Promise<VCAlert[]> {
return this.allAlerts.find({
where: {
guild_id: this.guildId,
requestor_id: requestorId,
},
});
}
async delete(id) {
await this.allAlerts.delete({
guild_id: this.guildId,
id,
});
}
async add(requestorId: string, userId: string, channelId: string, expiresAt: string, body: string) {
await this.allAlerts.insert({
guild_id: this.guildId,
requestor_id: requestorId,
user_id: userId,
channel_id: channelId,
expires_at: expiresAt,
body,
});
}
}

View file

@ -0,0 +1,62 @@
export enum LogType {
MEMBER_WARN = 1,
MEMBER_MUTE,
MEMBER_UNMUTE,
MEMBER_MUTE_EXPIRED,
MEMBER_KICK,
MEMBER_BAN,
MEMBER_UNBAN,
MEMBER_FORCEBAN,
MEMBER_SOFTBAN,
MEMBER_JOIN,
MEMBER_LEAVE,
MEMBER_ROLE_ADD,
MEMBER_ROLE_REMOVE,
MEMBER_NICK_CHANGE,
MEMBER_USERNAME_CHANGE,
MEMBER_RESTORE,
CHANNEL_CREATE,
CHANNEL_DELETE,
ROLE_CREATE,
ROLE_DELETE,
MESSAGE_EDIT,
MESSAGE_DELETE,
MESSAGE_DELETE_BULK,
MESSAGE_DELETE_BARE,
VOICE_CHANNEL_JOIN,
VOICE_CHANNEL_LEAVE,
VOICE_CHANNEL_MOVE,
COMMAND,
MESSAGE_SPAM_DETECTED,
CENSOR,
CLEAN,
CASE_CREATE,
MASSBAN,
MEMBER_TIMED_MUTE,
MEMBER_TIMED_UNMUTE,
MEMBER_JOIN_WITH_PRIOR_RECORDS,
OTHER_SPAM_DETECTED,
MEMBER_ROLE_CHANGES,
VOICE_CHANNEL_FORCE_MOVE,
CASE_UPDATE,
MEMBER_MUTE_REJOIN,
SCHEDULED_MESSAGE,
POSTED_SCHEDULED_MESSAGE,
BOT_ALERT,
AUTOMOD_ACTION,
}

View file

@ -0,0 +1,65 @@
import { getRepository, Repository } from "typeorm";
import { UsernameHistoryEntry } from "./entities/UsernameHistoryEntry";
import { sorter } from "../utils";
import { BaseRepository } from "./BaseRepository";
export const MAX_USERNAME_ENTRIES_PER_USER = 10;
export class UsernameHistory extends BaseRepository {
private usernameHistory: Repository<UsernameHistoryEntry>;
constructor() {
super();
this.usernameHistory = getRepository(UsernameHistoryEntry);
}
async getByUserId(userId): Promise<UsernameHistoryEntry[]> {
return this.usernameHistory.find({
where: {
user_id: userId,
},
order: {
id: "DESC",
},
take: MAX_USERNAME_ENTRIES_PER_USER,
});
}
getLastEntry(userId): Promise<UsernameHistoryEntry> {
return this.usernameHistory.findOne({
where: {
user_id: userId,
},
order: {
id: "DESC",
},
});
}
async addEntry(userId, username) {
await this.usernameHistory.insert({
user_id: userId,
username,
});
// Cleanup (leave only the last MAX_USERNAME_ENTRIES_PER_USER entries)
const lastEntries = await this.getByUserId(userId);
if (lastEntries.length > MAX_USERNAME_ENTRIES_PER_USER) {
const earliestEntry = lastEntries
.sort(sorter("timestamp", "DESC"))
.slice(0, 10)
.reduce((earliest, entry) => {
if (earliest == null) return entry;
if (entry.id < earliest.id) return entry;
return earliest;
}, null);
this.usernameHistory
.createQueryBuilder()
.andWhere("user_id = :userId", { userId })
.andWhere("id < :id", { id: earliestEntry.id })
.delete()
.execute();
}
}
}

117
backend/src/data/Zalgo.ts Normal file
View file

@ -0,0 +1,117 @@
// From https://github.com/b1naryth1ef/rowboat/blob/master/rowboat/util/zalgo.py
const zalgoChars = [
"\u030d",
"\u030e",
"\u0304",
"\u0305",
"\u033f",
"\u0311",
"\u0306",
"\u0310",
"\u0352",
"\u0357",
"\u0351",
"\u0307",
"\u0308",
"\u030a",
"\u0342",
"\u0343",
"\u0344",
"\u034a",
"\u034b",
"\u034c",
"\u0303",
"\u0302",
"\u030c",
"\u0350",
"\u0300",
"\u030b",
"\u030f",
"\u0312",
"\u0313",
"\u0314",
"\u033d",
"\u0309",
"\u0363",
"\u0364",
"\u0365",
"\u0366",
"\u0367",
"\u0368",
"\u0369",
"\u036a",
"\u036b",
"\u036c",
"\u036d",
"\u036e",
"\u036f",
"\u033e",
"\u035b",
"\u0346",
"\u031a",
"\u0315",
"\u031b",
"\u0340",
"\u0341",
"\u0358",
"\u0321",
"\u0322",
"\u0327",
"\u0328",
"\u0334",
"\u0335",
"\u0336",
"\u034f",
"\u035c",
"\u035d",
"\u035e",
"\u035f",
"\u0360",
"\u0362",
"\u0338",
"\u0337",
"\u0361",
"\u0489",
"\u0316",
"\u0317",
"\u0318",
"\u0319",
"\u031c",
"\u031d",
"\u031e",
"\u031f",
"\u0320",
"\u0324",
"\u0325",
"\u0326",
"\u0329",
"\u032a",
"\u032b",
"\u032c",
"\u032d",
"\u032e",
"\u032f",
"\u0330",
"\u0331",
"\u0332",
"\u0333",
"\u0339",
"\u033a",
"\u033b",
"\u033c",
"\u0345",
"\u0347",
"\u0348",
"\u0349",
"\u034d",
"\u034e",
"\u0353",
"\u0354",
"\u0355",
"\u0356",
"\u0359",
"\u035a",
"\u0323",
];
export const ZalgoRegex = new RegExp(zalgoChars.join("|"));

24
backend/src/data/db.ts Normal file
View file

@ -0,0 +1,24 @@
import { SimpleError } from "../SimpleError";
import { Connection, createConnection } from "typeorm";
let connectionPromise: Promise<Connection>;
export let connection: Connection;
export function connect() {
if (!connectionPromise) {
connectionPromise = createConnection().then(newConnection => {
// Verify the DB timezone is set to UTC
return newConnection.query("SELECT TIMEDIFF(NOW(), UTC_TIMESTAMP) AS tz").then(r => {
if (r[0].tz !== "00:00:00") {
throw new SimpleError(`Database timezone must be UTC (detected ${r[0].tz})`);
}
connection = newConnection;
return newConnection;
});
});
}
return connectionPromise;
}

View file

@ -0,0 +1,17 @@
import { Entity, Column, PrimaryColumn, CreateDateColumn } from "typeorm";
@Entity("allowed_guilds")
export class AllowedGuild {
@Column()
@PrimaryColumn()
id: string;
@Column()
name: string;
@Column()
icon: string;
@Column()
owner_id: string;
}

View file

@ -0,0 +1,25 @@
import { Entity, Column, PrimaryColumn, OneToOne, ManyToOne, JoinColumn } from "typeorm";
import { ApiUserInfo } from "./ApiUserInfo";
@Entity("api_logins")
export class ApiLogin {
@Column()
@PrimaryColumn()
id: string;
@Column()
token: string;
@Column()
user_id: string;
@Column()
logged_in_at: string;
@Column()
expires_at: string;
@ManyToOne(type => ApiUserInfo, userInfo => userInfo.logins)
@JoinColumn({ name: "user_id" })
userInfo: ApiUserInfo;
}

View file

@ -0,0 +1,20 @@
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from "typeorm";
import { ApiUserInfo } from "./ApiUserInfo";
@Entity("api_permissions")
export class ApiPermission {
@Column()
@PrimaryColumn()
guild_id: string;
@Column()
@PrimaryColumn()
user_id: string;
@Column()
role: string;
@ManyToOne(type => ApiUserInfo, userInfo => userInfo.permissions)
@JoinColumn({ name: "user_id" })
userInfo: ApiUserInfo;
}

View file

@ -0,0 +1,28 @@
import { Entity, Column, PrimaryColumn, OneToMany } from "typeorm";
import { ApiLogin } from "./ApiLogin";
import { ApiPermission } from "./ApiPermission";
export interface ApiUserInfoData {
username: string;
discriminator: string;
avatar: string;
}
@Entity("api_user_info")
export class ApiUserInfo {
@Column()
@PrimaryColumn()
id: string;
@Column("simple-json")
data: ApiUserInfoData;
@Column()
updated_at: string;
@OneToMany(type => ApiLogin, login => login.userInfo)
logins: ApiLogin[];
@OneToMany(type => ApiPermission, perm => perm.userInfo)
permissions: ApiPermission[];
}

View file

@ -0,0 +1,16 @@
import { Entity, Column, PrimaryGeneratedColumn } from "typeorm";
@Entity("archives")
export class ArchiveEntry {
@Column()
@PrimaryGeneratedColumn("uuid")
id: string;
@Column() guild_id: string;
@Column() body: string;
@Column() created_at: string;
@Column() expires_at: string;
}

View file

@ -0,0 +1,15 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
import { ISavedMessageData } from "./SavedMessage";
@Entity("auto_reactions")
export class AutoReaction {
@Column()
@PrimaryColumn()
guild_id: string;
@Column()
@PrimaryColumn()
channel_id: string;
@Column("simple-array") reactions: string[];
}

View file

@ -0,0 +1,34 @@
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm";
import { CaseNote } from "./CaseNote";
@Entity("cases")
export class Case {
@PrimaryGeneratedColumn() id: number;
@Column() guild_id: string;
@Column() case_number: number;
@Column() user_id: string;
@Column() user_name: string;
@Column() mod_id: string;
@Column() mod_name: string;
@Column() type: number;
@Column() audit_log_id: string;
@Column() created_at: string;
@Column() is_hidden: boolean;
@Column() pp_id: string;
@Column() pp_name: string;
@OneToMany(type => CaseNote, note => note.case)
notes: CaseNote[];
}

View file

@ -0,0 +1,21 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from "typeorm";
import { Case } from "./Case";
@Entity("case_notes")
export class CaseNote {
@PrimaryGeneratedColumn() id: number;
@Column() case_id: number;
@Column() mod_id: string;
@Column() mod_name: string;
@Column() body: string;
@Column() created_at: string;
@ManyToOne(type => Case, theCase => theCase.notes)
@JoinColumn({ name: "case_id" })
case: Case;
}

View file

@ -0,0 +1,28 @@
import { Entity, Column, PrimaryColumn, CreateDateColumn, ManyToOne, JoinColumn } from "typeorm";
import { ApiUserInfo } from "./ApiUserInfo";
@Entity("configs")
export class Config {
@Column()
@PrimaryColumn()
id: number;
@Column()
key: string;
@Column()
config: string;
@Column()
is_active: boolean;
@Column()
edited_by: string;
@Column()
edited_at: string;
@ManyToOne(type => ApiUserInfo)
@JoinColumn({ name: "edited_by" })
userInfo: ApiUserInfo;
}

View file

@ -0,0 +1,18 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
@Entity("mutes")
export class Mute {
@Column()
@PrimaryColumn()
guild_id: string;
@Column()
@PrimaryColumn()
user_id: string;
@Column() created_at: string;
@Column() expires_at: string;
@Column() case_id: number;
}

View file

@ -0,0 +1,16 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
@Entity("nickname_history")
export class NicknameHistoryEntry {
@Column()
@PrimaryColumn()
id: string;
@Column() guild_id: string;
@Column() user_id: string;
@Column() nickname: string;
@Column() timestamp: string;
}

View file

@ -0,0 +1,18 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
@Entity("persisted_data")
export class PersistedData {
@Column()
@PrimaryColumn()
guild_id: string;
@Column()
@PrimaryColumn()
user_id: string;
@Column("simple-array") roles: string[];
@Column() nickname: string;
@Column() is_voice_muted: number;
}

View file

@ -0,0 +1,15 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
import { ISavedMessageData } from "./SavedMessage";
@Entity("pingable_roles")
export class PingableRole {
@Column()
@PrimaryColumn()
id: number;
@Column() guild_id: string;
@Column() channel_id: string;
@Column() role_id: string;
}

View file

@ -0,0 +1,22 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
@Entity("reaction_roles")
export class ReactionRole {
@Column()
@PrimaryColumn()
guild_id: string;
@Column()
@PrimaryColumn()
channel_id: string;
@Column()
@PrimaryColumn()
message_id: string;
@Column()
@PrimaryColumn()
emoji: string;
@Column() role_id: string;
}

View file

@ -0,0 +1,18 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
@Entity("reminders")
export class Reminder {
@Column()
@PrimaryColumn()
id: number;
@Column() guild_id: string;
@Column() user_id: string;
@Column() channel_id: string;
@Column() remind_at: string;
@Column() body: string;
}

View file

@ -0,0 +1,35 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
export interface ISavedMessageData {
attachments?: object[];
author: {
username: string;
discriminator: string;
};
content: string;
embeds?: object[];
timestamp: number;
}
@Entity("messages")
export class SavedMessage {
@Column()
@PrimaryColumn()
id: string;
@Column() guild_id: string;
@Column() channel_id: string;
@Column() user_id: string;
@Column() is_bot: boolean;
@Column("simple-json") data: ISavedMessageData;
@Column() posted_at: string;
@Column() deleted_at: string;
@Column() is_permanent: boolean;
}

View file

@ -0,0 +1,26 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
import { Attachment } from "eris";
import { StrictMessageContent } from "../../utils";
@Entity("scheduled_posts")
export class ScheduledPost {
@Column()
@PrimaryColumn()
id: number;
@Column() guild_id: string;
@Column() author_id: string;
@Column() author_name: string;
@Column() channel_id: string;
@Column("simple-json") content: StrictMessageContent;
@Column("simple-json") attachments: Attachment[];
@Column() post_at: string;
@Column() enable_mentions: boolean;
}

View file

@ -0,0 +1,16 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
@Entity("self_grantable_roles")
export class SelfGrantableRole {
@Column()
@PrimaryColumn()
id: number;
@Column() guild_id: string;
@Column() channel_id: string;
@Column() role_id: string;
@Column("simple-array") aliases: string[];
}

View file

@ -0,0 +1,14 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
@Entity("slowmode_channels")
export class SlowmodeChannel {
@Column()
@PrimaryColumn()
guild_id: string;
@Column()
@PrimaryColumn()
channel_id: string;
@Column() slowmode_seconds: number;
}

View file

@ -0,0 +1,18 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
@Entity("slowmode_users")
export class SlowmodeUser {
@Column()
@PrimaryColumn()
guild_id: string;
@Column()
@PrimaryColumn()
channel_id: string;
@Column()
@PrimaryColumn()
user_id: string;
@Column() expires_at: string;
}

View file

@ -0,0 +1,23 @@
import { Entity, Column, PrimaryColumn, OneToMany } from "typeorm";
import { CaseNote } from "./CaseNote";
import { StarboardMessage } from "./StarboardMessage";
@Entity("starboards")
export class Starboard {
@Column()
@PrimaryColumn()
id: number;
@Column() guild_id: string;
@Column() channel_id: string;
@Column() channel_whitelist: string;
@Column() emoji: string;
@Column() reactions_required: number;
@OneToMany(type => StarboardMessage, msg => msg.starboard)
starboardMessages: StarboardMessage[];
}

View file

@ -0,0 +1,25 @@
import { Entity, Column, PrimaryColumn, OneToMany, ManyToOne, JoinColumn, OneToOne } from "typeorm";
import { Starboard } from "./Starboard";
import { Case } from "./Case";
import { SavedMessage } from "./SavedMessage";
@Entity("starboard_messages")
export class StarboardMessage {
@Column()
@PrimaryColumn()
starboard_id: number;
@Column()
@PrimaryColumn()
message_id: string;
@Column() starboard_message_id: string;
@ManyToOne(type => Starboard, sb => sb.starboardMessages)
@JoinColumn({ name: "starboard_id" })
starboard: Starboard;
@OneToOne(type => SavedMessage)
@JoinColumn({ name: "message_id" })
message: SavedMessage;
}

View file

@ -0,0 +1,18 @@
import { Entity, Column, PrimaryColumn, CreateDateColumn } from "typeorm";
@Entity("tags")
export class Tag {
@Column()
@PrimaryColumn()
guild_id: string;
@Column()
@PrimaryColumn()
tag: string;
@Column() user_id: string;
@Column() body: string;
@Column() created_at: string;
}

View file

@ -0,0 +1,14 @@
import { Entity, Column, PrimaryColumn, CreateDateColumn } from "typeorm";
@Entity("tag_responses")
export class TagResponse {
@Column()
@PrimaryColumn()
id: string;
@Column() guild_id: string;
@Column() command_message_id: string;
@Column() response_message_id: string;
}

View file

@ -0,0 +1,14 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
@Entity("username_history")
export class UsernameHistoryEntry {
@Column()
@PrimaryColumn()
id: string;
@Column() user_id: string;
@Column() username: string;
@Column() timestamp: string;
}

View file

@ -0,0 +1,20 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
@Entity("vc_alerts")
export class VCAlert {
@Column()
@PrimaryColumn()
id: number;
@Column() guild_id: string;
@Column() requestor_id: string;
@Column() user_id: string;
@Column() channel_id: string;
@Column() expires_at: string;
@Column() body: string;
}