Compare commits

..

No commits in common. "542070a4cb6f9a58546a16b949d8a27114ea557a" and "63dabb8466d76ff0e520f464a5523c915e807e4b" have entirely different histories.

27 changed files with 148 additions and 554 deletions

View File

@ -1,16 +0,0 @@
-- 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';

Binary file not shown.

View File

@ -49,7 +49,7 @@
"pino": "^9.6.0", "pino": "^9.6.0",
"pino-logfmt": "^0.1.1", "pino-logfmt": "^0.1.1",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"postgres": "^3.4.7", "postgres": "^3.4.5",
"superjson": "^2.2.2", "superjson": "^2.2.2",
"ts-markdown-builder": "^0.4.0", "ts-markdown-builder": "^0.4.0",
"why-is-node-running": "^3.2.2", "why-is-node-running": "^3.2.2",

View File

@ -1,35 +1,27 @@
import { type InteractionCallbackData, type InteractionCallbackOptions, MessageFlags } from 'discordeno' import { type InteractionCallbackData, type InteractionCallbackOptions, InteractionResponseTypes, InteractionTypes, MessageFlags } from 'discordeno'
import { c } from '#/di' import { c } from '#/di'
import type { InteractionRef } from '#/discord' import type { InteractionRef } from '#/discord'
import { InteractionResponseTypes } from '@discordeno/types'
import { Bot } from '#/discord/bot' import { Bot } from '#/discord/bot'
// from https://github.com/discordeno/discordeno/blob/21.0.0/packages/bot/src/transformers/interaction.ts#L33 // from https://github.com/discordeno/discordeno/blob/21.0.0/packages/bot/src/transformers/interaction.ts#L33
export const reply_to_interaction = async (props: { export const reply_to_interaction = async (props: {
ref: InteractionRef ref: InteractionRef
type: InteractionResponseTypes type: number
options?: InteractionCallbackOptions & { isPrivate?: boolean; content?: string } options: InteractionCallbackOptions & { isPrivate?: boolean; content?: string }
}) => { }) => {
const bot = await c.getAsync(Bot) const bot = await c.getAsync(Bot)
const { ref, type, options } = props const { ref, type, options } = props
const data: InteractionCallbackData = options || {} const data: InteractionCallbackData = options
if (options?.isPrivate) { if (options?.isPrivate) {
data.flags = MessageFlags.Ephemeral data.flags = MessageFlags.Ephemeral
} }
switch (type) {
case InteractionResponseTypes.UpdateMessage:
if (ref.acknowledged) { if (ref.acknowledged) {
return await bot.helpers.editOriginalInteractionResponse(ref.token, data) return await bot.helpers.sendFollowupMessage(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 })
} }
}

View File

@ -1,61 +0,0 @@
import { c } from '#/di'
import { PG } from '#/services/pg'
import { DiscordGuildSettingsService, DiscordGuildSettingKey, DiscordGuildSettingValue } from '#/services/guild_settings'
import { logger } from '#/logger'
export interface SetDiscordGuildSettingParams<K extends DiscordGuildSettingKey> {
guildId: bigint
key: K
value: DiscordGuildSettingValue<K>
}
export async function set_discord_guild_setting<K extends DiscordGuildSettingKey>(
params: SetDiscordGuildSettingParams<K>
): Promise<void> {
const settingsService = await c.getAsync(DiscordGuildSettingsService)
await settingsService.setSetting(params.guildId, params.key, params.value)
logger.info({
guildId: params.guildId,
key: params.key,
}, 'discord guild setting updated')
}
export async function get_discord_guild_setting<K extends DiscordGuildSettingKey>(
guildId: bigint,
key: K
): Promise<DiscordGuildSettingValue<K> | null> {
const settingsService = await c.getAsync(DiscordGuildSettingsService)
return await settingsService.getSetting(guildId, key)
}
export async function delete_discord_guild_setting(
guildId: bigint,
key: DiscordGuildSettingKey
): Promise<boolean> {
const settingsService = await c.getAsync(DiscordGuildSettingsService)
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
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]
}

View File

@ -4,7 +4,6 @@
export * from './database' export * from './database'
export * from './discord' export * from './discord'
export * from './discord_guild_settings'
export * from './guild' export * from './guild'
export * from './guild_messages' export * from './guild_messages'
export * from './leaderboards' export * from './leaderboards'

View File

