mirror of
https://github.com/ZeppelinBot/Zeppelin.git
synced 2025-03-15 05:41:51 +00:00
feat: add internal role manager plugin; add role buttons plugin
This commit is contained in:
parent
9314d57645
commit
3fe71b3e27
23 changed files with 732 additions and 1 deletions
39
backend/src/data/GuildRoleButtons.ts
Normal file
39
backend/src/data/GuildRoleButtons.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { getRepository, Repository } from "typeorm";
|
||||
import { Reminder } from "./entities/Reminder";
|
||||
import { BaseRepository } from "./BaseRepository";
|
||||
import moment from "moment-timezone";
|
||||
import { DBDateFormat } from "../utils";
|
||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { RoleQueueItem } from "./entities/RoleQueueItem";
|
||||
import { connection } from "./db";
|
||||
import { RoleButtonsItem } from "./entities/RoleButtonsItem";
|
||||
|
||||
export class GuildRoleButtons extends BaseGuildRepository {
|
||||
private roleButtons: Repository<RoleButtonsItem>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.roleButtons = getRepository(RoleButtonsItem);
|
||||
}
|
||||
|
||||
getSavedRoleButtons(): Promise<RoleButtonsItem[]> {
|
||||
return this.roleButtons.find({ guild_id: this.guildId });
|
||||
}
|
||||
|
||||
async deleteRoleButtonItem(name: string): Promise<void> {
|
||||
await this.roleButtons.delete({
|
||||
guild_id: this.guildId,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
async saveRoleButtonItem(name: string, channelId: string, messageId: string, hash: string): Promise<void> {
|
||||
await this.roleButtons.insert({
|
||||
guild_id: this.guildId,
|
||||
name,
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
hash,
|
||||
});
|
||||
}
|
||||
}
|
48
backend/src/data/GuildRoleQueue.ts
Normal file
48
backend/src/data/GuildRoleQueue.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { getRepository, Repository } from "typeorm";
|
||||
import { Reminder } from "./entities/Reminder";
|
||||
import { BaseRepository } from "./BaseRepository";
|
||||
import moment from "moment-timezone";
|
||||
import { DBDateFormat } from "../utils";
|
||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { RoleQueueItem } from "./entities/RoleQueueItem";
|
||||
import { connection } from "./db";
|
||||
|
||||
export class GuildRoleQueue extends BaseGuildRepository {
|
||||
private roleQueue: Repository<RoleQueueItem>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.roleQueue = getRepository(RoleQueueItem);
|
||||
}
|
||||
|
||||
consumeNextRoleAssignments(count: number): Promise<RoleQueueItem[]> {
|
||||
return connection.transaction(async (entityManager) => {
|
||||
const repository = entityManager.getRepository(RoleQueueItem);
|
||||
|
||||
const nextAssignments = await repository
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guildId", { guildId: this.guildId })
|
||||
.addOrderBy("priority", "DESC")
|
||||
.addOrderBy("id", "ASC")
|
||||
.take(count)
|
||||
.getMany();
|
||||
|
||||
if (nextAssignments.length > 0) {
|
||||
const ids = nextAssignments.map((assignment) => assignment.id);
|
||||
await repository.createQueryBuilder().where("id IN (:ids)", { ids }).delete().execute();
|
||||
}
|
||||
|
||||
return nextAssignments;
|
||||
});
|
||||
}
|
||||
|
||||
async addQueueItem(userId: string, roleId: string, shouldAdd: boolean, priority = 0) {
|
||||
await this.roleQueue.insert({
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
role_id: roleId,
|
||||
should_add: shouldAdd,
|
||||
priority,
|
||||
});
|
||||
}
|
||||
}
|
16
backend/src/data/entities/RoleButtonsItem.ts
Normal file
16
backend/src/data/entities/RoleButtonsItem.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||
|
||||
@Entity("role_buttons")
|
||||
export class RoleButtonsItem {
|
||||
@PrimaryGeneratedColumn() id: number;
|
||||
|
||||
@Column() guild_id: string;
|
||||
|
||||
@Column() name: string;
|
||||
|
||||
@Column() channel_id: string;
|
||||
|
||||
@Column() message_id: string;
|
||||
|
||||
@Column() hash: string;
|
||||
}
|
16
backend/src/data/entities/RoleQueueItem.ts
Normal file
16
backend/src/data/entities/RoleQueueItem.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||
|
||||
@Entity("role_queue")
|
||||
export class RoleQueueItem {
|
||||
@PrimaryGeneratedColumn() id: number;
|
||||
|
||||
@Column() guild_id: string;
|
||||
|
||||
@Column() user_id: string;
|
||||
|
||||
@Column() role_id: string;
|
||||
|
||||
@Column() should_add: boolean;
|
||||
|
||||
@Column() priority: number;
|
||||
}
|
50
backend/src/migrations/1650709103864-CreateRoleQueueTable.ts
Normal file
50
backend/src/migrations/1650709103864-CreateRoleQueueTable.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateRoleQueueTable1650709103864 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "role_queue",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "int",
|
||||
isPrimary: true,
|
||||
isGenerated: true,
|
||||
generationStrategy: "increment",
|
||||
},
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
},
|
||||
{
|
||||
name: "user_id",
|
||||
type: "bigint",
|
||||
},
|
||||
{
|
||||
name: "role_id",
|
||||
type: "bigint",
|
||||
},
|
||||
{
|
||||
name: "should_add",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
name: "priority",
|
||||
type: "smallint",
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
columnNames: ["guild_id"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable("role_queue");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateRoleButtonsTable1650712828384 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "role_buttons",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "int",
|
||||
isPrimary: true,
|
||||
isGenerated: true,
|
||||
generationStrategy: "increment",
|
||||
},
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
type: "varchar",
|
||||
length: "255",
|
||||
},
|
||||
{
|
||||
name: "channel_id",
|
||||
type: "bigint",
|
||||
},
|
||||
{
|
||||
name: "message_id",
|
||||
type: "bigint",
|
||||
},
|
||||
{
|
||||
name: "hash",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
columnNames: ["guild_id", "name"],
|
||||
isUnique: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable("role_buttons");
|
||||
}
|
||||
}
|
56
backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts
Normal file
56
backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
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";
|
||||
|
||||
export const RoleButtonsPlugin = zeppelinGuildPlugin<RoleButtonsPluginType>()({
|
||||
name: "role_buttons",
|
||||
configSchema: ConfigSchema,
|
||||
|
||||
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;
|
||||
// 5 action rows * 5 buttons
|
||||
if (buttonsConfig.options?.length > 25) {
|
||||
throw new StrictValidationError(["A single message can have at most 25 role buttons"]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
},
|
||||
|
||||
dependencies: () => [LogsPlugin, RoleManagerPlugin],
|
||||
|
||||
events: [onButtonInteraction],
|
||||
|
||||
beforeLoad(pluginData) {
|
||||
pluginData.state.roleButtons = GuildRoleButtons.getGuildInstance(pluginData.guild.id);
|
||||
},
|
||||
|
||||
async afterLoad(pluginData) {
|
||||
await applyAllRoleButtons(pluginData);
|
||||
},
|
||||
});
|
50
backend/src/plugins/RoleButtons/events/buttonInteraction.ts
Normal file
50
backend/src/plugins/RoleButtons/events/buttonInteraction.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { typedGuildEventListener } from "knub";
|
||||
import { RoleButtonsPluginType, TRoleButtonOption } from "../types";
|
||||
import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin";
|
||||
|
||||
export const onButtonInteraction = typedGuildEventListener<RoleButtonsPluginType>()({
|
||||
event: "interactionCreate",
|
||||
async listener({ pluginData, args }) {
|
||||
if (!args.interaction.isButton() || !args.interaction.customId.startsWith("roleButtons:")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = pluginData.config.get();
|
||||
const [, name, optionIndex] = args.interaction.customId.split(":");
|
||||
// For some reason TS's type inference fails here so using a type annotation
|
||||
const option: TRoleButtonOption | undefined = config.buttons[name]?.options[optionIndex];
|
||||
if (!option) {
|
||||
args.interaction.reply({
|
||||
ephemeral: true,
|
||||
content: "Invalid option selected",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const member = args.interaction.member || (await pluginData.guild.members.fetch(args.interaction.user.id));
|
||||
if (!member) {
|
||||
args.interaction.reply({
|
||||
ephemeral: true,
|
||||
content: "Error while fetching member to apply roles for",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const hasRole = Array.isArray(member.roles)
|
||||
? member.roles.includes(option.role_id)
|
||||
: member.roles.cache.has(option.role_id);
|
||||
if (hasRole) {
|
||||
pluginData.getPlugin(RoleManagerPlugin).removeRole(member.user.id, option.role_id);
|
||||
args.interaction.reply({
|
||||
ephemeral: true,
|
||||
content: "The selected role will be removed shortly!",
|
||||
});
|
||||
} else {
|
||||
pluginData.getPlugin(RoleManagerPlugin).addRole(member.user.id, option.role_id);
|
||||
args.interaction.reply({
|
||||
ephemeral: true,
|
||||
content: "You will receive the selected role shortly!",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
129
backend/src/plugins/RoleButtons/functions/applyRoleButtons.ts
Normal file
129
backend/src/plugins/RoleButtons/functions/applyRoleButtons.ts
Normal file
|
@ -0,0 +1,129 @@
|
|||
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 { splitButtonsIntoRows } from "./splitButtonsIntoRows";
|
||||
|
||||
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 buttons = configItem.options.map((opt, index) => {
|
||||
const button = new MessageButton()
|
||||
.setLabel(opt.label ?? "")
|
||||
.setStyle(opt.style ?? "PRIMARY")
|
||||
.setCustomId(`roleButtons:${configItem.name}:${index}:${Math.round(Date.now() / 1000)}`);
|
||||
|
||||
if (opt.emoji) {
|
||||
const emo = pluginData.client.emojis.resolve(opt.emoji as Snowflake) ?? opt.emoji;
|
||||
button.setEmoji(emo);
|
||||
}
|
||||
|
||||
return button;
|
||||
});
|
||||
const rows = splitButtonsIntoRows(buttons);
|
||||
|
||||
await message.edit({
|
||||
components: rows,
|
||||
});
|
||||
|
||||
return {
|
||||
channel_id: message.channelId,
|
||||
message_id: message.id,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { MessageActionRow, MessageButton } from "discord.js";
|
||||
import { chunkArray } from "../../../utils";
|
||||
|
||||
export function splitButtonsIntoRows(buttons: MessageButton[]): MessageActionRow[] {
|
||||
// Max 5 buttons per row
|
||||
const buttonChunks = chunkArray(buttons, 5);
|
||||
return buttonChunks.map((chunk) => {
|
||||
const row = new MessageActionRow();
|
||||
row.setComponents(chunk);
|
||||
return row;
|
||||
});
|
||||
}
|
48
backend/src/plugins/RoleButtons/types.ts
Normal file
48
backend/src/plugins/RoleButtons/types.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
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
|
||||
});
|
||||
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),
|
||||
});
|
||||
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;
|
||||
};
|
||||
}
|
39
backend/src/plugins/RoleManager/RoleManagerPlugin.ts
Normal file
39
backend/src/plugins/RoleManager/RoleManagerPlugin.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
|
||||
import { ConfigSchema, RoleManagerPluginType } from "./types";
|
||||
import { GuildRoleQueue } from "../../data/GuildRoleQueue";
|
||||
import { mapToPublicFn } from "../../pluginUtils";
|
||||
import { addRole } from "./functions/addRole";
|
||||
import { removeRole } from "./functions/removeRole";
|
||||
import { addPriorityRole } from "./functions/addPriorityRole";
|
||||
import { removePriorityRole } from "./functions/removePriorityRole";
|
||||
import { runRoleAssignmentLoop } from "./functions/runRoleAssignmentLoop";
|
||||
import { LogsPlugin } from "../Logs/LogsPlugin";
|
||||
|
||||
export const RoleManagerPlugin = zeppelinGuildPlugin<RoleManagerPluginType>()({
|
||||
name: "role_manager",
|
||||
configSchema: ConfigSchema,
|
||||
showInDocs: false,
|
||||
|
||||
dependencies: () => [LogsPlugin],
|
||||
|
||||
public: {
|
||||
addRole: mapToPublicFn(addRole),
|
||||
removeRole: mapToPublicFn(removeRole),
|
||||
addPriorityRole: mapToPublicFn(addPriorityRole),
|
||||
removePriorityRole: mapToPublicFn(removePriorityRole),
|
||||
},
|
||||
|
||||
beforeLoad(pluginData) {
|
||||
pluginData.state.roleQueue = GuildRoleQueue.getGuildInstance(pluginData.guild.id);
|
||||
pluginData.state.pendingRoleAssignmentPromise = Promise.resolve();
|
||||
},
|
||||
|
||||
afterLoad(pluginData) {
|
||||
runRoleAssignmentLoop(pluginData);
|
||||
},
|
||||
|
||||
async afterUnload(pluginData) {
|
||||
pluginData.state.abortRoleAssignmentLoop = true;
|
||||
await pluginData.state.pendingRoleAssignmentPromise;
|
||||
},
|
||||
});
|
1
backend/src/plugins/RoleManager/constants.ts
Normal file
1
backend/src/plugins/RoleManager/constants.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const PRIORITY_ROLE_PRIORITY = 10;
|
13
backend/src/plugins/RoleManager/functions/addPriorityRole.ts
Normal file
13
backend/src/plugins/RoleManager/functions/addPriorityRole.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { GuildPluginData } from "knub";
|
||||
import { RoleManagerPluginType } from "../types";
|
||||
import { PRIORITY_ROLE_PRIORITY } from "../constants";
|
||||
import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop";
|
||||
|
||||
export async function addPriorityRole(
|
||||
pluginData: GuildPluginData<RoleManagerPluginType>,
|
||||
userId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
await pluginData.state.roleQueue.addQueueItem(userId, roleId, true, PRIORITY_ROLE_PRIORITY);
|
||||
runRoleAssignmentLoop(pluginData);
|
||||
}
|
8
backend/src/plugins/RoleManager/functions/addRole.ts
Normal file
8
backend/src/plugins/RoleManager/functions/addRole.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { GuildPluginData } from "knub";
|
||||
import { RoleManagerPluginType } from "../types";
|
||||
import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop";
|
||||
|
||||
export async function addRole(pluginData: GuildPluginData<RoleManagerPluginType>, userId: string, roleId: string) {
|
||||
await pluginData.state.roleQueue.addQueueItem(userId, roleId, true);
|
||||
runRoleAssignmentLoop(pluginData);
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { GuildPluginData } from "knub";
|
||||
import { RoleManagerPluginType } from "../types";
|
||||
import { PRIORITY_ROLE_PRIORITY } from "../constants";
|
||||
import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop";
|
||||
|
||||
export async function removePriorityRole(
|
||||
pluginData: GuildPluginData<RoleManagerPluginType>,
|
||||
userId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
await pluginData.state.roleQueue.addQueueItem(userId, roleId, false, PRIORITY_ROLE_PRIORITY);
|
||||
runRoleAssignmentLoop(pluginData);
|
||||
}
|
8
backend/src/plugins/RoleManager/functions/removeRole.ts
Normal file
8
backend/src/plugins/RoleManager/functions/removeRole.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { GuildPluginData } from "knub";
|
||||
import { RoleManagerPluginType } from "../types";
|
||||
import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop";
|
||||
|
||||
export async function removeRole(pluginData: GuildPluginData<RoleManagerPluginType>, userId: string, roleId: string) {
|
||||
await pluginData.state.roleQueue.addQueueItem(userId, roleId, false);
|
||||
runRoleAssignmentLoop(pluginData);
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
import { GuildPluginData } from "knub";
|
||||
import { RoleManagerPluginType } from "../types";
|
||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||
import { logger } from "../../../logger";
|
||||
import { RoleQueueItem } from "../../../data/entities/RoleQueueItem";
|
||||
|
||||
const ROLE_ASSIGNMENTS_PER_BATCH = 20;
|
||||
|
||||
export async function runRoleAssignmentLoop(pluginData: GuildPluginData<RoleManagerPluginType>) {
|
||||
if (pluginData.state.roleAssignmentLoopRunning || pluginData.state.abortRoleAssignmentLoop) {
|
||||
return;
|
||||
}
|
||||
pluginData.state.roleAssignmentLoopRunning = true;
|
||||
|
||||
while (true) {
|
||||
// Abort on unload
|
||||
if (pluginData.state.abortRoleAssignmentLoop) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!pluginData.state.roleAssignmentLoopRunning) {
|
||||
break;
|
||||
}
|
||||
|
||||
await (pluginData.state.pendingRoleAssignmentPromise = (async () => {
|
||||
// Process assignments in batches, stopping once the queue's exhausted
|
||||
const nextAssignments = await pluginData.state.roleQueue.consumeNextRoleAssignments(ROLE_ASSIGNMENTS_PER_BATCH);
|
||||
if (nextAssignments.length === 0) {
|
||||
pluginData.state.roleAssignmentLoopRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove assignments that cancel each other out (e.g. from spam-clicking a role button)
|
||||
const validAssignments = new Map<string, RoleQueueItem>();
|
||||
for (const assignment of nextAssignments) {
|
||||
const key = `${assignment.should_add ? 1 : 0}|${assignment.user_id}|${assignment.role_id}`;
|
||||
const oppositeKey = `${assignment.should_add ? 0 : 1}|${assignment.user_id}|${assignment.role_id}`;
|
||||
if (validAssignments.has(oppositeKey)) {
|
||||
validAssignments.delete(oppositeKey);
|
||||
continue;
|
||||
}
|
||||
validAssignments.set(key, assignment);
|
||||
}
|
||||
|
||||
// Apply batch in parallel
|
||||
await Promise.all(
|
||||
Array.from(validAssignments.values()).map(async (assignment) => {
|
||||
const member = await pluginData.guild.members.fetch(assignment.user_id).catch(() => null);
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
|
||||
const operation = assignment.should_add
|
||||
? member.roles.add(assignment.role_id)
|
||||
: member.roles.remove(assignment.role_id);
|
||||
|
||||
await operation.catch((err) => {
|
||||
logger.warn(err);
|
||||
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||
body: `Could not ${assignment.should_add ? "assign" : "remove"} role <@&${assignment.role_id}> (\`${
|
||||
assignment.role_id
|
||||
}\`) ${assignment.should_add ? "to" : "from"} <@!${assignment.user_id}> (\`${assignment.user_id}\`)`,
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
})());
|
||||
}
|
||||
}
|
17
backend/src/plugins/RoleManager/types.ts
Normal file
17
backend/src/plugins/RoleManager/types.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import * as t from "io-ts";
|
||||
import { BasePluginType, typedGuildCommand } from "knub";
|
||||
import { GuildLogs } from "../../data/GuildLogs";
|
||||
import { GuildRoleQueue } from "../../data/GuildRoleQueue";
|
||||
|
||||
export const ConfigSchema = t.type({});
|
||||
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||
|
||||
export interface RoleManagerPluginType extends BasePluginType {
|
||||
config: TConfigSchema;
|
||||
state: {
|
||||
roleQueue: GuildRoleQueue;
|
||||
roleAssignmentLoopRunning: boolean;
|
||||
abortRoleAssignmentLoop: boolean;
|
||||
pendingRoleAssignmentPromise: Promise<unknown>;
|
||||
};
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 = [];
|
||||
}
|
||||
|
|
|
@ -43,4 +43,6 @@ export const PERMISSION_NAMES: Record<keyof PermissionFlags, string> = {
|
|||
VIEW_AUDIT_LOG: "View Audit Log",
|
||||
VIEW_CHANNEL: "View Channels",
|
||||
VIEW_GUILD_INSIGHTS: "View Guild Insights",
|
||||
MODERATE_MEMBERS: "Moderate Members",
|
||||
MANAGE_EVENTS: "Manage Events",
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue