From bd8d00a3badbae3e70b99630ced9bd103e2ee761 Mon Sep 17 00:00:00 2001 From: holzmaster Date: Wed, 8 Jul 2026 23:55:37 +0200 Subject: [PATCH 01/10] Add zod --- package-lock.json | 12 +++++++++++- package.json | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) 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 6dcfdf56..f599f4c1 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", From de0fa3105b58b38b2b1b2a914883b172b16045e5 Mon Sep 17 00:00:00 2001 From: holzmaster Date: Thu, 9 Jul 2026 00:04:00 +0200 Subject: [PATCH 02/10] Add runtime type check of config --- src/service/config.ts | 320 +++++++++++++++++++++++------------------- 1 file changed, 177 insertions(+), 143 deletions(-) diff --git a/src/service/config.ts b/src/service/config.ts index 95d64120..d2fa3477 100644 --- a/src/service/config.ts +++ b/src/service/config.ts @@ -3,8 +3,9 @@ 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 } from "discord.js"; import log from "#log"; @@ -30,12 +31,20 @@ export async function readConfig() { process.exit(1); } + let parsed: unknown; try { - return JSONC.parse(jsonString) as unknown as Config; + parsed = JSONC.parse(jsonString); } catch (e) { log.error(e, "Config is not valid JSON. Stopping..."); return process.exit(1); } + + const result = configSchema.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"); @@ -49,144 +58,169 @@ 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; - } - >; - }; - }; - - deleteThreadMessagesInChannelIds: readonly Snowflake[]; - flameTrustedUserOnBotPing: boolean; - - guildGuildId: Snowflake; - - textChannel: { - banReasonChannelId: Snowflake; - bannedChannelId: Snowflake; - botLogChannelId: Snowflake; - hauptchatChannelId: Snowflake; - votesChannelId: Snowflake; - botSpamChannelId: Snowflake; - hauptwoisTextChannelId: Snowflake; - roleAssignerChannelId: 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 snowflake = z.string(); +const activityType = z.number() as unknown as z.ZodType; + +/** 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; + } +}); + +export const configSchema = z.object({ + auth: z.object({ + clientId: z.string(), + token: z.string(), + }), + + development: z + .object({ + enableCommands: z.boolean().optional(), + }) + .optional(), + + sentry: z + .object({ + dsn: z.string().nullish(), + tracesSampleRate: z.number().optional(), + }) + .optional(), + + spotify: z + .object({ + clientId: z.string().optional(), + clientSecret: z.string().optional(), + }) + .optional(), + + youtube: z + .object({ + cookieFilePath: z.string().nullish(), + }) + .optional(), + + activity: z.object({ + type: activityType, + name: z.string(), + state: z.string().optional(), + url: z.string().optional(), + }), + + prefix: z.object({ + command: z.string(), + modCommand: z.string(), + }), + + sendWelcomeMessage: z.boolean().optional(), + + moderatorRoleIds: z.array(snowflake), + + command: z.object({ + faulenzerPing: z.object({ + allowedRoleIds: z.array(snowflake), + maxNumberOfPings: z.number(), + minRequiredReactions: z.number(), + }), + nickName: z + .object({ + skippedUserIds: z.array(snowflake), + }) + .optional(), + woisPing: z.object({ + limit: z.number(), + threshold: z.number(), + }), + ehre: z.object({ + emojiNames: z.array(z.string()), + }), + instagram: z + .object({ + rapidApiInstagramApiKey: z.string().nullish(), + }) + .optional(), + loot: z.object({ + enabled: z.boolean(), + scheduleCron: z.string(), + dropChance: z.number(), + allowedChannelIds: z.array(snowflake).nullish(), + maxTimePassedSinceLastMessage: iso8601Duration, + + roles: z.object({ + asseGuardShiftDuration: iso8601Duration, + }), + }), + quotes: z.object({ + emojiName: z.string(), + allowedGroupIds: z.array(snowflake), + anonymousChannelIds: z.array(snowflake), + anonymousCategoryIds: z.array(snowflake), + voteThreshold: z.number(), + blacklistedChannelIds: z.array(snowflake), + targetChannelOverrides: z.record(z.string(), z.string()), + defaultTargetChannelId: snowflake, + }), + aoc: z.object({ + enabled: z.boolean(), + + targetChannelId: snowflake, + sessionToken: z.string(), + leaderBoardJsonUrl: z.string(), + userMap: z.record( + snowflake, + z.object({ + displayName: z.string(), + language: z.string(), + }), + ), + }), + }), + + deleteThreadMessagesInChannelIds: z.array(snowflake), + flameTrustedUserOnBotPing: z.boolean(), + + guildGuildId: snowflake, + + textChannel: z.object({ + banReasonChannelId: snowflake, + bannedChannelId: snowflake, + botLogChannelId: snowflake, + hauptchatChannelId: snowflake, + votesChannelId: snowflake, + botSpamChannelId: snowflake, + hauptwoisTextChannelId: snowflake, + roleAssignerChannelId: snowflake, + }), + + voiceChannel: z.object({ + hauptWoischatChannelId: snowflake, + }), + + role: z.object({ + 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: z.object({ + alarmEmojiId: snowflake, + sadHamsterEmojiId: snowflake, + trichterEmojiId: snowflake, + }), +}); + +export type Config = z.infer; From 754b7451abef51c4632b7cd936b1da5c3a5e5f3a Mon Sep 17 00:00:00 2001 From: holzmaster Date: Thu, 9 Jul 2026 00:05:22 +0200 Subject: [PATCH 03/10] Better duration parsing --- src/context.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/context.ts b/src/context.ts index 6c7ed4ba..8e795d71 100644 --- a/src/context.ts +++ b/src/context.ts @@ -298,14 +298,14 @@ export async function createBotContext(client: Client): Promise Date: Thu, 9 Jul 2026 00:18:02 +0200 Subject: [PATCH 04/10] Use set entry --- src/context.ts | 2 +- src/service/lootDrop.ts | 9 +++++++-- src/service/random.ts | 3 +++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/context.ts b/src/context.ts index 8e795d71..937e92f4 100644 --- a/src/context.ts +++ b/src/context.ts @@ -66,7 +66,7 @@ export interface BotContext { enabled: boolean; scheduleCron: string; dropChance: number; - allowedChannelIds?: readonly Snowflake[]; + allowedChannelIds?: Set; maxTimePassedSinceLastMessage: Temporal.Duration; roles: { 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[], From 9285849a45ce031d9619918479f7efe3af6d6f96 Mon Sep 17 00:00:00 2001 From: holzmaster Date: Thu, 9 Jul 2026 00:20:00 +0200 Subject: [PATCH 05/10] Use branded types with transforms to move set creation --- src/context.ts | 25 ++++++---- src/service/config.ts | 109 ++++++++++++++++++++++++++---------------- 2 files changed, 82 insertions(+), 52 deletions(-) diff --git a/src/context.ts b/src/context.ts index 937e92f4..fa287704 100644 --- a/src/context.ts +++ b/src/context.ts @@ -268,30 +268,30 @@ export async function createBotContext(client: Client): Promise ensureRole(guild, id)), + moderatorRoles: Array.from(config.moderatorRoleIds, id => ensureRole(guild, id)), commandConfig: { faulenzerPing: { - allowedRoleIds: new Set(config.command.faulenzerPing.allowedRoleIds), + allowedRoleIds: 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"]), + emojiNames: config.command.ehre.emojiNames ?? new Set(["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), + allowedGroupIds: config.command.quotes.allowedGroupIds, + anonymousCategoryIds: config.command.quotes.anonymousCategoryIds, + anonymousChannelIds: config.command.quotes.anonymousChannelIds, + blacklistedChannelIds: 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 ?? []), + skippedUserIds: config.command.nickName?.skippedUserIds ?? new Set(), }, loot: { enabled: config.command.loot?.enabled ?? false, @@ -397,8 +397,13 @@ export async function createBotContext(client: Client): Promise 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.ts b/src/service/config.ts index d2fa3477..fe3b9938 100644 --- a/src/service/config.ts +++ b/src/service/config.ts @@ -58,9 +58,34 @@ export const args = parseArgs({ }, }); -const snowflake = z.string(); 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 emojiId = z.string().brand("EmojiId"); +const guildId = z.string().brand("GuildId"); + +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 EmojiId = 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 { @@ -117,17 +142,17 @@ export const configSchema = z.object({ sendWelcomeMessage: z.boolean().optional(), - moderatorRoleIds: z.array(snowflake), + moderatorRoleIds: roleIdSet, command: z.object({ faulenzerPing: z.object({ - allowedRoleIds: z.array(snowflake), + allowedRoleIds: roleIdSet, maxNumberOfPings: z.number(), minRequiredReactions: z.number(), }), nickName: z .object({ - skippedUserIds: z.array(snowflake), + skippedUserIds: userIdSet, }) .optional(), woisPing: z.object({ @@ -135,7 +160,7 @@ export const configSchema = z.object({ threshold: z.number(), }), ehre: z.object({ - emojiNames: z.array(z.string()), + emojiNames: stringSet, }), instagram: z .object({ @@ -146,7 +171,7 @@ export const configSchema = z.object({ enabled: z.boolean(), scheduleCron: z.string(), dropChance: z.number(), - allowedChannelIds: z.array(snowflake).nullish(), + allowedChannelIds: channelIdSet.nullish(), maxTimePassedSinceLastMessage: iso8601Duration, roles: z.object({ @@ -155,22 +180,22 @@ export const configSchema = z.object({ }), quotes: z.object({ emojiName: z.string(), - allowedGroupIds: z.array(snowflake), - anonymousChannelIds: z.array(snowflake), - anonymousCategoryIds: z.array(snowflake), + allowedGroupIds: groupIdSet, + anonymousChannelIds: channelIdSet, + anonymousCategoryIds: categoryIdSet, voteThreshold: z.number(), - blacklistedChannelIds: z.array(snowflake), - targetChannelOverrides: z.record(z.string(), z.string()), - defaultTargetChannelId: snowflake, + blacklistedChannelIds: channelIdSet, + targetChannelOverrides: z.record(channelId, channelId), + defaultTargetChannelId: channelId, }), aoc: z.object({ enabled: z.boolean(), - targetChannelId: snowflake, + targetChannelId: channelId, sessionToken: z.string(), leaderBoardJsonUrl: z.string(), userMap: z.record( - snowflake, + userId, z.object({ displayName: z.string(), language: z.string(), @@ -179,47 +204,47 @@ export const configSchema = z.object({ }), }), - deleteThreadMessagesInChannelIds: z.array(snowflake), + deleteThreadMessagesInChannelIds: channelIdSet, flameTrustedUserOnBotPing: z.boolean(), - guildGuildId: snowflake, + guildGuildId: guildId, textChannel: z.object({ - banReasonChannelId: snowflake, - bannedChannelId: snowflake, - botLogChannelId: snowflake, - hauptchatChannelId: snowflake, - votesChannelId: snowflake, - botSpamChannelId: snowflake, - hauptwoisTextChannelId: snowflake, - roleAssignerChannelId: snowflake, + banReasonChannelId: channelId, + bannedChannelId: channelId, + botLogChannelId: channelId, + hauptchatChannelId: channelId, + votesChannelId: channelId, + botSpamChannelId: channelId, + hauptwoisTextChannelId: channelId, + roleAssignerChannelId: channelId, }), voiceChannel: z.object({ - hauptWoischatChannelId: snowflake, + hauptWoischatChannelId: channelId, }), role: z.object({ - 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, + bannedRoleId: roleId, + birthdayRoleId: roleId, + botDenyRoleId: roleId, + defaultRoleId: roleId, + gruendervaeterRoleId: roleId, + gruendervaeterBannedRoleId: roleId, + roleDenyRoleId: roleId, + shameRoleId: roleId, + trustedRoleId: roleId, + trustedBannedRoleId: roleId, + woisgangRoleId: roleId, + winnerRoleId: roleId, + emotifiziererRoleId: roleId, + lootRoleAsseGuardRoleId: roleId, }), emoji: z.object({ - alarmEmojiId: snowflake, - sadHamsterEmojiId: snowflake, - trichterEmojiId: snowflake, + alarmEmojiId: emojiId, + sadHamsterEmojiId: emojiId, + trichterEmojiId: emojiId, }), }); From 344f5882ae28e85a51e13b62e034664ed7d382ad Mon Sep 17 00:00:00 2001 From: holzmaster Date: Thu, 9 Jul 2026 00:35:39 +0200 Subject: [PATCH 06/10] Fix config type --- .github/config.json | 2 +- config.template.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/config.json b/.github/config.json index 241c7d70..9170bdf3 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 d0ba396d..ced3d191 100644 --- a/config.template.json +++ b/config.template.json @@ -70,7 +70,7 @@ "anonymousCategoryIds": [], "blacklistedChannelIds": [], "defaultTargetChannelId": "893179191556706386", - "targetChannelOverrides": [] + "targetChannelOverrides": {} }, "aoc": { "enabled": false, From 7093dc81dff606e39a511c44bd46c4b3496ea157 Mon Sep 17 00:00:00 2001 From: holzmaster Date: Thu, 9 Jul 2026 00:35:50 +0200 Subject: [PATCH 07/10] Move defaults to config parser --- src/context.ts | 30 ++++++++++++------------------ src/service/config.ts | 29 +++++++++++++++++------------ 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/src/context.ts b/src/context.ts index fa287704..a5a1df24 100644 --- a/src/context.ts +++ b/src/context.ts @@ -46,7 +46,7 @@ export interface BotContext { spotifyClient: SpotifyApi | null; youtube: { - cookieFilePath?: string | null; + cookieFilePath: string | null; }; commandConfig: { @@ -256,7 +256,7 @@ export async function createBotContext(client: Client): Promise): Promise ensureRole(guild, id)), commandConfig: { faulenzerPing: { allowedRoleIds: config.command.faulenzerPing.allowedRoleIds, - maxNumberOfPings: Number(config.command.faulenzerPing.maxNumberOfPings ?? "15"), - minRequiredReactions: Number( - config.command.faulenzerPing.minRequiredReactions ?? "5", - ), + maxNumberOfPings: config.command.faulenzerPing.maxNumberOfPings, + minRequiredReactions: config.command.faulenzerPing.minRequiredReactions, }, ehre: { - emojiNames: config.command.ehre.emojiNames ?? new Set(["aehre"]), + emojiNames: config.command.ehre.emojiNames, }, quote: { emojiName: config.command.quotes.emojiName, @@ -286,7 +284,7 @@ export async function createBotContext(client: Client): Promise): Promise Date: Thu, 9 Jul 2026 00:41:23 +0200 Subject: [PATCH 08/10] Simplify context loading --- src/context.ts | 49 ++++++++----------------------------------- src/service/config.ts | 15 +++++++++---- 2 files changed, 20 insertions(+), 44 deletions(-) diff --git a/src/context.ts b/src/context.ts index a5a1df24..d276904b 100644 --- a/src/context.ts +++ b/src/context.ts @@ -244,18 +244,12 @@ export async function createBotContext(client: Client): Promise): Promise ensureRole(guild, id)), commandConfig: { - faulenzerPing: { - allowedRoleIds: config.command.faulenzerPing.allowedRoleIds, - maxNumberOfPings: config.command.faulenzerPing.maxNumberOfPings, - minRequiredReactions: config.command.faulenzerPing.minRequiredReactions, - }, - ehre: { - emojiNames: config.command.ehre.emojiNames, - }, - quote: { - emojiName: config.command.quotes.emojiName, - allowedGroupIds: config.command.quotes.allowedGroupIds, - anonymousCategoryIds: config.command.quotes.anonymousCategoryIds, - anonymousChannelIds: config.command.quotes.anonymousChannelIds, - blacklistedChannelIds: config.command.quotes.blacklistedChannelIds, - voteThreshold: config.command.quotes.voteThreshold, - defaultTargetChannelId: config.command.quotes.defaultTargetChannelId, - targetChannelOverrides: config.command.quotes.targetChannelOverrides, - }, - nickName: { - skippedUserIds: config.command.nickName?.skippedUserIds ?? new Set(), - }, + faulenzerPing: config.command.faulenzerPing, + ehre: config.command.ehre, + quote: config.command.quotes, + nickName: config.command.nickName, loot: { enabled: config.command.loot?.enabled, scheduleCron: config.command.loot?.scheduleCron, @@ -304,15 +279,9 @@ export async function createBotContext(client: Client): Promise k.trim()); + export type ChannelId = z.infer; export type CategoryId = z.infer; export type UserId = z.infer; @@ -153,9 +158,10 @@ export const configSchema = z.object({ }), nickName: z .object({ - skippedUserIds: userIdSet, + skippedUserIds: userIdSet.optional().default(new Set()), }) - .optional(), + .optional() + .default({ skippedUserIds: new Set() }), woisPing: z.object({ limit: z.number(), threshold: z.number(), @@ -165,9 +171,10 @@ export const configSchema = z.object({ }), instagram: z .object({ - rapidApiInstagramApiKey: z.string().nullish(), + rapidApiInstagramApiKey: apiKey.nullish().default(null), }) - .optional(), + .optional() + .default({ rapidApiInstagramApiKey: null }), loot: z.object({ enabled: z.boolean().optional().default(false), scheduleCron: z.string().optional().default("*/15 * * * *"), From 3910f4c02d6f6e0d8c102e57a6c6e9c507746f67 Mon Sep 17 00:00:00 2001 From: holzmaster Date: Sun, 12 Jul 2026 20:51:59 +0200 Subject: [PATCH 09/10] Better config --- src/context.ts | 19 +++++++++---------- src/service/config.ts | 9 ++++----- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/context.ts b/src/context.ts index 583a3cde..97bc0093 100644 --- a/src/context.ts +++ b/src/context.ts @@ -46,8 +46,8 @@ export interface BotContext { spotifyClient: SpotifyApi | null; - youtube: { - cookieFilePath: string | null; + youtube?: { + cookieFilePath: string; }; commandConfig: { @@ -265,14 +265,13 @@ export async function createBotContext(client: Client): Promise ensureRole(guild, id)), commandConfig: { diff --git a/src/service/config.ts b/src/service/config.ts index a5afb572..8bbfeaee 100644 --- a/src/service/config.ts +++ b/src/service/config.ts @@ -122,17 +122,16 @@ export const configSchema = z.object({ spotify: z .object({ - clientId: z.string().optional(), - clientSecret: z.string().optional(), + clientId: z.string(), + clientSecret: z.string(), }) .optional(), youtube: z .object({ - cookieFilePath: z.string().nullish().default(null), + cookieFilePath: z.string(), }) - .optional() - .default({ cookieFilePath: null }), + .optional(), activity: z.object({ type: activityType, From 857955ce6d64e6db55f6cae0402e8dfba0533bd5 Mon Sep 17 00:00:00 2001 From: holzmaster Date: Sun, 12 Jul 2026 21:32:52 +0200 Subject: [PATCH 10/10] Refactor config to also ensure channels --- src/app.ts | 6 +- src/context.ts | 160 +++++-------- src/service/config.test.ts | 74 ++++++ src/service/config.ts | 445 +++++++++++++++++++++++-------------- src/service/ytDl.ts | 2 +- 5 files changed, 410 insertions(+), 277 deletions(-) create mode 100644 src/service/config.test.ts 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 97bc0093..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"; /** @@ -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..."); @@ -273,7 +224,7 @@ export async function createBotContext(client: Client): Promise ensureRole(guild, id)), + moderatorRoles: config.moderatorRoleIds, commandConfig: { faulenzerPing: config.command.faulenzerPing, ehre: config.command.ehre, @@ -311,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, }, }; } 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 8bbfeaee..dc459f6f 100644 --- a/src/service/config.ts +++ b/src/service/config.ts @@ -5,13 +5,14 @@ import { parseArgs } from "node:util"; import * as JSONC from "comment-json"; import * as z from "zod"; -import type { 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,15 +32,32 @@ export async function readConfig() { process.exit(1); } - let parsed: unknown; try { - parsed = JSONC.parse(jsonString); + 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; +} - const result = configSchema.safeParse(parsed); +/** + * 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); @@ -66,7 +84,6 @@ 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 emojiId = z.string().brand("EmojiId"); const guildId = z.string().brand("GuildId"); const apiKey = z @@ -79,7 +96,6 @@ export type CategoryId = z.infer; export type UserId = z.infer; export type RoleId = z.infer; export type GroupId = z.infer; -export type EmojiId = z.infer; export type GuildId = z.infer; // Arrays of ids parsed into Sets for O(1) membership checks. @@ -101,176 +117,271 @@ const iso8601Duration = z.string().transform((s, ctx) => { } }); -export const configSchema = z.object({ - auth: z.object({ - clientId: z.string(), - token: z.string(), - }), - - development: z - .object({ - enableCommands: z.boolean().optional(), - }) - .optional(), - - sentry: z - .object({ - dsn: z.string().nullish(), - tracesSampleRate: z.number().optional(), - }) - .optional(), - - spotify: z - .object({ - clientId: z.string(), - clientSecret: z.string(), - }) - .optional(), - - youtube: z - .object({ - cookieFilePath: z.string(), - }) - .optional(), - - activity: z.object({ - type: activityType, - name: z.string(), - state: z.string().optional(), - url: z.string().optional(), - }), - - prefix: z.object({ - command: z.string(), - modCommand: z.string(), - }), - - sendWelcomeMessage: z.boolean().optional().default(false), - - moderatorRoleIds: roleIdSet, - - command: z.object({ - faulenzerPing: z.object({ - allowedRoleIds: roleIdSet, - maxNumberOfPings: z.number().optional().default(15), - minRequiredReactions: z.number().optional().default(5), - }), - nickName: z +// #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({ - skippedUserIds: userIdSet.optional().default(new Set()), + enableCommands: z.boolean().optional(), }) - .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 + .optional(), + + spotify: z .object({ - rapidApiInstagramApiKey: apiKey.nullish().default(null), + clientId: z.string(), + clientSecret: z.string(), }) - .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")), + .optional(), + + youtube: z + .object({ + cookieFilePath: z.string(), + }) + .optional(), + + sendWelcomeMessage: z.boolean().optional().default(false), + + moderatorRoleIds: z.array(guildRole).readonly(), - roles: z.object({ - asseGuardShiftDuration: iso8601Duration + 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("PT8H")), + .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(), }), - 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(), - }), - ), + + 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(), }), - 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(), + voiceChannel: z.object({ + hauptWoischatChannelId: voiceChannel(guild), + }), - guildGuildId: guildId, + 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, + }), - textChannel: z.object({ - banReasonChannelId: channelId, - bannedChannelId: channelId, - botLogChannelId: channelId, - hauptchatChannelId: channelId, - votesChannelId: channelId, - botSpamChannelId: channelId, - hauptwoisTextChannelId: channelId, - roleAssignerChannelId: channelId, - /** Channel ID for the mod-only spam audit log. Leave empty to disable. */ - spamLogChannelId: channelId.optional(), - }), - - voiceChannel: z.object({ - hauptWoischatChannelId: channelId, - }), - - role: z.object({ - bannedRoleId: roleId, - birthdayRoleId: roleId, - botDenyRoleId: roleId, - defaultRoleId: roleId, - gruendervaeterRoleId: roleId, - gruendervaeterBannedRoleId: roleId, - roleDenyRoleId: roleId, - shameRoleId: roleId, - trustedRoleId: roleId, - trustedBannedRoleId: roleId, - woisgangRoleId: roleId, - winnerRoleId: roleId, - emotifiziererRoleId: roleId, - lootRoleAsseGuardRoleId: roleId, - }), - - emoji: z.object({ - alarmEmojiId: emojiId, - sadHamsterEmojiId: emojiId, - trichterEmojiId: emojiId, - }), -}); + emoji: z.object({ + alarmEmojiId: guildEmoji(guild, "alarm"), + sadHamsterEmojiId: guildEmoji(guild, "sad_hamster"), + trichterEmojiId: guildEmoji(guild, "trichter"), + }), + }); +}; -export type Config = z.infer; +export type BootstrapConfig = z.infer; +export type Config = z.infer>; 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) {