@ -6,36 +6,58 @@ import { WApi } from '#/lib/wynn/wapi'
import { PG } from '#/services/pg' import { PG } from '#/services/pg'
const playerSchemaFail = type({ const playerSchemaFail = type({
path: 'string', code: 'string',
errorMessage: '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',
}) })
const playerSchemaSuccess = type({ const playerSchemaSuccess = type({
code: 'string',
message: 'string',
data: type({
player: {
meta: {
cached_at: 'number',
},
username: 'string',
id: 'string', id: 'string',
raw_id: 'string',
avatar: 'string',
skin_texture: 'string',
properties: [
{
name: 'string', name: 'string',
value: 'string',
signature: 'string',
},
],
name_history: 'unknown[]',
},
}),
success: 'true',
}) })
const playerSchema = playerSchemaFail.or(playerSchemaSuccess) 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 () => { export const scrape_online_players = async () => {
const api = await c.getAsync(WApi) const api = await c.getAsync(WApi)
const raw = await api.get('/v3/player') const raw = await api.get('/v3/player')
@ -55,7 +77,19 @@ export const scrape_online_players = async () => {
if (ans.length === 0) { if (ans.length === 0) {
// the user doesn't exist, so we need to grab their uuid // the user doesn't exist, so we need to grab their uuid
try { try {
const uuid = await getUUIDForUsername(playerName) 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
// insert the user. // insert the user.
await sql`insert into minecraft.user (name, uid, server) values (${playerName}, ${uuid},${server}) await sql`insert into minecraft.user (name, uid, server) values (${playerName}, ${uuid},${server})
on conflict (uid) do update set on conflict (uid) do update set

View File

@ -6,9 +6,8 @@ import { config } from '#/config'
import { DISCORD_GUILD_ID } from '#/constants' import { DISCORD_GUILD_ID } from '#/constants'
import { c } from '#/di' import { c } from '#/di'
import { Bot } from '#/discord/bot' import { Bot } from '#/discord/bot'
import { events } from '#/discord/handler' import { events } from '#/discord/botevent/handler'
import { SLASH_COMMANDS } from '#/discord/slash_commands' import { SLASH_COMMANDS } from '#/discord/botevent/slash_commands'
import { logger } from '#/logger'
export class BotCommand extends Command { export class BotCommand extends Command {
static paths = [['bot']] static paths = [['bot']]
@ -18,12 +17,12 @@ export class BotCommand extends Command {
} }
const bot = await c.getAsync(Bot) const bot = await c.getAsync(Bot)
bot.events = events() bot.events = events()
logger.info('registering slash commands') console.log('registring 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(DISCORD_GUILD_ID, SLASH_COMMANDS).catch(console.error)
await bot.rest.upsertGuildApplicationCommands('547828454972850196', SLASH_COMMANDS).catch((err) => logger.error(err, 'failed to register slash commands for secondary guild')) await bot.rest.upsertGuildApplicationCommands('547828454972850196', SLASH_COMMANDS).catch(console.error)
logger.info('connecting bot to gateway') console.log('connecting bot to gateway')
await bot.start() await bot.start()
logger.info('bot connected') console.log('bot connected')
} }
} }

View File

@ -6,13 +6,12 @@ import { c } from '#/di'
import '#/services/temporal' import '#/services/temporal'
import path from 'node:path' import path from 'node:path'
import { Client, ScheduleNotFoundError, type ScheduleOptions, ScheduleOverlapPolicy } from '@temporalio/client' import { Client, ScheduleNotFoundError, type ScheduleOptions, ScheduleOverlapPolicy } from '@temporalio/client'
import { NativeConnection, Worker, Runtime } from '@temporalio/worker' import { NativeConnection, Worker } from '@temporalio/worker'
import { PG } from '#/services/pg' import { PG } from '#/services/pg'
import { workflowSyncAllGuilds, workflowSyncGuildLeaderboardInfo, workflowSyncGuilds, workflowSyncOnline } from '#/workflows' import { workflowSyncAllGuilds, workflowSyncGuildLeaderboardInfo, workflowSyncGuilds, workflowSyncOnline } from '#/workflows'
import * as activities from '../activities' import * as activities from '../activities'
import { config } from '#/config' import { config } from '#/config'
import { logger } from '#/logger'
const schedules: ScheduleOptions[] = [ const schedules: ScheduleOptions[] = [
{ {
@ -94,11 +93,10 @@ const addSchedules = async (c: Client) => {
const handle = c.schedule.getHandle(o.scheduleId) const handle = c.schedule.getHandle(o.scheduleId)
try { try {
const desc = await handle.describe() const desc = await handle.describe()
logger.info({ scheduleId: o.scheduleId, description: desc }, 'schedule already exists') console.log(desc)
} catch (e: any) { } catch (e: any) {
if (e instanceof ScheduleNotFoundError) { if (e instanceof ScheduleNotFoundError) {
await c.schedule.create(o) await c.schedule.create(o)
logger.info({ scheduleId: o.scheduleId }, 'created schedule')
} else { } else {
throw e throw e
} }
@ -109,38 +107,15 @@ const addSchedules = async (c: Client) => {
export class WorkerCommand extends Command { export class WorkerCommand extends Command {
static paths = [['worker']] static paths = [['worker']]
async execute() { 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 { db } = await c.getAsync(PG)
const client = await c.getAsync(Client) const client = await c.getAsync(Client)
// schedules // schedules
logger.info('configuring schedules')
await addSchedules(client) await addSchedules(client)
const connection = await NativeConnection.connect({ const connection = await NativeConnection.connect({
address: config.TEMPORAL_HOSTPORT, address: config.TEMPORAL_HOSTPORT,
}) })
logger.info({ address: config.TEMPORAL_HOSTPORT }, 'connected to temporal')
const worker = await Worker.create({ const worker = await Worker.create({
connection, connection,
namespace: config.TEMPORAL_NAMESPACE, namespace: config.TEMPORAL_NAMESPACE,
@ -163,10 +138,9 @@ export class WorkerCommand extends Command {
stickyQueueScheduleToStartTimeout: 5 * 1000, stickyQueueScheduleToStartTimeout: 5 * 1000,
activities, activities,
}) })
logger.info({ taskQueue: 'wynn-worker-ts', namespace: config.TEMPORAL_NAMESPACE }, 'starting temporal worker')
await worker.run() await worker.run()
logger.info('worker.run exited') console.log('worked.run exited')
await db.end() await db.end()
await connection.close() await connection.close()
} }

View File

@ -2,27 +2,12 @@ import { InjectionToken } from '@needle-di/core'
import { createBot } from 'discordeno' import { createBot } from 'discordeno'
import { config } from '#/config' import { config } from '#/config'
import { c } from '#/di' import { c } from '#/di'
import { logger } from '#/logger' import { type BotType, createBotParameters } from './index'
import { type BotType, desiredProperties, intents } from './index'
const createBotWithToken = (token: string) => { const createBotWithToken = (token: string) => {
return createBot({ return createBot({
intents: intents.reduce((acc, curr) => acc | curr, 1 as any), ...createBotParameters,
desiredProperties,
token, 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<BotType>('DISCORD_BOT') export const Bot = new InjectionToken<BotType>('DISCORD_BOT')

View File

@ -31,8 +31,6 @@ export const events = () => {
token: interaction.token, token: interaction.token,
type: interaction.type, type: interaction.type,
acknowledged: interaction.acknowledged, acknowledged: interaction.acknowledged,
guildId: interaction.guildId,
channelId: interaction.channel.id || interaction.channelId,
}, },
data, data,
}, },
@ -61,5 +59,5 @@ export const events = () => {
], ],
}) })
}, },
} as const satisfies BotType['events'] } as BotType['events']
} }

View File

@ -33,14 +33,6 @@ export const SLASH_COMMANDS = [
name: 'set_wynn_guild', name: 'set_wynn_guild',
description: 'set the default wynncraft guild for the server', description: 'set the default wynncraft guild for the server',
type: ApplicationCommandOptionTypes.SubCommand, type: ApplicationCommandOptionTypes.SubCommand,
options: [
{
name: 'guild',
description: 'the wynncraft guild',
type: ApplicationCommandOptionTypes.String,
required: true,
},
],
}, },
], ],
}, },

View File

@ -0,0 +1,11 @@
import type { BotType } from '#/discord'
export type BotEventsType = BotType['events']
export type InteractionHandler = NonNullable<BotType['events']['interactionCreate']>
export type InteractionType = Parameters<InteractionHandler>[0]
export type MuxHandler<T> = (interaction: InteractionType, params?: T) => Promise<any>
export interface SlashHandler {
[key: string]: MuxHandler<any> | SlashHandler
}

View File

@ -1,5 +1,5 @@
import { Intents, type InteractionTypes } from '@discordeno/types' import { Intents, type InteractionTypes } from '@discordeno/types'
import type { Bot, CompleteDesiredProperties, DesiredPropertiesBehavior, DiscordInteractionContextType, InteractionData, RecursivePartial, TransformersDesiredProperties } from 'discordeno' import type { Bot, CompleteDesiredProperties, DesiredPropertiesBehavior, InteractionData } from 'discordeno'
export const intents = [ export const intents = [
Intents.GuildModeration, Intents.GuildModeration,
Intents.GuildWebhooks, Intents.GuildWebhooks,
@ -18,8 +18,9 @@ export const intents = [
Intents.GuildMessages, Intents.GuildMessages,
] as const ] as const
export const createBotParameters = {
export const desiredProperties = { intents: intents.reduce((acc, curr) => acc | curr, Intents.Guilds),
desiredProperties: {
interaction: { interaction: {
id: true, id: true,
data: true, data: true,
@ -32,49 +33,27 @@ export const desiredProperties = {
guild: true, guild: true,
user: true, user: true,
member: true, member: true,
context: true,
}, },
message: { message: {
id: true, id: true,
member: true, member: true,
guildId: true, guildId: true,
}, },
} as const satisfies RecursivePartial<TransformersDesiredProperties> },
export type DesiredProperties = typeof desiredProperties
export const createBotParameters = {
intents: intents.reduce((acc, curr) => acc | curr, Intents.Guilds),
desiredProperties,
} as const } as const
export type BotType = Bot<CompleteDesiredProperties<DesiredProperties>, DesiredPropertiesBehavior.RemoveKey> // 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<CompleteDesiredProperties<ExtractedDesiredProperties>, DesiredPropertiesBehavior.RemoveKey>
// Type for the interaction reference passed to workflows/activities // Type for the interaction reference passed to workflows/activities
export interface InteractionRef { export interface InteractionRef {
// id of the interaction
id: bigint id: bigint
/** A continuation token for responding to the interaction */
token: string token: string
type: InteractionTypes type: InteractionTypes
acknowledged?: boolean acknowledged?: boolean
/** The guild it was sent from */
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;
/** 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;
/** Context where the interaction was triggered from */
context?: DiscordInteractionContextType;
} }
// Type for the complete interaction handling payload // Type for the complete interaction handling payload

View File

@ -0,0 +1,4 @@
import { c } from '#/di'
export class EventMux {
}

View File

@ -1,12 +1,10 @@
import { pino } from 'pino' import { pino } from 'pino'
export const logger = pino({ export const logger = pino({
transport: {
target: 'pino-logfmt',
},
level: process.env.PINO_LOG_LEVEL || 'info', level: process.env.PINO_LOG_LEVEL || 'info',
formatters: {
level: (label) => {
return { level: label }
},
},
timestamp: pino.stdTimeFunctions.isoTime,
redact: [], // prevent logging of sensitive data redact: [], // prevent logging of sensitive data
}) })

View File

@ -1,163 +0,0 @@
import { type } from 'arktype'
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({
guild_id: 'string', // Discord guild ID
key: 'string',
value: 'unknown', // JSONB can be any valid JSON
})
export type DiscordGuildSetting = typeof DiscordGuildSettingSchema.infer
// 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 = keyof typeof DISCORD_GUILD_SETTING_SCHEMAS
export type DiscordGuildSettingValue<K extends DiscordGuildSettingKey> =
typeof DISCORD_GUILD_SETTING_SCHEMAS[K]['infer']
export class DiscordGuildSettingsService {
constructor(private readonly pg = inject(PG)) {}
/**
* Get a specific setting for a guild
*/
async getSetting<K extends DiscordGuildSettingKey>(
guildId: bigint,
key: K
): Promise<DiscordGuildSettingValue<K> | null> {
const { sql } = this.pg
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
// 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<K>
}
/**
* Get all settings for a guild
*/
async getAllSettings(guildId: bigint): Promise<DiscordGuildSetting[]> {
const { sql } = this.pg
const result = await sql<DiscordGuildSetting[]>`
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<K extends DiscordGuildSettingKey>(
guildId: bigint,
key: K,
value: DiscordGuildSettingValue<K>
): Promise<void> {
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(parsed as JSONValue)})
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: DiscordGuildSettingKey): Promise<boolean> {
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<number> {
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)

View File

@ -1,7 +1,6 @@
import { Client, Connection } from '@temporalio/client' import { Client, Connection } from '@temporalio/client'
import { config } from '#/config' import { config } from '#/config'
import { c } from '#/di' import { c } from '#/di'
import { logger } from '#/logger'
c.bind({ c.bind({
provide: Client, provide: Client,
@ -18,7 +17,7 @@ c.bind({
}, },
}) })
process.on('exit', () => { process.on('exit', () => {
logger.info('closing temporal client') console.log('closing temporal client')
client.connection.close() client.connection.close()
}) })
return client return client

View File

@ -1 +0,0 @@
export * from './snowflake'

View File

@ -1,15 +0,0 @@
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

View File

@ -2,11 +2,10 @@ import { InteractionTypes } from '@discordeno/types'
import { proxyActivities, startChild, workflowInfo } from '@temporalio/workflow' import { proxyActivities, startChild, workflowInfo } from '@temporalio/workflow'
import type * as activities from '#/activities' import type * as activities from '#/activities'
import type { InteractionCreatePayload } from '#/discord' import type { InteractionCreatePayload } from '#/discord'
import { CommandHandlers, createCommandHandler } from '#/discord/command_parser' import { CommandHandlers, createCommandHandler } from '#/discord/botevent/command_parser'
import { SLASH_COMMANDS } from '#/discord/slash_commands' import { SLASH_COMMANDS } from '#/discord/botevent/slash_commands'
import { handleCommandGuildInfo, handleCommandGuildLeaderboard, handleCommandGuildOnline } from './guild_messages' import { handleCommandGuildInfo, handleCommandGuildLeaderboard, handleCommandGuildOnline } from './guild_messages'
import { handleCommandPlayerLookup } from './player_messages' import { handleCommandPlayerLookup } from './player_messages'
import { handleCommandSetWynnGuild } from './set_wynn_guild'
const { reply_to_interaction } = proxyActivities<typeof activities>({ const { reply_to_interaction } = proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute', startToCloseTimeout: '1 minute',
@ -74,14 +73,14 @@ const workflowHandleApplicationCommand = async (payload: InteractionCreatePayloa
}, },
admin: { admin: {
set_wynn_guild: async (args) => { set_wynn_guild: async (args) => {
const handle = await startChild(handleCommandSetWynnGuild, { await reply_to_interaction({
workflowId: `${workflowInfo().workflowId}-set-wynn-guild`,
args: [{
ref, ref,
args, type: 4,
}], options: {
content: 'Not implemented yet',
isPrivate: true,
},
}) })
await handle.result()
}, },
}, },
}, },

View File

@ -3,28 +3,17 @@ import type * as activities from '#/activities'
import { WYNN_GUILD_ID } from '#/constants' import { WYNN_GUILD_ID } from '#/constants'
import type { InteractionRef } from '#/discord' import type { InteractionRef } from '#/discord'
const { formGuildInfoMessage, formGuildOnlineMessage, formGuildLeaderboardMessage, reply_to_interaction, get_discord_guild_setting } = proxyActivities<typeof activities>({ const { formGuildInfoMessage, formGuildOnlineMessage, formGuildLeaderboardMessage, reply_to_interaction } = proxyActivities<typeof activities>({
startToCloseTimeout: '30 seconds', startToCloseTimeout: '30 seconds',
}) })
interface CommandPayload { interface CommandPayload {
ref: InteractionRef ref: InteractionRef
discordGuildId?: bigint
} }
export async function handleCommandGuildInfo(payload: CommandPayload): Promise<void> { export async function handleCommandGuildInfo(payload: CommandPayload): Promise<void> {
const { ref, discordGuildId } = payload const { ref } = payload
const msg = await formGuildInfoMessage(WYNN_GUILD_ID)
// 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(discordGuildId, 'wynn_guild')
if (wynnGuild?.uid) {
guildId = wynnGuild.uid
}
}
const msg = await formGuildInfoMessage(guildId)
await reply_to_interaction({ await reply_to_interaction({
ref, ref,
type: 4, type: 4,
@ -33,18 +22,8 @@ export async function handleCommandGuildInfo(payload: CommandPayload): Promise<v
} }
export async function handleCommandGuildOnline(payload: CommandPayload): Promise<void> { export async function handleCommandGuildOnline(payload: CommandPayload): Promise<void> {
const { ref, discordGuildId } = payload const { ref } = payload
const msg = await formGuildOnlineMessage(WYNN_GUILD_ID)
// 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(discordGuildId, 'wynn_guild')
if (wynnGuild?.uid) {
guildId = wynnGuild.uid
}
}
const msg = await formGuildOnlineMessage(guildId)
await reply_to_interaction({ await reply_to_interaction({
ref, ref,
type: 4, type: 4,
@ -53,18 +32,8 @@ export async function handleCommandGuildOnline(payload: CommandPayload): Promise
} }
export async function handleCommandGuildLeaderboard(payload: CommandPayload): Promise<void> { export async function handleCommandGuildLeaderboard(payload: CommandPayload): Promise<void> {
const { ref, discordGuildId } = payload const { ref } = payload
const msg = await formGuildLeaderboardMessage(WYNN_GUILD_ID)
// 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(discordGuildId, 'wynn_guild')
if (wynnGuild?.uid) {
guildId = wynnGuild.uid
}
}
const msg = await formGuildLeaderboardMessage(guildId)
await reply_to_interaction({ await reply_to_interaction({
ref, ref,
type: 4, type: 4,

View File

@ -8,4 +8,3 @@ export * from './guilds'
export * from './items' export * from './items'
export * from './player_messages' export * from './player_messages'
export * from './players' export * from './players'
export * from './set_wynn_guild'

View File

@ -1,81 +0,0 @@
import { proxyActivities } from '@temporalio/workflow'
import type * as activities from '#/activities'
import type { InteractionRef } from '#/discord'
import { InteractionResponseTypes } from '@discordeno/types'
const { reply_to_interaction, set_discord_guild_setting, get_wynn_guild_info } = proxyActivities<typeof activities>({
startToCloseTimeout: '10 seconds',
})
export interface SetWynnGuildPayload {
ref: InteractionRef
args: {
guild: string
}
}
export async function handleCommandSetWynnGuild(payload: SetWynnGuildPayload): Promise<void> {
const { ref, args } = payload
// Defer the response since this might take a moment
await reply_to_interaction({
ref,
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
const guildInfo = await get_wynn_guild_info(args.guild)
if (!guildInfo) {
await reply_to_interaction({
ref,
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,
},
})
return
}
// Set the association in the database using the generic activity
await set_discord_guild_setting({
guildId: ref.guildId,
key: 'wynn_guild',
value: guildInfo.uid,
})
await reply_to_interaction({
ref,
type: InteractionResponseTypes.UpdateMessage, // 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: InteractionResponseTypes.UpdateMessage, // Update the deferred response
options: {
content: `❌ An error occurred while setting the guild: ${error}`,
isPrivate: true,
},
})
}
}

View File

@ -1922,7 +1922,7 @@ __metadata:
pino: "npm:^9.6.0" pino: "npm:^9.6.0"
pino-logfmt: "npm:^0.1.1" pino-logfmt: "npm:^0.1.1"
pino-pretty: "npm:^13.0.0" pino-pretty: "npm:^13.0.0"
postgres: "npm:^3.4.7" postgres: "npm:^3.4.5"
rollup: "npm:^4.34.8" rollup: "npm:^4.34.8"
superjson: "npm:^2.2.2" superjson: "npm:^2.2.2"
ts-markdown-builder: "npm:^0.4.0" ts-markdown-builder: "npm:^0.4.0"
@ -4178,10 +4178,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"postgres@npm:^3.4.7": "postgres@npm:^3.4.5":
version: 3.4.7 version: 3.4.5
resolution: "postgres@npm:3.4.7" resolution: "postgres@npm:3.4.5"
checksum: 10c0/b2e61b1064d38e7e1df8291f6d5a7e11f892a3240e00cf2b5e5542bf9abbfe97f3963164aeb56b42c1ab6b8aae3454c57f5bbc1791df0769375542740a7cde72 checksum: 10c0/53415acea77e97bdc1eeb861048f34964e2236e4d4d42f408fc9b901e62bfcf7443a487ebfdad18b57b468c6e297bf8d22097106a200f62eb1262eb5a71355df
languageName: node languageName: node
linkType: hard linkType: hard