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

Merge branch 'master' into master

This commit is contained in:
Ruby 2022-04-23 20:25:27 +04:00 committed by GitHub
commit 73ace8b220
46 changed files with 962 additions and 459 deletions

View file

@ -1,5 +1,6 @@
{
"contributors": [
"dependabot",
"CleverSource",
"DarkView",
"Jernik",

View file

@ -94,7 +94,7 @@ export class ApiLogins extends BaseRepository {
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(
{ id: loginId },
{

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

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

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

View file

@ -1,29 +1,20 @@
import { PluginOptions } from "knub";
import { ConfigPreprocessorFn } from "knub/dist/config/configTypes";
import { GuildButtonRoles } from "../../data/GuildButtonRoles";
import { GuildReactionRoles } from "../../data/GuildReactionRoles";
import { GuildSavedMessages } from "../../data/GuildSavedMessages";
import { Queue } from "../../Queue";
import { isValidSnowflake } from "../../utils";
import { StrictValidationError } from "../../validatorUtils";
import { LogsPlugin } from "../Logs/LogsPlugin";
import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
import { ClearReactionRolesCmd } from "./commands/ClearReactionRolesCmd";
import { InitReactionRolesCmd } from "./commands/InitReactionRolesCmd";
import { PostButtonRolesCmd } from "./commands/PostButtonRolesCmd";
import { RefreshReactionRolesCmd } from "./commands/RefreshReactionRolesCmd";
import { AddReactionRoleEvt } from "./events/AddReactionRoleEvt";
import { ButtonInteractionEvt } from "./events/ButtonInteractionEvt";
import { MessageDeletedEvt } from "./events/MessageDeletedEvt";
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 defaultOptions: PluginOptions<ReactionRolesPluginType> = {
config: {
button_groups: {},
auto_refresh_interval: MIN_AUTO_REFRESH,
remove_user_reactions: true,
@ -40,72 +31,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>()({
name: "reaction_roles",
showInDocs: true,
info: {
prettyName: "Reaction roles",
legacy: "Consider using the [Role buttons](/docs/plugins/role_buttons) plugin instead.",
},
dependencies: () => [LogsPlugin],
@ -117,23 +48,19 @@ export const ReactionRolesPlugin = zeppelinGuildPlugin<ReactionRolesPluginType>(
RefreshReactionRolesCmd,
ClearReactionRolesCmd,
InitReactionRolesCmd,
// PostButtonRolesCmd,
],
// prettier-ignore
events: [
AddReactionRoleEvt,
// ButtonInteractionEvt,
MessageDeletedEvt,
],
configPreprocessor,
beforeLoad(pluginData) {
const { state, guild } = pluginData;
state.reactionRoles = GuildReactionRoles.getGuildInstance(guild.id);
state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);
state.buttonRoles = GuildButtonRoles.getGuildInstance(guild.id);
state.reactionRemoveQueue = new Queue();
state.roleChangeQueue = new Queue();
state.pendingRoleChanges = new Map();

View file

@ -1,4 +1,4 @@
import { Message, Snowflake } from "discord.js";
import { Message } from "discord.js";
import { commandTypeHelpers as ct } from "../../../commandTypes";
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
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) {
const pluginData = meta.pluginData;
await pluginData.state.buttonRoles.removeAllForMessageId(meta.args.message.id);
await pluginData.state.reactionRoles.removeFromMessage(meta.args.message.id);
},
});

View file

@ -1,40 +1,10 @@
import * as t from "io-ts";
import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub";
import { GuildButtonRoles } from "src/data/GuildButtonRoles";
import { GuildReactionRoles } from "../../data/GuildReactionRoles";
import { GuildSavedMessages } from "../../data/GuildSavedMessages";
import { Queue } from "../../Queue";
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({
button_groups: t.record(t.string, ButtonPairOpts),
auto_refresh_interval: t.number,
remove_user_reactions: t.boolean,
can_manage: t.boolean,
@ -61,7 +31,6 @@ export interface ReactionRolesPluginType extends BasePluginType {
state: {
reactionRoles: GuildReactionRoles;
savedMessages: GuildSavedMessages;
buttonRoles: GuildButtonRoles;
reactionRemoveQueue: Queue;
roleChangeQueue: Queue;

View file

@ -1,7 +1,6 @@
import { Snowflake, TextChannel } from "discord.js";
import { GuildPluginData } from "knub";
import { ReactionRole } from "../../../data/entities/ReactionRole";
import { LogType } from "../../../data/LogType";
import { isDiscordAPIError, sleep } from "../../../utils";
import { LogsPlugin } from "../../Logs/LogsPlugin";
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,68 @@
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";
export const RoleButtonsPlugin = zeppelinGuildPlugin<RoleButtonsPluginType>()({
name: "role_buttons",
configSchema: ConfigSchema,
info: pluginInfo,
showInDocs: 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],
beforeLoad(pluginData) {
pluginData.state.roleButtons = GuildRoleButtons.getGuildInstance(pluginData.guild.id);
},
async afterLoad(pluginData) {
await applyAllRoleButtons(pluginData);
},
});

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,114 @@
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);
const existingMessage = await (existingChannel?.isText() &&
existingChannel.messages.fetch(existingSavedButtons.message_id));
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);
const messageCandidate = await (channel?.isText() && channel.messages.fetch(configItem.message.message_id));
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);
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}`,
});
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 the bot`,
});
return null;
}
// Apply role buttons
const components = createButtonComponents(configItem);
await message.edit({ components });
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
messages:
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:
messages:
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:
messages:
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:
messages:
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,50 @@
import * as t from "io-ts";
import { BasePluginType } from "knub";
import { tMessageContent, tNullable } from "../../utils";
import { GuildRoleButtons } from "../../data/GuildRoleButtons";
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 RoleButtonOption = t.type({
role_id: t.string,
label: tNullable(t.string),
emoji: tNullable(t.string),
style: tNullable(t.keyof(ButtonStyles)), // https://discord.js.org/#/docs/discord.js/v13/typedef/MessageButtonStyle
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),
});
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

@ -3,9 +3,9 @@ import { TemplateFunction } from "./types";
export function generateTemplateMarkdown(definitions: TemplateFunction[]): string {
return definitions
.map(def => {
.map((def) => {
const usage = def.signature ?? `(${def.arguments.join(", ")})`;
const examples = def.examples?.map(ex => `> \`{${ex}}\``).join("\n") ?? null;
const examples = def.examples?.map((ex) => `> \`{${ex}}\``).join("\n") ?? null;
return trimPluginDescription(`
## ${def.name}
**${def.description}**\n

View file

@ -27,7 +27,7 @@ export interface ZeppelinGuildPluginBlueprint<TPluginData extends GuildPluginDat
description?: TMarkdown;
usageGuide?: TMarkdown;
configurationGuide?: TMarkdown;
legacy?: boolean;
legacy?: boolean | string;
};
configPreprocessor?: (

View file

@ -36,6 +36,8 @@ import { WelcomeMessagePlugin } from "./WelcomeMessage/WelcomeMessagePlugin";
import { ZeppelinGlobalPluginBlueprint, ZeppelinGuildPluginBlueprint } from "./ZeppelinPluginBlueprint";
import { PhishermanPlugin } from "./Phisherman/PhishermanPlugin";
import { InternalPosterPlugin } from "./InternalPoster/InternalPosterPlugin";
import { RoleManagerPlugin } from "./RoleManager/RoleManagerPlugin";
import { RoleButtonsPlugin } from "./RoleButtons/RoleButtonsPlugin";
// prettier-ignore
export const guildPlugins: Array<ZeppelinGuildPluginBlueprint<any>> = [
@ -73,6 +75,8 @@ export const guildPlugins: Array<ZeppelinGuildPluginBlueprint<any>> = [
ContextMenuPlugin,
PhishermanPlugin,
InternalPosterPlugin,
RoleManagerPlugin,
RoleButtonsPlugin,
];
// prettier-ignore

View file

@ -327,7 +327,7 @@ export const zEmbedInput = z.object({
title: z.string().optional(),
description: z.string().optional(),
url: z.string().optional(),
timestamp: z.number().optional(),
timestamp: z.string().optional(),
color: z.number().optional(),
footer: z.optional(
@ -876,7 +876,7 @@ export function chunkArray<T>(arr: T[], chunkSize): T[][] {
for (let i = 0; i < arr.length; 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);
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,17 @@
const customIdFormat = /^([^:]+):\d+:(.*)$/;
export function parseCustomId(customId: string): { namespace: string; data: any } {
const parts = customId.match(customIdFormat);
if (!parts) {
return {
namespace: "",
data: null,
};
}
return {
namespace: parts[1],
// Skipping timestamp
data: JSON.parse(parts[2]),
};
}

View file

@ -22,7 +22,6 @@ export const PERMISSION_NAMES: Record<keyof PermissionFlags, string> = {
MANAGE_ROLES: "Manage Roles",
MANAGE_THREADS: "Manage Threads",
MANAGE_WEBHOOKS: "Manage Webhooks",
MANAGE_EVENTS: "Manage Events",
MENTION_EVERYONE: `Mention @${EMPTY_CHAR}everyone, @${EMPTY_CHAR}here, and All Roles`,
MOVE_MEMBERS: "Move Members",
MUTE_MEMBERS: "Mute Members",
@ -45,4 +44,5 @@ export const PERMISSION_NAMES: Record<keyof PermissionFlags, string> = {
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 -->
<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>
<Tab :active="tab === 'usage'">
<router-link class="unstyled" v-bind:to="'/docs/plugins/' + pluginName + '/usage'">Usage</router-link>
@ -172,12 +186,13 @@
import Tab from "../Tab.vue";
import Expandable from "../Expandable.vue";
import { DocsState } from "../../store/types";
import Alert from 'vue-material-design-icons/Alert.vue';
const validTabs = ['usage', 'configuration'];
const defaultTab = 'usage';
export default {
components: { CodeBlock, MarkdownBlock, Tabs, Tab, Expandable },
components: { CodeBlock, MarkdownBlock, Tabs, Tab, Expandable, Alert },
async mounted() {
this.loading = true;