noot
This commit is contained in:
parent
63dabb8466
commit
a43c1959c0
16
migrations/004_guild_settings.sql
Normal file
16
migrations/004_guild_settings.sql
Normal file
@ -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';
|
||||||
@ -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 { c } from '#/di'
|
||||||
import type { InteractionRef } from '#/discord'
|
import type { InteractionRef } from '#/discord'
|
||||||
import { Bot } from '#/discord/bot'
|
import { Bot } from '#/discord/bot'
|
||||||
@ -7,13 +7,13 @@ import { Bot } from '#/discord/bot'
|
|||||||
export const reply_to_interaction = async (props: {
|
export const reply_to_interaction = async (props: {
|
||||||
ref: InteractionRef
|
ref: InteractionRef
|
||||||
type: number
|
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
|
||||||
|
|||||||
53
ts/src/activities/discord_guild_settings.ts
Normal file
53
ts/src/activities/discord_guild_settings.ts
Normal file
@ -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<T> {
|
||||||
|
discordGuildId: bigint
|
||||||
|
key: string
|
||||||
|
value: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function set_discord_guild_setting<T>(params: SetDiscordGuildSettingParams<T>): Promise<void> {
|
||||||
|
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<T>(discordGuildId: bigint, key: string): Promise<T | null> {
|
||||||
|
const settingsService = await c.getAsync(DiscordGuildSettingsService)
|
||||||
|
|
||||||
|
return await settingsService.getSetting<T>(discordGuildId, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function delete_discord_guild_setting(discordGuildId: bigint, key: string): Promise<boolean> {
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
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'
|
||||||
|
|||||||
@ -6,58 +6,36 @@ import { WApi } from '#/lib/wynn/wapi'
|
|||||||
import { PG } from '#/services/pg'
|
import { PG } from '#/services/pg'
|
||||||
|
|
||||||
const playerSchemaFail = type({
|
const playerSchemaFail = type({
|
||||||
code: 'string',
|
path: 'string',
|
||||||
message: 'string',
|
errorMessage: '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',
|
id: 'string',
|
||||||
message: 'string',
|
name: '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',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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')
|
||||||
@ -77,19 +55,7 @@ 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 resp = await axios.get(`https://playerdb.co/api/player/minecraft/${playerName}`, {
|
const uuid = await getUUIDForUsername(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
|
||||||
|
|||||||
@ -6,8 +6,9 @@ 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/botevent/handler'
|
import { events } from '#/discord/handler'
|
||||||
import { SLASH_COMMANDS } from '#/discord/botevent/slash_commands'
|
import { SLASH_COMMANDS } from '#/discord/slash_commands'
|
||||||
|
import { logger } from '#/logger'
|
||||||
|
|
||||||
export class BotCommand extends Command {
|
export class BotCommand extends Command {
|
||||||
static paths = [['bot']]
|
static paths = [['bot']]
|
||||||
@ -17,12 +18,12 @@ export class BotCommand extends Command {
|
|||||||
}
|
}
|
||||||
const bot = await c.getAsync(Bot)
|
const bot = await c.getAsync(Bot)
|
||||||
bot.events = events()
|
bot.events = events()
|
||||||
console.log('registring slash commands')
|
logger.info('registering slash commands')
|
||||||
await bot.rest.upsertGuildApplicationCommands(DISCORD_GUILD_ID, SLASH_COMMANDS).catch(console.error)
|
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(console.error)
|
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()
|
await bot.start()
|
||||||
console.log('bot connected')
|
logger.info('bot connected')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,12 +6,13 @@ 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 } from '@temporalio/worker'
|
import { NativeConnection, Worker, Runtime } 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[] = [
|
||||||
{
|
{
|
||||||
@ -93,10 +94,11 @@ 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()
|
||||||
console.log(desc)
|
logger.info({ scheduleId: o.scheduleId, description: desc }, 'schedule already exists')
|
||||||
} 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
|
||||||
}
|
}
|
||||||
@ -107,15 +109,38 @@ 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,
|
||||||
@ -138,9 +163,10 @@ 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()
|
||||||
|
|
||||||
console.log('worked.run exited')
|
logger.info('worker.run exited')
|
||||||
await db.end()
|
await db.end()
|
||||||
await connection.close()
|
await connection.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { Intents, type InteractionTypes } from '@discordeno/types'
|
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 = [
|
export const intents = [
|
||||||
Intents.GuildModeration,
|
Intents.GuildModeration,
|
||||||
Intents.GuildWebhooks,
|
Intents.GuildWebhooks,
|
||||||
@ -54,6 +54,34 @@ export interface InteractionRef {
|
|||||||
token: string
|
token: string
|
||||||
type: InteractionTypes
|
type: InteractionTypes
|
||||||
acknowledged?: boolean
|
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
|
// Type for the complete interaction handling payload
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
import { c } from '#/di'
|
|
||||||
|
|
||||||
export class EventMux {
|
|
||||||
}
|
|
||||||
@ -33,6 +33,14 @@ 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -1,10 +1,12 @@
|
|||||||
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
|
||||||
})
|
})
|
||||||
|
|||||||
123
ts/src/services/guild_settings.ts
Normal file
123
ts/src/services/guild_settings.ts
Normal file
@ -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<T>(guildId: bigint, key: string): Promise<T | null> {
|
||||||
|
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<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<T extends JSONValue>(guildId: bigint, key: string, value: T): Promise<void> {
|
||||||
|
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<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)
|
||||||
@ -1,6 +1,7 @@
|
|||||||
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,
|
||||||
@ -17,7 +18,7 @@ c.bind({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
process.on('exit', () => {
|
process.on('exit', () => {
|
||||||
console.log('closing temporal client')
|
logger.info('closing temporal client')
|
||||||
client.connection.close()
|
client.connection.close()
|
||||||
})
|
})
|
||||||
return client
|
return client
|
||||||
|
|||||||
@ -2,10 +2,11 @@ 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/botevent/command_parser'
|
import { CommandHandlers, createCommandHandler } from '#/discord/command_parser'
|
||||||
import { SLASH_COMMANDS } from '#/discord/botevent/slash_commands'
|
import { SLASH_COMMANDS } from '#/discord/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',
|
||||||
@ -49,7 +50,7 @@ const workflowHandleApplicationCommand = async (payload: InteractionCreatePayloa
|
|||||||
info: async (args) => {
|
info: async (args) => {
|
||||||
const { workflowId } = workflowInfo()
|
const { workflowId } = workflowInfo()
|
||||||
const handle = await startChild(handleCommandGuildInfo, {
|
const handle = await startChild(handleCommandGuildInfo, {
|
||||||
args: [{ ref }],
|
args: [{ ref, discordGuildId: payload.guildId ? BigInt(payload.guildId) : undefined }],
|
||||||
workflowId: `${workflowId}-guild-info`,
|
workflowId: `${workflowId}-guild-info`,
|
||||||
})
|
})
|
||||||
await handle.result()
|
await handle.result()
|
||||||
@ -57,7 +58,7 @@ const workflowHandleApplicationCommand = async (payload: InteractionCreatePayloa
|
|||||||
online: async (args) => {
|
online: async (args) => {
|
||||||
const { workflowId } = workflowInfo()
|
const { workflowId } = workflowInfo()
|
||||||
const handle = await startChild(handleCommandGuildOnline, {
|
const handle = await startChild(handleCommandGuildOnline, {
|
||||||
args: [{ ref }],
|
args: [{ ref, discordGuildId: payload.guildId ? BigInt(payload.guildId) : undefined }],
|
||||||
workflowId: `${workflowId}-guild-online`,
|
workflowId: `${workflowId}-guild-online`,
|
||||||
})
|
})
|
||||||
await handle.result()
|
await handle.result()
|
||||||
@ -65,7 +66,7 @@ const workflowHandleApplicationCommand = async (payload: InteractionCreatePayloa
|
|||||||
leaderboard: async (args) => {
|
leaderboard: async (args) => {
|
||||||
const { workflowId } = workflowInfo()
|
const { workflowId } = workflowInfo()
|
||||||
const handle = await startChild(handleCommandGuildLeaderboard, {
|
const handle = await startChild(handleCommandGuildLeaderboard, {
|
||||||
args: [{ ref }],
|
args: [{ ref, discordGuildId: payload.guildId ? BigInt(payload.guildId) : undefined }],
|
||||||
workflowId: `${workflowId}-guild-leaderboard`,
|
workflowId: `${workflowId}-guild-leaderboard`,
|
||||||
})
|
})
|
||||||
await handle.result()
|
await handle.result()
|
||||||
@ -73,14 +74,15 @@ const workflowHandleApplicationCommand = async (payload: InteractionCreatePayloa
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
set_wynn_guild: async (args) => {
|
set_wynn_guild: async (args) => {
|
||||||
await reply_to_interaction({
|
const handle = await startChild(handleCommandSetWynnGuild, {
|
||||||
ref,
|
workflowId: `${workflowInfo().workflowId}-set-wynn-guild`,
|
||||||
type: 4,
|
args: [{
|
||||||
options: {
|
ref,
|
||||||
content: 'Not implemented yet',
|
args,
|
||||||
isPrivate: true,
|
discordGuildId: BigInt(payload.guildId!),
|
||||||
},
|
}],
|
||||||
})
|
})
|
||||||
|
await handle.result()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,18 +2,30 @@ import { proxyActivities } from '@temporalio/workflow'
|
|||||||
import type * as activities from '#/activities'
|
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'
|
||||||
|
import { DISCORD_GUILD_SETTING_KEYS } from '#/services/guild_settings'
|
||||||
|
|
||||||
const { formGuildInfoMessage, formGuildOnlineMessage, formGuildLeaderboardMessage, reply_to_interaction } = proxyActivities<typeof activities>({
|
const { formGuildInfoMessage, formGuildOnlineMessage, formGuildLeaderboardMessage, reply_to_interaction, get_discord_guild_setting } = 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 } = payload
|
const { ref, discordGuildId } = 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<{ uid: string }>(discordGuildId, DISCORD_GUILD_SETTING_KEYS.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,
|
||||||
@ -22,8 +34,18 @@ 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 } = payload
|
const { ref, discordGuildId } = 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<{ uid: string }>(discordGuildId, DISCORD_GUILD_SETTING_KEYS.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,
|
||||||
@ -32,8 +54,18 @@ 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 } = payload
|
const { ref, discordGuildId } = 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<{ uid: string }>(discordGuildId, DISCORD_GUILD_SETTING_KEYS.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,
|
||||||
|
|||||||
@ -8,3 +8,4 @@ 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'
|
||||||
|
|||||||
73
ts/src/workflows/set_wynn_guild.ts
Normal file
73
ts/src/workflows/set_wynn_guild.ts
Normal file
@ -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<typeof activities>({
|
||||||
|
startToCloseTimeout: '10 seconds',
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface SetWynnGuildPayload {
|
||||||
|
ref: InteractionRef
|
||||||
|
args: {
|
||||||
|
guild: string
|
||||||
|
}
|
||||||
|
discordGuildId: bigint
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCommandSetWynnGuild(payload: SetWynnGuildPayload): Promise<void> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user