add administration commands
This commit is contained in:
parent
c106b1f21d
commit
9dda450728
4 changed files with 241 additions and 39 deletions
180
src/bot.ts
180
src/bot.ts
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
CacheType,
|
CacheType,
|
||||||
|
ChatInputCommandInteraction,
|
||||||
Client,
|
Client,
|
||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
GatewayIntentBits,
|
GatewayIntentBits,
|
||||||
|
@ -10,6 +11,9 @@ import {
|
||||||
SlashCommandStringOption,
|
SlashCommandStringOption,
|
||||||
} from "npm:discord.js@14.14.1";
|
} from "npm:discord.js@14.14.1";
|
||||||
import { channel, log_from, SimpleResult, split_promise } from "./utils.ts";
|
import { channel, log_from, SimpleResult, split_promise } from "./utils.ts";
|
||||||
|
import { RuleSet } from "./rules.ts";
|
||||||
|
import { SlashCommandSubcommandBuilder } from "npm:discord.js@14.14.1";
|
||||||
|
import { SlashCommandRoleOption } from "npm:discord.js@14.14.1";
|
||||||
const log = log_from(import.meta.url);
|
const log = log_from(import.meta.url);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -19,14 +23,16 @@ export class EpitlsBot {
|
||||||
private bot;
|
private bot;
|
||||||
private token;
|
private token;
|
||||||
private rest;
|
private rest;
|
||||||
|
private rules;
|
||||||
private assoc_channel;
|
private assoc_channel;
|
||||||
|
|
||||||
public constructor(bot_token: string) {
|
public constructor(bot_token: string, rules: RuleSet) {
|
||||||
this.token = bot_token;
|
this.token = bot_token;
|
||||||
const intents = [GatewayIntentBits.Guilds];
|
const intents = [GatewayIntentBits.Guilds];
|
||||||
this.bot = new Client({ intents });
|
this.bot = new Client({ intents });
|
||||||
this.bot.on("interactionCreate", (interaction) => this.handle_interaction(interaction));
|
this.bot.on("interactionCreate", (interaction) => this.handle_interaction(interaction));
|
||||||
this.rest = new REST().setToken(bot_token);
|
this.rest = new REST().setToken(bot_token);
|
||||||
|
this.rules = rules;
|
||||||
this.assoc_channel = channel<
|
this.assoc_channel = channel<
|
||||||
{ cri_login: string; discord_user_id: string; callback: (success: SimpleResult) => void }
|
{ cri_login: string; discord_user_id: string; callback: (success: SimpleResult) => void }
|
||||||
>();
|
>();
|
||||||
|
@ -48,12 +54,16 @@ export class EpitlsBot {
|
||||||
* Assigns a discord role to a discord user.
|
* Assigns a discord role to a discord user.
|
||||||
*/
|
*/
|
||||||
public async assign_role(user_id: string, guild_id: string, role_id: string) {
|
public async assign_role(user_id: string, guild_id: string, role_id: string) {
|
||||||
|
try {
|
||||||
const guild = await this.bot.guilds.fetch(guild_id);
|
const guild = await this.bot.guilds.fetch(guild_id);
|
||||||
const member = await guild.members.fetch({ user: user_id });
|
const member = await guild.members.fetch({ user: user_id });
|
||||||
const role = await guild.roles.fetch(role_id);
|
const role = await guild.roles.fetch(role_id);
|
||||||
if (role === null) return console.error("Role", role_id, "not found in guild", guild_id);
|
if (role === null) return console.error("Role", role_id, "not found in guild", guild_id);
|
||||||
member.roles.add(role_id);
|
member.roles.add(role_id);
|
||||||
log(`Assigned role '${role.name}' to user '${member.displayName}'.`);
|
log(`Assigned role '${role.name}' to user '${member.displayName}'.`);
|
||||||
|
} catch (_) {
|
||||||
|
//
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -80,30 +90,78 @@ export class EpitlsBot {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async register_commands() {
|
private async register_commands() {
|
||||||
const cmd = new SlashCommandBuilder()
|
const assoc_cmd = new SlashCommandBuilder()
|
||||||
.setName("associate")
|
.setName("associate")
|
||||||
.setDescription("Associates a cri account to your discord account.")
|
.setDescription("Associates a cri account to your discord account.")
|
||||||
.addStringOption(
|
.addStringOption(
|
||||||
new SlashCommandStringOption()
|
new SlashCommandStringOption()
|
||||||
.setName("cri_email")
|
.setName("cri_email")
|
||||||
.setDescription("claude.nougaro@epita.fr")
|
.setDescription("par exemple, claude.nougaro@epita.fr")
|
||||||
.setRequired(true),
|
.setRequired(true),
|
||||||
).toJSON();
|
).toJSON();
|
||||||
|
|
||||||
|
const rule_cmd = new SlashCommandBuilder()
|
||||||
|
.setName("rule")
|
||||||
|
.setDescription("Gestion des règles d'associations groupes cri / rôles.")
|
||||||
|
.setDefaultMemberPermissions(0x0000000000000008n)
|
||||||
|
.addSubcommand(
|
||||||
|
new SlashCommandSubcommandBuilder()
|
||||||
|
.setName("list")
|
||||||
|
.setDescription("Liste les règles d'association."),
|
||||||
|
)
|
||||||
|
.addSubcommand(
|
||||||
|
new SlashCommandSubcommandBuilder()
|
||||||
|
.setName("add")
|
||||||
|
.setDescription("Ajoute une règle d'association.")
|
||||||
|
.addStringOption(
|
||||||
|
new SlashCommandStringOption()
|
||||||
|
.setName("group")
|
||||||
|
.setDescription("Groupe CRI à associer.")
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addRoleOption(
|
||||||
|
new SlashCommandRoleOption()
|
||||||
|
.setName("role")
|
||||||
|
.setDescription("Role à assigner.")
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand(
|
||||||
|
new SlashCommandSubcommandBuilder()
|
||||||
|
.setName("remove")
|
||||||
|
.setDescription("Retire une règle d'association.")
|
||||||
|
.addStringOption(
|
||||||
|
new SlashCommandStringOption()
|
||||||
|
.setName("group")
|
||||||
|
.setDescription("Groupe CRI à retirer.")
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toJSON();
|
||||||
|
|
||||||
for (const guild_id of this.bot.guilds.cache.keys()) {
|
for (const guild_id of this.bot.guilds.cache.keys()) {
|
||||||
await this.rest.put(
|
await this.rest.put(
|
||||||
Routes.applicationGuildCommands(this.bot.application!.id, guild_id),
|
Routes.applicationGuildCommands(this.bot.application!.id, guild_id),
|
||||||
{ body: [cmd] },
|
{ body: [assoc_cmd, rule_cmd] },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handle_interaction(interaction: Interaction<CacheType>) {
|
private async handle_interaction(interaction: Interaction<CacheType>) {
|
||||||
if (!interaction.isChatInputCommand()) return;
|
if (!interaction.isChatInputCommand()) return;
|
||||||
if (interaction.commandName != "associate") return;
|
if (interaction.commandName === "associate") await this.handle_command_associate(interaction);
|
||||||
|
if (interaction.commandName === "rule") {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
if (subcommand === "list") await this.handle_command_rule_list(interaction);
|
||||||
|
if (subcommand === "add") await this.handle_command_rule_add(interaction);
|
||||||
|
if (subcommand === "remove") await this.handle_command_rule_remove(interaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handle_command_associate(interaction: ChatInputCommandInteraction<CacheType>) {
|
||||||
const email = interaction.options.getString("cri_email")!;
|
const email = interaction.options.getString("cri_email")!;
|
||||||
if (!email.endsWith("@epita.fr")) {
|
if (!email.endsWith("@epita.fr")) {
|
||||||
await interaction.reply(message_command_response_error("invalid cri email " + email));
|
await interaction.reply(message_command_response_assoc_error("invalid cri email " + email));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [cri_login] = email.split("@epita.fr");
|
const [cri_login] = email.split("@epita.fr");
|
||||||
|
@ -111,17 +169,70 @@ export class EpitlsBot {
|
||||||
const { promise, resolver } = split_promise<SimpleResult>();
|
const { promise, resolver } = split_promise<SimpleResult>();
|
||||||
|
|
||||||
this.assoc_channel.send({ cri_login, discord_user_id, callback: resolver });
|
this.assoc_channel.send({ cri_login, discord_user_id, callback: resolver });
|
||||||
await interaction.reply(message_command_response_sending_email(email));
|
await interaction.reply(message_command_response_assoc_pending(email));
|
||||||
log(`Started verification for discord id '${discord_user_id}' with cri login '${cri_login}'.`);
|
log(`Started verification for discord id '${discord_user_id}' with cri login '${cri_login}'.`);
|
||||||
const result = await promise;
|
const result = await promise;
|
||||||
if (result === true) interaction.editReply(message_command_response_success());
|
if (result === true) interaction.editReply(message_command_response_assoc_success());
|
||||||
else interaction.editReply(message_command_response_error(result));
|
else interaction.editReply(message_command_response_assoc_error(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handle_command_rule_list(interaction: ChatInputCommandInteraction<CacheType>) {
|
||||||
|
const guild_id = interaction.guildId;
|
||||||
|
if (guild_id === null) {
|
||||||
|
await interaction.reply(
|
||||||
|
message_command_response_list_error("Cette commande peut être exécutée uniquement sur un serveur."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rules = [] as GuildRule[];
|
||||||
|
for (const { group_id, role_id } of this.rules.roles_for_guild(guild_id)) {
|
||||||
|
const role = await interaction.guild!.roles.fetch(role_id);
|
||||||
|
const role_name = role?.name ?? "<DELETED>";
|
||||||
|
rules.push({ group_id, role_name });
|
||||||
|
}
|
||||||
|
await interaction.reply(message_command_response_rule_list_success(rules));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handle_command_rule_add(interaction: ChatInputCommandInteraction<CacheType>) {
|
||||||
|
const guild_id = interaction.guildId;
|
||||||
|
if (guild_id === null) {
|
||||||
|
await interaction.reply(
|
||||||
|
message_command_response_list_error("Cette commande peut être exécutée uniquement sur un serveur."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const group_id = interaction.options.getString("group", true);
|
||||||
|
const role = interaction.options.getRole("role", true);
|
||||||
|
// TODO : test if role exists
|
||||||
|
const role_id = role.id;
|
||||||
|
const role_name = role.name;
|
||||||
|
await this.rules.append_rule(group_id, { guild_id, role_id });
|
||||||
|
await interaction.reply(message_command_response_rule_add_success({ group_id, role_name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handle_command_rule_remove(interaction: ChatInputCommandInteraction<CacheType>) {
|
||||||
|
const guild_id = interaction.guildId;
|
||||||
|
if (guild_id === null) {
|
||||||
|
await interaction.reply(
|
||||||
|
message_command_response_list_error("Cette commande peut être exécutée uniquement sur un serveur."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const group_id = interaction.options.getString("group", true);
|
||||||
|
const removed = await this.rules.remove_rules(group_id, guild_id);
|
||||||
|
const rules = [] as GuildRule[];
|
||||||
|
for (const { group_id, target_role: { role_id } } of removed) {
|
||||||
|
const role = await interaction.guild!.roles.fetch(role_id);
|
||||||
|
const role_name = role?.name ?? "<DELETED>";
|
||||||
|
rules.push({ group_id, role_name });
|
||||||
|
}
|
||||||
|
await interaction.reply(message_command_response_rule_remove_success(rules));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function message_command_response_sending_email(email: string) {
|
function message_command_response_assoc_pending(email: string) {
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle("`🟡`| Vérification")
|
.setTitle("`🟡`| Association")
|
||||||
.setDescription(`
|
.setDescription(`
|
||||||
Un lien de vérification a été envoyé par e-mail à \`${email}\`.
|
Un lien de vérification a été envoyé par e-mail à \`${email}\`.
|
||||||
Le lien expirera dans 5 minutes.
|
Le lien expirera dans 5 minutes.
|
||||||
|
@ -130,16 +241,53 @@ Le lien expirera dans 5 minutes.
|
||||||
return { embeds: [embed] };
|
return { embeds: [embed] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function message_command_response_error(msg: string) {
|
function message_command_response_assoc_error(msg: string) {
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle("`❌`| Vérification")
|
.setTitle("`❌`| Association")
|
||||||
.setDescription(`Échec de la vérification :\n\`${msg}\``);
|
.setDescription(`Échec de l'association :\n\`${msg}\``);
|
||||||
return { embeds: [embed] };
|
return { embeds: [embed] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function message_command_response_success() {
|
function message_command_response_assoc_success() {
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle("`✅`| Vérification")
|
.setTitle("`✅`| Association")
|
||||||
.setDescription("Votre e-mail CRI a bien été associé à ce compte Discord.");
|
.setDescription("Votre e-mail CRI a bien été associé à ce compte Discord.");
|
||||||
return { embeds: [embed] };
|
return { embeds: [embed] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function message_command_response_list_error(msg: string) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle("`❌`| Liste des règles")
|
||||||
|
.setDescription(`Échec :\n\`${msg}\``);
|
||||||
|
return { embeds: [embed] };
|
||||||
|
}
|
||||||
|
|
||||||
|
type GuildRule = { group_id: string; role_name: string };
|
||||||
|
function message_command_response_rule_list_success(rules: GuildRule[]) {
|
||||||
|
const rule_list = rules.map((r) => `- \`${r.group_id}\` → \`@${r.role_name}\``).join("\n");
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle("`📚️`| Liste des règles")
|
||||||
|
.setDescription(`Règles d'associations groupe / rôle.
|
||||||
|
${rule_list}
|
||||||
|
`);
|
||||||
|
return { embeds: [embed] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function message_command_response_rule_add_success(rule: GuildRule) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle("`✅`| Règle ajouté")
|
||||||
|
.setDescription(`Règle d'association ajoutée :
|
||||||
|
- \`${rule.group_id}\` → \`@${rule.role_name}\`
|
||||||
|
`);
|
||||||
|
return { embeds: [embed] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function message_command_response_rule_remove_success(rules: GuildRule[]) {
|
||||||
|
const rule_list = rules.map((r) => `- \`${r.group_id}\` → \`@${r.role_name}\``).join("\n");
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle("`✅`| Règle ajouté")
|
||||||
|
.setDescription(`Règle d'association retirées :
|
||||||
|
${rule_list}
|
||||||
|
`);
|
||||||
|
return { embeds: [embed] };
|
||||||
|
}
|
||||||
|
|
11
src/main.ts
11
src/main.ts
|
@ -18,7 +18,7 @@ async function main() {
|
||||||
const state = await State.from_dir(root_path() + "/local");
|
const state = await State.from_dir(root_path() + "/local");
|
||||||
log("Loaded state with", await state.users_count(), "users.");
|
log("Loaded state with", await state.users_count(), "users.");
|
||||||
|
|
||||||
const bot = new EpitlsBot(conf.discord.bot_token);
|
const bot = new EpitlsBot(conf.discord.bot_token, rules);
|
||||||
await bot.start();
|
await bot.start();
|
||||||
log(`Started bot '${bot.bot_name()}' .`);
|
log(`Started bot '${bot.bot_name()}' .`);
|
||||||
|
|
||||||
|
@ -73,6 +73,13 @@ class Service {
|
||||||
this.emailer.send_confirmation_mail(username ?? "<unknown>", cri_login + "@epita.fr", link);
|
this.emailer.send_confirmation_mail(username ?? "<unknown>", cri_login + "@epita.fr", link);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// for all updates to rule set, reassign all roles.
|
||||||
|
(async () => {
|
||||||
|
for await (const _ of this.rules.receive_rule_update()) {
|
||||||
|
await this.update_all_users_roles();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update_all_users_roles() {
|
async update_all_users_roles() {
|
||||||
|
@ -86,7 +93,7 @@ class Service {
|
||||||
async update_user_roles(cri_login: string, discord_user_id: string) {
|
async update_user_roles(cri_login: string, discord_user_id: string) {
|
||||||
const groups = await this.cri_api.groups_of(cri_login);
|
const groups = await this.cri_api.groups_of(cri_login);
|
||||||
// log("found groups", groups);
|
// log("found groups", groups);
|
||||||
const roles = groups.map((group) => this.rules.roles_for_group(group)).flat();
|
const roles = groups.map((group) => [...this.rules.roles_for_group(group)]).flat();
|
||||||
// log("found setting roles", roles);
|
// log("found setting roles", roles);
|
||||||
for (const { guild_id, role_id } of roles) await this.bot.assign_role(discord_user_id, guild_id, role_id);
|
for (const { guild_id, role_id } of roles) await this.bot.assign_role(discord_user_id, guild_id, role_id);
|
||||||
}
|
}
|
||||||
|
|
67
src/rules.ts
67
src/rules.ts
|
@ -1,4 +1,5 @@
|
||||||
import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts";
|
import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts";
|
||||||
|
import { channel } from "./utils.ts";
|
||||||
|
|
||||||
export type TargetRole = { guild_id: string; role_id: string };
|
export type TargetRole = { guild_id: string; role_id: string };
|
||||||
|
|
||||||
|
@ -7,48 +8,84 @@ export type TargetRole = { guild_id: string; role_id: string };
|
||||||
*/
|
*/
|
||||||
export class RuleSet {
|
export class RuleSet {
|
||||||
private rules;
|
private rules;
|
||||||
|
private file;
|
||||||
|
private update_channel;
|
||||||
|
|
||||||
public constructor() {
|
public constructor(file: string) {
|
||||||
this.rules = new Map<string, TargetRole[]>();
|
this.rules = [] as SerializedRule[];
|
||||||
|
this.file = file;
|
||||||
|
this.update_channel = channel<{ updated_groups: string[] }>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads a RuleSet from a JSON serialized file.
|
* Reads a RuleSet from a JSON serialized file.
|
||||||
*/
|
*/
|
||||||
public static async from_file(path: string) {
|
public static async from_file(path: string) {
|
||||||
const result = new RuleSet();
|
const result = new RuleSet(path);
|
||||||
const file_content = await Deno.readTextFile(path);
|
const file_content = await Deno.readTextFile(path);
|
||||||
const parsed = parse_rules(file_content);
|
const parsed = parse_rules(file_content);
|
||||||
for (const { group_id, target_role } of parsed) result.append_rule(group_id, target_role);
|
for (const { group_id, target_role } of parsed) await result.append_rule(group_id, target_role);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets which Discord roles must be assigned to a user which is part of a given CRI group.
|
* Gets which Discord roles must be assigned to a user which is part of a given CRI group.
|
||||||
*/
|
*/
|
||||||
public roles_for_group(group_id: string) {
|
public *roles_for_group(group_id: string) {
|
||||||
return this.rules.get(group_id) ?? [];
|
for (const rule of this.rules) if (rule.group_id === group_id) yield rule.target_role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public *roles_for_guild(target_guild_id: string) {
|
||||||
|
for (const { group_id, target_role: { guild_id, role_id } } of this.rules) {
|
||||||
|
if (target_guild_id === guild_id) yield { group_id, role_id };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of managed Discord roles.
|
* Number of managed Discord roles.
|
||||||
*/
|
*/
|
||||||
public size() {
|
public size() {
|
||||||
let result = 0;
|
return this.rules.length;
|
||||||
for (const roles of this.rules.values()) result += roles.length;
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private append_rule(group_id: string, target_role: TargetRole) {
|
public async append_rule(group_id: string, target_role: TargetRole) {
|
||||||
let roles = this.rules.get(group_id);
|
for (const r of this.rules) {
|
||||||
if (roles === undefined) {
|
const same_group = r.group_id === group_id;
|
||||||
roles = [];
|
const same_target_role = r.target_role.guild_id === target_role.guild_id &&
|
||||||
this.rules.set(group_id, roles);
|
r.target_role.role_id === target_role.role_id;
|
||||||
|
if (same_group && same_target_role) return;
|
||||||
}
|
}
|
||||||
roles.push(target_role);
|
this.rules.push({ group_id, target_role });
|
||||||
|
await this.save();
|
||||||
|
this.update_channel.send({ updated_groups: [group_id] });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async remove_rules(group_id: string, guild_id: string) {
|
||||||
|
const removed = [] as SerializedRule[];
|
||||||
|
const kept = [] as SerializedRule[];
|
||||||
|
for (const rule of this.rules) {
|
||||||
|
const is_deleted_group = rule.group_id === group_id;
|
||||||
|
const is_from_guild = rule.target_role.guild_id = guild_id;
|
||||||
|
if (is_deleted_group && is_from_guild) removed.push(rule);
|
||||||
|
else kept.push(rule);
|
||||||
|
}
|
||||||
|
this.rules = kept;
|
||||||
|
await this.save();
|
||||||
|
this.update_channel.send({ updated_groups: removed.map((r) => r.group_id) });
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async *receive_rule_update() {
|
||||||
|
while (true) yield await this.update_channel.receive();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async save() {
|
||||||
|
const serialized = JSON.stringify(this.rules, null, 4);
|
||||||
|
await Deno.writeTextFile(this.file, serialized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SerializedRule = ReturnType<typeof parse_rules>[0];
|
||||||
function parse_rules(content: string) {
|
function parse_rules(content: string) {
|
||||||
return z.array(z.object({
|
return z.array(z.object({
|
||||||
group_id: z.string(),
|
group_id: z.string(),
|
||||||
|
|
10
src/utils.ts
10
src/utils.ts
|
@ -112,3 +112,13 @@ export async function wait(ms: number) {
|
||||||
setTimeout(resolver, ms);
|
setTimeout(resolver, ms);
|
||||||
await promise;
|
await promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function* enumerate<T>(items: Iterable<T>) {
|
||||||
|
let index = 0;
|
||||||
|
for (const item of items) yield [item, index++] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function* reverse<T>(items: Iterable<T>) {
|
||||||
|
const collected = [...items];
|
||||||
|
for (const item of collected.reverse()) yield item;
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue