3
0
Fork 0
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:
Shadow 2022-04-25 20:57:30 -05:00
commit 6a18b139c8
60 changed files with 2982 additions and 4594 deletions

19
.clabot Normal file
View 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!"
}

5724
backend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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": [

View file

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

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

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

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

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

View file

@ -390,8 +390,10 @@ connect().then(async () => {
}, 100); }, 100);
setInterval(() => { setInterval(() => {
// FIXME: Debug // FIXME: Debug
if (lowestGlobalRemaining < 30) {
// tslint:disable-next-line:no-console // tslint:disable-next-line:no-console
console.log("Lowest global remaining in the past 15 seconds:", lowestGlobalRemaining); console.log("[DEBUG] Lowest global remaining in the past 15 seconds:", lowestGlobalRemaining);
}
lowestGlobalRemaining = Infinity; lowestGlobalRemaining = Infinity;
}, 15000); }, 15000);

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

View file

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

View file

@ -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",
},
],
}),
);
}
}

View file

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

View file

@ -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!)
); );

View file

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

View file

@ -72,6 +72,6 @@ export const LocateUserPlugin = zeppelinGuildPlugin<LocateUserPluginType>()({
}, },
beforeUnload(pluginData) { beforeUnload(pluginData) {
pluginData.state.unregisterGuildEventListener(); pluginData.state.unregisterGuildEventListener?.();
}, },
}); });

View file

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

View file

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

View file

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

View file

@ -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";

View file

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

View file

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

View file

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

View file

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

View file

@ -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";

View file

@ -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!`,
});
}
}

View file

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

View file

@ -1,4 +0,0 @@
export enum ButtonMenuActions {
OPEN_MENU = "goto",
MODIFY_ROLE = "grant",
}

View file

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

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

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

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

View file

@ -0,0 +1 @@
export class TooManyComponentsError extends Error {}

View file

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

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

View file

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

View file

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

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

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

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

View file

@ -0,0 +1 @@
export const PRIORITY_ROLE_PRIORITY = 10;

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

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

View 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 removePriorityRole(
pluginData: GuildPluginData<RoleManagerPluginType>,
userId: string,
roleId: string,
) {
await pluginData.state.roleQueue.addQueueItem(userId, roleId, false, PRIORITY_ROLE_PRIORITY);
runRoleAssignmentLoop(pluginData);
}

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

View file

@ -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}\`)`,
});
});
}),
);
})());
}
}

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

View file

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

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

View 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", "!")'],
},
];

View file

@ -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>();

View file

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

View file

@ -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}>`)) : ""}
`), `),
}); });

View file

@ -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?: (

View file

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

View file

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

View file

@ -0,0 +1,3 @@
export function buildCustomId(namespace: string, data: any = {}) {
return `${namespace}:${Date.now()}:${JSON.stringify(data)}`;
}

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

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

View file

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