diff --git a/migrations/004_guild_settings.sql b/migrations/004_guild_settings.sql new file mode 100644 index 0000000..ad9d008 --- /dev/null +++ b/migrations/004_guild_settings.sql @@ -0,0 +1,16 @@ +-- Create Discord guild settings table for storing Discord guild configuration +CREATE TABLE IF NOT EXISTS discord.guild_settings ( + guild_id Numeric NOT NULL, -- Discord guild ID + key TEXT NOT NULL, + value JSONB NOT NULL DEFAULT '{}', + PRIMARY KEY (guild_id, key) +); + +-- Create index for faster lookups by guild_id +CREATE INDEX idx_discord_guild_settings_guild_id ON discord.guild_settings(guild_id); + +-- Add comments for documentation +COMMENT ON TABLE discord.guild_settings IS 'Stores Discord guild-specific configuration settings as key-value pairs with JSONB values'; +COMMENT ON COLUMN discord.guild_settings.guild_id IS 'Discord guild ID'; +COMMENT ON COLUMN discord.guild_settings.key IS 'Setting key/name'; +COMMENT ON COLUMN discord.guild_settings.value IS 'Setting value stored as JSONB for flexibility'; diff --git a/ts/src/activities/discord.ts b/ts/src/activities/discord.ts index 9875889..02d9b25 100644 --- a/ts/src/activities/discord.ts +++ b/ts/src/activities/discord.ts @@ -1,4 +1,4 @@ -import { type InteractionCallbackData, type InteractionCallbackOptions, InteractionResponseTypes, InteractionTypes, MessageFlags } from 'discordeno' +import { type InteractionCallbackData, type InteractionCallbackOptions, MessageFlags } from 'discordeno' import { c } from '#/di' import type { InteractionRef } from '#/discord' import { Bot } from '#/discord/bot' @@ -7,13 +7,13 @@ import { Bot } from '#/discord/bot' export const reply_to_interaction = async (props: { ref: InteractionRef type: number - options: InteractionCallbackOptions & { isPrivate?: boolean; content?: string } + options?: InteractionCallbackOptions & { isPrivate?: boolean; content?: string } }) => { const bot = await c.getAsync(Bot) const { ref, type, options } = props - const data: InteractionCallbackData = options + const data: InteractionCallbackData = options || {} if (options?.isPrivate) { data.flags = MessageFlags.Ephemeral diff --git a/ts/src/activities/discord_guild_settings.ts b/ts/src/activities/discord_guild_settings.ts new file mode 100644 index 0000000..c2a62b0 --- /dev/null +++ b/ts/src/activities/discord_guild_settings.ts @@ -0,0 +1,53 @@ +import { c } from '#/di' +import { PG } from '#/services/pg' +import { DiscordGuildSettingsService, DISCORD_GUILD_SETTING_KEYS } from '#/services/guild_settings' +import { logger } from '#/logger' + +export interface SetDiscordGuildSettingParams { + discordGuildId: bigint + key: string + value: T +} + +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) + + logger.info({ + discordGuildId: params.discordGuildId, + key: params.key, + }, 'discord guild setting updated') +} + +export async function get_discord_guild_setting(discordGuildId: bigint, key: string): Promise { + const settingsService = await c.getAsync(DiscordGuildSettingsService) + + return await settingsService.getSetting(discordGuildId, key) +} + +export async function delete_discord_guild_setting(discordGuildId: bigint, key: string): Promise { + const settingsService = await c.getAsync(DiscordGuildSettingsService) + + return await settingsService.deleteSetting(discordGuildId, 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 + FROM wynn.guild_info + WHERE LOWER(name) = LOWER(${guildNameOrTag}) + OR LOWER(prefix) = LOWER(${guildNameOrTag}) + LIMIT 1 + ` + + if (result.length === 0) { + return null + } + + return result[0] +} + diff --git a/ts/src/activities/index.ts b/ts/src/activities/index.ts index a40c09e..fd97e96 100644 --- a/ts/src/activities/index.ts +++ b/ts/src/activities/index.ts @@ -4,6 +4,7 @@ export * from './database' export * from './discord' +export * from './discord_guild_settings' export * from './guild' export * from './guild_messages' export * from './leaderboards' diff --git a/ts/src/activities/players.ts b/ts/src/activities/players.ts index c68e17f..8b38dbb 100644 --- a/ts/src/activities/players.ts +++ b/ts/src/activities/players.ts @@ -6,58 +6,36 @@ import { WApi } from '#/lib/wynn/wapi' import { PG } from '#/services/pg' const playerSchemaFail = type({ - code: 'string', - message: 'string', - data: type({ - player: { - meta: { - cached_at: 'number', - }, - username: 'string', - id: 'string', - raw_id: 'string', - avatar: 'string', - skin_texture: 'string', - properties: [ - { - name: 'string', - value: 'string', - signature: 'string', - }, - ], - name_history: 'unknown[]', - }, - }), - success: 'false', + path: 'string', + errorMessage: 'string', }) const playerSchemaSuccess = type({ - code: 'string', - message: 'string', - data: type({ - player: { - meta: { - cached_at: 'number', - }, - username: 'string', - id: 'string', - raw_id: 'string', - avatar: 'string', - skin_texture: 'string', - properties: [ - { - name: 'string', - value: 'string', - signature: 'string', - }, - ], - name_history: 'unknown[]', - }, - }), - success: 'true', + id: 'string', + name: 'string', }) const playerSchema = playerSchemaFail.or(playerSchemaSuccess) + +const getUUIDForUsername = async (username: string) => { + const resp = await axios.get(`https://api.mojang.com/users/profiles/minecraft/${username}`, { + validateStatus: function (status) { + return status < 500; + }, + }) + if (resp.headers['content-type'] !== 'application/json') { + throw new Error('invalid content type') + } + log.info('response data', {data: resp.data}) + const parsed = playerSchema.assert(resp.data) + if('errorMessage' in parsed) { + throw new Error(`error message: ${parsed.errorMessage}`) + } + const pid = parsed.id + // TODO: the pid is a uuid with no hyphens. add the hyphens back in. + return pid +} + export const scrape_online_players = async () => { const api = await c.getAsync(WApi) const raw = await api.get('/v3/player') @@ -77,19 +55,7 @@ export const scrape_online_players = async () => { if (ans.length === 0) { // the user doesn't exist, so we need to grab their uuid try { - const resp = await axios.get(`https://playerdb.co/api/player/minecraft/${playerName}`, { - headers: { - 'User-Agent': 'lil-robot-guy (a@tuxpa.in)', - }, - }) - const parsedPlayer = playerSchema.assert(resp.data) - if (!parsedPlayer.success) { - log.warn(`failed to get uuid for ${playerName}`, { - payload: parsedPlayer, - }) - continue - } - const uuid = parsedPlayer.data.player.id + const uuid = await getUUIDForUsername(playerName) // insert the user. await sql`insert into minecraft.user (name, uid, server) values (${playerName}, ${uuid},${server}) on conflict (uid) do update set diff --git a/ts/src/cmd/bot.ts b/ts/src/cmd/bot.ts index aeea6ca..2d29ccc 100644 --- a/ts/src/cmd/bot.ts +++ b/ts/src/cmd/bot.ts @@ -6,8 +6,9 @@ import { config } from '#/config' import { DISCORD_GUILD_ID } from '#/constants' import { c } from '#/di' import { Bot } from '#/discord/bot' -import { events } from '#/discord/botevent/handler' -import { SLASH_COMMANDS } from '#/discord/botevent/slash_commands' +import { events } from '#/discord/handler' +import { SLASH_COMMANDS } from '#/discord/slash_commands' +import { logger } from '#/logger' export class BotCommand extends Command { static paths = [['bot']] @@ -17,12 +18,12 @@ export class BotCommand extends Command { } const bot = await c.getAsync(Bot) bot.events = events() - console.log('registring slash commands') - await bot.rest.upsertGuildApplicationCommands(DISCORD_GUILD_ID, SLASH_COMMANDS).catch(console.error) - await bot.rest.upsertGuildApplicationCommands('547828454972850196', SLASH_COMMANDS).catch(console.error) + logger.info('registering slash commands') + await bot.rest.upsertGuildApplicationCommands(DISCORD_GUILD_ID, SLASH_COMMANDS).catch((err) => logger.error(err, 'failed to register slash commands for main guild')) + await bot.rest.upsertGuildApplicationCommands('547828454972850196', SLASH_COMMANDS).catch((err) => logger.error(err, 'failed to register slash commands for secondary guild')) - console.log('connecting bot to gateway') + logger.info('connecting bot to gateway') await bot.start() - console.log('bot connected') + logger.info('bot connected') } } diff --git a/ts/src/cmd/worker.ts b/ts/src/cmd/worker.ts index 6652d15..aaf08e2 100644 --- a/ts/src/cmd/worker.ts +++ b/ts/src/cmd/worker.ts @@ -6,12 +6,13 @@ import { c } from '#/di' import '#/services/temporal' import path from 'node:path' import { Client, ScheduleNotFoundError, type ScheduleOptions, ScheduleOverlapPolicy } from '@temporalio/client' -import { NativeConnection, Worker } from '@temporalio/worker' +import { NativeConnection, Worker, Runtime } from '@temporalio/worker' import { PG } from '#/services/pg' import { workflowSyncAllGuilds, workflowSyncGuildLeaderboardInfo, workflowSyncGuilds, workflowSyncOnline } from '#/workflows' import * as activities from '../activities' import { config } from '#/config' +import { logger } from '#/logger' const schedules: ScheduleOptions[] = [ { @@ -93,10 +94,11 @@ const addSchedules = async (c: Client) => { const handle = c.schedule.getHandle(o.scheduleId) try { const desc = await handle.describe() - console.log(desc) + logger.info({ scheduleId: o.scheduleId, description: desc }, 'schedule already exists') } catch (e: any) { if (e instanceof ScheduleNotFoundError) { await c.schedule.create(o) + logger.info({ scheduleId: o.scheduleId }, 'created schedule') } else { throw e } @@ -107,15 +109,38 @@ const addSchedules = async (c: Client) => { export class WorkerCommand extends Command { static paths = [['worker']] async execute() { + logger.info('starting worker') + + // Install pino logger for Temporal + Runtime.install({ + logger: { + log: (level, message, attrs) => { + const pinoLevel = level.toLowerCase() + if (pinoLevel in logger) { + (logger as any)[pinoLevel](attrs, message) + } else { + logger.info(attrs, message) + } + }, + info: (message, attrs) => logger.info(attrs, message), + warn: (message, attrs) => logger.warn(attrs, message), + error: (message, attrs) => logger.error(attrs, message), + debug: (message, attrs) => logger.debug(attrs, message), + trace: (message, attrs) => logger.trace(attrs, message), + }, + }) + const { db } = await c.getAsync(PG) const client = await c.getAsync(Client) // schedules + logger.info('configuring schedules') await addSchedules(client) const connection = await NativeConnection.connect({ address: config.TEMPORAL_HOSTPORT, }) + logger.info({ address: config.TEMPORAL_HOSTPORT }, 'connected to temporal') const worker = await Worker.create({ connection, namespace: config.TEMPORAL_NAMESPACE, @@ -138,9 +163,10 @@ export class WorkerCommand extends Command { stickyQueueScheduleToStartTimeout: 5 * 1000, activities, }) + logger.info({ taskQueue: 'wynn-worker-ts', namespace: config.TEMPORAL_NAMESPACE }, 'starting temporal worker') await worker.run() - console.log('worked.run exited') + logger.info('worker.run exited') await db.end() await connection.close() } diff --git a/ts/src/discord/botevent/types.ts b/ts/src/discord/botevent/types.ts deleted file mode 100644 index 66714e7..0000000 --- a/ts/src/discord/botevent/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { BotType } from '#/discord' - -export type BotEventsType = BotType['events'] -export type InteractionHandler = NonNullable -export type InteractionType = Parameters[0] - -export type MuxHandler = (interaction: InteractionType, params?: T) => Promise - -export interface SlashHandler { - [key: string]: MuxHandler | SlashHandler -} diff --git a/ts/src/discord/botevent/command_parser.spec.ts b/ts/src/discord/command_parser.spec.ts similarity index 100% rename from ts/src/discord/botevent/command_parser.spec.ts rename to ts/src/discord/command_parser.spec.ts diff --git a/ts/src/discord/botevent/command_parser.ts b/ts/src/discord/command_parser.ts similarity index 100% rename from ts/src/discord/botevent/command_parser.ts rename to ts/src/discord/command_parser.ts diff --git a/ts/src/discord/botevent/handler.ts b/ts/src/discord/handler.ts similarity index 100% rename from ts/src/discord/botevent/handler.ts rename to ts/src/discord/handler.ts diff --git a/ts/src/discord/index.ts b/ts/src/discord/index.ts index febf9ae..5b0735c 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, InteractionData } from 'discordeno' +import type { Bot, CompleteDesiredProperties, DesiredPropertiesBehavior, DiscordInteractionContextType, Guild, Interaction, InteractionData, Member, Message } from 'discordeno' export const intents = [ Intents.GuildModeration, Intents.GuildWebhooks, @@ -54,6 +54,34 @@ export interface InteractionRef { 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; + /** 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?: 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; } // Type for the complete interaction handling payload diff --git a/ts/src/discord/mux/index.ts b/ts/src/discord/mux/index.ts deleted file mode 100644 index cebe75d..0000000 --- a/ts/src/discord/mux/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { c } from '#/di' - -export class EventMux { -} diff --git a/ts/src/discord/botevent/slash_commands.ts b/ts/src/discord/slash_commands.ts similarity index 88% rename from ts/src/discord/botevent/slash_commands.ts rename to ts/src/discord/slash_commands.ts index 4315bbd..1c5e83b 100644 --- a/ts/src/discord/botevent/slash_commands.ts +++ b/ts/src/discord/slash_commands.ts @@ -33,6 +33,14 @@ export const SLASH_COMMANDS = [ name: 'set_wynn_guild', description: 'set the default wynncraft guild for the server', type: ApplicationCommandOptionTypes.SubCommand, + options: [ + { + name: 'guild', + description: 'the wynncraft guild', + type: ApplicationCommandOptionTypes.String, + required: true, + }, + ], }, ], }, diff --git a/ts/src/logger/index.ts b/ts/src/logger/index.ts index 25860e8..c92c11c 100644 --- a/ts/src/logger/index.ts +++ b/ts/src/logger/index.ts @@ -1,10 +1,12 @@ import { pino } from 'pino' export const logger = pino({ - transport: { - target: 'pino-logfmt', - }, level: process.env.PINO_LOG_LEVEL || 'info', - + formatters: { + level: (label) => { + return { level: label } + }, + }, + timestamp: pino.stdTimeFunctions.isoTime, redact: [], // prevent logging of sensitive data }) diff --git a/ts/src/services/guild_settings.ts b/ts/src/services/guild_settings.ts new file mode 100644 index 0000000..c30b949 --- /dev/null +++ b/ts/src/services/guild_settings.ts @@ -0,0 +1,123 @@ +import { type } from 'arktype' +import { c } from '#/di' +import { PG } from '#/services/pg' +import { logger } from '#/logger' +import { JSONValue } from 'postgres' + +// Define the guild settings types +export const DiscordGuildSettingSchema = type({ + guild_id: 'string', // Discord guild ID + key: 'string', + value: 'unknown', // JSONB can be any valid JSON +}) + +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', +} as const + +export type DiscordGuildSettingKey = typeof DISCORD_GUILD_SETTING_KEYS[keyof typeof DISCORD_GUILD_SETTING_KEYS] + +export class DiscordGuildSettingsService { + constructor(private readonly pg: PG) {} + + /** + * Get a specific setting for a guild + */ + async getSetting(guildId: bigint, key: string): Promise { + const { sql } = this.pg + + const result = await sql<{ value: T }[]>` + SELECT value + FROM discord.guild_settings + WHERE guild_id = ${guildId.toString()} AND key = ${key} + ` + + if (result.length === 0) return null + return result[0].value + } + + /** + * Get all settings for a guild + */ + async getAllSettings(guildId: bigint): Promise { + const { sql } = this.pg + + const result = await sql` + SELECT guild_id, key, value + FROM discord.guild_settings + WHERE guild_id = ${guildId.toString()} + ORDER BY key + ` + return result.map(row => DiscordGuildSettingSchema.assert(row)) + } + + /** + * Set a setting for a guild (upsert) + */ + async setSetting(guildId: bigint, key: string, value: T): Promise { + const { sql } = this.pg + + await sql` + INSERT INTO discord.guild_settings (guild_id, key, value) + VALUES (${guildId.toString()}, ${key}, ${sql.json(value)}) + ON CONFLICT (guild_id, key) + DO UPDATE SET + value = EXCLUDED.value + ` + + logger.info({ guildId, key }, 'guild setting updated') + } + + /** + * Delete a specific setting for a guild + */ + async deleteSetting(guildId: bigint, key: string): Promise { + const { sql } = this.pg + + const result = await sql` + DELETE FROM discord.guild_settings + WHERE guild_id = ${guildId.toString()} AND key = ${key} + RETURNING 1 + ` + + const deleted = result.length > 0 + if (deleted) { + logger.info({ guildId, key }, 'guild setting deleted') + } + + return deleted + } + + /** + * Delete all settings for a guild + */ + async deleteAllSettings(guildId: bigint): Promise { + const { sql } = this.pg + + const result = await sql` + DELETE FROM discord.guild_settings + WHERE guild_id = ${guildId.toString()} + RETURNING 1 + ` + + const count = result.length + if (count > 0) { + logger.info({ guildId, count }, 'guild settings deleted') + } + + return count + } + + +} + +// Register the service with dependency injection +c.bind(DiscordGuildSettingsService) diff --git a/ts/src/services/temporal/index.ts b/ts/src/services/temporal/index.ts index 565e567..8d5e44b 100644 --- a/ts/src/services/temporal/index.ts +++ b/ts/src/services/temporal/index.ts @@ -1,6 +1,7 @@ import { Client, Connection } from '@temporalio/client' import { config } from '#/config' import { c } from '#/di' +import { logger } from '#/logger' c.bind({ provide: Client, @@ -17,7 +18,7 @@ c.bind({ }, }) process.on('exit', () => { - console.log('closing temporal client') + logger.info('closing temporal client') client.connection.close() }) return client diff --git a/ts/src/workflows/discord.ts b/ts/src/workflows/discord.ts index 0d85c54..25bbeaf 100644 --- a/ts/src/workflows/discord.ts +++ b/ts/src/workflows/discord.ts @@ -2,10 +2,11 @@ import { InteractionTypes } from '@discordeno/types' import { proxyActivities, startChild, workflowInfo } from '@temporalio/workflow' import type * as activities from '#/activities' import type { InteractionCreatePayload } from '#/discord' -import { CommandHandlers, createCommandHandler } from '#/discord/botevent/command_parser' -import { SLASH_COMMANDS } from '#/discord/botevent/slash_commands' +import { CommandHandlers, createCommandHandler } from '#/discord/command_parser' +import { SLASH_COMMANDS } from '#/discord/slash_commands' import { handleCommandGuildInfo, handleCommandGuildLeaderboard, handleCommandGuildOnline } from './guild_messages' import { handleCommandPlayerLookup } from './player_messages' +import { handleCommandSetWynnGuild } from './set_wynn_guild' const { reply_to_interaction } = proxyActivities({ startToCloseTimeout: '1 minute', @@ -49,7 +50,7 @@ const workflowHandleApplicationCommand = async (payload: InteractionCreatePayloa info: async (args) => { const { workflowId } = workflowInfo() const handle = await startChild(handleCommandGuildInfo, { - args: [{ ref }], + args: [{ ref, discordGuildId: payload.guildId ? BigInt(payload.guildId) : undefined }], workflowId: `${workflowId}-guild-info`, }) await handle.result() @@ -57,7 +58,7 @@ const workflowHandleApplicationCommand = async (payload: InteractionCreatePayloa online: async (args) => { const { workflowId } = workflowInfo() const handle = await startChild(handleCommandGuildOnline, { - args: [{ ref }], + args: [{ ref, discordGuildId: payload.guildId ? BigInt(payload.guildId) : undefined }], workflowId: `${workflowId}-guild-online`, }) await handle.result() @@ -65,7 +66,7 @@ const workflowHandleApplicationCommand = async (payload: InteractionCreatePayloa leaderboard: async (args) => { const { workflowId } = workflowInfo() const handle = await startChild(handleCommandGuildLeaderboard, { - args: [{ ref }], + args: [{ ref, discordGuildId: payload.guildId ? BigInt(payload.guildId) : undefined }], workflowId: `${workflowId}-guild-leaderboard`, }) await handle.result() @@ -73,14 +74,15 @@ const workflowHandleApplicationCommand = async (payload: InteractionCreatePayloa }, admin: { set_wynn_guild: async (args) => { - await reply_to_interaction({ - ref, - type: 4, - options: { - content: 'Not implemented yet', - isPrivate: true, - }, + const handle = await startChild(handleCommandSetWynnGuild, { + workflowId: `${workflowInfo().workflowId}-set-wynn-guild`, + 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 fb1bd95..d61dfdd 100644 --- a/ts/src/workflows/guild_messages.ts +++ b/ts/src/workflows/guild_messages.ts @@ -2,18 +2,30 @@ 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 } = proxyActivities({ +const { formGuildInfoMessage, formGuildOnlineMessage, formGuildLeaderboardMessage, reply_to_interaction, get_discord_guild_setting } = proxyActivities({ startToCloseTimeout: '30 seconds', }) interface CommandPayload { ref: InteractionRef + discordGuildId?: bigint } export async function handleCommandGuildInfo(payload: CommandPayload): Promise { - const { ref } = payload - const msg = await formGuildInfoMessage(WYNN_GUILD_ID) + const { ref, discordGuildId } = payload + + // 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) + if (wynnGuild?.uid) { + guildId = wynnGuild.uid + } + } + + const msg = await formGuildInfoMessage(guildId) await reply_to_interaction({ ref, type: 4, @@ -22,8 +34,18 @@ export async function handleCommandGuildInfo(payload: CommandPayload): Promise { - const { ref } = payload - const msg = await formGuildOnlineMessage(WYNN_GUILD_ID) + const { ref, discordGuildId } = payload + + // 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) + if (wynnGuild?.uid) { + guildId = wynnGuild.uid + } + } + + const msg = await formGuildOnlineMessage(guildId) await reply_to_interaction({ ref, type: 4, @@ -32,8 +54,18 @@ export async function handleCommandGuildOnline(payload: CommandPayload): Promise } export async function handleCommandGuildLeaderboard(payload: CommandPayload): Promise { - const { ref } = payload - const msg = await formGuildLeaderboardMessage(WYNN_GUILD_ID) + const { ref, discordGuildId } = payload + + // 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) + if (wynnGuild?.uid) { + guildId = wynnGuild.uid + } + } + + const msg = await formGuildLeaderboardMessage(guildId) await reply_to_interaction({ ref, type: 4, diff --git a/ts/src/workflows/index.ts b/ts/src/workflows/index.ts index 667f03d..11137d8 100644 --- a/ts/src/workflows/index.ts +++ b/ts/src/workflows/index.ts @@ -8,3 +8,4 @@ export * from './guilds' export * from './items' export * from './player_messages' export * from './players' +export * from './set_wynn_guild' diff --git a/ts/src/workflows/set_wynn_guild.ts b/ts/src/workflows/set_wynn_guild.ts new file mode 100644 index 0000000..dacc40d --- /dev/null +++ b/ts/src/workflows/set_wynn_guild.ts @@ -0,0 +1,73 @@ +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' + +const { reply_to_interaction, set_discord_guild_setting, get_wynn_guild_info } = proxyActivities({ + startToCloseTimeout: '10 seconds', +}) + +export interface SetWynnGuildPayload { + ref: InteractionRef + args: { + guild: string + } + discordGuildId: bigint +} + +export async function handleCommandSetWynnGuild(payload: SetWynnGuildPayload): Promise { + const { ref, args, discordGuildId } = payload + + // Defer the response since this might take a moment + await reply_to_interaction({ + ref, + type: 5, // Deferred response + }) + + try { + // Validate the Wynncraft guild exists + const guildInfo = await get_wynn_guild_info(args.guild) + + if (!guildInfo) { + await reply_to_interaction({ + ref, + type: 7, // Update the deferred response + options: { + content: `❌ Could not find Wynncraft guild "${args.guild}". Please check the guild name and try again.`, + isPrivate: true, + }, + }) + return + } + + // 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(), + }, + }) + + await reply_to_interaction({ + ref, + type: 7, // Update the deferred response + options: { + content: `✅ Successfully linked this Discord server to Wynncraft guild **[${guildInfo.prefix}] ${guildInfo.name}**`, + isPrivate: true, + }, + }) + } catch (error) { + await reply_to_interaction({ + ref, + type: 7, // Update the deferred response + options: { + content: `❌ An error occurred while setting the guild: ${error}`, + isPrivate: true, + }, + }) + } +}