mirror of
https://github.com/ZeppelinBot/Zeppelin.git
synced 2025-05-10 12:25:02 +00:00
Merge branch 'dashboard'
This commit is contained in:
commit
1dae3019c4
50 changed files with 9552 additions and 57 deletions
119
src/api/auth.ts
Normal file
119
src/api/auth.ts
Normal file
|
@ -0,0 +1,119 @@
|
|||
import express, { Request, Response } from "express";
|
||||
import passport from "passport";
|
||||
import OAuth2Strategy from "passport-oauth2";
|
||||
import CustomStrategy from "passport-custom";
|
||||
import { DashboardLogins, DashboardLoginUserData } from "../data/DashboardLogins";
|
||||
import pick from "lodash.pick";
|
||||
import https from "https";
|
||||
|
||||
const DISCORD_API_URL = "https://discordapp.com/api";
|
||||
|
||||
function simpleDiscordAPIRequest(bearerToken, path): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(
|
||||
`${DISCORD_API_URL}/${path}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
},
|
||||
},
|
||||
res => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`Discord API error ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
res.on("data", data => resolve(JSON.parse(data)));
|
||||
},
|
||||
);
|
||||
|
||||
request.on("error", err => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
export function initAuth(app: express.Express) {
|
||||
app.use(passport.initialize());
|
||||
|
||||
if (!process.env.CLIENT_ID) {
|
||||
throw new Error("Auth: CLIENT ID missing");
|
||||
}
|
||||
|
||||
if (!process.env.CLIENT_SECRET) {
|
||||
throw new Error("Auth: CLIENT SECRET missing");
|
||||
}
|
||||
|
||||
if (!process.env.OAUTH_CALLBACK_URL) {
|
||||
throw new Error("Auth: OAUTH CALLBACK URL missing");
|
||||
}
|
||||
|
||||
if (!process.env.DASHBOARD_URL) {
|
||||
throw new Error("DASHBOARD_URL missing!");
|
||||
}
|
||||
|
||||
passport.serializeUser((user, done) => done(null, user));
|
||||
passport.deserializeUser((user, done) => done(null, user));
|
||||
|
||||
const dashboardLogins = new DashboardLogins();
|
||||
|
||||
// Initialize API tokens
|
||||
passport.use(
|
||||
"api-token",
|
||||
new CustomStrategy(async (req, cb) => {
|
||||
console.log("in api-token strategy");
|
||||
const apiKey = req.header("X-Api-Key");
|
||||
if (!apiKey) return cb();
|
||||
|
||||
const userId = await dashboardLogins.getUserIdByApiKey(apiKey);
|
||||
if (userId) {
|
||||
cb(null, { userId });
|
||||
}
|
||||
|
||||
cb();
|
||||
}),
|
||||
);
|
||||
|
||||
// Initialize OAuth2 for Discord login
|
||||
passport.use(
|
||||
new OAuth2Strategy(
|
||||
{
|
||||
authorizationURL: "https://discordapp.com/api/oauth2/authorize",
|
||||
tokenURL: "https://discordapp.com/api/oauth2/token",
|
||||
clientID: process.env.CLIENT_ID,
|
||||
clientSecret: process.env.CLIENT_SECRET,
|
||||
callbackURL: process.env.OAUTH_CALLBACK_URL,
|
||||
scope: ["identify"],
|
||||
},
|
||||
async (accessToken, refreshToken, profile, cb) => {
|
||||
const user = await simpleDiscordAPIRequest(accessToken, "users/@me");
|
||||
const userData = pick(user, ["username", "discriminator", "avatar"]) as DashboardLoginUserData;
|
||||
const apiKey = await dashboardLogins.addLogin(user.id, userData);
|
||||
// TODO: Revoke access token, we don't need it anymore
|
||||
console.log("done, calling cb with", apiKey);
|
||||
cb(null, { apiKey });
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
app.get("/auth/login", passport.authenticate("oauth2"));
|
||||
app.get(
|
||||
"/auth/oauth-callback",
|
||||
passport.authenticate("oauth2", { failureRedirect: "/", session: false }),
|
||||
(req, res) => {
|
||||
console.log("redirecting to a non-existent page haHAA");
|
||||
res.redirect(`${process.env.DASHBOARD_URL}/login-callback/?apiKey=${req.user.apiKey}`);
|
||||
},
|
||||
);
|
||||
app.post("/auth/validate-key", async (req: Request, res: Response) => {
|
||||
const key = req.params.key || req.query.key;
|
||||
if (!key) {
|
||||
return res.status(400).json({ error: "No key supplied" });
|
||||
}
|
||||
|
||||
const userId = await dashboardLogins.getUserIdByApiKey(key);
|
||||
if (!userId) {
|
||||
return res.status(403).json({ error: "Invalid key" });
|
||||
}
|
||||
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
}
|
15
src/api/guilds.ts
Normal file
15
src/api/guilds.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import express from "express";
|
||||
import passport from "passport";
|
||||
import { AllowedGuilds } from "../data/AllowedGuilds";
|
||||
|
||||
export function initGuildsAPI(app: express.Express) {
|
||||
const guildAPIRouter = express.Router();
|
||||
guildAPIRouter.use(passport.authenticate("api-token"));
|
||||
|
||||
const allowedGuilds = new AllowedGuilds();
|
||||
|
||||
guildAPIRouter.get("/guilds/available", async (req, res) => {
|
||||
const guilds = await allowedGuilds.getForDashboardUser(req.user.userId);
|
||||
res.end(guilds);
|
||||
});
|
||||
}
|
29
src/api/index.ts
Normal file
29
src/api/index.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
require("dotenv").config();
|
||||
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { initAuth } from "./auth";
|
||||
import { initGuildsAPI } from "./guilds";
|
||||
import { connect } from "../data/db";
|
||||
|
||||
console.log("Connecting to database...");
|
||||
connect().then(() => {
|
||||
const app = express();
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.DASHBOARD_URL,
|
||||
}),
|
||||
);
|
||||
app.use(express.json());
|
||||
|
||||
initAuth(app);
|
||||
initGuildsAPI(app);
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.end({ status: "cookies" });
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
app.listen(port, () => console.log(`API server listening on port ${port}`));
|
||||
});
|
41
src/data/AllowedGuilds.ts
Normal file
41
src/data/AllowedGuilds.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
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: {
|
||||
guild_id: guildId,
|
||||
},
|
||||
});
|
||||
return count !== 0;
|
||||
}
|
||||
|
||||
getForDashboardUser(userId) {
|
||||
return this.allowedGuilds
|
||||
.createQueryBuilder("allowed_guilds")
|
||||
.innerJoin(
|
||||
"dashboard_users",
|
||||
"dashboard_users",
|
||||
"dashboard_users.guild_id = allowed_guilds.guild_id AND dashboard_users.user_id = :userId",
|
||||
{ userId },
|
||||
)
|
||||
.getMany();
|
||||
}
|
||||
}
|
|
@ -7,5 +7,5 @@ export enum CaseTypes {
|
|||
Mute,
|
||||
Unmute,
|
||||
Expunged,
|
||||
Softban
|
||||
Softban,
|
||||
}
|
||||
|
|
49
src/data/Configs.ts
Normal file
49
src/data/Configs.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
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 hasConfig(key) {
|
||||
return (await this.getActiveByKey(key)) != null;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
85
src/data/DashboardLogins.ts
Normal file
85
src/data/DashboardLogins.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
import { getRepository, Repository } from "typeorm";
|
||||
import { DashboardLogin } from "./entities/DashboardLogin";
|
||||
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 interface DashboardLoginUserData {
|
||||
username: string;
|
||||
discriminator: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export class DashboardLogins extends BaseRepository {
|
||||
private dashboardLogins: Repository<DashboardLogin>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.dashboardLogins = getRepository(DashboardLogin);
|
||||
}
|
||||
|
||||
async getUserIdByApiKey(apiKey: string): Promise<string | null> {
|
||||
const [loginId, token] = apiKey.split(".");
|
||||
if (!loginId || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const login = await this.dashboardLogins
|
||||
.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, userData: DashboardLoginUserData): Promise<string> {
|
||||
// Generate random login id
|
||||
let loginId;
|
||||
while (true) {
|
||||
loginId = uuidv4();
|
||||
const existing = await this.dashboardLogins.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.dashboardLogins.insert({
|
||||
id: loginId,
|
||||
token: hashedToken,
|
||||
user_id: userId,
|
||||
user_data: userData,
|
||||
logged_in_at: moment().format(DBDateFormat),
|
||||
expires_at: moment()
|
||||
.add(1, "day")
|
||||
.format(DBDateFormat),
|
||||
});
|
||||
|
||||
return `${loginId}.${token}`;
|
||||
}
|
||||
}
|
6
src/data/DashboardRoles.ts
Normal file
6
src/data/DashboardRoles.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export enum DashboardRoles {
|
||||
Viewer = 1,
|
||||
Editor,
|
||||
Manager,
|
||||
ServerOwner,
|
||||
}
|
12
src/data/DashboardUsers.ts
Normal file
12
src/data/DashboardUsers.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { getRepository, Repository } from "typeorm";
|
||||
import { DashboardUser } from "./entities/DashboardUser";
|
||||
import { BaseRepository } from "./BaseRepository";
|
||||
|
||||
export class DashboardUsers extends BaseRepository {
|
||||
private dashboardUsers: Repository<DashboardUser>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.dashboardUsers = getRepository(DashboardUser);
|
||||
}
|
||||
}
|
|
@ -201,7 +201,7 @@ export class GuildSavedMessages extends BaseGuildRepository {
|
|||
const deleted = await this.messages
|
||||
.createQueryBuilder()
|
||||
.where("id IN (:ids)", { ids })
|
||||
.where("deleted_at = :deletedAt", { deletedAt })
|
||||
.andWhere("deleted_at = :deletedAt", { deletedAt })
|
||||
.getMany();
|
||||
|
||||
if (deleted.length) {
|
||||
|
|
14
src/data/entities/AllowedGuild.ts
Normal file
14
src/data/entities/AllowedGuild.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { Entity, Column, PrimaryColumn, CreateDateColumn } from "typeorm";
|
||||
|
||||
@Entity("allowed_guilds")
|
||||
export class AllowedGuild {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
guild_id: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column()
|
||||
icon: string;
|
||||
}
|
23
src/data/entities/Config.ts
Normal file
23
src/data/entities/Config.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { Entity, Column, PrimaryColumn, CreateDateColumn } from "typeorm";
|
||||
|
||||
@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;
|
||||
}
|
24
src/data/entities/DashboardLogin.ts
Normal file
24
src/data/entities/DashboardLogin.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
import { DashboardLoginUserData } from "../DashboardLogins";
|
||||
|
||||
@Entity("dashboard_logins")
|
||||
export class DashboardLogin {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
token: string;
|
||||
|
||||
@Column()
|
||||
user_id: string;
|
||||
|
||||
@Column("simple-json")
|
||||
user_data: DashboardLoginUserData;
|
||||
|
||||
@Column()
|
||||
logged_in_at: string;
|
||||
|
||||
@Column()
|
||||
expires_at: string;
|
||||
}
|
18
src/data/entities/DashboardUser.ts
Normal file
18
src/data/entities/DashboardUser.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
|
||||
@Entity("dashboard_users")
|
||||
export class DashboardUser {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
guild_id: string;
|
||||
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
user_id: string;
|
||||
|
||||
@Column()
|
||||
username: string;
|
||||
|
||||
@Column()
|
||||
role: string;
|
||||
}
|
32
src/index.ts
32
src/index.ts
|
@ -10,6 +10,8 @@ import { SimpleError } from "./SimpleError";
|
|||
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line
|
||||
import DiscordHTTPError from "eris/lib/errors/DiscordHTTPError"; // tslint:disable-line
|
||||
|
||||
import { Configs } from "./data/Configs";
|
||||
|
||||
require("dotenv").config();
|
||||
|
||||
// Error handling
|
||||
|
@ -71,8 +73,8 @@ import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin";
|
|||
import { customArgumentTypes } from "./customArgumentTypes";
|
||||
import { errorMessage, successMessage } from "./utils";
|
||||
import { startUptimeCounter } from "./uptime";
|
||||
import { AllowedGuilds } from "./data/AllowedGuilds";
|
||||
|
||||
// Run latest database migrations
|
||||
logger.info("Connecting to database");
|
||||
connect().then(async conn => {
|
||||
const client = new Client(`Bot ${process.env.TOKEN}`, {
|
||||
|
@ -87,18 +89,25 @@ connect().then(async conn => {
|
|||
}
|
||||
});
|
||||
|
||||
const allowedGuilds = new AllowedGuilds();
|
||||
const guildConfigs = new Configs();
|
||||
|
||||
const bot = new Knub(client, {
|
||||
plugins: availablePlugins,
|
||||
globalPlugins: availableGlobalPlugins,
|
||||
|
||||
options: {
|
||||
canLoadGuild(guildId): Promise<boolean> {
|
||||
return allowedGuilds.isAllowed(guildId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Plugins are enabled if they...
|
||||
* - are base plugins, i.e. always enabled, or
|
||||
* - are dependencies of other enabled plugins, or
|
||||
* - are explicitly enabled in the guild config
|
||||
*/
|
||||
getEnabledPlugins(guildId, guildConfig): string[] {
|
||||
async getEnabledPlugins(guildId, guildConfig): Promise<string[]> {
|
||||
const configuredPlugins = guildConfig.plugins || {};
|
||||
const pluginNames: string[] = Array.from(this.plugins.keys());
|
||||
const plugins: Array<typeof Plugin> = Array.from(this.plugins.values());
|
||||
|
@ -125,22 +134,15 @@ connect().then(async conn => {
|
|||
return Array.from(finalEnabledPlugins.values());
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads the requested config file from the config dir
|
||||
* TODO: Move to the database
|
||||
*/
|
||||
async getConfig(id) {
|
||||
const configFile = id ? `${id}.yml` : "global.yml";
|
||||
const configPath = path.join("config", configFile);
|
||||
|
||||
try {
|
||||
await fsp.access(configPath);
|
||||
} catch (e) {
|
||||
return {};
|
||||
const key = id === "global" ? "global" : `guild-${id}`;
|
||||
const row = await guildConfigs.getActiveByKey(key);
|
||||
if (row) {
|
||||
return yaml.safeLoad(row.config);
|
||||
}
|
||||
|
||||
const yamlString = await fsp.readFile(configPath, { encoding: "utf8" });
|
||||
return yaml.safeLoad(yamlString);
|
||||
logger.warn(`No config with key "${key}"`);
|
||||
return {};
|
||||
},
|
||||
|
||||
logFn: (level, msg) => {
|
||||
|
|
39
src/migrateConfigsToDB.ts
Normal file
39
src/migrateConfigsToDB.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { connect } from "./data/db";
|
||||
import { Configs } from "./data/Configs";
|
||||
import path from "path";
|
||||
import * as _fs from "fs";
|
||||
const fs = _fs.promises;
|
||||
|
||||
const authorId = process.argv[2];
|
||||
if (!authorId) {
|
||||
console.error("No author id specified");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Connecting to database");
|
||||
connect().then(async () => {
|
||||
const configs = new Configs();
|
||||
|
||||
console.log("Loading config files");
|
||||
const configDir = path.join(__dirname, "..", "config");
|
||||
const configFiles = await fs.readdir(configDir);
|
||||
|
||||
console.log("Looping through config files");
|
||||
for (const configFile of configFiles) {
|
||||
const parts = configFile.split(".");
|
||||
const ext = parts[parts.length - 1];
|
||||
if (ext !== "yml") continue;
|
||||
|
||||
const id = parts.slice(0, -1).join(".");
|
||||
const key = id === "global" ? "global" : `guild-${id}`;
|
||||
if (await configs.hasConfig(key)) continue;
|
||||
|
||||
const content = await fs.readFile(path.join(configDir, configFile), { encoding: "utf8" });
|
||||
|
||||
console.log(`Migrating config for ${key}`);
|
||||
await configs.saveNewRevision(key, content, authorId);
|
||||
}
|
||||
|
||||
console.log("Done!");
|
||||
process.exit(0);
|
||||
});
|
54
src/migrations/1558804433320-CreateDashboardLoginsTable.ts
Normal file
54
src/migrations/1558804433320-CreateDashboardLoginsTable.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateDashboardLoginsTable1558804433320 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "dashboard_logins",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "varchar",
|
||||
length: "36",
|
||||
isPrimary: true,
|
||||
collation: "ascii_bin",
|
||||
},
|
||||
{
|
||||
name: "token",
|
||||
type: "varchar",
|
||||
length: "64",
|
||||
collation: "ascii_bin",
|
||||
},
|
||||
{
|
||||
name: "user_id",
|
||||
type: "bigint",
|
||||
},
|
||||
{
|
||||
name: "user_data",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "logged_in_at",
|
||||
type: "DATETIME",
|
||||
},
|
||||
{
|
||||
name: "expires_at",
|
||||
type: "DATETIME",
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
columnNames: ["user_id"],
|
||||
},
|
||||
{
|
||||
columnNames: ["expires_at"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("dashboard_logins", true);
|
||||
}
|
||||
}
|
43
src/migrations/1558804449510-CreateDashboardUsersTable.ts
Normal file
43
src/migrations/1558804449510-CreateDashboardUsersTable.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm";
|
||||
|
||||
export class CreateDashboardUsersTable1558804449510 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "dashboard_users",
|
||||
columns: [
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
},
|
||||
{
|
||||
name: "user_id",
|
||||
type: "bigint",
|
||||
},
|
||||
{
|
||||
name: "username",
|
||||
type: "varchar",
|
||||
length: "255",
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
type: "varchar",
|
||||
length: "32",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createPrimaryKey("dashboard_users", ["guild_id", "user_id"]);
|
||||
await queryRunner.createIndex(
|
||||
"dashboard_users",
|
||||
new TableIndex({
|
||||
columnNames: ["user_id"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("dashboard_users", true);
|
||||
}
|
||||
}
|
51
src/migrations/1561111990357-CreateConfigsTable.ts
Normal file
51
src/migrations/1561111990357-CreateConfigsTable.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm";
|
||||
|
||||
export class CreateConfigsTable1561111990357 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "configs",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "int",
|
||||
isPrimary: true,
|
||||
isGenerated: true,
|
||||
generationStrategy: "increment",
|
||||
},
|
||||
{
|
||||
name: "key",
|
||||
type: "varchar",
|
||||
length: "48",
|
||||
},
|
||||
{
|
||||
name: "config",
|
||||
type: "mediumtext",
|
||||
},
|
||||
{
|
||||
name: "is_active",
|
||||
type: "tinyint",
|
||||
},
|
||||
{
|
||||
name: "edited_by",
|
||||
type: "bigint",
|
||||
},
|
||||
{
|
||||
name: "edited_at",
|
||||
type: "datetime",
|
||||
default: "now()",
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
columnNames: ["key", "is_active"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("configs", true);
|
||||
}
|
||||
}
|
39
src/migrations/1561117545258-CreateAllowedGuildsTable.ts
Normal file
39
src/migrations/1561117545258-CreateAllowedGuildsTable.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateAllowedGuildsTable1561117545258 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "allowed_guilds",
|
||||
columns: [
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
type: "varchar",
|
||||
length: "255",
|
||||
},
|
||||
{
|
||||
name: "icon",
|
||||
type: "varchar",
|
||||
length: "255",
|
||||
collation: "ascii_general_ci",
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: "owner_id",
|
||||
type: "bigint",
|
||||
},
|
||||
],
|
||||
indices: [{ columnNames: ["owner_id"] }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("allowed_guilds", true);
|
||||
}
|
||||
}
|
190
src/plugins/CustomEvents.ts
Normal file
190
src/plugins/CustomEvents.ts
Normal file
|
@ -0,0 +1,190 @@
|
|||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import { IPluginOptions } from "knub";
|
||||
import { Message, TextChannel, VoiceChannel } from "eris";
|
||||
import { renderTemplate } from "../templateFormatter";
|
||||
import { stripObjectToScalars } from "../utils";
|
||||
import { CasesPlugin } from "./Cases";
|
||||
import { CaseTypes } from "../data/CaseTypes";
|
||||
|
||||
// Triggers
|
||||
type CommandTrigger = {
|
||||
type: "command";
|
||||
name: string;
|
||||
params: string;
|
||||
can_use: boolean;
|
||||
};
|
||||
|
||||
type AnyTrigger = CommandTrigger;
|
||||
|
||||
// Actions
|
||||
type AddRoleAction = {
|
||||
type: "add_role";
|
||||
target: string;
|
||||
role: string | string[];
|
||||
};
|
||||
|
||||
type CreateCaseAction = {
|
||||
type: "create_case";
|
||||
case_type: string;
|
||||
mod: string;
|
||||
target: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type MoveToVoiceChannelAction = {
|
||||
type: "move_to_vc";
|
||||
target: string;
|
||||
channel: string;
|
||||
};
|
||||
|
||||
type MessageAction = {
|
||||
type: "message";
|
||||
channel: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type AnyAction = AddRoleAction | CreateCaseAction | MoveToVoiceChannelAction | MessageAction;
|
||||
|
||||
// Event
|
||||
type CustomEvent = {
|
||||
name: string;
|
||||
trigger: AnyTrigger;
|
||||
actions: AnyAction[];
|
||||
};
|
||||
|
||||
interface ICustomEventsPluginConfig {
|
||||
events: {
|
||||
[key: string]: CustomEvent;
|
||||
};
|
||||
}
|
||||
|
||||
class ActionError extends Error {}
|
||||
|
||||
export class CustomEventsPlugin extends ZeppelinPlugin<ICustomEventsPluginConfig> {
|
||||
public static pluginName = "custom_events";
|
||||
private clearTriggers: () => void;
|
||||
|
||||
public static dependencies = ["cases"];
|
||||
|
||||
getDefaultOptions(): IPluginOptions<ICustomEventsPluginConfig> {
|
||||
return {
|
||||
config: {
|
||||
events: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onLoad() {
|
||||
for (const [key, event] of Object.entries(this.getConfig().events)) {
|
||||
if (event.trigger.type === "command") {
|
||||
this.commands.add(
|
||||
event.trigger.name,
|
||||
event.trigger.params,
|
||||
(msg, args) => {
|
||||
const strippedMsg = stripObjectToScalars(msg, ["channel", "author"]);
|
||||
this.runEvent(event, { msg, args }, { args, msg: strippedMsg });
|
||||
},
|
||||
{
|
||||
requiredPermission: `events.${key}.trigger.can_use`,
|
||||
locks: [],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onUnload() {
|
||||
// TODO: Run this.clearTriggers() once we actually have something there
|
||||
}
|
||||
|
||||
async runEvent(event: CustomEvent, eventData: any, values: any) {
|
||||
try {
|
||||
for (const action of event.actions) {
|
||||
if (action.type === "add_role") {
|
||||
await this.addRoleAction(action, values, event, eventData);
|
||||
} else if (action.type === "create_case") {
|
||||
await this.createCaseAction(action, values, event, eventData);
|
||||
} else if (action.type === "move_to_vc") {
|
||||
await this.moveToVoiceChannelAction(action, values, event, eventData);
|
||||
} else if (action.type === "message") {
|
||||
await this.messageAction(action, values);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ActionError) {
|
||||
if (event.trigger.type === "command") {
|
||||
this.sendErrorMessage((eventData.msg as Message).channel, e.message);
|
||||
} else {
|
||||
// TODO: Where to log action errors from other kinds of triggers?
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async addRoleAction(action: AddRoleAction, values: any, event: CustomEvent, eventData: any) {
|
||||
const targetId = await renderTemplate(action.target, values, false);
|
||||
const target = await this.getMember(targetId);
|
||||
if (!target) throw new ActionError(`Unknown target member: ${targetId}`);
|
||||
|
||||
if (event.trigger.type === "command" && !this.canActOn((eventData.msg as Message).member, target)) {
|
||||
throw new ActionError("Missing permissions");
|
||||
}
|
||||
|
||||
const rolesToAdd = Array.isArray(action.role) ? action.role : [action.role];
|
||||
await target.edit({
|
||||
roles: Array.from(new Set([...target.roles, ...rolesToAdd])),
|
||||
});
|
||||
}
|
||||
|
||||
async createCaseAction(action: CreateCaseAction, values: any, event: CustomEvent, eventData: any) {
|
||||
const modId = await renderTemplate(action.mod, values, false);
|
||||
const targetId = await renderTemplate(action.target, values, false);
|
||||
|
||||
const reason = await renderTemplate(action.reason, values, false);
|
||||
|
||||
if (CaseTypes[action.case_type] == null) {
|
||||
throw new ActionError(`Invalid case type: ${action.type}`);
|
||||
}
|
||||
|
||||
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
|
||||
await casesPlugin.createCase({
|
||||
userId: targetId,
|
||||
modId: modId,
|
||||
type: CaseTypes[action.case_type],
|
||||
reason: `__[${event.name}]__ ${reason}`,
|
||||
});
|
||||
}
|
||||
|
||||
async moveToVoiceChannelAction(action: MoveToVoiceChannelAction, values: any, event: CustomEvent, eventData: any) {
|
||||
const targetId = await renderTemplate(action.target, values, false);
|
||||
const target = await this.getMember(targetId);
|
||||
if (!target) throw new ActionError("Unknown target member");
|
||||
|
||||
if (event.trigger.type === "command" && !this.canActOn((eventData.msg as Message).member, target)) {
|
||||
throw new ActionError("Missing permissions");
|
||||
}
|
||||
|
||||
const targetChannelId = await renderTemplate(action.channel, values, false);
|
||||
const targetChannel = this.guild.channels.get(targetChannelId);
|
||||
if (!targetChannel) throw new ActionError("Unknown target channel");
|
||||
if (!(targetChannel instanceof VoiceChannel)) throw new ActionError("Target channel is not a voice channel");
|
||||
|
||||
if (!target.voiceState.channelID) return;
|
||||
await target.edit({
|
||||
channelID: targetChannel.id,
|
||||
});
|
||||
}
|
||||
|
||||
async messageAction(action: MessageAction, values: any) {
|
||||
const targetChannelId = await renderTemplate(action.channel, values, false);
|
||||
const targetChannel = this.guild.channels.get(targetChannelId);
|
||||
if (!targetChannel) throw new ActionError("Unknown target channel");
|
||||
if (!(targetChannel instanceof TextChannel)) throw new ActionError("Target channel is not a text channel");
|
||||
|
||||
await targetChannel.createMessage({ content: action.content });
|
||||
}
|
||||
}
|
|
@ -75,8 +75,8 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
|
|||
dm_on_mute: false,
|
||||
message_on_mute: false,
|
||||
message_channel: null,
|
||||
mute_message: "You have been muted on {guildName}. Reason given: {reason}",
|
||||
timed_mute_message: "You have been muted on {guildName} for {time}. Reason given: {reason}",
|
||||
mute_message: "You have been muted on the {guildName} server. Reason given: {reason}",
|
||||
timed_mute_message: "You have been muted on the {guildName} server for {time}. Reason given: {reason}",
|
||||
|
||||
can_view_list: false,
|
||||
can_cleanup: false,
|
||||
|
|
|
@ -21,6 +21,7 @@ import { WelcomeMessagePlugin } from "./WelcomeMessage";
|
|||
import { BotControlPlugin } from "./BotControl";
|
||||
import { LogServerPlugin } from "./LogServer";
|
||||
import { UsernameSaver } from "./UsernameSaver";
|
||||
import { CustomEventsPlugin } from "./CustomEvents";
|
||||
|
||||
/**
|
||||
* Plugins available to be loaded for individual guilds
|
||||
|
@ -46,6 +47,7 @@ export const availablePlugins = [
|
|||
SelfGrantableRolesPlugin,
|
||||
RemindersPlugin,
|
||||
WelcomeMessagePlugin,
|
||||
CustomEventsPlugin,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import has from "lodash.has";
|
||||
import at from "lodash.at";
|
||||
import { has, get } from "./utils";
|
||||
|
||||
const TEMPLATE_CACHE_SIZE = 200;
|
||||
const templateCache: Map<string, ParsedTemplate> = new Map();
|
||||
|
@ -219,7 +218,7 @@ export function parseTemplate(str: string): ParsedTemplate {
|
|||
}
|
||||
|
||||
async function evaluateTemplateVariable(theVar: ITemplateVar, values) {
|
||||
let value = has(values, theVar.identifier) ? at(values, theVar.identifier)[0] : undefined;
|
||||
const value = has(values, theVar.identifier) ? get(values, theVar.identifier) : undefined;
|
||||
|
||||
if (typeof value === "function") {
|
||||
const args = [];
|
||||
|
|
16
src/utils.ts
16
src/utils.ts
|
@ -5,7 +5,6 @@ import {
|
|||
Guild,
|
||||
GuildAuditLogEntry,
|
||||
Member,
|
||||
MessageContent,
|
||||
TextableChannel,
|
||||
TextChannel,
|
||||
User,
|
||||
|
@ -61,8 +60,19 @@ export function errorMessage(str) {
|
|||
return `⚠ ${str}`;
|
||||
}
|
||||
|
||||
export function uclower(str) {
|
||||
return str[0].toLowerCase() + str.slice(1);
|
||||
export function get(obj, path, def?): any {
|
||||
let cursor = obj;
|
||||
const pathParts = path.split(".");
|
||||
for (const part of pathParts) {
|
||||
cursor = cursor[part];
|
||||
if (cursor === undefined) return def;
|
||||
if (cursor == null) return null;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
export function has(obj, path): boolean {
|
||||
return get(obj, path) !== undefined;
|
||||
}
|
||||
|
||||
export function stripObjectToScalars(obj, includedNested: string[] = []) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue