diff --git a/ts/.yarn/install-state.gz b/ts/.yarn/install-state.gz index 95ad7f5..445edfb 100644 Binary files a/ts/.yarn/install-state.gz and b/ts/.yarn/install-state.gz differ diff --git a/ts/package.json b/ts/package.json index 92df4c3..5db1171 100644 --- a/ts/package.json +++ b/ts/package.json @@ -49,7 +49,7 @@ "pino": "^9.6.0", "pino-logfmt": "^0.1.1", "pino-pretty": "^13.0.0", - "postgres": "^3.4.5", + "postgres": "^3.4.7", "superjson": "^2.2.2", "ts-markdown-builder": "^0.4.0", "why-is-node-running": "^3.2.2", diff --git a/ts/src/activities/discord.ts b/ts/src/activities/discord.ts index 02d9b25..753b121 100644 --- a/ts/src/activities/discord.ts +++ b/ts/src/activities/discord.ts @@ -1,12 +1,13 @@ import { type InteractionCallbackData, type InteractionCallbackOptions, MessageFlags } from 'discordeno' import { c } from '#/di' import type { InteractionRef } from '#/discord' +import { InteractionResponseTypes } from '@discordeno/types' import { Bot } from '#/discord/bot' // from https://github.com/discordeno/discordeno/blob/21.0.0/packages/bot/src/transformers/interaction.ts#L33 export const reply_to_interaction = async (props: { ref: InteractionRef - type: number + type: InteractionResponseTypes options?: InteractionCallbackOptions & { isPrivate?: boolean; content?: string } }) => { const bot = await c.getAsync(Bot) @@ -19,9 +20,16 @@ export const reply_to_interaction = async (props: { data.flags = MessageFlags.Ephemeral } - if (ref.acknowledged) { - return await bot.helpers.sendFollowupMessage(ref.token, data) + switch (type) { + case InteractionResponseTypes.UpdateMessage: + if(ref.acknowledged) { + return await bot.helpers.editOriginalInteractionResponse(ref.token, data) + } + default: + if (ref.acknowledged) { + const followUp = await bot.helpers.sendFollowupMessage(ref.token, data) + return followUp + } + return await bot.helpers.sendInteractionResponse(ref.id, ref.token, { type, data }, { withResponse: options?.withResponse }) } - - return await bot.helpers.sendInteractionResponse(ref.id, ref.token, { type, data }, { withResponse: options?.withResponse }) } diff --git a/ts/src/activities/discord_guild_settings.ts b/ts/src/activities/discord_guild_settings.ts index c2a62b0..980818f 100644 --- a/ts/src/activities/discord_guild_settings.ts +++ b/ts/src/activities/discord_guild_settings.ts @@ -1,40 +1,48 @@ import { c } from '#/di' import { PG } from '#/services/pg' -import { DiscordGuildSettingsService, DISCORD_GUILD_SETTING_KEYS } from '#/services/guild_settings' +import { DiscordGuildSettingsService, DiscordGuildSettingKey, DiscordGuildSettingValue } from '#/services/guild_settings' import { logger } from '#/logger' -export interface SetDiscordGuildSettingParams { - discordGuildId: bigint - key: string - value: T +export interface SetDiscordGuildSettingParams { + guildId: bigint + key: K + value: DiscordGuildSettingValue } -export async function set_discord_guild_setting(params: SetDiscordGuildSettingParams): Promise { +export async function set_discord_guild_setting( + params: SetDiscordGuildSettingParams +): Promise { const settingsService = await c.getAsync(DiscordGuildSettingsService) - - await settingsService.setSetting(params.discordGuildId, params.key, params.value) - + + await settingsService.setSetting(params.guildId, params.key, params.value) + logger.info({ - discordGuildId: params.discordGuildId, + guildId: params.guildId, key: params.key, }, 'discord guild setting updated') } -export async function get_discord_guild_setting(discordGuildId: bigint, key: string): Promise { +export async function get_discord_guild_setting( + guildId: bigint, + key: K +): Promise | null> { const settingsService = await c.getAsync(DiscordGuildSettingsService) - - return await settingsService.getSetting(discordGuildId, key) + + return await settingsService.getSetting(guildId, key) } -export async function delete_discord_guild_setting(discordGuildId: bigint, key: string): Promise { +export async function delete_discord_guild_setting( + guildId: bigint, + key: DiscordGuildSettingKey +): Promise { const settingsService = await c.getAsync(DiscordGuildSettingsService) - - return await settingsService.deleteSetting(discordGuildId, key) + + return await settingsService.deleteSetting(guildId, key) } export async function get_wynn_guild_info(guildNameOrTag: string): Promise<{ uid: string; name: string; prefix: string } | null> { const { sql } = await c.getAsync(PG) - + // Try to find by name or prefix (case-insensitive) const result = await sql<{ uid: string; name: string; prefix: string }[]>` SELECT uid, name, prefix @@ -43,11 +51,11 @@ export async function get_wynn_guild_info(guildNameOrTag: string): Promise<{ uid OR LOWER(prefix) = LOWER(${guildNameOrTag}) LIMIT 1 ` - + if (result.length === 0) { return null } - + return result[0] } diff --git a/ts/src/discord/bot.ts b/ts/src/discord/bot.ts index aaffa7a..92652c8 100644 --- a/ts/src/discord/bot.ts +++ b/ts/src/discord/bot.ts @@ -2,12 +2,27 @@ import { InjectionToken } from '@needle-di/core' import { createBot } from 'discordeno' import { config } from '#/config' import { c } from '#/di' -import { type BotType, createBotParameters } from './index' +import { logger } from '#/logger' +import { type BotType, desiredProperties, intents } from './index' const createBotWithToken = (token: string) => { return createBot({ - ...createBotParameters, + intents: intents.reduce((acc, curr) => acc | curr, 1 as any), + desiredProperties, token, + loggerFactory: (name: 'REST' | 'GATEWAY' | 'BOT') => { + // Create a child logger with the component name + const childLogger = logger.child({ component: `discordeno-${name.toLowerCase()}` }) + + // Return logger object with required methods + return { + debug: (message: string, ...args: any[]) => childLogger.debug({ args }, message), + info: (message: string, ...args: any[]) => childLogger.info({ args }, message), + warn: (message: string, ...args: any[]) => childLogger.warn({ args }, message), + error: (message: string, ...args: any[]) => childLogger.error({ args }, message), + fatal: (message: string, ...args: any[]) => childLogger.fatal({ args }, message), + } + }, }) } export const Bot = new InjectionToken('DISCORD_BOT') diff --git a/ts/src/discord/handler.ts b/ts/src/discord/handler.ts index 41920ab..fd7aa5e 100644 --- a/ts/src/discord/handler.ts +++ b/ts/src/discord/handler.ts @@ -31,6 +31,8 @@ export const events = () => { token: interaction.token, type: interaction.type, acknowledged: interaction.acknowledged, + guildId: interaction.guildId, + channelId: interaction.channel.id || interaction.channelId, }, data, }, @@ -59,5 +61,5 @@ export const events = () => { ], }) }, - } as BotType['events'] + } as const satisfies BotType['events'] } diff --git a/ts/src/discord/index.ts b/ts/src/discord/index.ts index 5b0735c..7b442f7 100644 --- a/ts/src/discord/index.ts +++ b/ts/src/discord/index.ts @@ -1,5 +1,5 @@ import { Intents, type InteractionTypes } from '@discordeno/types' -import type { Bot, CompleteDesiredProperties, DesiredPropertiesBehavior, DiscordInteractionContextType, Guild, Interaction, InteractionData, Member, Message } from 'discordeno' +import type { Bot, CompleteDesiredProperties, DesiredPropertiesBehavior, DiscordInteractionContextType, InteractionData, RecursivePartial, TransformersDesiredProperties } from 'discordeno' export const intents = [ Intents.GuildModeration, Intents.GuildWebhooks, @@ -18,68 +18,61 @@ export const intents = [ Intents.GuildMessages, ] as const + +export const desiredProperties = { + interaction: { + id: true, + data: true, + type: true, + token: true, + message: true, + channelId: true, + channel: true, + guildId: true, + guild: true, + user: true, + member: true, + context: true, + }, + message: { + id: true, + member: true, + guildId: true, + }, +} as const satisfies RecursivePartial + +export type DesiredProperties = typeof desiredProperties + export const createBotParameters = { intents: intents.reduce((acc, curr) => acc | curr, Intents.Guilds), - desiredProperties: { - interaction: { - id: true, - data: true, - type: true, - token: true, - message: true, - channelId: true, - channel: true, - guildId: true, - guild: true, - user: true, - member: true, - }, - message: { - id: true, - member: true, - guildId: true, - }, - }, + desiredProperties, } as const -// Extract the type of desired properties from our parameters -type ExtractedDesiredProperties = typeof createBotParameters.desiredProperties - -// The BotType uses the CompleteDesiredProperties helper to fill in the missing properties -export type BotType = Bot, DesiredPropertiesBehavior.RemoveKey> +export type BotType = Bot, DesiredPropertiesBehavior.RemoveKey> // Type for the interaction reference passed to workflows/activities export interface InteractionRef { + // id of the interaction id: bigint + /** A continuation token for responding to the interaction */ token: string type: InteractionTypes acknowledged?: boolean - /** Id of the application this interaction is for */ - applicationId: bigint; - /** Guild that the interaction was sent from */ /** The guild it was sent from */ - guildId: bigint; - /** - * The ID of channel it was sent from - * - * @remarks - * It is recommended that you begin using this channel field to identify the source channel of the interaction as they may deprecate the existing channel_id field in the future. - */ - channelId: bigint; + guildId?: bigint; + channelId?: bigint; /** Guild member data for the invoking user, including permissions */ memberId?: bigint ; /** User object for the invoking user, if invoked in a DM */ userId?: bigint; - /** A continuation token for responding to the interaction */ - /** Read-only property, always `1` */ - version: 1; /** For the message the button was attached to */ messageId?: bigint; + + // locale of the interaction locale?: string; /** The guild's preferred locale, if invoked in a guild */ guildLocale?: string; - /** The computed permissions for a bot or app in the context of a specific interaction (including channel overwrites) */ - appPermissions: bigint; + /** Context where the interaction was triggered from */ context?: DiscordInteractionContextType; } diff --git a/ts/src/services/guild_settings.ts b/ts/src/services/guild_settings.ts index c30b949..5ecc902 100644 --- a/ts/src/services/guild_settings.ts +++ b/ts/src/services/guild_settings.ts @@ -3,6 +3,8 @@ import { c } from '#/di' import { PG } from '#/services/pg' import { logger } from '#/logger' import { JSONValue } from 'postgres' +import { snowflake } from '#/utils/types' +import { inject } from '@needle-di/core' // Define the guild settings types export const DiscordGuildSettingSchema = type({ @@ -13,35 +15,61 @@ export const DiscordGuildSettingSchema = type({ export type DiscordGuildSetting = typeof DiscordGuildSettingSchema.infer -// Common setting keys as constants -export const DISCORD_GUILD_SETTING_KEYS = { - WYNN_GUILD: 'wynn_guild', // The associated Wynncraft guild ID - ANNOUNCEMENT_CHANNEL: 'announcement_channel', - MEMBER_ROLE: 'member_role', - OFFICER_ROLE: 'officer_role', - NOTIFICATION_SETTINGS: 'notification_settings', - FEATURES: 'features', + +// Define setting schemas with arktype +export const DISCORD_GUILD_SETTING_SCHEMAS = { + wynn_guild: type('string.uuid'), + rank_roles: type({ + member: snowflake.optional(), + recruiter: snowflake.optional(), + captain: snowflake.optional(), + strategist: snowflake.optional(), + chief: snowflake.optional(), + owner: snowflake.optional(), + }), + features: type({ + autoRole: 'boolean', + warTracking: 'boolean', + activityTracking: 'boolean', + leaderboard: 'boolean', + }), } as const -export type DiscordGuildSettingKey = typeof DISCORD_GUILD_SETTING_KEYS[keyof typeof DISCORD_GUILD_SETTING_KEYS] +export type DiscordGuildSettingKey = keyof typeof DISCORD_GUILD_SETTING_SCHEMAS + +export type DiscordGuildSettingValue = + typeof DISCORD_GUILD_SETTING_SCHEMAS[K]['infer'] export class DiscordGuildSettingsService { - constructor(private readonly pg: PG) {} + constructor(private readonly pg = inject(PG)) {} /** * Get a specific setting for a guild */ - async getSetting(guildId: bigint, key: string): Promise { + async getSetting( + guildId: bigint, + key: K + ): Promise | null> { const { sql } = this.pg - const result = await sql<{ value: T }[]>` + const result = await sql<{ value: unknown }[]>` SELECT value FROM discord.guild_settings WHERE guild_id = ${guildId.toString()} AND key = ${key} ` if (result.length === 0) return null - return result[0].value + + // Validate the value with arktype + const schema = DISCORD_GUILD_SETTING_SCHEMAS[key] + const parsed = schema(result[0].value) + + if (parsed instanceof type.errors) { + logger.error({ guildId, key, errors: parsed.summary }, 'Invalid guild setting value in database') + return null + } + + return parsed as DiscordGuildSettingValue } /** @@ -62,12 +90,24 @@ export class DiscordGuildSettingsService { /** * Set a setting for a guild (upsert) */ - async setSetting(guildId: bigint, key: string, value: T): Promise { + async setSetting( + guildId: bigint, + key: K, + value: DiscordGuildSettingValue + ): Promise { const { sql } = this.pg + // Validate the value with arktype + const schema = DISCORD_GUILD_SETTING_SCHEMAS[key] + const parsed = schema(value) + + if (parsed instanceof type.errors) { + throw new Error(`Invalid value for guild setting ${key}: ${parsed.summary}`) + } + await sql` INSERT INTO discord.guild_settings (guild_id, key, value) - VALUES (${guildId.toString()}, ${key}, ${sql.json(value)}) + VALUES (${guildId.toString()}, ${key}, ${sql.json(parsed as JSONValue)}) ON CONFLICT (guild_id, key) DO UPDATE SET value = EXCLUDED.value @@ -79,7 +119,7 @@ export class DiscordGuildSettingsService { /** * Delete a specific setting for a guild */ - async deleteSetting(guildId: bigint, key: string): Promise { + async deleteSetting(guildId: bigint, key: DiscordGuildSettingKey): Promise { const { sql } = this.pg const result = await sql` diff --git a/ts/src/utils/types/index.ts b/ts/src/utils/types/index.ts new file mode 100644 index 0000000..863d88d --- /dev/null +++ b/ts/src/utils/types/index.ts @@ -0,0 +1 @@ +export * from './snowflake' \ No newline at end of file diff --git a/ts/src/utils/types/snowflake.ts b/ts/src/utils/types/snowflake.ts new file mode 100644 index 0000000..d31a6a8 --- /dev/null +++ b/ts/src/utils/types/snowflake.ts @@ -0,0 +1,15 @@ +import { type } from 'arktype' + +// Custom arktype for Discord snowflake IDs (bigint) +export const snowflake = type('string|bigint').pipe((value, ctx) => { + if (typeof value === 'bigint') return value + + // Parse string as base 10 bigint + try { + return BigInt(value) + } catch { + return ctx.error(`Invalid snowflake ID: ${value}`) + } +}) + +export type Snowflake = bigint \ No newline at end of file diff --git a/ts/src/workflows/discord.ts b/ts/src/workflows/discord.ts index 25bbeaf..0487db3 100644 --- a/ts/src/workflows/discord.ts +++ b/ts/src/workflows/discord.ts @@ -50,7 +50,7 @@ const workflowHandleApplicationCommand = async (payload: InteractionCreatePayloa info: async (args) => { const { workflowId } = workflowInfo() const handle = await startChild(handleCommandGuildInfo, { - args: [{ ref, discordGuildId: payload.guildId ? BigInt(payload.guildId) : undefined }], + args: [{ ref }], workflowId: `${workflowId}-guild-info`, }) await handle.result() @@ -58,7 +58,7 @@ const workflowHandleApplicationCommand = async (payload: InteractionCreatePayloa online: async (args) => { const { workflowId } = workflowInfo() const handle = await startChild(handleCommandGuildOnline, { - args: [{ ref, discordGuildId: payload.guildId ? BigInt(payload.guildId) : undefined }], + args: [{ ref }], workflowId: `${workflowId}-guild-online`, }) await handle.result() @@ -66,7 +66,7 @@ const workflowHandleApplicationCommand = async (payload: InteractionCreatePayloa leaderboard: async (args) => { const { workflowId } = workflowInfo() const handle = await startChild(handleCommandGuildLeaderboard, { - args: [{ ref, discordGuildId: payload.guildId ? BigInt(payload.guildId) : undefined }], + args: [{ ref }], workflowId: `${workflowId}-guild-leaderboard`, }) await handle.result() @@ -79,7 +79,6 @@ const workflowHandleApplicationCommand = async (payload: InteractionCreatePayloa args: [{ ref, args, - discordGuildId: BigInt(payload.guildId!), }], }) await handle.result() diff --git a/ts/src/workflows/guild_messages.ts b/ts/src/workflows/guild_messages.ts index d61dfdd..04ed0c4 100644 --- a/ts/src/workflows/guild_messages.ts +++ b/ts/src/workflows/guild_messages.ts @@ -2,7 +2,6 @@ import { proxyActivities } from '@temporalio/workflow' import type * as activities from '#/activities' import { WYNN_GUILD_ID } from '#/constants' import type { InteractionRef } from '#/discord' -import { DISCORD_GUILD_SETTING_KEYS } from '#/services/guild_settings' const { formGuildInfoMessage, formGuildOnlineMessage, formGuildLeaderboardMessage, reply_to_interaction, get_discord_guild_setting } = proxyActivities({ startToCloseTimeout: '30 seconds', @@ -19,7 +18,7 @@ export async function handleCommandGuildInfo(payload: CommandPayload): Promise(discordGuildId, DISCORD_GUILD_SETTING_KEYS.WYNN_GUILD) + const wynnGuild = await get_discord_guild_setting(discordGuildId, 'wynn_guild') if (wynnGuild?.uid) { guildId = wynnGuild.uid } @@ -39,7 +38,7 @@ export async function handleCommandGuildOnline(payload: CommandPayload): Promise // Try to get the associated Wynncraft guild for this Discord guild let guildId = WYNN_GUILD_ID // Default fallback if (discordGuildId) { - const wynnGuild = await get_discord_guild_setting<{ uid: string }>(discordGuildId, DISCORD_GUILD_SETTING_KEYS.WYNN_GUILD) + const wynnGuild = await get_discord_guild_setting(discordGuildId, 'wynn_guild') if (wynnGuild?.uid) { guildId = wynnGuild.uid } @@ -59,7 +58,7 @@ export async function handleCommandGuildLeaderboard(payload: CommandPayload): Pr // Try to get the associated Wynncraft guild for this Discord guild let guildId = WYNN_GUILD_ID // Default fallback if (discordGuildId) { - const wynnGuild = await get_discord_guild_setting<{ uid: string }>(discordGuildId, DISCORD_GUILD_SETTING_KEYS.WYNN_GUILD) + const wynnGuild = await get_discord_guild_setting(discordGuildId, 'wynn_guild') if (wynnGuild?.uid) { guildId = wynnGuild.uid } diff --git a/ts/src/workflows/set_wynn_guild.ts b/ts/src/workflows/set_wynn_guild.ts index dacc40d..0e83812 100644 --- a/ts/src/workflows/set_wynn_guild.ts +++ b/ts/src/workflows/set_wynn_guild.ts @@ -1,7 +1,7 @@ import { proxyActivities } from '@temporalio/workflow' import type * as activities from '#/activities' import type { InteractionRef } from '#/discord' -import { DISCORD_GUILD_SETTING_KEYS } from '#/services/guild_settings' +import { InteractionResponseTypes } from '@discordeno/types' const { reply_to_interaction, set_discord_guild_setting, get_wynn_guild_info } = proxyActivities({ startToCloseTimeout: '10 seconds', @@ -12,17 +12,30 @@ export interface SetWynnGuildPayload { args: { guild: string } - discordGuildId: bigint } export async function handleCommandSetWynnGuild(payload: SetWynnGuildPayload): Promise { - const { ref, args, discordGuildId } = payload + const { ref, args } = payload // Defer the response since this might take a moment await reply_to_interaction({ ref, - type: 5, // Deferred response + type: InteractionResponseTypes.DeferredChannelMessageWithSource, }) + ref.acknowledged = true + + + if (!ref.guildId) { + await reply_to_interaction({ + ref, + type: InteractionResponseTypes.UpdateMessage, + options: { + content: `❌ Could not find discord guild. Please try again.`, + isPrivate: true, + }, + }) + return + } try { // Validate the Wynncraft guild exists @@ -31,7 +44,7 @@ export async function handleCommandSetWynnGuild(payload: SetWynnGuildPayload): P if (!guildInfo) { await reply_to_interaction({ ref, - type: 7, // Update the deferred response + type: InteractionResponseTypes.UpdateMessage, // Update the deferred response options: { content: `❌ Could not find Wynncraft guild "${args.guild}". Please check the guild name and try again.`, isPrivate: true, @@ -42,19 +55,14 @@ export async function handleCommandSetWynnGuild(payload: SetWynnGuildPayload): P // Set the association in the database using the generic activity await set_discord_guild_setting({ - discordGuildId, - key: DISCORD_GUILD_SETTING_KEYS.WYNN_GUILD, - value: { - uid: guildInfo.uid, - name: guildInfo.name, - prefix: guildInfo.prefix, - linkedAt: new Date().toISOString(), - }, + guildId: ref.guildId, + key: 'wynn_guild', + value: guildInfo.uid, }) await reply_to_interaction({ ref, - type: 7, // Update the deferred response + type: InteractionResponseTypes.UpdateMessage, // Update the deferred response options: { content: `✅ Successfully linked this Discord server to Wynncraft guild **[${guildInfo.prefix}] ${guildInfo.name}**`, isPrivate: true, @@ -63,7 +71,7 @@ export async function handleCommandSetWynnGuild(payload: SetWynnGuildPayload): P } catch (error) { await reply_to_interaction({ ref, - type: 7, // Update the deferred response + type: InteractionResponseTypes.UpdateMessage, // Update the deferred response options: { content: `❌ An error occurred while setting the guild: ${error}`, isPrivate: true, diff --git a/ts/yarn.lock b/ts/yarn.lock index 9b451d5..3433168 100644 --- a/ts/yarn.lock +++ b/ts/yarn.lock @@ -1922,7 +1922,7 @@ __metadata: pino: "npm:^9.6.0" pino-logfmt: "npm:^0.1.1" pino-pretty: "npm:^13.0.0" - postgres: "npm:^3.4.5" + postgres: "npm:^3.4.7" rollup: "npm:^4.34.8" superjson: "npm:^2.2.2" ts-markdown-builder: "npm:^0.4.0" @@ -4178,10 +4178,10 @@ __metadata: languageName: node linkType: hard -"postgres@npm:^3.4.5": - version: 3.4.5 - resolution: "postgres@npm:3.4.5" - checksum: 10c0/53415acea77e97bdc1eeb861048f34964e2236e4d4d42f408fc9b901e62bfcf7443a487ebfdad18b57b468c6e297bf8d22097106a200f62eb1262eb5a71355df +"postgres@npm:^3.4.7": + version: 3.4.7 + resolution: "postgres@npm:3.4.7" + checksum: 10c0/b2e61b1064d38e7e1df8291f6d5a7e11f892a3240e00cf2b5e5542bf9abbfe97f3963164aeb56b42c1ab6b8aae3454c57f5bbc1791df0769375542740a7cde72 languageName: node linkType: hard