mirror of
https://github.com/ZeppelinBot/Zeppelin.git
synced 2025-05-25 10:25:01 +00:00
Merge branch 'master' of https://github.com/ZeppelinBot/Zeppelin into ZeppelinBot-master
This commit is contained in:
commit
6a18b139c8
60 changed files with 2982 additions and 4594 deletions
19
.clabot
Normal file
19
.clabot
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"contributors": [
|
||||||
|
"dependabot",
|
||||||
|
"CleverSource",
|
||||||
|
"DarkView",
|
||||||
|
"Jernik",
|
||||||
|
"WeebHiroyuki",
|
||||||
|
"almeidx",
|
||||||
|
"axisiscool",
|
||||||
|
"dexbiobot",
|
||||||
|
"greenbigfrog",
|
||||||
|
"metal0",
|
||||||
|
"roflmaoqwerty",
|
||||||
|
"usoka",
|
||||||
|
"vcokltfre",
|
||||||
|
"Rstar284"
|
||||||
|
],
|
||||||
|
"message": "Thank you for contributing to Zeppelin! We require contributors to sign our Contributor License Agreement (CLA). To let us review and merge your code, please visit https://github.com/ZeppelinBot/CLA to sign the CLA!"
|
||||||
|
}
|
5738
backend/package-lock.json
generated
5738
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -28,11 +28,11 @@
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cross-env": "^5.2.0",
|
"cross-env": "^5.2.0",
|
||||||
"deep-diff": "^1.0.2",
|
"deep-diff": "^1.0.2",
|
||||||
"discord-api-types": "^0.22.0",
|
"discord-api-types": "^0.31.0",
|
||||||
"discord.js": "^13.3.1",
|
"discord.js": "^13.6.0",
|
||||||
"dotenv": "^4.0.0",
|
"dotenv": "^4.0.0",
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
"erlpack": "github:almeidx/erlpack#f0c535f73817fd914806d6ca26a7730c14e0fb7c",
|
"erlpack": "github:discord/erlpack",
|
||||||
"escape-string-regexp": "^1.0.5",
|
"escape-string-regexp": "^1.0.5",
|
||||||
"express": "^4.17.0",
|
"express": "^4.17.0",
|
||||||
"fp-ts": "^2.0.1",
|
"fp-ts": "^2.0.1",
|
||||||
|
@ -51,11 +51,11 @@
|
||||||
"moment-timezone": "^0.5.21",
|
"moment-timezone": "^0.5.21",
|
||||||
"multer": "^1.4.3",
|
"multer": "^1.4.3",
|
||||||
"mysql": "^2.16.0",
|
"mysql": "^2.16.0",
|
||||||
"node-fetch": "^2.6.5",
|
"node-fetch": "^2.6.7",
|
||||||
"parse-color": "^1.0.0",
|
"parse-color": "^1.0.0",
|
||||||
"passport": "^0.4.0",
|
"passport": "^0.4.0",
|
||||||
"passport-custom": "^1.0.5",
|
"passport-custom": "^1.0.5",
|
||||||
"passport-oauth2": "^1.5.0",
|
"passport-oauth2": "^1.6.1",
|
||||||
"pkg-up": "^3.1.0",
|
"pkg-up": "^3.1.0",
|
||||||
"reflect-metadata": "^0.1.12",
|
"reflect-metadata": "^0.1.12",
|
||||||
"regexp-worker": "^1.1.0",
|
"regexp-worker": "^1.1.0",
|
||||||
|
@ -90,10 +90,10 @@
|
||||||
"@types/safe-regex": "^1.1.2",
|
"@types/safe-regex": "^1.1.2",
|
||||||
"@types/tmp": "0.0.33",
|
"@types/tmp": "0.0.33",
|
||||||
"@types/twemoji": "^12.1.0",
|
"@types/twemoji": "^12.1.0",
|
||||||
"ava": "^3.10.0",
|
"ava": "^3.15.0",
|
||||||
"rimraf": "^2.6.2",
|
"rimraf": "^2.6.2",
|
||||||
"source-map-support": "^0.5.16",
|
"source-map-support": "^0.5.16",
|
||||||
"tsc-watch": "^4.0.0"
|
"tsc-watch": "^5.0.2"
|
||||||
},
|
},
|
||||||
"ava": {
|
"ava": {
|
||||||
"files": [
|
"files": [
|
||||||
|
|
|
@ -90,10 +90,15 @@ export class ApiLogins extends BaseRepository {
|
||||||
const [loginId, token] = apiKey.split(".");
|
const [loginId, token] = apiKey.split(".");
|
||||||
if (!loginId || !token) return;
|
if (!loginId || !token) return;
|
||||||
|
|
||||||
|
const updatedTime = moment().utc().add(LOGIN_EXPIRY_TIME, "ms");
|
||||||
|
|
||||||
|
const login = await this.apiLogins.createQueryBuilder().where("id = :id", { id: loginId }).getOne();
|
||||||
|
if (!login || moment.utc(login.expires_at).isSameOrAfter(updatedTime)) return;
|
||||||
|
|
||||||
await this.apiLogins.update(
|
await this.apiLogins.update(
|
||||||
{ id: loginId },
|
{ id: loginId },
|
||||||
{
|
{
|
||||||
expires_at: moment().utc().add(LOGIN_EXPIRY_TIME, "ms").format(DBDateFormat),
|
expires_at: updatedTime.format(DBDateFormat),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
39
backend/src/data/GuildRoleButtons.ts
Normal file
39
backend/src/data/GuildRoleButtons.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { getRepository, Repository } from "typeorm";
|
||||||
|
import { Reminder } from "./entities/Reminder";
|
||||||
|
import { BaseRepository } from "./BaseRepository";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import { DBDateFormat } from "../utils";
|
||||||
|
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||||
|
import { RoleQueueItem } from "./entities/RoleQueueItem";
|
||||||
|
import { connection } from "./db";
|
||||||
|
import { RoleButtonsItem } from "./entities/RoleButtonsItem";
|
||||||
|
|
||||||
|
export class GuildRoleButtons extends BaseGuildRepository {
|
||||||
|
private roleButtons: Repository<RoleButtonsItem>;
|
||||||
|
|
||||||
|
constructor(guildId) {
|
||||||
|
super(guildId);
|
||||||
|
this.roleButtons = getRepository(RoleButtonsItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSavedRoleButtons(): Promise<RoleButtonsItem[]> {
|
||||||
|
return this.roleButtons.find({ guild_id: this.guildId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRoleButtonItem(name: string): Promise<void> {
|
||||||
|
await this.roleButtons.delete({
|
||||||
|
guild_id: this.guildId,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveRoleButtonItem(name: string, channelId: string, messageId: string, hash: string): Promise<void> {
|
||||||
|
await this.roleButtons.insert({
|
||||||
|
guild_id: this.guildId,
|
||||||
|
name,
|
||||||
|
channel_id: channelId,
|
||||||
|
message_id: messageId,
|
||||||
|
hash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
48
backend/src/data/GuildRoleQueue.ts
Normal file
48
backend/src/data/GuildRoleQueue.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { getRepository, Repository } from "typeorm";
|
||||||
|
import { Reminder } from "./entities/Reminder";
|
||||||
|
import { BaseRepository } from "./BaseRepository";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import { DBDateFormat } from "../utils";
|
||||||
|
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||||
|
import { RoleQueueItem } from "./entities/RoleQueueItem";
|
||||||
|
import { connection } from "./db";
|
||||||
|
|
||||||
|
export class GuildRoleQueue extends BaseGuildRepository {
|
||||||
|
private roleQueue: Repository<RoleQueueItem>;
|
||||||
|
|
||||||
|
constructor(guildId) {
|
||||||
|
super(guildId);
|
||||||
|
this.roleQueue = getRepository(RoleQueueItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
consumeNextRoleAssignments(count: number): Promise<RoleQueueItem[]> {
|
||||||
|
return connection.transaction(async (entityManager) => {
|
||||||
|
const repository = entityManager.getRepository(RoleQueueItem);
|
||||||
|
|
||||||
|
const nextAssignments = await repository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.where("guild_id = :guildId", { guildId: this.guildId })
|
||||||
|
.addOrderBy("priority", "DESC")
|
||||||
|
.addOrderBy("id", "ASC")
|
||||||
|
.take(count)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
if (nextAssignments.length > 0) {
|
||||||
|
const ids = nextAssignments.map((assignment) => assignment.id);
|
||||||
|
await repository.createQueryBuilder().where("id IN (:ids)", { ids }).delete().execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextAssignments;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addQueueItem(userId: string, roleId: string, shouldAdd: boolean, priority = 0) {
|
||||||
|
await this.roleQueue.insert({
|
||||||
|
guild_id: this.guildId,
|
||||||
|
user_id: userId,
|
||||||
|
role_id: roleId,
|
||||||
|
should_add: shouldAdd,
|
||||||
|
priority,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
16
backend/src/data/entities/RoleButtonsItem.ts
Normal file
16
backend/src/data/entities/RoleButtonsItem.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||||
|
|
||||||
|
@Entity("role_buttons")
|
||||||
|
export class RoleButtonsItem {
|
||||||
|
@PrimaryGeneratedColumn() id: number;
|
||||||
|
|
||||||
|
@Column() guild_id: string;
|
||||||
|
|
||||||
|
@Column() name: string;
|
||||||
|
|
||||||
|
@Column() channel_id: string;
|
||||||
|
|
||||||
|
@Column() message_id: string;
|
||||||
|
|
||||||
|
@Column() hash: string;
|
||||||
|
}
|
16
backend/src/data/entities/RoleQueueItem.ts
Normal file
16
backend/src/data/entities/RoleQueueItem.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||||
|
|
||||||
|
@Entity("role_queue")
|
||||||
|
export class RoleQueueItem {
|
||||||
|
@PrimaryGeneratedColumn() id: number;
|
||||||
|
|
||||||
|
@Column() guild_id: string;
|
||||||
|
|
||||||
|
@Column() user_id: string;
|
||||||
|
|
||||||
|
@Column() role_id: string;
|
||||||
|
|
||||||
|
@Column() should_add: boolean;
|
||||||
|
|
||||||
|
@Column() priority: number;
|
||||||
|
}
|
|
@ -390,8 +390,10 @@ connect().then(async () => {
|
||||||
}, 100);
|
}, 100);
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
// FIXME: Debug
|
// FIXME: Debug
|
||||||
// tslint:disable-next-line:no-console
|
if (lowestGlobalRemaining < 30) {
|
||||||
console.log("Lowest global remaining in the past 15 seconds:", lowestGlobalRemaining);
|
// tslint:disable-next-line:no-console
|
||||||
|
console.log("[DEBUG] Lowest global remaining in the past 15 seconds:", lowestGlobalRemaining);
|
||||||
|
}
|
||||||
lowestGlobalRemaining = Infinity;
|
lowestGlobalRemaining = Infinity;
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
|
|
50
backend/src/migrations/1650709103864-CreateRoleQueueTable.ts
Normal file
50
backend/src/migrations/1650709103864-CreateRoleQueueTable.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||||
|
|
||||||
|
export class CreateRoleQueueTable1650709103864 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: "role_queue",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
type: "int",
|
||||||
|
isPrimary: true,
|
||||||
|
isGenerated: true,
|
||||||
|
generationStrategy: "increment",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "guild_id",
|
||||||
|
type: "bigint",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user_id",
|
||||||
|
type: "bigint",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "role_id",
|
||||||
|
type: "bigint",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "should_add",
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "priority",
|
||||||
|
type: "smallint",
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indices: [
|
||||||
|
{
|
||||||
|
columnNames: ["guild_id"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable("role_queue");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||||
|
|
||||||
|
export class CreateRoleButtonsTable1650712828384 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: "role_buttons",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
type: "int",
|
||||||
|
isPrimary: true,
|
||||||
|
isGenerated: true,
|
||||||
|
generationStrategy: "increment",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "guild_id",
|
||||||
|
type: "bigint",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
type: "varchar",
|
||||||
|
length: "255",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "channel_id",
|
||||||
|
type: "bigint",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "message_id",
|
||||||
|
type: "bigint",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hash",
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indices: [
|
||||||
|
{
|
||||||
|
columnNames: ["guild_id", "name"],
|
||||||
|
isUnique: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable("role_buttons");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||||
|
|
||||||
|
export class RemoveButtonRolesTable1650721020704 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable("button_roles");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: "button_roles",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: "guild_id",
|
||||||
|
type: "bigint",
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "channel_id",
|
||||||
|
type: "bigint",
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "message_id",
|
||||||
|
type: "bigint",
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "button_id",
|
||||||
|
type: "varchar",
|
||||||
|
length: "100",
|
||||||
|
isPrimary: true,
|
||||||
|
isUnique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "button_group",
|
||||||
|
type: "varchar",
|
||||||
|
length: "100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "button_name",
|
||||||
|
type: "varchar",
|
||||||
|
length: "100",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,7 +25,7 @@ export const BanAction = automodAction({
|
||||||
const reason = actionConfig.reason || "Kicked automatically";
|
const reason = actionConfig.reason || "Kicked automatically";
|
||||||
const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : undefined;
|
const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : undefined;
|
||||||
const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined;
|
const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined;
|
||||||
const deleteMessageDays = actionConfig.deleteMessageDays || undefined;
|
const deleteMessageDays = actionConfig.deleteMessageDays ?? undefined;
|
||||||
|
|
||||||
const caseArgs: Partial<CaseArgs> = {
|
const caseArgs: Partial<CaseArgs> = {
|
||||||
modId: pluginData.client.user!.id,
|
modId: pluginData.client.user!.id,
|
||||||
|
|
|
@ -72,7 +72,7 @@ export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
|
||||||
return (
|
return (
|
||||||
asSingleLine(`
|
asSingleLine(`
|
||||||
Matched attachment type \`${Util.escapeInlineCode(matchResult.extra.matchedType)}\`
|
Matched attachment type \`${Util.escapeInlineCode(matchResult.extra.matchedType)}\`
|
||||||
(${matchResult.extra.mode === "blacklist" ? "(blacklisted)" : "(not in whitelist)"})
|
(${matchResult.extra.mode === "blacklist" ? "blacklisted" : "not in whitelist"})
|
||||||
in message (\`${contexts[0].message!.id}\`) in ${prettyChannel}:
|
in message (\`${contexts[0].message!.id}\`) in ${prettyChannel}:
|
||||||
`) + messageSummary(contexts[0].message!)
|
`) + messageSummary(contexts[0].message!)
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { Snowflake } from "discord-api-types/globals";
|
||||||
import { GuildPluginData } from "knub";
|
import { GuildPluginData } from "knub";
|
||||||
import { logger } from "../../../logger";
|
import { logger } from "../../../logger";
|
||||||
import { resolveUser } from "../../../utils";
|
import { resolveUser } from "../../../utils";
|
||||||
|
@ -13,9 +14,11 @@ export async function createCase(pluginData: GuildPluginData<CasesPluginType>, a
|
||||||
const modName = mod.tag;
|
const modName = mod.tag;
|
||||||
|
|
||||||
let ppName: string | null = null;
|
let ppName: string | null = null;
|
||||||
|
let ppId: Snowflake | null = null;
|
||||||
if (args.ppId) {
|
if (args.ppId) {
|
||||||
const pp = await resolveUser(pluginData.client, args.ppId);
|
const pp = await resolveUser(pluginData.client, args.ppId);
|
||||||
ppName = pp.tag;
|
ppName = pp.tag;
|
||||||
|
ppId = pp.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.auditLogId) {
|
if (args.auditLogId) {
|
||||||
|
@ -28,20 +31,20 @@ export async function createCase(pluginData: GuildPluginData<CasesPluginType>, a
|
||||||
|
|
||||||
const createdCase = await pluginData.state.cases.create({
|
const createdCase = await pluginData.state.cases.create({
|
||||||
type: args.type,
|
type: args.type,
|
||||||
user_id: args.userId,
|
user_id: user.id,
|
||||||
user_name: userName,
|
user_name: userName,
|
||||||
mod_id: args.modId,
|
mod_id: mod.id,
|
||||||
mod_name: modName,
|
mod_name: modName,
|
||||||
audit_log_id: args.auditLogId,
|
audit_log_id: args.auditLogId,
|
||||||
pp_id: args.ppId,
|
pp_id: ppId,
|
||||||
pp_name: ppName,
|
pp_name: ppName,
|
||||||
is_hidden: Boolean(args.hide),
|
is_hidden: Boolean(args.hide),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (args.reason || (args.noteDetails && args.noteDetails.length)) {
|
if (args.reason || args.noteDetails?.length) {
|
||||||
await createCaseNote(pluginData, {
|
await createCaseNote(pluginData, {
|
||||||
caseId: createdCase.id,
|
caseId: createdCase.id,
|
||||||
modId: args.modId,
|
modId: mod.id,
|
||||||
body: args.reason || "",
|
body: args.reason || "",
|
||||||
automatic: args.automatic,
|
automatic: args.automatic,
|
||||||
postInCaseLogOverride: false,
|
postInCaseLogOverride: false,
|
||||||
|
@ -53,7 +56,7 @@ export async function createCase(pluginData: GuildPluginData<CasesPluginType>, a
|
||||||
for (const extraNote of args.extraNotes) {
|
for (const extraNote of args.extraNotes) {
|
||||||
await createCaseNote(pluginData, {
|
await createCaseNote(pluginData, {
|
||||||
caseId: createdCase.id,
|
caseId: createdCase.id,
|
||||||
modId: args.modId,
|
modId: mod.id,
|
||||||
body: extraNote,
|
body: extraNote,
|
||||||
automatic: args.automatic,
|
automatic: args.automatic,
|
||||||
postInCaseLogOverride: false,
|
postInCaseLogOverride: false,
|
||||||
|
|
|
@ -72,6 +72,6 @@ export const LocateUserPlugin = zeppelinGuildPlugin<LocateUserPluginType>()({
|
||||||
},
|
},
|
||||||
|
|
||||||
beforeUnload(pluginData) {
|
beforeUnload(pluginData) {
|
||||||
pluginData.state.unregisterGuildEventListener();
|
pluginData.state.unregisterGuildEventListener?.();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -85,7 +85,7 @@ export async function banUserId(
|
||||||
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId);
|
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId);
|
||||||
ignoreEvent(pluginData, IgnoredEventType.Ban, userId);
|
ignoreEvent(pluginData, IgnoredEventType.Ban, userId);
|
||||||
try {
|
try {
|
||||||
const deleteMessageDays = Math.min(30, Math.max(0, banOptions.deleteMessageDays ?? 1));
|
const deleteMessageDays = Math.min(7, Math.max(0, banOptions.deleteMessageDays ?? 1));
|
||||||
await pluginData.guild.bans.create(userId as Snowflake, {
|
await pluginData.guild.bans.create(userId as Snowflake, {
|
||||||
days: deleteMessageDays,
|
days: deleteMessageDays,
|
||||||
reason: reason ?? undefined,
|
reason: reason ?? undefined,
|
||||||
|
|
|
@ -8,7 +8,7 @@ export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
|
||||||
`),
|
`),
|
||||||
configurationGuide: trimPluginDescription(`
|
configurationGuide: trimPluginDescription(`
|
||||||
### Getting started
|
### Getting started
|
||||||
To get started, request an API key for Phisherman following the instructions at https://docs.phisherman.gg/#/api/getting-started?id=requesting-api-access.
|
To get started, request an API key for Phisherman following the instructions at https://docs.phisherman.gg/guide/getting-started.html#requesting-api-access
|
||||||
Then, add the api key to the plugin's config:
|
Then, add the api key to the plugin's config:
|
||||||
|
|
||||||
~~~yml
|
~~~yml
|
||||||
|
|
|
@ -1,33 +1,26 @@
|
||||||
import { PluginOptions } from "knub";
|
import { PluginOptions } from "knub";
|
||||||
import { ConfigPreprocessorFn } from "knub/dist/config/configTypes";
|
|
||||||
import { GuildButtonRoles } from "../../data/GuildButtonRoles";
|
|
||||||
import { GuildReactionRoles } from "../../data/GuildReactionRoles";
|
import { GuildReactionRoles } from "../../data/GuildReactionRoles";
|
||||||
import { GuildSavedMessages } from "../../data/GuildSavedMessages";
|
import { GuildSavedMessages } from "../../data/GuildSavedMessages";
|
||||||
import { Queue } from "../../Queue";
|
import { Queue } from "../../Queue";
|
||||||
import { isValidSnowflake } from "../../utils";
|
|
||||||
import { StrictValidationError } from "../../validatorUtils";
|
|
||||||
import { LogsPlugin } from "../Logs/LogsPlugin";
|
import { LogsPlugin } from "../Logs/LogsPlugin";
|
||||||
import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
|
import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
|
||||||
import { ClearReactionRolesCmd } from "./commands/ClearReactionRolesCmd";
|
import { ClearReactionRolesCmd } from "./commands/ClearReactionRolesCmd";
|
||||||
import { InitReactionRolesCmd } from "./commands/InitReactionRolesCmd";
|
import { InitReactionRolesCmd } from "./commands/InitReactionRolesCmd";
|
||||||
import { PostButtonRolesCmd } from "./commands/PostButtonRolesCmd";
|
|
||||||
import { RefreshReactionRolesCmd } from "./commands/RefreshReactionRolesCmd";
|
import { RefreshReactionRolesCmd } from "./commands/RefreshReactionRolesCmd";
|
||||||
import { AddReactionRoleEvt } from "./events/AddReactionRoleEvt";
|
import { AddReactionRoleEvt } from "./events/AddReactionRoleEvt";
|
||||||
import { ButtonInteractionEvt } from "./events/ButtonInteractionEvt";
|
|
||||||
import { MessageDeletedEvt } from "./events/MessageDeletedEvt";
|
import { MessageDeletedEvt } from "./events/MessageDeletedEvt";
|
||||||
import { ConfigSchema, ReactionRolesPluginType } from "./types";
|
import { ConfigSchema, ReactionRolesPluginType } from "./types";
|
||||||
import { autoRefreshLoop } from "./util/autoRefreshLoop";
|
|
||||||
import { getRowCount } from "./util/splitButtonsIntoRows";
|
|
||||||
|
|
||||||
const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API
|
const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API
|
||||||
|
|
||||||
const defaultOptions: PluginOptions<ReactionRolesPluginType> = {
|
const defaultOptions: PluginOptions<ReactionRolesPluginType> = {
|
||||||
config: {
|
config: {
|
||||||
button_groups: {},
|
|
||||||
auto_refresh_interval: MIN_AUTO_REFRESH,
|
auto_refresh_interval: MIN_AUTO_REFRESH,
|
||||||
remove_user_reactions: true,
|
remove_user_reactions: true,
|
||||||
|
|
||||||
can_manage: false,
|
can_manage: false,
|
||||||
|
|
||||||
|
button_groups: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
overrides: [
|
overrides: [
|
||||||
|
@ -40,72 +33,12 @@ const defaultOptions: PluginOptions<ReactionRolesPluginType> = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAXIMUM_COMPONENT_ROWS = 5;
|
|
||||||
|
|
||||||
const configPreprocessor: ConfigPreprocessorFn<ReactionRolesPluginType> = (options) => {
|
|
||||||
if (options.config.button_groups) {
|
|
||||||
for (const [groupName, group] of Object.entries(options.config.button_groups)) {
|
|
||||||
const defaultButtonNames = Object.keys(group.default_buttons);
|
|
||||||
const defaultButtons = Object.values(group.default_buttons);
|
|
||||||
const menuNames = Object.keys(group.button_menus ?? []);
|
|
||||||
|
|
||||||
const defaultBtnRowCount = getRowCount(defaultButtons);
|
|
||||||
if (defaultBtnRowCount > MAXIMUM_COMPONENT_ROWS || defaultBtnRowCount === 0) {
|
|
||||||
throw new StrictValidationError([
|
|
||||||
`Invalid row count for default_buttons: You currently have ${defaultBtnRowCount}, the maximum is 5. A new row is started automatically each 5 consecutive buttons.`,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < defaultButtons.length; i++) {
|
|
||||||
const defBtn = defaultButtons[i];
|
|
||||||
if (!menuNames.includes(defBtn.role_or_menu) && !isValidSnowflake(defBtn.role_or_menu)) {
|
|
||||||
throw new StrictValidationError([
|
|
||||||
`Invalid value for default_buttons/${defaultButtonNames[i]}/role_or_menu: ${defBtn.role_or_menu} is neither an existing menu nor a valid snowflake.`,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if (!defBtn.label && !defBtn.emoji) {
|
|
||||||
throw new StrictValidationError([
|
|
||||||
`Invalid values for default_buttons/${defaultButtonNames[i]}/(label|emoji): Must have label, emoji or both set for the button to be valid.`,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [menuName, menuButtonEntries] of Object.entries(group.button_menus ?? [])) {
|
|
||||||
const menuButtonNames = Object.keys(menuButtonEntries);
|
|
||||||
const menuButtons = Object.values(menuButtonEntries);
|
|
||||||
|
|
||||||
const menuButtonRowCount = getRowCount(menuButtons);
|
|
||||||
if (menuButtonRowCount > MAXIMUM_COMPONENT_ROWS || menuButtonRowCount === 0) {
|
|
||||||
throw new StrictValidationError([
|
|
||||||
`Invalid row count for button_menus/${menuName}: You currently have ${menuButtonRowCount}, the maximum is 5. A new row is started automatically each 5 consecutive buttons.`,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < menuButtons.length; i++) {
|
|
||||||
const menuBtn = menuButtons[i];
|
|
||||||
if (!menuNames.includes(menuBtn.role_or_menu) && !isValidSnowflake(menuBtn.role_or_menu)) {
|
|
||||||
throw new StrictValidationError([
|
|
||||||
`Invalid value for button_menus/${menuButtonNames[i]}/role_or_menu: ${menuBtn.role_or_menu} is neither an existing menu nor a valid snowflake.`,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if (!menuBtn.label && !menuBtn.emoji) {
|
|
||||||
throw new StrictValidationError([
|
|
||||||
`Invalid values for default_buttons/${defaultButtonNames[i]}/(label|emoji): Must have label, emoji or both set for the button to be valid.`,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return options;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ReactionRolesPlugin = zeppelinGuildPlugin<ReactionRolesPluginType>()({
|
export const ReactionRolesPlugin = zeppelinGuildPlugin<ReactionRolesPluginType>()({
|
||||||
name: "reaction_roles",
|
name: "reaction_roles",
|
||||||
showInDocs: true,
|
showInDocs: true,
|
||||||
info: {
|
info: {
|
||||||
prettyName: "Reaction roles",
|
prettyName: "Reaction roles",
|
||||||
|
legacy: "Consider using the [Role buttons](/docs/plugins/role_buttons) plugin instead.",
|
||||||
},
|
},
|
||||||
|
|
||||||
dependencies: () => [LogsPlugin],
|
dependencies: () => [LogsPlugin],
|
||||||
|
@ -117,23 +50,19 @@ export const ReactionRolesPlugin = zeppelinGuildPlugin<ReactionRolesPluginType>(
|
||||||
RefreshReactionRolesCmd,
|
RefreshReactionRolesCmd,
|
||||||
ClearReactionRolesCmd,
|
ClearReactionRolesCmd,
|
||||||
InitReactionRolesCmd,
|
InitReactionRolesCmd,
|
||||||
PostButtonRolesCmd,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
events: [
|
events: [
|
||||||
AddReactionRoleEvt,
|
AddReactionRoleEvt,
|
||||||
ButtonInteractionEvt,
|
|
||||||
MessageDeletedEvt,
|
MessageDeletedEvt,
|
||||||
],
|
],
|
||||||
configPreprocessor,
|
|
||||||
|
|
||||||
beforeLoad(pluginData) {
|
beforeLoad(pluginData) {
|
||||||
const { state, guild } = pluginData;
|
const { state, guild } = pluginData;
|
||||||
|
|
||||||
state.reactionRoles = GuildReactionRoles.getGuildInstance(guild.id);
|
state.reactionRoles = GuildReactionRoles.getGuildInstance(guild.id);
|
||||||
state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);
|
state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);
|
||||||
state.buttonRoles = GuildButtonRoles.getGuildInstance(guild.id);
|
|
||||||
state.reactionRemoveQueue = new Queue();
|
state.reactionRemoveQueue = new Queue();
|
||||||
state.roleChangeQueue = new Queue();
|
state.roleChangeQueue = new Queue();
|
||||||
state.pendingRoleChanges = new Map();
|
state.pendingRoleChanges = new Map();
|
||||||
|
@ -141,11 +70,12 @@ export const ReactionRolesPlugin = zeppelinGuildPlugin<ReactionRolesPluginType>(
|
||||||
},
|
},
|
||||||
|
|
||||||
afterLoad(pluginData) {
|
afterLoad(pluginData) {
|
||||||
// let autoRefreshInterval = pluginData.config.get().auto_refresh_interval;
|
const config = pluginData.config.get();
|
||||||
// if (autoRefreshInterval != null) {
|
if (config.button_groups) {
|
||||||
// autoRefreshInterval = Math.max(MIN_AUTO_REFRESH, autoRefreshInterval);
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
// autoRefreshLoop(pluginData, autoRefreshInterval);
|
body: "The 'button_groups' option of the 'reaction_roles' plugin is deprecated and non-functional. Consider using the new 'role_buttons' plugin instead!",
|
||||||
// }
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
beforeUnload(pluginData) {
|
beforeUnload(pluginData) {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Message, Snowflake } from "discord.js";
|
import { Message } from "discord.js";
|
||||||
import { commandTypeHelpers as ct } from "../../../commandTypes";
|
import { commandTypeHelpers as ct } from "../../../commandTypes";
|
||||||
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
|
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
|
||||||
import { isDiscordAPIError } from "../../../utils";
|
import { isDiscordAPIError } from "../../../utils";
|
||||||
|
|
|
@ -1,71 +0,0 @@
|
||||||
import { createHash } from "crypto";
|
|
||||||
import { MessageButton, Snowflake } from "discord.js";
|
|
||||||
import moment from "moment";
|
|
||||||
import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
|
|
||||||
import { commandTypeHelpers as ct } from "../../../commandTypes";
|
|
||||||
import { reactionRolesCmd } from "../types";
|
|
||||||
import { splitButtonsIntoRows } from "../util/splitButtonsIntoRows";
|
|
||||||
|
|
||||||
export const PostButtonRolesCmd = reactionRolesCmd({
|
|
||||||
trigger: "reaction_roles post",
|
|
||||||
permission: "can_manage",
|
|
||||||
|
|
||||||
signature: {
|
|
||||||
channel: ct.textChannel(),
|
|
||||||
buttonGroup: ct.string(),
|
|
||||||
},
|
|
||||||
|
|
||||||
async run({ message: msg, args, pluginData }) {
|
|
||||||
const cfg = pluginData.config.get();
|
|
||||||
if (!cfg.button_groups) {
|
|
||||||
sendErrorMessage(pluginData, msg.channel, "No button groups defined in config");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const group = cfg.button_groups[args.buttonGroup];
|
|
||||||
|
|
||||||
if (!group) {
|
|
||||||
sendErrorMessage(pluginData, msg.channel, `No button group matches the name **${args.buttonGroup}**`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttons: MessageButton[] = [];
|
|
||||||
const toInsert: Array<{ customId; buttonGroup; buttonName }> = [];
|
|
||||||
for (const [buttonName, button] of Object.entries(group.default_buttons)) {
|
|
||||||
const customId = createHash("md5").update(`${buttonName}${moment.utc().valueOf()}`).digest("hex");
|
|
||||||
|
|
||||||
const btn = new MessageButton()
|
|
||||||
.setLabel(button.label ?? "")
|
|
||||||
.setStyle(button.style ?? "PRIMARY")
|
|
||||||
.setCustomId(customId)
|
|
||||||
.setDisabled(button.disabled ?? false);
|
|
||||||
|
|
||||||
if (button.emoji) {
|
|
||||||
const emo = pluginData.client.emojis.resolve(button.emoji as Snowflake) ?? button.emoji;
|
|
||||||
btn.setEmoji(emo);
|
|
||||||
}
|
|
||||||
|
|
||||||
buttons.push(btn);
|
|
||||||
toInsert.push({ customId, buttonGroup: args.buttonGroup, buttonName });
|
|
||||||
}
|
|
||||||
const rows = splitButtonsIntoRows(buttons, Object.values(group.default_buttons)); // new MessageActionRow().addComponents(buttons);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newMsg = await args.channel.send({ content: group.message, components: rows });
|
|
||||||
|
|
||||||
for (const btn of toInsert) {
|
|
||||||
await pluginData.state.buttonRoles.add(
|
|
||||||
args.channel.id,
|
|
||||||
newMsg.id,
|
|
||||||
btn.customId,
|
|
||||||
btn.buttonGroup,
|
|
||||||
btn.buttonName,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
sendErrorMessage(pluginData, msg.channel, `Error trying to post message: ${e}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendSuccessMessage(pluginData, msg.channel, `Successfully posted message in <#${args.channel.id}>`);
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,86 +0,0 @@
|
||||||
import { MessageComponentInteraction } from "discord.js";
|
|
||||||
import humanizeDuration from "humanize-duration";
|
|
||||||
import moment from "moment";
|
|
||||||
import { LogType } from "src/data/LogType";
|
|
||||||
import { logger } from "src/logger";
|
|
||||||
import { LogsPlugin } from "src/plugins/Logs/LogsPlugin";
|
|
||||||
import { MINUTES } from "src/utils";
|
|
||||||
import { idToTimestamp } from "src/utils/idToTimestamp";
|
|
||||||
import { reactionRolesEvt } from "../types";
|
|
||||||
import { handleModifyRole, handleOpenMenu } from "../util/buttonActionHandlers";
|
|
||||||
import { BUTTON_CONTEXT_SEPARATOR, resolveStatefulCustomId } from "../util/buttonCustomIdFunctions";
|
|
||||||
import { ButtonMenuActions } from "../util/buttonMenuActions";
|
|
||||||
|
|
||||||
const BUTTON_INVALIDATION_TIME = 15 * MINUTES;
|
|
||||||
|
|
||||||
export const ButtonInteractionEvt = reactionRolesEvt({
|
|
||||||
event: "interactionCreate",
|
|
||||||
|
|
||||||
async listener(meta) {
|
|
||||||
const int = meta.args.interaction;
|
|
||||||
if (!int.isMessageComponent()) return;
|
|
||||||
|
|
||||||
const cfg = meta.pluginData.config.get();
|
|
||||||
const split = int.customId.split(BUTTON_CONTEXT_SEPARATOR);
|
|
||||||
const context = (await resolveStatefulCustomId(meta.pluginData, int.customId)) ?? {
|
|
||||||
groupName: split[0],
|
|
||||||
action: split[1],
|
|
||||||
roleOrMenu: split[2],
|
|
||||||
stateless: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (context.stateless) {
|
|
||||||
if (context.roleOrMenu == null) {
|
|
||||||
// Not reaction from this plugin
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timeSinceCreation = moment.utc().valueOf() - idToTimestamp(int.message.id)!;
|
|
||||||
if (timeSinceCreation >= BUTTON_INVALIDATION_TIME) {
|
|
||||||
sendEphemeralReply(
|
|
||||||
int,
|
|
||||||
`Sorry, but these buttons are invalid because they are older than ${humanizeDuration(
|
|
||||||
BUTTON_INVALIDATION_TIME,
|
|
||||||
)}.\nIf the menu is still available, open it again to assign yourself roles!`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const group = cfg.button_groups[context.groupName];
|
|
||||||
if (!group) {
|
|
||||||
await sendEphemeralReply(int, `A configuration error was encountered, please contact the Administrators!`);
|
|
||||||
meta.pluginData.getPlugin(LogsPlugin).logBotAlert({
|
|
||||||
body: `**A configuration error occurred** on buttons for message ${int.message.id}, group **${context.groupName}** not found in config`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that detected action is known by us
|
|
||||||
if (!(<any>Object).values(ButtonMenuActions).includes(context.action)) {
|
|
||||||
await sendEphemeralReply(int, `A internal error was encountered, please contact the Administrators!`);
|
|
||||||
meta.pluginData.getPlugin(LogsPlugin).logBotAlert({
|
|
||||||
body: `**A internal error occurred** on buttons for message ${int.message.id}, action **${context.action}** is not known`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.action === ButtonMenuActions.MODIFY_ROLE) {
|
|
||||||
await handleModifyRole(meta.pluginData, int, group, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.action === ButtonMenuActions.OPEN_MENU) {
|
|
||||||
await handleOpenMenu(meta.pluginData, int, group, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn(
|
|
||||||
`Action ${context.action} on button ${int.customId} (Guild: ${int.guildId}, Channel: ${int.channelId}) is unknown!`,
|
|
||||||
);
|
|
||||||
await sendEphemeralReply(int, `A internal error was encountered, please contact the Administrators!`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function sendEphemeralReply(interaction: MessageComponentInteraction, message: string) {
|
|
||||||
await interaction.reply({ content: message, ephemeral: true });
|
|
||||||
}
|
|
|
@ -8,7 +8,6 @@ export const MessageDeletedEvt = reactionRolesEvt({
|
||||||
async listener(meta) {
|
async listener(meta) {
|
||||||
const pluginData = meta.pluginData;
|
const pluginData = meta.pluginData;
|
||||||
|
|
||||||
await pluginData.state.buttonRoles.removeAllForMessageId(meta.args.message.id);
|
|
||||||
await pluginData.state.reactionRoles.removeFromMessage(meta.args.message.id);
|
await pluginData.state.reactionRoles.removeFromMessage(meta.args.message.id);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,43 +1,15 @@
|
||||||
import * as t from "io-ts";
|
import * as t from "io-ts";
|
||||||
import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub";
|
import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub";
|
||||||
import { GuildButtonRoles } from "src/data/GuildButtonRoles";
|
|
||||||
import { GuildReactionRoles } from "../../data/GuildReactionRoles";
|
import { GuildReactionRoles } from "../../data/GuildReactionRoles";
|
||||||
import { GuildSavedMessages } from "../../data/GuildSavedMessages";
|
import { GuildSavedMessages } from "../../data/GuildSavedMessages";
|
||||||
import { Queue } from "../../Queue";
|
import { Queue } from "../../Queue";
|
||||||
import { tNullable } from "../../utils";
|
import { tNullable } from "../../utils";
|
||||||
|
|
||||||
// These need to be updated every time discord adds/removes a style,
|
|
||||||
// but i cant figure out how to import MessageButtonStyles at runtime
|
|
||||||
enum ButtonStyles {
|
|
||||||
PRIMARY = 1,
|
|
||||||
SECONDARY = 2,
|
|
||||||
SUCCESS = 3,
|
|
||||||
DANGER = 4,
|
|
||||||
// LINK = 5, We do not want users to create link buttons, but it would be style 5
|
|
||||||
}
|
|
||||||
|
|
||||||
const ButtonOpts = t.type({
|
|
||||||
label: tNullable(t.string),
|
|
||||||
emoji: tNullable(t.string),
|
|
||||||
role_or_menu: t.string,
|
|
||||||
style: tNullable(t.keyof(ButtonStyles)), // https://discord.js.org/#/docs/main/master/typedef/MessageButtonStyle
|
|
||||||
disabled: tNullable(t.boolean),
|
|
||||||
end_row: tNullable(t.boolean),
|
|
||||||
});
|
|
||||||
export type TButtonOpts = t.TypeOf<typeof ButtonOpts>;
|
|
||||||
|
|
||||||
const ButtonPairOpts = t.type({
|
|
||||||
message: t.string,
|
|
||||||
default_buttons: t.record(t.string, ButtonOpts),
|
|
||||||
button_menus: tNullable(t.record(t.string, t.record(t.string, ButtonOpts))),
|
|
||||||
});
|
|
||||||
export type TButtonPairOpts = t.TypeOf<typeof ButtonPairOpts>;
|
|
||||||
|
|
||||||
export const ConfigSchema = t.type({
|
export const ConfigSchema = t.type({
|
||||||
button_groups: t.record(t.string, ButtonPairOpts),
|
|
||||||
auto_refresh_interval: t.number,
|
auto_refresh_interval: t.number,
|
||||||
remove_user_reactions: t.boolean,
|
remove_user_reactions: t.boolean,
|
||||||
can_manage: t.boolean,
|
can_manage: t.boolean,
|
||||||
|
button_groups: tNullable(t.unknown),
|
||||||
});
|
});
|
||||||
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
@ -61,7 +33,6 @@ export interface ReactionRolesPluginType extends BasePluginType {
|
||||||
state: {
|
state: {
|
||||||
reactionRoles: GuildReactionRoles;
|
reactionRoles: GuildReactionRoles;
|
||||||
savedMessages: GuildSavedMessages;
|
savedMessages: GuildSavedMessages;
|
||||||
buttonRoles: GuildButtonRoles;
|
|
||||||
|
|
||||||
reactionRemoveQueue: Queue;
|
reactionRemoveQueue: Queue;
|
||||||
roleChangeQueue: Queue;
|
roleChangeQueue: Queue;
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { Snowflake, TextChannel } from "discord.js";
|
import { Snowflake, TextChannel } from "discord.js";
|
||||||
import { GuildPluginData } from "knub";
|
import { GuildPluginData } from "knub";
|
||||||
import { ReactionRole } from "../../../data/entities/ReactionRole";
|
import { ReactionRole } from "../../../data/entities/ReactionRole";
|
||||||
import { LogType } from "../../../data/LogType";
|
|
||||||
import { isDiscordAPIError, sleep } from "../../../utils";
|
import { isDiscordAPIError, sleep } from "../../../utils";
|
||||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||||
import { ReactionRolesPluginType } from "../types";
|
import { ReactionRolesPluginType } from "../types";
|
||||||
|
|
|
@ -1,94 +0,0 @@
|
||||||
import { MessageButton, MessageComponentInteraction, Snowflake } from "discord.js";
|
|
||||||
import { GuildPluginData } from "knub";
|
|
||||||
import { LogType } from "../../../data/LogType";
|
|
||||||
import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin";
|
|
||||||
import { ReactionRolesPluginType, TButtonPairOpts } from "../types";
|
|
||||||
import { generateStatelessCustomId } from "./buttonCustomIdFunctions";
|
|
||||||
import { splitButtonsIntoRows } from "./splitButtonsIntoRows";
|
|
||||||
|
|
||||||
export async function handleOpenMenu(
|
|
||||||
pluginData: GuildPluginData<ReactionRolesPluginType>,
|
|
||||||
int: MessageComponentInteraction,
|
|
||||||
group: TButtonPairOpts,
|
|
||||||
context,
|
|
||||||
) {
|
|
||||||
const menuButtons: MessageButton[] = [];
|
|
||||||
if (group.button_menus == null) {
|
|
||||||
await int.reply({
|
|
||||||
content: `A configuration error was encountered, please contact the Administrators!`,
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
|
||||||
body: `**A configuration error occurred** on buttons for message ${int.message.id}, no menus found in config`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const menuButton of Object.values(group.button_menus[context.roleOrMenu])) {
|
|
||||||
const customId = await generateStatelessCustomId(pluginData, context.groupName, menuButton.role_or_menu);
|
|
||||||
|
|
||||||
const btn = new MessageButton()
|
|
||||||
.setLabel(menuButton.label ?? "")
|
|
||||||
.setStyle("PRIMARY")
|
|
||||||
.setCustomId(customId)
|
|
||||||
.setDisabled(menuButton.disabled ?? false);
|
|
||||||
|
|
||||||
if (menuButton.emoji) {
|
|
||||||
const emo = pluginData.client.emojis.resolve(menuButton.emoji as Snowflake) ?? menuButton.emoji;
|
|
||||||
btn.setEmoji(emo);
|
|
||||||
}
|
|
||||||
menuButtons.push(btn);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (menuButtons.length === 0) {
|
|
||||||
await int.reply({
|
|
||||||
content: `A configuration error was encountered, please contact the Administrators!`,
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
|
||||||
body: `**A configuration error occurred** on buttons for message ${int.message.id}, menu **${context.roleOrMenu}** not found in config`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rows = splitButtonsIntoRows(menuButtons, Object.values(group.button_menus[context.roleOrMenu])); // new MessageActionRow().addComponents(menuButtons);
|
|
||||||
|
|
||||||
int.reply({ content: `Click to add/remove a role`, components: rows, ephemeral: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleModifyRole(
|
|
||||||
pluginData: GuildPluginData<ReactionRolesPluginType>,
|
|
||||||
int: MessageComponentInteraction,
|
|
||||||
group: TButtonPairOpts,
|
|
||||||
context,
|
|
||||||
) {
|
|
||||||
const role = await pluginData.guild.roles.fetch(context.roleOrMenu);
|
|
||||||
if (!role) {
|
|
||||||
await int.reply({
|
|
||||||
content: `A configuration error was encountered, please contact the Administrators!`,
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
|
||||||
body: `**A configuration error occurred** on buttons for message ${int.message.id}, role **${context.roleOrMenu}** not found on server`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const member = await pluginData.guild.members.fetch(int.user.id);
|
|
||||||
try {
|
|
||||||
if (member.roles.cache.has(role.id)) {
|
|
||||||
await member.roles.remove(role, `Button Roles on message ${int.message.id}`);
|
|
||||||
await int.reply({ content: `Role **${role.name}** removed`, ephemeral: true });
|
|
||||||
} else {
|
|
||||||
await member.roles.add(role, `Button Roles on message ${int.message.id}`);
|
|
||||||
await int.reply({ content: `Role **${role.name}** added`, ephemeral: true });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
await int.reply({
|
|
||||||
content: "A configuration error was encountered, please contact the Administrators!",
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
|
||||||
body: `**A configuration error occurred** on buttons for message ${int.message.id}, error: ${e}. We might be missing permissions!`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,45 +0,0 @@
|
||||||
import { Snowflake } from "discord.js";
|
|
||||||
import { GuildPluginData } from "knub";
|
|
||||||
import { ReactionRolesPluginType } from "../types";
|
|
||||||
import { ButtonMenuActions } from "./buttonMenuActions";
|
|
||||||
|
|
||||||
export const BUTTON_CONTEXT_SEPARATOR = ":rb:";
|
|
||||||
|
|
||||||
export async function getButtonAction(pluginData: GuildPluginData<ReactionRolesPluginType>, roleOrMenu: string) {
|
|
||||||
if (await pluginData.guild.roles.fetch(roleOrMenu as Snowflake).catch(() => false)) {
|
|
||||||
return ButtonMenuActions.MODIFY_ROLE;
|
|
||||||
} else {
|
|
||||||
return ButtonMenuActions.OPEN_MENU;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateStatelessCustomId(
|
|
||||||
pluginData: GuildPluginData<ReactionRolesPluginType>,
|
|
||||||
groupName: string,
|
|
||||||
roleOrMenu: string,
|
|
||||||
) {
|
|
||||||
let id = groupName + BUTTON_CONTEXT_SEPARATOR;
|
|
||||||
|
|
||||||
id += `${await getButtonAction(pluginData, roleOrMenu)}${BUTTON_CONTEXT_SEPARATOR}${roleOrMenu}`;
|
|
||||||
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveStatefulCustomId(pluginData: GuildPluginData<ReactionRolesPluginType>, id: string) {
|
|
||||||
const button = await pluginData.state.buttonRoles.getForButtonId(id);
|
|
||||||
|
|
||||||
if (button) {
|
|
||||||
const group = pluginData.config.get().button_groups[button.button_group];
|
|
||||||
if (!group) return null;
|
|
||||||
const cfgButton = group.default_buttons[button.button_name];
|
|
||||||
|
|
||||||
return {
|
|
||||||
groupName: button.button_group,
|
|
||||||
action: await getButtonAction(pluginData, cfgButton.role_or_menu),
|
|
||||||
roleOrMenu: cfgButton.role_or_menu,
|
|
||||||
stateless: false,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +0,0 @@
|
||||||
export enum ButtonMenuActions {
|
|
||||||
OPEN_MENU = "goto",
|
|
||||||
MODIFY_ROLE = "grant",
|
|
||||||
}
|
|
|
@ -1,42 +0,0 @@
|
||||||
import { MessageActionRow, MessageButton } from "discord.js";
|
|
||||||
import { TButtonOpts } from "../types";
|
|
||||||
|
|
||||||
export function splitButtonsIntoRows(actualButtons: MessageButton[], configButtons: TButtonOpts[]): MessageActionRow[] {
|
|
||||||
const rows: MessageActionRow[] = [];
|
|
||||||
let curRow = new MessageActionRow();
|
|
||||||
let consecutive = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < actualButtons.length; i++) {
|
|
||||||
const aBtn = actualButtons[i];
|
|
||||||
const cBtn = configButtons[i];
|
|
||||||
|
|
||||||
curRow.addComponents(aBtn);
|
|
||||||
if (((consecutive + 1) % 5 === 0 || cBtn.end_row) && i + 1 < actualButtons.length) {
|
|
||||||
rows.push(curRow);
|
|
||||||
curRow = new MessageActionRow();
|
|
||||||
consecutive = 0;
|
|
||||||
} else {
|
|
||||||
consecutive++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (curRow.components.length >= 1) rows.push(curRow);
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRowCount(configButtons: TButtonOpts[]): number {
|
|
||||||
let count = 1;
|
|
||||||
let consecutive = 0;
|
|
||||||
for (let i = 0; i < configButtons.length; i++) {
|
|
||||||
const cBtn = configButtons[i];
|
|
||||||
|
|
||||||
if (((consecutive + 1) % 5 === 0 || cBtn.end_row) && i + 1 < configButtons.length) {
|
|
||||||
count++;
|
|
||||||
consecutive = 0;
|
|
||||||
} else {
|
|
||||||
consecutive++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return count;
|
|
||||||
}
|
|
86
backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts
Normal file
86
backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
|
||||||
|
import { ConfigSchema, RoleButtonsPluginType } from "./types";
|
||||||
|
import { mapToPublicFn } from "../../pluginUtils";
|
||||||
|
import { LogsPlugin } from "../Logs/LogsPlugin";
|
||||||
|
import { applyAllRoleButtons } from "./functions/applyAllRoleButtons";
|
||||||
|
import { GuildRoleButtons } from "../../data/GuildRoleButtons";
|
||||||
|
import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin";
|
||||||
|
import { StrictValidationError } from "../../validatorUtils";
|
||||||
|
import { onButtonInteraction } from "./events/buttonInteraction";
|
||||||
|
import { pluginInfo } from "./info";
|
||||||
|
import { createButtonComponents } from "./functions/createButtonComponents";
|
||||||
|
import { TooManyComponentsError } from "./functions/TooManyComponentsError";
|
||||||
|
import { resetButtonsCmd } from "./commands/resetButtons";
|
||||||
|
|
||||||
|
export const RoleButtonsPlugin = zeppelinGuildPlugin<RoleButtonsPluginType>()({
|
||||||
|
name: "role_buttons",
|
||||||
|
configSchema: ConfigSchema,
|
||||||
|
info: pluginInfo,
|
||||||
|
showInDocs: true,
|
||||||
|
|
||||||
|
defaultOptions: {
|
||||||
|
config: {
|
||||||
|
buttons: {},
|
||||||
|
can_reset: false,
|
||||||
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
level: ">=100",
|
||||||
|
config: {
|
||||||
|
can_reset: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
configPreprocessor(options) {
|
||||||
|
// Auto-fill "name" property for buttons based on the object key
|
||||||
|
const buttonsArray = Array.isArray(options.config?.buttons) ? options.config.buttons : [];
|
||||||
|
const seenMessages = new Set();
|
||||||
|
for (const [name, buttonsConfig] of Object.entries(options.config?.buttons ?? {})) {
|
||||||
|
if (name.length > 16) {
|
||||||
|
throw new StrictValidationError(["Name for role buttons can be at most 16 characters long"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buttonsConfig) {
|
||||||
|
buttonsConfig.name = name;
|
||||||
|
|
||||||
|
if (buttonsConfig.message) {
|
||||||
|
if ("message_id" in buttonsConfig.message) {
|
||||||
|
if (seenMessages.has(buttonsConfig.message.message_id)) {
|
||||||
|
throw new StrictValidationError(["Can't target the same message with two sets of role buttons"]);
|
||||||
|
}
|
||||||
|
seenMessages.add(buttonsConfig.message.message_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buttonsConfig.options) {
|
||||||
|
try {
|
||||||
|
createButtonComponents(buttonsConfig);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TooManyComponentsError) {
|
||||||
|
throw new StrictValidationError(["Too many options; can only have max 5 buttons per row on max 5 rows."]);
|
||||||
|
}
|
||||||
|
throw new StrictValidationError(["Error validating options"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
},
|
||||||
|
|
||||||
|
dependencies: () => [LogsPlugin, RoleManagerPlugin],
|
||||||
|
|
||||||
|
events: [onButtonInteraction],
|
||||||
|
|
||||||
|
commands: [resetButtonsCmd],
|
||||||
|
|
||||||
|
beforeLoad(pluginData) {
|
||||||
|
pluginData.state.roleButtons = GuildRoleButtons.getGuildInstance(pluginData.guild.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async afterLoad(pluginData) {
|
||||||
|
await applyAllRoleButtons(pluginData);
|
||||||
|
},
|
||||||
|
});
|
27
backend/src/plugins/RoleButtons/commands/resetButtons.ts
Normal file
27
backend/src/plugins/RoleButtons/commands/resetButtons.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { typedGuildCommand } from "knub";
|
||||||
|
import { RoleButtonsPluginType } from "../types";
|
||||||
|
import { commandTypeHelpers as ct } from "../../../commandTypes";
|
||||||
|
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
|
||||||
|
import { applyAllRoleButtons } from "../functions/applyAllRoleButtons";
|
||||||
|
|
||||||
|
export const resetButtonsCmd = typedGuildCommand<RoleButtonsPluginType>()({
|
||||||
|
trigger: "role_buttons reset",
|
||||||
|
description:
|
||||||
|
"In case of issues, you can run this command to have Zeppelin 'forget' about specific role buttons and re-apply them. This will also repost the message, if not targeting an existing message.",
|
||||||
|
usage: "!role_buttons reset my_roles",
|
||||||
|
permission: "can_reset",
|
||||||
|
signature: {
|
||||||
|
name: ct.string(),
|
||||||
|
},
|
||||||
|
async run({ pluginData, args, message }) {
|
||||||
|
const config = pluginData.config.get();
|
||||||
|
if (!config.buttons[args.name]) {
|
||||||
|
sendErrorMessage(pluginData, message.channel, `Can't find role buttons with the name "${args.name}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pluginData.state.roleButtons.deleteRoleButtonItem(args.name);
|
||||||
|
await applyAllRoleButtons(pluginData);
|
||||||
|
sendSuccessMessage(pluginData, message.channel, "Done!");
|
||||||
|
},
|
||||||
|
});
|
70
backend/src/plugins/RoleButtons/events/buttonInteraction.ts
Normal file
70
backend/src/plugins/RoleButtons/events/buttonInteraction.ts
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import { typedGuildEventListener } from "knub";
|
||||||
|
import { RoleButtonsPluginType, TRoleButtonOption } from "../types";
|
||||||
|
import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin";
|
||||||
|
import { GuildMember } from "discord.js";
|
||||||
|
import { getAllRolesInButtons } from "../functions/getAllRolesInButtons";
|
||||||
|
import { parseCustomId } from "../../../utils/parseCustomId";
|
||||||
|
|
||||||
|
export const onButtonInteraction = typedGuildEventListener<RoleButtonsPluginType>()({
|
||||||
|
event: "interactionCreate",
|
||||||
|
async listener({ pluginData, args }) {
|
||||||
|
if (!args.interaction.isButton()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { namespace, data } = parseCustomId(args.interaction.customId);
|
||||||
|
if (namespace !== "roleButtons") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = pluginData.config.get();
|
||||||
|
const { name, index: optionIndex } = data;
|
||||||
|
// For some reason TS's type inference fails here so using a type annotation
|
||||||
|
const buttons = config.buttons[name];
|
||||||
|
const option: TRoleButtonOption | undefined = buttons?.options[optionIndex];
|
||||||
|
if (!buttons || !option) {
|
||||||
|
args.interaction.reply({
|
||||||
|
ephemeral: true,
|
||||||
|
content: "Invalid option selected",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = args.interaction.member as GuildMember;
|
||||||
|
const role = pluginData.guild.roles.cache.get(option.role_id);
|
||||||
|
const roleName = role?.name || option.role_id;
|
||||||
|
|
||||||
|
const rolesToRemove: string[] = [];
|
||||||
|
const rolesToAdd: string[] = [];
|
||||||
|
|
||||||
|
if (member.roles.cache.has(option.role_id)) {
|
||||||
|
rolesToRemove.push(option.role_id);
|
||||||
|
args.interaction.reply({
|
||||||
|
ephemeral: true,
|
||||||
|
content: `The role **${roleName}** will be removed shortly!`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
rolesToAdd.push(option.role_id);
|
||||||
|
|
||||||
|
if (buttons.exclusive) {
|
||||||
|
for (const roleId of getAllRolesInButtons(buttons)) {
|
||||||
|
if (member.roles.cache.has(roleId)) {
|
||||||
|
rolesToRemove.push(roleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args.interaction.reply({
|
||||||
|
ephemeral: true,
|
||||||
|
content: `You will receive the **${roleName}** role shortly!`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const roleId of rolesToAdd) {
|
||||||
|
pluginData.getPlugin(RoleManagerPlugin).addRole(member.user.id, roleId);
|
||||||
|
}
|
||||||
|
for (const roleId of rolesToRemove) {
|
||||||
|
pluginData.getPlugin(RoleManagerPlugin).removeRole(member.user.id, roleId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1 @@
|
||||||
|
export class TooManyComponentsError extends Error {}
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { GuildPluginData } from "knub";
|
||||||
|
import { RoleButtonsPluginType } from "../types";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import { applyRoleButtons } from "./applyRoleButtons";
|
||||||
|
|
||||||
|
export async function applyAllRoleButtons(pluginData: GuildPluginData<RoleButtonsPluginType>) {
|
||||||
|
const savedRoleButtons = await pluginData.state.roleButtons.getSavedRoleButtons();
|
||||||
|
const config = pluginData.config.get();
|
||||||
|
for (const buttons of Object.values(config.buttons)) {
|
||||||
|
// Use the hash of the config to quickly check if we need to update buttons
|
||||||
|
const hash = createHash("md5").update(JSON.stringify(buttons)).digest("hex");
|
||||||
|
const savedButtonsItem = savedRoleButtons.find((bt) => bt.name === buttons.name);
|
||||||
|
if (savedButtonsItem?.hash === hash) {
|
||||||
|
// No changes
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedButtonsItem) {
|
||||||
|
await pluginData.state.roleButtons.deleteRoleButtonItem(buttons.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyResult = await applyRoleButtons(pluginData, buttons, savedButtonsItem ?? null);
|
||||||
|
if (!applyResult) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pluginData.state.roleButtons.saveRoleButtonItem(
|
||||||
|
buttons.name,
|
||||||
|
applyResult.channel_id,
|
||||||
|
applyResult.message_id,
|
||||||
|
hash,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove saved role buttons from the DB that are no longer in the config
|
||||||
|
const savedRoleButtonsToDelete = savedRoleButtons
|
||||||
|
.filter((savedRoleButton) => !config.buttons[savedRoleButton.name])
|
||||||
|
.map((savedRoleButton) => savedRoleButton.name);
|
||||||
|
for (const name of savedRoleButtonsToDelete) {
|
||||||
|
await pluginData.state.roleButtons.deleteRoleButtonItem(name);
|
||||||
|
}
|
||||||
|
}
|
120
backend/src/plugins/RoleButtons/functions/applyRoleButtons.ts
Normal file
120
backend/src/plugins/RoleButtons/functions/applyRoleButtons.ts
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
import { GuildPluginData } from "knub";
|
||||||
|
import { RoleButtonsPluginType, TRoleButtonsConfigItem } from "../types";
|
||||||
|
import { isSnowflake, snowflakeRegex } from "../../../utils";
|
||||||
|
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||||
|
import { Message, MessageButton, MessageEditOptions, MessageOptions, Snowflake } from "discord.js";
|
||||||
|
import { RoleButtonsItem } from "../../../data/entities/RoleButtonsItem";
|
||||||
|
import { buildCustomId } from "../../../utils/buildCustomId";
|
||||||
|
import { createButtonComponents } from "./createButtonComponents";
|
||||||
|
|
||||||
|
const channelMessageRegex = new RegExp(`^(${snowflakeRegex.source})-(${snowflakeRegex.source})$`);
|
||||||
|
|
||||||
|
export async function applyRoleButtons(
|
||||||
|
pluginData: GuildPluginData<RoleButtonsPluginType>,
|
||||||
|
configItem: TRoleButtonsConfigItem,
|
||||||
|
existingSavedButtons: RoleButtonsItem | null,
|
||||||
|
): Promise<{ channel_id: string; message_id: string } | null> {
|
||||||
|
let message: Message;
|
||||||
|
|
||||||
|
// Remove existing role buttons, if any
|
||||||
|
if (existingSavedButtons?.channel_id) {
|
||||||
|
const existingChannel = await pluginData.guild.channels.fetch(configItem.message.channel_id).catch(() => null);
|
||||||
|
const existingMessage = await (existingChannel?.isText() &&
|
||||||
|
existingChannel.messages.fetch(existingSavedButtons.message_id).catch(() => null));
|
||||||
|
if (existingMessage && existingMessage.components.length) {
|
||||||
|
await existingMessage.edit({
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create message for role buttons
|
||||||
|
if ("message_id" in configItem.message) {
|
||||||
|
// channel id + message id: apply role buttons to existing message
|
||||||
|
const channel = await pluginData.guild.channels.fetch(configItem.message.channel_id).catch(() => null);
|
||||||
|
const messageCandidate = await (channel?.isText() &&
|
||||||
|
channel.messages.fetch(configItem.message.message_id).catch(() => null));
|
||||||
|
if (!messageCandidate) {
|
||||||
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
|
body: `Message not found for role_buttons/${configItem.name}`,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
message = messageCandidate;
|
||||||
|
} else {
|
||||||
|
// channel id + message content: post new message to apply role buttons to
|
||||||
|
const contentIsValid =
|
||||||
|
typeof configItem.message.content === "string"
|
||||||
|
? configItem.message.content.trim() !== ""
|
||||||
|
: Boolean(configItem.message.content.content?.trim()) || configItem.message.content.embeds?.length;
|
||||||
|
if (!contentIsValid) {
|
||||||
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
|
body: `Invalid message content for role_buttons/${configItem.name}`,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await pluginData.guild.channels.fetch(configItem.message.channel_id).catch(() => null);
|
||||||
|
if (!channel || !channel.isText()) {
|
||||||
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
|
body: `Text channel not found for role_buttons/${configItem.name}`,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidateMessage: Message | null = null;
|
||||||
|
|
||||||
|
if (existingSavedButtons?.channel_id === configItem.message.channel_id && existingSavedButtons.message_id) {
|
||||||
|
try {
|
||||||
|
candidateMessage = await channel.messages.fetch(existingSavedButtons.message_id);
|
||||||
|
// Make sure message contents are up-to-date
|
||||||
|
const editContent =
|
||||||
|
typeof configItem.message.content === "string"
|
||||||
|
? { content: configItem.message.content }
|
||||||
|
: { ...configItem.message.content };
|
||||||
|
if (!editContent.content) {
|
||||||
|
// Editing with empty content doesn't go through at all for whatever reason, even if there's differences in e.g. the embeds,
|
||||||
|
// so send a space as the content instead. This still functions as if there's no content at all.
|
||||||
|
editContent.content = " ";
|
||||||
|
}
|
||||||
|
await candidateMessage.edit(editContent as MessageEditOptions);
|
||||||
|
} catch (err) {
|
||||||
|
// Message was deleted or is inaccessible. Proceed with reposting it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!candidateMessage) {
|
||||||
|
try {
|
||||||
|
candidateMessage = await channel.send(configItem.message.content as string | MessageOptions);
|
||||||
|
} catch (err) {
|
||||||
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
|
body: `Error while posting message for role_buttons/${configItem.name}: ${String(err)}`,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message = candidateMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.author.id !== pluginData.client.user?.id) {
|
||||||
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
|
body: `Error applying role buttons for role_buttons/${configItem.name}: target message must be posted by Zeppelin`,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply role buttons
|
||||||
|
const components = createButtonComponents(configItem);
|
||||||
|
await message.edit({ components }).catch((err) => {
|
||||||
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
|
body: `Error applying role buttons for role_buttons/${configItem.name}: ${String(err)}`,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
channel_id: message.channelId,
|
||||||
|
message_id: message.id,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { MessageActionRow, MessageButton, Snowflake } from "discord.js";
|
||||||
|
import { chunkArray } from "../../../utils";
|
||||||
|
import { RoleButtonsPluginType, TRoleButtonOption, TRoleButtonsConfigItem } from "../types";
|
||||||
|
import { buildCustomId } from "../../../utils/buildCustomId";
|
||||||
|
import { GuildPluginData } from "knub";
|
||||||
|
import { TooManyComponentsError } from "./TooManyComponentsError";
|
||||||
|
|
||||||
|
export function createButtonComponents(configItem: TRoleButtonsConfigItem): MessageActionRow[] {
|
||||||
|
const rows: MessageActionRow[] = [];
|
||||||
|
|
||||||
|
let currentRow = new MessageActionRow();
|
||||||
|
for (const [index, option] of configItem.options.entries()) {
|
||||||
|
if (currentRow.components.length === 5 || (currentRow.components.length > 0 && option.start_new_row)) {
|
||||||
|
rows.push(currentRow);
|
||||||
|
currentRow = new MessageActionRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = new MessageButton()
|
||||||
|
.setLabel(option.label ?? "")
|
||||||
|
.setStyle(option.style ?? "PRIMARY")
|
||||||
|
.setCustomId(buildCustomId("roleButtons", { name: configItem.name, index }));
|
||||||
|
|
||||||
|
if (option.emoji) {
|
||||||
|
button.setEmoji(option.emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRow.components.push(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRow.components.length > 0) {
|
||||||
|
rows.push(currentRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length > 5) {
|
||||||
|
throw new TooManyComponentsError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { TRoleButtonsConfigItem } from "../types";
|
||||||
|
|
||||||
|
// This function will be more complex in the future when the plugin supports select menus + sub-menus
|
||||||
|
export function getAllRolesInButtons(buttons: TRoleButtonsConfigItem): string[] {
|
||||||
|
const roles = new Set<string>();
|
||||||
|
for (const option of buttons.options) {
|
||||||
|
roles.add(option.role_id);
|
||||||
|
}
|
||||||
|
return Array.from(roles);
|
||||||
|
}
|
80
backend/src/plugins/RoleButtons/info.ts
Normal file
80
backend/src/plugins/RoleButtons/info.ts
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import { trimPluginDescription } from "../../utils";
|
||||||
|
import { ZeppelinGuildPluginBlueprint } from "../ZeppelinPluginBlueprint";
|
||||||
|
|
||||||
|
export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
|
||||||
|
prettyName: "Role buttons",
|
||||||
|
description: trimPluginDescription(`
|
||||||
|
Allow users to pick roles by clicking on buttons
|
||||||
|
`),
|
||||||
|
configurationGuide: trimPluginDescription(`
|
||||||
|
Button roles are entirely config-based; this is in contrast to the old reaction roles. They can either be added to an existing message posted by Zeppelin or posted as a new message.
|
||||||
|
|
||||||
|
## Basic role buttons
|
||||||
|
~~~yml
|
||||||
|
role_buttons:
|
||||||
|
config:
|
||||||
|
buttons:
|
||||||
|
my_roles: # You can use any name you want here, but make sure not to change it afterwards
|
||||||
|
message:
|
||||||
|
channel_id: "967407495544983552"
|
||||||
|
content: "Click the reactions below to get roles! Click again to remove the role."
|
||||||
|
options:
|
||||||
|
- role_id: "878339100015489044"
|
||||||
|
label: "Role 1"
|
||||||
|
- role_id: "967410091571703808"
|
||||||
|
emoji: "😁" # Default emoji as a unicode emoji
|
||||||
|
label: "Role 2"
|
||||||
|
- role_id: "967410091571703234"
|
||||||
|
emoji: "967412591683047445" # Custom emoji ID
|
||||||
|
- role_id: "967410091571703567"
|
||||||
|
label: "Role 4"
|
||||||
|
style: DANGER # Button style (in all caps), see https://discord.com/developers/docs/interactions/message-components#button-object-button-styles
|
||||||
|
~~~
|
||||||
|
|
||||||
|
### Or with an embed:
|
||||||
|
~~~yml
|
||||||
|
role_buttons:
|
||||||
|
config:
|
||||||
|
buttons:
|
||||||
|
my_roles:
|
||||||
|
message:
|
||||||
|
channel_id: "967407495544983552"
|
||||||
|
content:
|
||||||
|
embeds:
|
||||||
|
- title: "Pick your role below!"
|
||||||
|
color: 0x0088FF
|
||||||
|
description: "You can pick any role you want by clicking the buttons below."
|
||||||
|
options:
|
||||||
|
... # See above for examples for options
|
||||||
|
~~~
|
||||||
|
|
||||||
|
## Role buttons for an existing message
|
||||||
|
This message must be posted by Zeppelin.
|
||||||
|
~~~yml
|
||||||
|
role_buttons:
|
||||||
|
config:
|
||||||
|
buttons:
|
||||||
|
my_roles:
|
||||||
|
message:
|
||||||
|
channel_id: "967407495544983552"
|
||||||
|
message_id: "967407554412040193"
|
||||||
|
options:
|
||||||
|
... # See above for examples for options
|
||||||
|
~~~
|
||||||
|
|
||||||
|
## Limiting to one role ("exclusive" roles)
|
||||||
|
When the \`exclusive\` option is enabled, only one role can be selected at a time.
|
||||||
|
~~~yml
|
||||||
|
role_buttons:
|
||||||
|
config:
|
||||||
|
buttons:
|
||||||
|
my_roles:
|
||||||
|
message:
|
||||||
|
channel_id: "967407495544983552"
|
||||||
|
message_id: "967407554412040193"
|
||||||
|
exclusive: true # With this option set, only one role can be selected at a time
|
||||||
|
options:
|
||||||
|
... # See above for examples for options
|
||||||
|
~~~
|
||||||
|
`),
|
||||||
|
};
|
52
backend/src/plugins/RoleButtons/types.ts
Normal file
52
backend/src/plugins/RoleButtons/types.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { BasePluginType } from "knub";
|
||||||
|
import { tMessageContent, tNullable } from "../../utils";
|
||||||
|
import { GuildRoleButtons } from "../../data/GuildRoleButtons";
|
||||||
|
|
||||||
|
const RoleButtonOption = t.type({
|
||||||
|
role_id: t.string,
|
||||||
|
label: tNullable(t.string),
|
||||||
|
emoji: tNullable(t.string),
|
||||||
|
// https://discord.js.org/#/docs/discord.js/v13/typedef/MessageButtonStyle
|
||||||
|
style: tNullable(
|
||||||
|
t.union([
|
||||||
|
t.literal("PRIMARY"),
|
||||||
|
t.literal("SECONDARY"),
|
||||||
|
t.literal("SUCCESS"),
|
||||||
|
t.literal("DANGER"),
|
||||||
|
// t.literal("LINK"), // Role buttons don't use link buttons, but adding this here so it's documented why it's not available
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
start_new_row: tNullable(t.boolean),
|
||||||
|
});
|
||||||
|
export type TRoleButtonOption = t.TypeOf<typeof RoleButtonOption>;
|
||||||
|
|
||||||
|
const RoleButtonsConfigItem = t.type({
|
||||||
|
name: t.string,
|
||||||
|
message: t.union([
|
||||||
|
t.type({
|
||||||
|
channel_id: t.string,
|
||||||
|
message_id: t.string,
|
||||||
|
}),
|
||||||
|
t.type({
|
||||||
|
channel_id: t.string,
|
||||||
|
content: tMessageContent,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
options: t.array(RoleButtonOption),
|
||||||
|
exclusive: tNullable(t.boolean),
|
||||||
|
});
|
||||||
|
export type TRoleButtonsConfigItem = t.TypeOf<typeof RoleButtonsConfigItem>;
|
||||||
|
|
||||||
|
export const ConfigSchema = t.type({
|
||||||
|
buttons: t.record(t.string, RoleButtonsConfigItem),
|
||||||
|
can_reset: t.boolean,
|
||||||
|
});
|
||||||
|
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
export interface RoleButtonsPluginType extends BasePluginType {
|
||||||
|
config: TConfigSchema;
|
||||||
|
state: {
|
||||||
|
roleButtons: GuildRoleButtons;
|
||||||
|
};
|
||||||
|
}
|
39
backend/src/plugins/RoleManager/RoleManagerPlugin.ts
Normal file
39
backend/src/plugins/RoleManager/RoleManagerPlugin.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
|
||||||
|
import { ConfigSchema, RoleManagerPluginType } from "./types";
|
||||||
|
import { GuildRoleQueue } from "../../data/GuildRoleQueue";
|
||||||
|
import { mapToPublicFn } from "../../pluginUtils";
|
||||||
|
import { addRole } from "./functions/addRole";
|
||||||
|
import { removeRole } from "./functions/removeRole";
|
||||||
|
import { addPriorityRole } from "./functions/addPriorityRole";
|
||||||
|
import { removePriorityRole } from "./functions/removePriorityRole";
|
||||||
|
import { runRoleAssignmentLoop } from "./functions/runRoleAssignmentLoop";
|
||||||
|
import { LogsPlugin } from "../Logs/LogsPlugin";
|
||||||
|
|
||||||
|
export const RoleManagerPlugin = zeppelinGuildPlugin<RoleManagerPluginType>()({
|
||||||
|
name: "role_manager",
|
||||||
|
configSchema: ConfigSchema,
|
||||||
|
showInDocs: false,
|
||||||
|
|
||||||
|
dependencies: () => [LogsPlugin],
|
||||||
|
|
||||||
|
public: {
|
||||||
|
addRole: mapToPublicFn(addRole),
|
||||||
|
removeRole: mapToPublicFn(removeRole),
|
||||||
|
addPriorityRole: mapToPublicFn(addPriorityRole),
|
||||||
|
removePriorityRole: mapToPublicFn(removePriorityRole),
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeLoad(pluginData) {
|
||||||
|
pluginData.state.roleQueue = GuildRoleQueue.getGuildInstance(pluginData.guild.id);
|
||||||
|
pluginData.state.pendingRoleAssignmentPromise = Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
afterLoad(pluginData) {
|
||||||
|
runRoleAssignmentLoop(pluginData);
|
||||||
|
},
|
||||||
|
|
||||||
|
async afterUnload(pluginData) {
|
||||||
|
pluginData.state.abortRoleAssignmentLoop = true;
|
||||||
|
await pluginData.state.pendingRoleAssignmentPromise;
|
||||||
|
},
|
||||||
|
});
|
1
backend/src/plugins/RoleManager/constants.ts
Normal file
1
backend/src/plugins/RoleManager/constants.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const PRIORITY_ROLE_PRIORITY = 10;
|
13
backend/src/plugins/RoleManager/functions/addPriorityRole.ts
Normal file
13
backend/src/plugins/RoleManager/functions/addPriorityRole.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { GuildPluginData } from "knub";
|
||||||
|
import { RoleManagerPluginType } from "../types";
|
||||||
|
import { PRIORITY_ROLE_PRIORITY } from "../constants";
|
||||||
|
import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop";
|
||||||
|
|
||||||
|
export async function addPriorityRole(
|
||||||
|
pluginData: GuildPluginData<RoleManagerPluginType>,
|
||||||
|
userId: string,
|
||||||
|
roleId: string,
|
||||||
|
) {
|
||||||
|
await pluginData.state.roleQueue.addQueueItem(userId, roleId, true, PRIORITY_ROLE_PRIORITY);
|
||||||
|
runRoleAssignmentLoop(pluginData);
|
||||||
|
}
|
8
backend/src/plugins/RoleManager/functions/addRole.ts
Normal file
8
backend/src/plugins/RoleManager/functions/addRole.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { GuildPluginData } from "knub";
|
||||||
|
import { RoleManagerPluginType } from "../types";
|
||||||
|
import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop";
|
||||||
|
|
||||||
|
export async function addRole(pluginData: GuildPluginData<RoleManagerPluginType>, userId: string, roleId: string) {
|
||||||
|
await pluginData.state.roleQueue.addQueueItem(userId, roleId, true);
|
||||||
|
runRoleAssignmentLoop(pluginData);
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { GuildPluginData } from "knub";
|
||||||
|
import { RoleManagerPluginType } from "../types";
|
||||||
|
import { PRIORITY_ROLE_PRIORITY } from "../constants";
|
||||||
|
import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop";
|
||||||
|
|
||||||
|
export async function removePriorityRole(
|
||||||
|
pluginData: GuildPluginData<RoleManagerPluginType>,
|
||||||
|
userId: string,
|
||||||
|
roleId: string,
|
||||||
|
) {
|
||||||
|
await pluginData.state.roleQueue.addQueueItem(userId, roleId, false, PRIORITY_ROLE_PRIORITY);
|
||||||
|
runRoleAssignmentLoop(pluginData);
|
||||||
|
}
|
8
backend/src/plugins/RoleManager/functions/removeRole.ts
Normal file
8
backend/src/plugins/RoleManager/functions/removeRole.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { GuildPluginData } from "knub";
|
||||||
|
import { RoleManagerPluginType } from "../types";
|
||||||
|
import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop";
|
||||||
|
|
||||||
|
export async function removeRole(pluginData: GuildPluginData<RoleManagerPluginType>, userId: string, roleId: string) {
|
||||||
|
await pluginData.state.roleQueue.addQueueItem(userId, roleId, false);
|
||||||
|
runRoleAssignmentLoop(pluginData);
|
||||||
|
}
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { GuildPluginData } from "knub";
|
||||||
|
import { RoleManagerPluginType } from "../types";
|
||||||
|
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||||
|
import { logger } from "../../../logger";
|
||||||
|
import { RoleQueueItem } from "../../../data/entities/RoleQueueItem";
|
||||||
|
|
||||||
|
const ROLE_ASSIGNMENTS_PER_BATCH = 20;
|
||||||
|
|
||||||
|
export async function runRoleAssignmentLoop(pluginData: GuildPluginData<RoleManagerPluginType>) {
|
||||||
|
if (pluginData.state.roleAssignmentLoopRunning || pluginData.state.abortRoleAssignmentLoop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pluginData.state.roleAssignmentLoopRunning = true;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// Abort on unload
|
||||||
|
if (pluginData.state.abortRoleAssignmentLoop) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pluginData.state.roleAssignmentLoopRunning) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await (pluginData.state.pendingRoleAssignmentPromise = (async () => {
|
||||||
|
// Process assignments in batches, stopping once the queue's exhausted
|
||||||
|
const nextAssignments = await pluginData.state.roleQueue.consumeNextRoleAssignments(ROLE_ASSIGNMENTS_PER_BATCH);
|
||||||
|
if (nextAssignments.length === 0) {
|
||||||
|
pluginData.state.roleAssignmentLoopRunning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove assignments that cancel each other out (e.g. from spam-clicking a role button)
|
||||||
|
const validAssignments = new Map<string, RoleQueueItem>();
|
||||||
|
for (const assignment of nextAssignments) {
|
||||||
|
const key = `${assignment.should_add ? 1 : 0}|${assignment.user_id}|${assignment.role_id}`;
|
||||||
|
const oppositeKey = `${assignment.should_add ? 0 : 1}|${assignment.user_id}|${assignment.role_id}`;
|
||||||
|
if (validAssignments.has(oppositeKey)) {
|
||||||
|
validAssignments.delete(oppositeKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
validAssignments.set(key, assignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply batch in parallel
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(validAssignments.values()).map(async (assignment) => {
|
||||||
|
const member = await pluginData.guild.members.fetch(assignment.user_id).catch(() => null);
|
||||||
|
if (!member) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operation = assignment.should_add
|
||||||
|
? member.roles.add(assignment.role_id)
|
||||||
|
: member.roles.remove(assignment.role_id);
|
||||||
|
|
||||||
|
await operation.catch((err) => {
|
||||||
|
logger.warn(err);
|
||||||
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
|
body: `Could not ${assignment.should_add ? "assign" : "remove"} role <@&${assignment.role_id}> (\`${
|
||||||
|
assignment.role_id
|
||||||
|
}\`) ${assignment.should_add ? "to" : "from"} <@!${assignment.user_id}> (\`${assignment.user_id}\`)`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
}
|
17
backend/src/plugins/RoleManager/types.ts
Normal file
17
backend/src/plugins/RoleManager/types.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { BasePluginType, typedGuildCommand } from "knub";
|
||||||
|
import { GuildLogs } from "../../data/GuildLogs";
|
||||||
|
import { GuildRoleQueue } from "../../data/GuildRoleQueue";
|
||||||
|
|
||||||
|
export const ConfigSchema = t.type({});
|
||||||
|
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
export interface RoleManagerPluginType extends BasePluginType {
|
||||||
|
config: TConfigSchema;
|
||||||
|
state: {
|
||||||
|
roleQueue: GuildRoleQueue;
|
||||||
|
roleAssignmentLoopRunning: boolean;
|
||||||
|
abortRoleAssignmentLoop: boolean;
|
||||||
|
pendingRoleAssignmentPromise: Promise<unknown>;
|
||||||
|
};
|
||||||
|
}
|
|
@ -8,7 +8,7 @@ import { GuildLogs } from "../../data/GuildLogs";
|
||||||
import { GuildSavedMessages } from "../../data/GuildSavedMessages";
|
import { GuildSavedMessages } from "../../data/GuildSavedMessages";
|
||||||
import { GuildTags } from "../../data/GuildTags";
|
import { GuildTags } from "../../data/GuildTags";
|
||||||
import { mapToPublicFn } from "../../pluginUtils";
|
import { mapToPublicFn } from "../../pluginUtils";
|
||||||
import { convertDelayStringToMS } from "../../utils";
|
import { convertDelayStringToMS, trimPluginDescription } from "../../utils";
|
||||||
import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
|
import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
|
||||||
import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
|
import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
|
||||||
import { TagCreateCmd } from "./commands/TagCreateCmd";
|
import { TagCreateCmd } from "./commands/TagCreateCmd";
|
||||||
|
@ -22,6 +22,8 @@ import { onMessageCreate } from "./util/onMessageCreate";
|
||||||
import { onMessageDelete } from "./util/onMessageDelete";
|
import { onMessageDelete } from "./util/onMessageDelete";
|
||||||
import { renderTagBody } from "./util/renderTagBody";
|
import { renderTagBody } from "./util/renderTagBody";
|
||||||
import { LogsPlugin } from "../Logs/LogsPlugin";
|
import { LogsPlugin } from "../Logs/LogsPlugin";
|
||||||
|
import { generateTemplateMarkdown } from "./docs";
|
||||||
|
import { TemplateFunctions } from "./templateFunctions";
|
||||||
|
|
||||||
const defaultOptions: PluginOptions<TagsPluginType> = {
|
const defaultOptions: PluginOptions<TagsPluginType> = {
|
||||||
config: {
|
config: {
|
||||||
|
@ -58,6 +60,17 @@ export const TagsPlugin = zeppelinGuildPlugin<TagsPluginType>()({
|
||||||
showInDocs: true,
|
showInDocs: true,
|
||||||
info: {
|
info: {
|
||||||
prettyName: "Tags",
|
prettyName: "Tags",
|
||||||
|
description: "Tags are a way to store and reuse information.",
|
||||||
|
configurationGuide: trimPluginDescription(`
|
||||||
|
### Template Functions
|
||||||
|
You can use template functions in your tags. These functions are called when the tag is rendered.
|
||||||
|
You can use these functions to render dynamic content, or to access information from the message and/or user calling the tag.
|
||||||
|
You use them by adding a \`{}\` on your tag.
|
||||||
|
|
||||||
|
Here are the functions you can use in your tags:
|
||||||
|
|
||||||
|
${generateTemplateMarkdown(TemplateFunctions)}
|
||||||
|
`),
|
||||||
},
|
},
|
||||||
|
|
||||||
configSchema: ConfigSchema,
|
configSchema: ConfigSchema,
|
||||||
|
|
17
backend/src/plugins/Tags/docs.ts
Normal file
17
backend/src/plugins/Tags/docs.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { trimPluginDescription } from "src/utils";
|
||||||
|
import { TemplateFunction } from "./types";
|
||||||
|
|
||||||
|
export function generateTemplateMarkdown(definitions: TemplateFunction[]): string {
|
||||||
|
return definitions
|
||||||
|
.map((def) => {
|
||||||
|
const usage = def.signature ?? `(${def.arguments.join(", ")})`;
|
||||||
|
const examples = def.examples?.map((ex) => `> \`{${ex}}\``).join("\n") ?? null;
|
||||||
|
return trimPluginDescription(`
|
||||||
|
## ${def.name}
|
||||||
|
**${def.description}**\n
|
||||||
|
__Usage__: \`{${def.name}${usage}}\`\n
|
||||||
|
${examples ? `__Examples__:\n${examples}` : ""}\n\n
|
||||||
|
`);
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
166
backend/src/plugins/Tags/templateFunctions.ts
Normal file
166
backend/src/plugins/Tags/templateFunctions.ts
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
import { TemplateFunction } from "./types";
|
||||||
|
|
||||||
|
// TODO: Generate this dynamically, lmao
|
||||||
|
export const TemplateFunctions: TemplateFunction[] = [
|
||||||
|
{
|
||||||
|
name: "if",
|
||||||
|
description: "Checks if a condition is true or false and returns the corresponding ifTrue or ifFalse",
|
||||||
|
returnValue: "boolean",
|
||||||
|
arguments: ["condition", "ifTrue", "ifFalse"],
|
||||||
|
examples: ['if(user.bot, "User is a bot", "User is not a bot")'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "and",
|
||||||
|
description: "Checks if all provided conditions are true",
|
||||||
|
returnValue: "boolean",
|
||||||
|
arguments: ["condition1", "condition2", "..."],
|
||||||
|
examples: ["and(user.bot, user.verified)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "or",
|
||||||
|
description: "Checks if atleast one of the provided conditions is true",
|
||||||
|
returnValue: "boolean",
|
||||||
|
arguments: ["condition1", "condition2", "..."],
|
||||||
|
examples: ["or(user.bot, user.verified)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not",
|
||||||
|
description: "Checks if the provided condition is false",
|
||||||
|
returnValue: "boolean",
|
||||||
|
arguments: ["condition"],
|
||||||
|
examples: ["not(user.bot)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "concat",
|
||||||
|
description: "Concatenates several arguments into a string",
|
||||||
|
returnValue: "string",
|
||||||
|
arguments: ["argument1", "argument2", "..."],
|
||||||
|
examples: ['concat("Hello ", user.username, "!")'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "concatArr",
|
||||||
|
description: "Joins a array with the provided separator",
|
||||||
|
returnValue: "string",
|
||||||
|
arguments: ["array", "separator"],
|
||||||
|
examples: ['concatArr(["Hello", "World"], " ")'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "eq",
|
||||||
|
description: "Checks if all provided arguments are equal to each other",
|
||||||
|
returnValue: "boolean",
|
||||||
|
arguments: ["argument1", "argument2", "..."],
|
||||||
|
examples: ['eq(user.id, "106391128718245888")'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gt",
|
||||||
|
description: "Checks if the first argument is greater than the second",
|
||||||
|
returnValue: "boolean",
|
||||||
|
arguments: ["argument1", "argument2"],
|
||||||
|
examples: ["gt(5, 2)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gte",
|
||||||
|
description: "Checks if the first argument is greater or equal to the second",
|
||||||
|
returnValue: "boolean",
|
||||||
|
arguments: ["argument1", "argument2"],
|
||||||
|
examples: ["gte(2, 2)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lt",
|
||||||
|
description: "Checks if the first argument is smaller than the second",
|
||||||
|
returnValue: "boolean",
|
||||||
|
arguments: ["argument1", "argument2"],
|
||||||
|
examples: ["lt(2, 5)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lte",
|
||||||
|
description: "Checks if the first argument is smaller or equal to the second",
|
||||||
|
returnValue: "boolean",
|
||||||
|
arguments: ["argument1", "argument2"],
|
||||||
|
examples: ["lte(2, 2)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "slice",
|
||||||
|
description: "Slices a string argument at start and end",
|
||||||
|
returnValue: "string",
|
||||||
|
arguments: ["string", "start", "end"],
|
||||||
|
examples: ['slice("Hello World", 0, 5)'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lower",
|
||||||
|
description: "Converts a string argument to lowercase",
|
||||||
|
returnValue: "string",
|
||||||
|
arguments: ["string"],
|
||||||
|
examples: ['lower("Hello World")'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upper",
|
||||||
|
description: "Converts a string argument to uppercase",
|
||||||
|
returnValue: "string",
|
||||||
|
arguments: ["string"],
|
||||||
|
examples: ['upper("Hello World")'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upperFirst",
|
||||||
|
description: "Converts the first character of a string argument to uppercase",
|
||||||
|
returnValue: "string",
|
||||||
|
arguments: ["string"],
|
||||||
|
examples: ['upperFirst("hello World")'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rand",
|
||||||
|
description: "Returns a random number between from and to, optionally using seed",
|
||||||
|
returnValue: "number",
|
||||||
|
arguments: ["from", "to", "seed"],
|
||||||
|
examples: ["rand(1, 10)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "round",
|
||||||
|
description: "Rounds a number to the given decimal places",
|
||||||
|
returnValue: "number",
|
||||||
|
arguments: ["number", "decimalPlaces"],
|
||||||
|
examples: ["round(1.2345, 2)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add",
|
||||||
|
description: "Adds two or more numbers",
|
||||||
|
returnValue: "number",
|
||||||
|
arguments: ["number1", "number2", "..."],
|
||||||
|
examples: ["add(1, 2)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sub",
|
||||||
|
description: "Subtracts two or more numbers",
|
||||||
|
returnValue: "number",
|
||||||
|
arguments: ["number1", "number2", "..."],
|
||||||
|
examples: ["sub(3, 1)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mul",
|
||||||
|
description: "Multiplies two or more numbers",
|
||||||
|
returnValue: "number",
|
||||||
|
arguments: ["number1", "number2", "..."],
|
||||||
|
examples: ["mul(2, 3)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "div",
|
||||||
|
description: "Divides two or more numbers",
|
||||||
|
returnValue: "number",
|
||||||
|
arguments: ["number1", "number2", "..."],
|
||||||
|
examples: ["div(6, 2)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cases",
|
||||||
|
description: "Returns the argument at position",
|
||||||
|
returnValue: "any",
|
||||||
|
arguments: ["position", "argument1", "argument2", "..."],
|
||||||
|
examples: ['cases(1, "Hello", "World")'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "choose",
|
||||||
|
description: "Returns a random argument",
|
||||||
|
returnValue: "any",
|
||||||
|
arguments: ["argument1", "argument2", "..."],
|
||||||
|
examples: ['choose("Hello", "World", "!")'],
|
||||||
|
},
|
||||||
|
];
|
|
@ -59,5 +59,14 @@ export interface TagsPluginType extends BasePluginType {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TemplateFunction {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
arguments: string[];
|
||||||
|
returnValue: string;
|
||||||
|
signature?: string;
|
||||||
|
examples?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export const tagsCmd = typedGuildCommand<TagsPluginType>();
|
export const tagsCmd = typedGuildCommand<TagsPluginType>();
|
||||||
export const tagsEvt = typedGuildEventListener<TagsPluginType>();
|
export const tagsEvt = typedGuildEventListener<TagsPluginType>();
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { MessageEmbedOptions, Role } from "discord.js";
|
import { MessageEmbedOptions, Permissions, Role } from "discord.js";
|
||||||
import humanizeDuration from "humanize-duration";
|
import humanizeDuration from "humanize-duration";
|
||||||
import { GuildPluginData } from "knub";
|
import { GuildPluginData } from "knub";
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
import { EmbedWith, preEmbedPadding, trimLines } from "../../../utils";
|
import { EmbedWith, preEmbedPadding, trimLines } from "../../../utils";
|
||||||
|
import { PERMISSION_NAMES } from "../../../utils/permissionNames.js";
|
||||||
import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin";
|
import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin";
|
||||||
import { UtilityPluginType } from "../types";
|
import { UtilityPluginType } from "../types";
|
||||||
|
|
||||||
|
@ -35,14 +36,9 @@ export async function getRoleInfoEmbed(
|
||||||
round: true,
|
round: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rolePerms = Object.keys(role.permissions.toJSON()).map((p) =>
|
const rolePerms = role.permissions.has(Permissions.FLAGS.ADMINISTRATOR)
|
||||||
p
|
? [PERMISSION_NAMES.ADMINISTRATOR]
|
||||||
// Voice channel related permission names start with 'voice'
|
: role.permissions.toArray().map((p) => PERMISSION_NAMES[p]);
|
||||||
.replace(/^voice/i, "")
|
|
||||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/(^\w{1})|(\s{1}\w{1})/g, (l) => l.toUpperCase()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// -1 because of the @everyone role
|
// -1 because of the @everyone role
|
||||||
const totalGuildRoles = pluginData.guild.roles.cache.size - 1;
|
const totalGuildRoles = pluginData.guild.roles.cache.size - 1;
|
||||||
|
|
|
@ -16,6 +16,13 @@ import {
|
||||||
import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin";
|
import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin";
|
||||||
import { UtilityPluginType } from "../types";
|
import { UtilityPluginType } from "../types";
|
||||||
|
|
||||||
|
const MAX_ROLES_TO_DISPLAY = 15;
|
||||||
|
|
||||||
|
const trimRoles = (roles: string[]) =>
|
||||||
|
roles.length > MAX_ROLES_TO_DISPLAY
|
||||||
|
? roles.slice(0, MAX_ROLES_TO_DISPLAY).join(", ") + `, and ${MAX_ROLES_TO_DISPLAY - roles.length} more roles`
|
||||||
|
: roles.join(", ");
|
||||||
|
|
||||||
export async function getUserInfoEmbed(
|
export async function getUserInfoEmbed(
|
||||||
pluginData: GuildPluginData<UtilityPluginType>,
|
pluginData: GuildPluginData<UtilityPluginType>,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
@ -109,7 +116,7 @@ export async function getUserInfoEmbed(
|
||||||
name: preEmbedPadding + "Member information",
|
name: preEmbedPadding + "Member information",
|
||||||
value: trimLines(`
|
value: trimLines(`
|
||||||
${user.bot ? "Added" : "Joined"}: **${joinAge} ago** (\`${prettyJoinedAt}\`)
|
${user.bot ? "Added" : "Joined"}: **${joinAge} ago** (\`${prettyJoinedAt}\`)
|
||||||
${roles.length > 0 ? "Roles: " + roles.map((r) => `<@&${r.id}>`).join(", ") : ""}
|
${roles.length > 0 ? "Roles: " + trimRoles(roles.map((r) => `<@&${r.id}>`)) : ""}
|
||||||
`),
|
`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,7 @@ export interface ZeppelinGuildPluginBlueprint<TPluginData extends GuildPluginDat
|
||||||
description?: TMarkdown;
|
description?: TMarkdown;
|
||||||
usageGuide?: TMarkdown;
|
usageGuide?: TMarkdown;
|
||||||
configurationGuide?: TMarkdown;
|
configurationGuide?: TMarkdown;
|
||||||
legacy?: boolean;
|
legacy?: boolean | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
configPreprocessor?: (
|
configPreprocessor?: (
|
||||||
|
|
|
@ -36,6 +36,8 @@ import { WelcomeMessagePlugin } from "./WelcomeMessage/WelcomeMessagePlugin";
|
||||||
import { ZeppelinGlobalPluginBlueprint, ZeppelinGuildPluginBlueprint } from "./ZeppelinPluginBlueprint";
|
import { ZeppelinGlobalPluginBlueprint, ZeppelinGuildPluginBlueprint } from "./ZeppelinPluginBlueprint";
|
||||||
import { PhishermanPlugin } from "./Phisherman/PhishermanPlugin";
|
import { PhishermanPlugin } from "./Phisherman/PhishermanPlugin";
|
||||||
import { InternalPosterPlugin } from "./InternalPoster/InternalPosterPlugin";
|
import { InternalPosterPlugin } from "./InternalPoster/InternalPosterPlugin";
|
||||||
|
import { RoleManagerPlugin } from "./RoleManager/RoleManagerPlugin";
|
||||||
|
import { RoleButtonsPlugin } from "./RoleButtons/RoleButtonsPlugin";
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
export const guildPlugins: Array<ZeppelinGuildPluginBlueprint<any>> = [
|
export const guildPlugins: Array<ZeppelinGuildPluginBlueprint<any>> = [
|
||||||
|
@ -73,6 +75,8 @@ export const guildPlugins: Array<ZeppelinGuildPluginBlueprint<any>> = [
|
||||||
ContextMenuPlugin,
|
ContextMenuPlugin,
|
||||||
PhishermanPlugin,
|
PhishermanPlugin,
|
||||||
InternalPosterPlugin,
|
InternalPosterPlugin,
|
||||||
|
RoleManagerPlugin,
|
||||||
|
RoleButtonsPlugin,
|
||||||
];
|
];
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
|
|
|
@ -327,7 +327,7 @@ export const zEmbedInput = z.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
url: z.string().optional(),
|
url: z.string().optional(),
|
||||||
timestamp: z.number().optional(),
|
timestamp: z.string().optional(),
|
||||||
color: z.number().optional(),
|
color: z.number().optional(),
|
||||||
|
|
||||||
footer: z.optional(
|
footer: z.optional(
|
||||||
|
@ -876,7 +876,7 @@ export function chunkArray<T>(arr: T[], chunkSize): T[][] {
|
||||||
|
|
||||||
for (let i = 0; i < arr.length; i++) {
|
for (let i = 0; i < arr.length; i++) {
|
||||||
currentChunk.push(arr[i]);
|
currentChunk.push(arr[i]);
|
||||||
if ((i !== 0 && i % chunkSize === 0) || i === arr.length - 1) {
|
if ((i !== 0 && (i + 1) % chunkSize === 0) || i === arr.length - 1) {
|
||||||
chunks.push(currentChunk);
|
chunks.push(currentChunk);
|
||||||
currentChunk = [];
|
currentChunk = [];
|
||||||
}
|
}
|
||||||
|
|
3
backend/src/utils/buildCustomId.ts
Normal file
3
backend/src/utils/buildCustomId.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export function buildCustomId(namespace: string, data: any = {}) {
|
||||||
|
return `${namespace}:${Date.now()}:${JSON.stringify(data)}`;
|
||||||
|
}
|
30
backend/src/utils/parseCustomId.ts
Normal file
30
backend/src/utils/parseCustomId.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
|
const customIdFormat = /^([^:]+):\d+:(.*)$/;
|
||||||
|
|
||||||
|
export function parseCustomId(customId: string): { namespace: string; data: any } {
|
||||||
|
const parts = customId.match(customIdFormat);
|
||||||
|
if (!parts) {
|
||||||
|
return {
|
||||||
|
namespace: "",
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedData: any;
|
||||||
|
try {
|
||||||
|
parsedData = JSON.parse(parts[2]);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(`Error while parsing custom id data (custom id: ${customId}): ${String(err)}`);
|
||||||
|
return {
|
||||||
|
namespace: "",
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
namespace: parts[1],
|
||||||
|
// Skipping timestamp
|
||||||
|
data: JSON.parse(parts[2]),
|
||||||
|
};
|
||||||
|
}
|
48
backend/src/utils/permissionNames.ts
Normal file
48
backend/src/utils/permissionNames.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { PermissionFlags } from "discord.js";
|
||||||
|
import { EMPTY_CHAR } from "../utils";
|
||||||
|
|
||||||
|
export const PERMISSION_NAMES: Record<keyof PermissionFlags, string> = {
|
||||||
|
ADD_REACTIONS: "Add Reactions",
|
||||||
|
ADMINISTRATOR: "Administrator",
|
||||||
|
ATTACH_FILES: "Attach Files",
|
||||||
|
BAN_MEMBERS: "Ban Members",
|
||||||
|
CHANGE_NICKNAME: "Change Nickname",
|
||||||
|
CONNECT: "Connect",
|
||||||
|
CREATE_INSTANT_INVITE: "Create Invite",
|
||||||
|
CREATE_PRIVATE_THREADS: "Create Private Threads",
|
||||||
|
CREATE_PUBLIC_THREADS: "Create Public Threads",
|
||||||
|
DEAFEN_MEMBERS: "Deafen Members",
|
||||||
|
EMBED_LINKS: "Embed Links",
|
||||||
|
KICK_MEMBERS: "Kick Members",
|
||||||
|
MANAGE_CHANNELS: "Manage Channels",
|
||||||
|
MANAGE_EMOJIS_AND_STICKERS: "Manage Emojis and Stickers",
|
||||||
|
MANAGE_GUILD: "Manage Server",
|
||||||
|
MANAGE_MESSAGES: "Manage Messages",
|
||||||
|
MANAGE_NICKNAMES: "Manage Nicknames",
|
||||||
|
MANAGE_ROLES: "Manage Roles",
|
||||||
|
MANAGE_THREADS: "Manage Threads",
|
||||||
|
MANAGE_WEBHOOKS: "Manage Webhooks",
|
||||||
|
MENTION_EVERYONE: `Mention @${EMPTY_CHAR}everyone, @${EMPTY_CHAR}here, and All Roles`,
|
||||||
|
MOVE_MEMBERS: "Move Members",
|
||||||
|
MUTE_MEMBERS: "Mute Members",
|
||||||
|
PRIORITY_SPEAKER: "Priority Speaker",
|
||||||
|
READ_MESSAGE_HISTORY: "Read Message History",
|
||||||
|
REQUEST_TO_SPEAK: "Request to Speak",
|
||||||
|
SEND_MESSAGES: "Send Messages",
|
||||||
|
SEND_MESSAGES_IN_THREADS: "Send Messages in Threads",
|
||||||
|
SEND_TTS_MESSAGES: "Send Text-To-Speech Messages",
|
||||||
|
SPEAK: "Speak",
|
||||||
|
START_EMBEDDED_ACTIVITIES: "Start Embedded Activities",
|
||||||
|
STREAM: "Video",
|
||||||
|
USE_APPLICATION_COMMANDS: "Use Application Commands",
|
||||||
|
USE_EXTERNAL_EMOJIS: "Use External Emoji",
|
||||||
|
USE_EXTERNAL_STICKERS: "Use External Stickers",
|
||||||
|
USE_PRIVATE_THREADS: "Use Private Threads",
|
||||||
|
USE_PUBLIC_THREADS: "Use Public Threads",
|
||||||
|
USE_VAD: "Use Voice Activity",
|
||||||
|
VIEW_AUDIT_LOG: "View Audit Log",
|
||||||
|
VIEW_CHANNEL: "View Channels",
|
||||||
|
VIEW_GUILD_INSIGHTS: "View Guild Insights",
|
||||||
|
MODERATE_MEMBERS: "Moderate Members",
|
||||||
|
MANAGE_EVENTS: "Manage Events",
|
||||||
|
};
|
|
@ -8,6 +8,20 @@
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<MarkdownBlock :content="data.info.description" class="content"></MarkdownBlock>
|
<MarkdownBlock :content="data.info.description" class="content"></MarkdownBlock>
|
||||||
|
|
||||||
|
<div v-if="data.info.legacy">
|
||||||
|
<div class="px-3 py-2 mb-4 rounded bg-gray-800 shadow-md inline-block flex">
|
||||||
|
<div class="flex-none mr-2">
|
||||||
|
<alert class="inline-icon mr-1 text-yellow-300" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-auto">
|
||||||
|
<strong>Note!</strong> This is a legacy plugin which is no longer actively maintained and may be removed in a future update.
|
||||||
|
<div v-if="typeof data.info.legacy === 'string'" class="mt-4">
|
||||||
|
<MarkdownBlock v-if="typeof data.info.legacy === 'string'" :content="data.info.legacy"></MarkdownBlock>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab :active="tab === 'usage'">
|
<Tab :active="tab === 'usage'">
|
||||||
<router-link class="unstyled" v-bind:to="'/docs/plugins/' + pluginName + '/usage'">Usage</router-link>
|
<router-link class="unstyled" v-bind:to="'/docs/plugins/' + pluginName + '/usage'">Usage</router-link>
|
||||||
|
@ -172,12 +186,13 @@
|
||||||
import Tab from "../Tab.vue";
|
import Tab from "../Tab.vue";
|
||||||
import Expandable from "../Expandable.vue";
|
import Expandable from "../Expandable.vue";
|
||||||
import { DocsState } from "../../store/types";
|
import { DocsState } from "../../store/types";
|
||||||
|
import Alert from 'vue-material-design-icons/Alert.vue';
|
||||||
|
|
||||||
const validTabs = ['usage', 'configuration'];
|
const validTabs = ['usage', 'configuration'];
|
||||||
const defaultTab = 'usage';
|
const defaultTab = 'usage';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { CodeBlock, MarkdownBlock, Tabs, Tab, Expandable },
|
components: { CodeBlock, MarkdownBlock, Tabs, Tab, Expandable, Alert },
|
||||||
|
|
||||||
async mounted() {
|
async mounted() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue