3
0
Fork 0
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:
Dragory 2019-06-22 18:52:54 +03:00
commit 1dae3019c4
50 changed files with 9552 additions and 57 deletions

119
src/api/auth.ts Normal file
View 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
View 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
View 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
View 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();
}
}

View file

@ -7,5 +7,5 @@ export enum CaseTypes {
Mute,
Unmute,
Expunged,
Softban
Softban,
}

49
src/data/Configs.ts Normal file
View 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,
});
});
}
}

View 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}`;
}
}

View file

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

View 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);
}
}

View file

@ -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) {

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View file

@ -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
View 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);
});

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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
View 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 });
}
}

View file

@ -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,

View file

@ -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,
];
/**

View file

@ -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 = [];

View file

@ -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[] = []) {