/** */ import {APIWebhook} from "discord-api-types"; import {FastifyLoggerInstance} from "fastify"; import {mkdir as fsMkdir, readFile as fsReadFile, rm as fsRm, writeFile as fsWriteFile} from "fs/promises"; import axios from "axios"; import {join} from "path"; import {isENOENT} from "./NodeErrorHelpers.js"; const WebhookPath = "runtime/webhooks" /** File-based storage for webhook instances. */ export class SavedWebhook { readonly filename: string private readonly logger: FastifyLoggerInstance | null private readonly readFile: typeof fsReadFile private readonly writeFile: typeof fsWriteFile private readonly mkdir: typeof fsMkdir private readonly rm: typeof fsRm private readonly delRequest: typeof axios["delete"] /** Initializes a SavedWebhook pointing at the given location. */ constructor(filename: string, { logger, state, readFile, writeFile, mkdir, rm, delRequest }: { logger?: FastifyLoggerInstance | null, state?: APIWebhook | null, readFile?: typeof fsReadFile, writeFile?: typeof fsWriteFile, mkdir?: typeof fsMkdir, rm?: typeof fsRm, delRequest?: typeof axios["delete"] }) { this.filename = filename this._state = state this.logger = logger?.child({webhookFile: filename}) ?? null this.readFile = readFile ?? fsReadFile this.writeFile = writeFile ?? fsWriteFile this.mkdir = mkdir ?? fsMkdir this.rm = rm ?? fsRm this.delRequest = delRequest ?? axios.delete.bind(axios) } private _state: APIWebhook | null | undefined /** Gets the current state of the webhook, either a webhook instance or null. */ get state(): APIWebhook | null { if (this._state === undefined) { this.logger?.warn("SavedWebhook was not initialized before having its state checked") return null } return this._state } /** The path of the webhook, including WebhookPath. */ get path(): string { return join(WebhookPath, this.filename) } /** Gets whether a webhook has been added to this SavedWebhook. */ get isPresent(): boolean { return this.state !== null } /** Loads the current state from the disk, including a null state if not present. */ async load(): Promise { if (this._state !== undefined) { this.logger?.warn(`SavedWebhook was double-initialized`) } try { const text = await this.readFile(this.path, {encoding: "utf-8"}) const state: APIWebhook = JSON.parse(text) this._state = state return state } catch (e) { if (isENOENT(e)) { this._state = null return null } throw e } } /** Replaces the webhook with a new one. */ async replaceHook(newHook: APIWebhook): Promise { if (this._state === undefined) { this.logger?.warn("SavedWebhook was not initialized before being replaced") return } if (this._state !== null) { await this.clearHook() } this._state = newHook await this.save() } /** Removes the webhook entirely. */ async clearHook(): Promise { if (this._state === undefined) { this.logger?.warn("SavedWebhook was not initialized before being cleared") return } if (this._state === null) { this.logger?.warn("SavedWebhook was already cleared and cleared again") return } await this.deleteHook() this._state = null await this.save() } /** Saves the current state to disk, deleting the file if the state is null. */ private async save(): Promise { if (this._state === undefined) { this.logger?.warn("SavedWebhook was not initialized before being saved") return } if (this._state === null) { try { await this.rm(this.path) } catch (e) { if (!isENOENT(e)) { throw e } this.logger?.warn("SavedWebhook was re-deleted despite not existing to begin with.") } return } try { const text = JSON.stringify(this._state) await this.writeFile(this.path, text, {encoding: "utf-8"}) } catch (e) { if (isENOENT(e)) { await this.mkdir(WebhookPath, {recursive: true}) const text = JSON.stringify(this._state) await this.writeFile(this.path, text, {encoding: "utf-8"}) } throw e } } /** Deletes the webhook from the server. */ private async deleteHook(): Promise { if (this._state === undefined) { this.logger?.warn("SavedWebhook was not initialized before being deleted") return } if (this._state === null) { this.logger?.warn("SavedWebhook was deleted despite being empty") return } await this.delRequest(`https://discord.com/api/webhooks/${this._state.id}/${this._state.token}`, {validateStatus: (s) => s === 204 || s === 404}) } }