diff --git a/.github/config.json b/.github/config.json index 0cb2d95c..e4a9b5c9 100644 --- a/.github/config.json +++ b/.github/config.json @@ -66,7 +66,7 @@ "anonymousCategoryIds": [], "blacklistedChannelIds": [], "defaultTargetChannelId": "893179191556706386", - "targetChannelOverrides": [] + "targetChannelOverrides": {} }, "aoc": { "enabled": false, diff --git a/config.template.json b/config.template.json index 2ad3187d..d59f4fb5 100644 --- a/config.template.json +++ b/config.template.json @@ -70,7 +70,7 @@ "anonymousCategoryIds": [], "blacklistedChannelIds": [], "defaultTargetChannelId": "893179191556706386", - "targetChannelOverrides": [] + "targetChannelOverrides": {} }, "aoc": { "enabled": false, diff --git a/package-lock.json b/package-lock.json index f3606140..0ba08481 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,8 @@ "pino": "^10.3.1", "pino-pretty": "^13.1.3", "splid-js": "^1.5.3", - "youtube-dl-exec": "^3.1.9" + "youtube-dl-exec": "^3.1.9", + "zod": "^4.4.3" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", @@ -5192,6 +5193,15 @@ "engines": { "node": ">= 18" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 52aa39e0..eaf12751 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "pino": "^10.3.1", "pino-pretty": "^13.1.3", "splid-js": "^1.5.3", - "youtube-dl-exec": "^3.1.9" + "youtube-dl-exec": "^3.1.9", + "zod": "^4.4.3" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", diff --git a/src/app.ts b/src/app.ts index 455d0d96..05de23cd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,7 @@ import { GatewayIntentBits, Partials, Client } from "discord.js"; import * as sentry from "@sentry/node"; -import { readConfig, databasePath, args } from "#/service/config.ts"; +import { readBootstrapConfig, databasePath, args } from "#/service/config.ts"; import log from "#log"; import * as kysely from "#/storage/db/db.ts"; @@ -54,7 +54,7 @@ const release = log.info(`Bot starting up...${release ? ` (release: ${release})` : ""}`); -const config = await readConfig(); +const config = await readBootstrapConfig(); if (config.sentry?.dsn) { sentry.init({ @@ -137,7 +137,7 @@ login().then( try { log.debug("Creating bot context..."); - botContext = await createBotContext(client); + botContext = await createBotContext(client, config.guildGuildId); log.debug("Bot context created, loading commands..."); await loadCommands(botContext); diff --git a/src/context.ts b/src/context.ts index 3541db15..08e9c008 100644 --- a/src/context.ts +++ b/src/context.ts @@ -13,11 +13,10 @@ import type { Message, GuildEmoji, } from "discord.js"; -import { ChannelType } from "discord.js"; import { SpotifyApi } from "@spotify/web-api-ts-sdk"; import type { UserMapEntry } from "#/commands/aoc.ts"; -import { readConfig } from "#/service/config.ts"; +import { readConfig, type GuildId } from "#/service/config.ts"; import log from "#log"; /** @@ -46,8 +45,8 @@ export interface BotContext { spotifyClient: SpotifyApi | null; - youtube: { - cookieFilePath?: string | null; + youtube?: { + cookieFilePath: string; }; commandConfig: { @@ -67,7 +66,7 @@ export interface BotContext { enabled: boolean; scheduleCron: string; dropChance: number; - allowedChannelIds?: readonly Snowflake[]; + allowedChannelIds?: Set; maxTimePassedSinceLastMessage: Temporal.Duration; roles: { @@ -185,71 +184,23 @@ export interface QuoteConfig { emojiName: string; } -// #region Ensure Stuff - -function ensureRole(guild: Guild, id: Snowflake): Role { - const role = guild.roles.cache.get(id); - if (!role) { - throw new Error(`Role with ID "${id}" not found in guild "${guild.id}"`); - } - return role; -} - -function ensureTextChannel(guild: Guild, channelId: Snowflake): TextChannel { - const channel = guild.channels.cache.get(channelId); - if (!channel) - throw new Error( - `Could not find main channel with id "${channelId}" on guild "${guild.id}"`, - ); - if (channel.type !== ChannelType.GuildText) - throw new Error(`Main channel is not a text channel. "${channel.id}" is "${channel.type}"`); - return channel; -} - -function ensureVoiceChannel(guild: Guild, channelId: Snowflake): VoiceChannel { - const channel = guild.channels.cache.get(channelId); - if (!channel) - throw new Error( - `Could not find main channel with id "${channelId}" on guild "${guild.id}"`, - ); - if (channel.type !== ChannelType.GuildVoice) - throw new Error( - `Main channel is not a voice channel. "${channel.id}" is "${channel.type}"`, - ); - return channel; -} - -function ensureEmoji(guild: Guild, emojiId: Snowflake, fallbackName: string): GuildEmoji { - const emoji = guild.emojis.resolve(emojiId); - if (emoji) { - return emoji; - } - - const fallback = guild.emojis.cache.find(e => e.name === fallbackName); - if (fallback) { - return fallback; - } - - throw new Error( - `Emoji with ID "${emojiId}" not found in guild "${guild.id}". Also did not find a fallback with name "${fallbackName}"`, - ); -} - -// #endregion - -export async function createBotContext(client: Client): Promise { - log.debug("createBotContext: reading config..."); - const config = await readConfig(); - - log.debug("createBotContext: config read, resolving guild..."); - const guild = client.guilds.cache.get(config.guildGuildId); +export async function createBotContext( + client: Client, + guildId: GuildId, +): Promise { + log.debug("createBotContext: resolving guild..."); + const guild = client.guilds.cache.get(guildId); if (!guild) { - throw new Error(`Cannot find configured guild "${config.guildGuildId}"`); + throw new Error(`Cannot find configured guild "${guildId}"`); } + log.debug("createBotContext: guild resolved, reading config..."); + const config = await readConfig(guild); + const role = config.role; const textChannel = config.textChannel; const voiceChannel = config.voiceChannel; + const moderatorRoleIds = new Set(config.moderatorRoleIds.map(r => r.id)); const soundsPath = path.resolve("data/sounds"); log.debug({ soundsPath }, "createBotContext: creating sounds directory..."); @@ -258,81 +209,43 @@ export async function createBotContext(client: Client): Promise ensureRole(guild, id)), + prefix: config.prefix, + sendWelcomeMessage: config.sendWelcomeMessage, + spotifyClient: config.spotify + ? SpotifyApi.withClientCredentials( + config.spotify.clientId, + config.spotify.clientSecret, + [], + ) + : null, + youtube: config.youtube, + moderatorRoles: config.moderatorRoleIds, commandConfig: { - faulenzerPing: { - allowedRoleIds: new Set(config.command.faulenzerPing.allowedRoleIds), - maxNumberOfPings: Number(config.command.faulenzerPing.maxNumberOfPings ?? "15"), - minRequiredReactions: Number( - config.command.faulenzerPing.minRequiredReactions ?? "5", - ), - }, - ehre: { - emojiNames: new Set(config.command.ehre.emojiNames ?? ["aehre"]), - }, - quote: { - emojiName: config.command.quotes.emojiName, - allowedGroupIds: new Set(config.command.quotes.allowedGroupIds), - anonymousCategoryIds: new Set(config.command.quotes.anonymousCategoryIds), - anonymousChannelIds: new Set(config.command.quotes.anonymousChannelIds), - blacklistedChannelIds: new Set(config.command.quotes.blacklistedChannelIds), - voteThreshold: config.command.quotes.voteThreshold ?? 2, - defaultTargetChannelId: config.command.quotes.defaultTargetChannelId, - targetChannelOverrides: config.command.quotes.targetChannelOverrides, - }, - nickName: { - skippedUserIds: new Set(config.command.nickName?.skippedUserIds ?? []), - }, + faulenzerPing: config.command.faulenzerPing, + ehre: config.command.ehre, + quote: config.command.quotes, + nickName: config.command.nickName, loot: { - enabled: config.command.loot?.enabled ?? false, - scheduleCron: config.command.loot?.scheduleCron ?? "*/15 * * * *", - dropChance: config.command.loot?.dropChance ?? 0.05, + enabled: config.command.loot?.enabled, + scheduleCron: config.command.loot?.scheduleCron, + dropChance: config.command.loot?.dropChance, allowedChannelIds: config.command.loot?.allowedChannelIds ?? undefined, - maxTimePassedSinceLastMessage: Temporal.Duration.from( - config.command.loot?.maxTimePassedSinceLastMessage ?? "PT30M", - ), + maxTimePassedSinceLastMessage: config.command.loot?.maxTimePassedSinceLastMessage, roles: { - asseGuardShiftDuration: Temporal.Duration.from( - config.command.loot?.roles?.asseGuardShiftDuration ?? "PT8H", - ), + asseGuardShiftDuration: config.command.loot?.roles?.asseGuardShiftDuration, }, }, instagram: { rapidApiInstagramApiKey: - config.command.instagram?.rapidApiInstagramApiKey?.trim() ?? undefined, - }, - aoc: { - enabled: config.command.aoc.enabled, - targetChannelId: config.command.aoc.targetChannelId, - sessionToken: config.command.aoc.sessionToken, - leaderBoardJsonUrl: config.command.aoc.leaderBoardJsonUrl, - userMap: config.command.aoc.userMap, + config.command.instagram?.rapidApiInstagramApiKey ?? undefined, }, + aoc: config.command.aoc, autoban: { enabled: config.command.autoban?.enabled ?? false, dryRun: config.command.autoban?.dryRun ?? true, @@ -349,39 +262,36 @@ export async function createBotContext(client: Client): Promise): Promise hasAnyRoleById(member, config.moderatorRoleIds), - isNerd: member => hasRoleById(member, config.role.defaultRoleId), + isMod: member => hasAnyRoleById(member, moderatorRoleIds), + isNerd: member => hasRoleById(member, role.defaultRoleId.id), isTrusted: member => - hasRoleById(member, config.role.trustedRoleId) || - hasRoleById(member, config.role.trustedBannedRoleId), + hasRoleById(member, role.trustedRoleId.id) || + hasRoleById(member, role.trustedBannedRoleId.id), isGruendervater: member => - hasRoleById(member, config.role.gruendervaeterRoleId) || - hasRoleById(member, config.role.gruendervaeterBannedRoleId), - isWoisGang: member => hasRoleById(member, config.role.woisgangRoleId), - isEmotifizierer: member => hasRoleById(member, config.role.emotifiziererRoleId), - hasBotDenyRole: member => hasRoleById(member, config.role.botDenyRoleId), - hasRoleDenyRole: member => hasRoleById(member, config.role.roleDenyRoleId), - isRejoiner: member => hasRoleById(member, config.role.shameRoleId), - - isLootRoleAsseGuard: member => hasRoleById(member, config.role.lootRoleAsseGuardRoleId), + hasRoleById(member, role.gruendervaeterRoleId.id) || + hasRoleById(member, role.gruendervaeterBannedRoleId.id), + isWoisGang: member => hasRoleById(member, role.woisgangRoleId.id), + isEmotifizierer: member => hasRoleById(member, role.emotifiziererRoleId.id), + hasBotDenyRole: member => hasRoleById(member, role.botDenyRoleId.id), + hasRoleDenyRole: member => hasRoleById(member, role.roleDenyRoleId.id), + isRejoiner: member => hasRoleById(member, role.shameRoleId.id), + + isLootRoleAsseGuard: member => hasRoleById(member, role.lootRoleAsseGuardRoleId.id), }, channelGuard: { - isInBotSpam: message => message.channelId === config.textChannel.botSpamChannelId, + isInBotSpam: message => message.channelId === textChannel.botSpamChannelId.id, }, emoji: { - alarm: ensureEmoji(guild, config.emoji.alarmEmojiId, "alarm"), - sadHamster: ensureEmoji(guild, config.emoji.sadHamsterEmojiId, "sad_hamster"), - trichter: ensureEmoji(guild, config.emoji.trichterEmojiId, "trichter"), + alarm: config.emoji.alarmEmojiId, + sadHamster: config.emoji.sadHamsterEmojiId, + trichter: config.emoji.trichterEmojiId, }, }; } -function hasAnyRoleById(member: GuildMember, roleIds: readonly Snowflake[]) { - return roleIds.some(role => hasRoleById(member, role)); +function hasAnyRoleById(member: GuildMember, roleIds: Iterable) { + for (const id of roleIds) { + if (hasRoleById(member, id)) { + return true; + } + } + return false; } function hasRoleById(member: GuildMember | APIInteractionGuildMember, id: Snowflake): boolean { diff --git a/src/service/config.test.ts b/src/service/config.test.ts new file mode 100644 index 00000000..ba4b78ea --- /dev/null +++ b/src/service/config.test.ts @@ -0,0 +1,74 @@ +import { describe, test } from "node:test"; + +import { expect } from "expect"; +import { ChannelType, type Guild, type GuildEmoji } from "discord.js"; + +import { configSchema } from "#/service/config.ts"; + +const alarm = { id: "400", name: "alarm" }; +const sadHamster = { id: "401", name: "sad_hamster" }; + +const guild = { + id: "1337", + channels: { + cache: new Map([ + ["100", { id: "100", type: ChannelType.GuildText }], + ["200", { id: "200", type: ChannelType.GuildVoice }], + ]), + }, + roles: { + cache: new Map([["300", { id: "300" }]]), + }, + emojis: { + resolve: (id: string) => (id === alarm.id ? alarm : null), + cache: { + find: (predicate: (e: GuildEmoji) => boolean) => + ([alarm, sadHamster] as unknown as GuildEmoji[]).find(predicate), + }, + }, +} as unknown as Guild; + +const schema = configSchema(guild); + +void describe("configSchema guild resolution", () => { + void test("resolves text channels and reports all bad ids at once", () => { + const result = schema.shape.textChannel.safeParse({ + banReasonChannelId: "100", + bannedChannelId: "100", + botLogChannelId: "200", // voice channel -> wrong type + hauptchatChannelId: "100", + votesChannelId: "999", // does not exist + botSpamChannelId: "100", + hauptwoisTextChannelId: "100", + roleAssignerChannelId: "100", + }); + + expect(result.success).toBe(false); + expect(result.error?.issues).toHaveLength(2); + expect(result.error?.issues.map(i => i.path[0])).toStrictEqual([ + "botLogChannelId", + "votesChannelId", + ]); + }); + + void test("resolves channel objects on success", () => { + const result = schema.shape.voiceChannel.safeParse({ + hauptWoischatChannelId: "200", + }); + + expect(result.success).toBe(true); + expect(result.data?.hauptWoischatChannelId).toMatchObject({ id: "200" }); + }); + + void test("resolves emoji by id, falls back by name, errors otherwise", () => { + const result = schema.shape.emoji.safeParse({ + alarmEmojiId: "400", // resolved by id + sadHamsterEmojiId: "999", // resolved via fallback name + trichterEmojiId: "999", // no fallback -> error + }); + + expect(result.success).toBe(false); + expect(result.error?.issues).toHaveLength(1); + expect(result.error?.issues[0]?.path).toStrictEqual(["trichterEmojiId"]); + }); +}); diff --git a/src/service/config.ts b/src/service/config.ts index aa912e70..dc459f6f 100644 --- a/src/service/config.ts +++ b/src/service/config.ts @@ -3,14 +3,16 @@ import * as path from "node:path"; import { parseArgs } from "node:util"; import * as JSONC from "comment-json"; +import * as z from "zod"; -import type { Snowflake, ActivityType } from "discord.js"; +import type { ActivityType, Guild, GuildEmoji, Role, TextChannel, VoiceChannel } from "discord.js"; +import { ChannelType } from "discord.js"; import log from "#log"; const configPath = path.resolve("config.json"); -export async function readConfig() { +async function readConfigFile(): Promise { try { await fs.stat(configPath); } catch { @@ -31,13 +33,38 @@ export async function readConfig() { } try { - return JSONC.parse(jsonString) as unknown as Config; + return JSONC.parse(jsonString); } catch (e) { log.error(e, "Config is not valid JSON. Stopping..."); return process.exit(1); } } +/** Reads the minimal part of the config that is needed before the bot has logged in. */ +export async function readBootstrapConfig() { + const parsed = await readConfigFile(); + const result = bootstrapSchema.safeParse(parsed); + if (!result.success) { + log.error(result.error, `Config is invalid. Stopping...\n${z.prettifyError(result.error)}`); + return process.exit(1); + } + return result.data; +} + +/** + * Reads the full config, resolving channel/role/emoji IDs to live objects of the given guild. + * Requires the client to be logged in. + */ +export async function readConfig(guild: Guild) { + const parsed = await readConfigFile(); + const result = configSchema(guild).safeParse(parsed); + if (!result.success) { + log.error(result.error, `Config is invalid. Stopping...\n${z.prettifyError(result.error)}`); + return process.exit(1); + } + return result.data; +} + export const databasePath = process.env.DATABASE_PATH ?? path.resolve("storage.db"); export const args = parseArgs({ @@ -49,156 +76,312 @@ export const args = parseArgs({ }, }); -export interface Config { - auth: { - clientId: string; - token: string; - }; - - development?: { - enableCommands?: boolean; - }; - - sentry?: { - dsn?: string | null; - tracesSampleRate?: number; - }; - - spotify?: { - clientId?: string; - clientSecret?: string; - }; - - youtube?: { - cookieFilePath?: string | null; - }; - - activity: { - type: ActivityType; - name: string; - state?: string; - url?: string; - }; - - prefix: { - command: string; - modCommand: string; - }; - - sendWelcomeMessage?: boolean; - - moderatorRoleIds: readonly Snowflake[]; - - command: { - faulenzerPing: { - allowedRoleIds: readonly Snowflake[]; - maxNumberOfPings: number; - minRequiredReactions: number; - }; - nickName?: { - skippedUserIds: readonly Snowflake[]; - }; - woisPing: { - limit: number; - threshold: number; - }; - ehre: { - emojiNames: readonly string[]; - }; - instagram?: { - rapidApiInstagramApiKey?: string | null; - }; - loot: { - enabled: boolean; - scheduleCron: string; - dropChance: number; - allowedChannelIds?: Array | null; - /** ISO8601 duration */ - maxTimePassedSinceLastMessage: string; - - roles: { - /** ISO8601 duration */ - asseGuardShiftDuration: string; - }; - }; - quotes: { - emojiName: string; - allowedGroupIds: readonly Snowflake[]; - anonymousChannelIds: readonly Snowflake[]; - anonymousCategoryIds: readonly Snowflake[]; - voteThreshold: number; - blacklistedChannelIds: readonly Snowflake[]; - targetChannelOverrides: Record; - defaultTargetChannelId: Snowflake; - }; - aoc: { - enabled: boolean; - - targetChannelId: Snowflake; - sessionToken: string; - leaderBoardJsonUrl: string; - userMap: Record< - Snowflake, - { - displayName: string; - language: string; - } - >; - }; - autoban?: { - enabled: boolean; - /** When true, spam is detected and logged but no message is deleted and no user is banned. */ - dryRun?: boolean; - deleteThreshold: number; - banThreshold: number; - banDurationHours: number; - /** ISO8601 duration string, e.g. "PT5M" for 5 minutes. */ - timeWindowDuration: string; - }; - }; - - deleteThreadMessagesInChannelIds: readonly Snowflake[]; - flameTrustedUserOnBotPing: boolean; - - guildGuildId: Snowflake; - - textChannel: { - banReasonChannelId: Snowflake; - bannedChannelId: Snowflake; - botLogChannelId: Snowflake; - hauptchatChannelId: Snowflake; - votesChannelId: Snowflake; - botSpamChannelId: Snowflake; - hauptwoisTextChannelId: Snowflake; - roleAssignerChannelId: Snowflake; - /** Channel ID for the mod-only spam audit log. Leave empty to disable. */ - spamLogChannelId?: Snowflake; - }; - - voiceChannel: { - hauptWoischatChannelId: Snowflake; - }; - - role: { - bannedRoleId: Snowflake; - birthdayRoleId: Snowflake; - botDenyRoleId: Snowflake; - defaultRoleId: Snowflake; - gruendervaeterRoleId: Snowflake; - gruendervaeterBannedRoleId: Snowflake; - roleDenyRoleId: Snowflake; - shameRoleId: Snowflake; - trustedRoleId: Snowflake; - trustedBannedRoleId: Snowflake; - woisgangRoleId: Snowflake; - winnerRoleId: Snowflake; - emotifiziererRoleId: Snowflake; - lootRoleAsseGuardRoleId: Snowflake; - }; - - emoji: { - alarmEmojiId: Snowflake; - sadHamsterEmojiId: Snowflake; - trichterEmojiId: Snowflake; - }; -} +const activityType = z.number() as unknown as z.ZodType; + +// Branded snowflakes so a channel id can't be passed where a user/role id is expected. +const channelId = z.string().brand("ChannelId"); +const categoryId = z.string().brand("CategoryId"); +const userId = z.string().brand("UserId"); +const roleId = z.string().brand("RoleId"); +const groupId = z.string().brand("GroupId"); +const guildId = z.string().brand("GuildId"); + +const apiKey = z + .string() + .brand("ApiKey") + .transform(k => k.trim()); + +export type ChannelId = z.infer; +export type CategoryId = z.infer; +export type UserId = z.infer; +export type RoleId = z.infer; +export type GroupId = z.infer; +export type GuildId = z.infer; + +// Arrays of ids parsed into Sets for O(1) membership checks. +const channelIdSet = z.array(channelId).transform(ids => new Set(ids)); +const categoryIdSet = z.array(categoryId).transform(ids => new Set(ids)); +const userIdSet = z.array(userId).transform(ids => new Set(ids)); +const roleIdSet = z.array(roleId).transform(ids => new Set(ids)); +const groupIdSet = z.array(groupId).transform(ids => new Set(ids)); + +const stringSet = z.array(z.string()).transform(ids => new Set(ids)); + +/** ISO8601 duration string (e.g. "P1DT2H30M") parsed into a Temporal.Duration. */ +const iso8601Duration = z.string().transform((s, ctx) => { + try { + return Temporal.Duration.from(s); + } catch { + ctx.addIssue({ code: "custom", message: "Invalid ISO8601 duration" }); + return z.NEVER; + } +}); + +// #region Guild resource resolution + +const textChannel = (guild: Guild) => + z.string().transform((id, ctx): TextChannel => { + const channel = guild.channels.cache.get(id); + if (!channel) { + ctx.addIssue({ + code: "custom", + message: `Channel with ID "${id}" not found in guild "${guild.id}"`, + }); + return z.NEVER; + } + if (channel.type !== ChannelType.GuildText) { + ctx.addIssue({ + code: "custom", + message: `Channel "${id}" is not a text channel but "${channel.type}"`, + }); + return z.NEVER; + } + return channel; + }); + +const voiceChannel = (guild: Guild) => + z.string().transform((id, ctx): VoiceChannel => { + const channel = guild.channels.cache.get(id); + if (!channel) { + ctx.addIssue({ + code: "custom", + message: `Channel with ID "${id}" not found in guild "${guild.id}"`, + }); + return z.NEVER; + } + if (channel.type !== ChannelType.GuildVoice) { + ctx.addIssue({ + code: "custom", + message: `Channel "${id}" is not a voice channel but "${channel.type}"`, + }); + return z.NEVER; + } + return channel; + }); + +const role = (guild: Guild) => + z.string().transform((id, ctx): Role => { + const role = guild.roles.cache.get(id); + if (!role) { + ctx.addIssue({ + code: "custom", + message: `Role with ID "${id}" not found in guild "${guild.id}"`, + }); + return z.NEVER; + } + return role; + }); + +const guildEmoji = (guild: Guild, fallbackName: string) => + z.string().transform((id, ctx): GuildEmoji => { + const emoji = guild.emojis.resolve(id); + if (emoji) { + return emoji; + } + + const fallback = guild.emojis.cache.find(e => e.name === fallbackName); + if (fallback) { + return fallback; + } + + ctx.addIssue({ + code: "custom", + message: `Emoji with ID "${id}" not found in guild "${guild.id}". Also did not find a fallback with name "${fallbackName}"`, + }); + return z.NEVER; + }); + +// #endregion + +const auth = z.object({ + clientId: z.string(), + token: z.string(), +}); + +const sentry = z + .object({ + dsn: z.string().nullish(), + tracesSampleRate: z.number().optional(), + }) + .optional(); + +const activity = z.object({ + type: activityType, + name: z.string(), + state: z.string().optional(), + url: z.string().optional(), +}); + +const prefix = z.object({ + command: z.string(), + modCommand: z.string(), +}); + +/** Everything that is needed before the bot has logged in. */ +export const bootstrapSchema = z.object({ + auth, + sentry, + activity, + prefix, + guildGuildId: guildId, +}); + +export const configSchema = (guild: Guild) => { + const guildRole = role(guild); + const guildTextChannel = textChannel(guild); + + return z.object({ + auth, + sentry, + activity, + prefix, + guildGuildId: guildId, + + development: z + .object({ + enableCommands: z.boolean().optional(), + }) + .optional(), + + spotify: z + .object({ + clientId: z.string(), + clientSecret: z.string(), + }) + .optional(), + + youtube: z + .object({ + cookieFilePath: z.string(), + }) + .optional(), + + sendWelcomeMessage: z.boolean().optional().default(false), + + moderatorRoleIds: z.array(guildRole).readonly(), + + command: z.object({ + faulenzerPing: z.object({ + allowedRoleIds: roleIdSet, + maxNumberOfPings: z.number().optional().default(15), + minRequiredReactions: z.number().optional().default(5), + }), + nickName: z + .object({ + skippedUserIds: userIdSet.optional().default(new Set()), + }) + .optional() + .default({ skippedUserIds: new Set() }), + woisPing: z.object({ + limit: z.number(), + threshold: z.number(), + }), + ehre: z.object({ + emojiNames: stringSet.optional().default(new Set(["aehre"])), + }), + instagram: z + .object({ + rapidApiInstagramApiKey: apiKey.nullish().default(null), + }) + .optional() + .default({ rapidApiInstagramApiKey: null }), + loot: z.object({ + enabled: z.boolean().optional().default(false), + scheduleCron: z.string().optional().default("*/15 * * * *"), + dropChance: z.number().optional().default(0.05), + allowedChannelIds: channelIdSet.nullish(), + maxTimePassedSinceLastMessage: iso8601Duration + .optional() + .default(Temporal.Duration.from("PT30M")), + + roles: z.object({ + asseGuardShiftDuration: iso8601Duration + .optional() + .default(Temporal.Duration.from("PT8H")), + }), + }), + quotes: z.object({ + emojiName: z.string(), + allowedGroupIds: groupIdSet, + anonymousChannelIds: channelIdSet, + anonymousCategoryIds: categoryIdSet, + voteThreshold: z.number().positive().optional().default(2), + blacklistedChannelIds: channelIdSet, + targetChannelOverrides: z.record(channelId, channelId), + defaultTargetChannelId: channelId, + }), + aoc: z.object({ + enabled: z.boolean(), + + targetChannelId: channelId, + sessionToken: z.string(), + leaderBoardJsonUrl: z.string(), + userMap: z.record( + userId, + z.object({ + displayName: z.string(), + language: z.string(), + }), + ), + }), + autoban: z + .object({ + enabled: z.boolean().optional(), + /** When true, spam is detected and logged but no message is deleted and no user is banned. */ + dryRun: z.boolean().optional(), + deleteThreshold: z.number().optional(), + banThreshold: z.number().optional(), + banDurationHours: z.number().optional(), + /** ISO8601 duration string, e.g. "PT5M" for 5 minutes. */ + timeWindowDuration: z.string().optional(), + }) + .optional(), + }), + + deleteThreadMessagesInChannelIds: channelIdSet, + flameTrustedUserOnBotPing: z.boolean(), + + textChannel: z.object({ + banReasonChannelId: guildTextChannel, + bannedChannelId: guildTextChannel, + botLogChannelId: guildTextChannel, + hauptchatChannelId: guildTextChannel, + votesChannelId: guildTextChannel, + botSpamChannelId: guildTextChannel, + hauptwoisTextChannelId: guildTextChannel, + roleAssignerChannelId: guildTextChannel, + /** Channel ID for the mod-only spam audit log. Leave empty to disable. */ + spamLogChannelId: guildTextChannel.optional(), + }), + + voiceChannel: z.object({ + hauptWoischatChannelId: voiceChannel(guild), + }), + + role: z.object({ + bannedRoleId: guildRole, + birthdayRoleId: guildRole, + botDenyRoleId: guildRole, + defaultRoleId: guildRole, + gruendervaeterRoleId: guildRole, + gruendervaeterBannedRoleId: guildRole, + roleDenyRoleId: guildRole, + shameRoleId: guildRole, + trustedRoleId: guildRole, + trustedBannedRoleId: guildRole, + woisgangRoleId: guildRole, + winnerRoleId: guildRole, + emotifiziererRoleId: guildRole, + lootRoleAsseGuardRoleId: guildRole, + }), + + emoji: z.object({ + alarmEmojiId: guildEmoji(guild, "alarm"), + sadHamsterEmojiId: guildEmoji(guild, "sad_hamster"), + trichterEmojiId: guildEmoji(guild, "trichter"), + }), + }); +}; + +export type BootstrapConfig = z.infer; +export type Config = z.infer>; diff --git a/src/service/lootDrop.ts b/src/service/lootDrop.ts index b650b903..b1a9d05a 100644 --- a/src/service/lootDrop.ts +++ b/src/service/lootDrop.ts @@ -23,7 +23,12 @@ import * as sentry from "@sentry/node"; import type { BotContext } from "#/context.ts"; import type { Loot, LootId } from "#/storage/db/model.ts"; import type { LootAttributeTemplate, LootTemplate, TimeBasedWeightConfig } from "#/storage/loot.ts"; -import { randomBoolean, randomEntry, randomEntryWeighted } from "#/service/random.ts"; +import { + randomBoolean, + randomEntry, + randomEntryWeighted, + randomSetEntry, +} from "#/service/random.ts"; import * as timeUtils from "#/utils/time.ts"; import { zonedNow } from "#/utils/dateUtils.ts"; @@ -48,7 +53,7 @@ export async function runDropAttempt(context: BotContext) { const fallbackChannel = context.textChannels.hauptchat; const targetChannelId = lootConfig.allowedChannelIds - ? randomEntry(lootConfig.allowedChannelIds) + ? randomSetEntry(lootConfig.allowedChannelIds) : fallbackChannel.id; const targetChannel = (await context.client.channels.fetch(targetChannelId)) ?? fallbackChannel; diff --git a/src/service/random.ts b/src/service/random.ts index 9a67ce7c..8f6dcdc3 100644 --- a/src/service/random.ts +++ b/src/service/random.ts @@ -31,6 +31,9 @@ export function randomBoolean(trueProbability = 0.5): boolean { export function randomEntry(array: readonly T[]): T { return array[(array.length * Math.random()) | 0]; } +export function randomSetEntry(set: ReadonlySet): T { + return randomEntry(Array.from(set)); +} export function randomEntryWeighted( array: readonly T[], diff --git a/src/service/ytDl.ts b/src/service/ytDl.ts index 6f1dedcf..4f797ea6 100644 --- a/src/service/ytDl.ts +++ b/src/service/ytDl.ts @@ -112,7 +112,7 @@ export async function downloadVideo( ): Promise { const signal = AbortSignal.timeout(60_000); - const downloader = new YoutubeDownloader(context.youtube.cookieFilePath ?? null); + const downloader = new YoutubeDownloader(context.youtube?.cookieFilePath ?? null); const result = await downloader.downloadVideo(url, targetDir, signal); if (signal.aborted) {