noot
Some checks failed
commit-tag / commit-tag-image (map[context:./migrations file:./migrations/Dockerfile name:migrations]) (push) Successful in 21s
commit-tag / commit-tag-image (map[context:./ts file:./ts/Dockerfile name:ts]) (push) Has been cancelled

This commit is contained in:
a 2025-06-14 18:04:46 -05:00
parent 6c26594bc6
commit 53f46934e8
No known key found for this signature in database
GPG Key ID: 2F22877AA4DFDADB
47 changed files with 1251 additions and 1220 deletions

Binary file not shown.

View File

@ -1,10 +1,5 @@
{ {
"delete": true, "delete": true,
"directory": [ "directory": ["./src/activities", "./src/workflows"],
"./src/activities", "exclude": ["types.ts"]
"./src/workflows"
],
"exclude": [
"types.ts"
]
} }

41
ts/biome.json Normal file
View File

@ -0,0 +1,41 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": ["**/dist/**", "**/node_modules/**"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 160,
"ignore": ["**/dist/**", "**/node_modules/**"]
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "asNeeded",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "single",
"attributePosition": "auto",
"bracketSpacing": true
}
}
}

View File

@ -10,6 +10,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4",
"@types/node": "^22.13.4", "@types/node": "^22.13.4",
"@types/object-hash": "^3", "@types/object-hash": "^3",
"@vitest/runner": "^3.2.3", "@vitest/runner": "^3.2.3",
@ -29,8 +30,8 @@
"@temporalio/common": "^1.11.7", "@temporalio/common": "^1.11.7",
"@temporalio/worker": "^1.11.7", "@temporalio/worker": "^1.11.7",
"@temporalio/workflow": "^1.11.7", "@temporalio/workflow": "^1.11.7",
"@ts-rest/core": "https://gitpkg.vercel.app/aidant/ts-rest/libs/ts-rest/core?feat-standard-schema", "@ts-rest/core": "^3.53.0-rc.1",
"@ts-rest/fastify": "https://gitpkg.vercel.app/aidant/ts-rest/libs/ts-rest/fastify?feat-standard-schema", "@ts-rest/fastify": "^3.53.0-rc.1",
"any-date-parser": "^2.0.3", "any-date-parser": "^2.0.3",
"arktype": "2.1.1", "arktype": "2.1.1",
"axios": "^1.7.9", "axios": "^1.7.9",

View File

@ -1,18 +1,17 @@
import {ArkErrors, type} from "arktype"; import { ArkErrors, type } from 'arktype'
const tupleType = type(['number', 'string'])
const tupleType = type(["number","string"])
const tupleArrayType = tupleType.array() const tupleArrayType = tupleType.array()
const unionType = tupleType.or(tupleArrayType) const unionType = tupleType.or(tupleArrayType)
// good // good
tupleType.assert([1,"2"]) tupleType.assert([1, '2'])
// good // good
tupleArrayType.assert([[1,"2"]]) tupleArrayType.assert([[1, '2']])
// no good! // no good!
const resp = unionType([[1,"2"]]) const resp = unionType([[1, '2']])
if(resp instanceof ArkErrors) { if (resp instanceof ArkErrors) {
const err = resp[0] const err = resp[0]
console.log(err.data) console.log(err.data)
console.log(err.problem) console.log(err.problem)

View File

@ -1,40 +1,40 @@
import stringify from 'json-stable-stringify'; import { log } from '@temporalio/activity'
import { c } from "#/di"; import { ArkErrors } from 'arktype'
import { WApiV3ItemDatabase } from "#/lib/wynn/types"; import stringify from 'json-stable-stringify'
import { WApi } from "#/lib/wynn/wapi"; import { c } from '#/di'
import { PG } from "#/services/pg"; import { sha1Hash } from '#/lib/util/hashers'
import { ArkErrors } from "arktype"; import { WApiV3ItemDatabase } from '#/lib/wynn/types'
import { sha1Hash } from '#/lib/util/hashers'; import { WApi } from '#/lib/wynn/wapi'
import { log } from '@temporalio/activity'; import { PG } from '#/services/pg'
export async function update_wynn_items() { export async function update_wynn_items() {
const api = await c.getAsync(WApi) const api = await c.getAsync(WApi)
const ans = await api.get('/v3/item/database', {fullResult: ''}) const ans = await api.get('/v3/item/database', { fullResult: '' })
if(ans.status !== 200){ if (ans.status !== 200) {
throw new Error('Failed to get wynn items') throw new Error('Failed to get wynn items')
} }
const parsed = WApiV3ItemDatabase(ans.data) const parsed = WApiV3ItemDatabase(ans.data)
if(parsed instanceof ArkErrors){ if (parsed instanceof ArkErrors) {
throw parsed throw parsed
} }
const {sql} = await c.getAsync(PG) const { sql } = await c.getAsync(PG)
// iterate over all items with their names // iterate over all items with their names
const serializedData = stringify(parsed) const serializedData = stringify(parsed)
if(!serializedData){ if (!serializedData) {
throw new Error('Failed to serialize wynn items') throw new Error('Failed to serialize wynn items')
} }
const dataHash = sha1Hash(serializedData) const dataHash = sha1Hash(serializedData)
let found_new = false let found_new = false
await sql.begin(async (sql) => { await sql.begin(async (sql) => {
const [{currenthash} = {}] = await sql`select value as currenthash from meta.hashes where key = 'wynn.items' limit 1` const [{ currenthash } = {}] = await sql`select value as currenthash from meta.hashes where key = 'wynn.items' limit 1`
if(currenthash === dataHash) { if (currenthash === dataHash) {
return return
} }
found_new = true found_new = true
log.info(`updating wynn with new hash`, {old: currenthash, new: dataHash}) log.info(`updating wynn with new hash`, { old: currenthash, new: dataHash })
for(const [displayName, item] of Object.entries(parsed)){ for (const [displayName, item] of Object.entries(parsed)) {
const json = stringify(item) const json = stringify(item)
if(!json){ if (!json) {
throw new Error('Failed to serialize wynn item') throw new Error('Failed to serialize wynn item')
} }
const itemHash = sha1Hash(json) const itemHash = sha1Hash(json)

View File

@ -1,32 +1,27 @@
import { Bot } from "#/discord/bot"; import { type InteractionCallbackData, type InteractionCallbackOptions, InteractionResponseTypes, InteractionTypes, MessageFlags } from 'discordeno'
import {c} from "#/di" import { c } from '#/di'
import { InteractionResponseTypes, InteractionCallbackOptions, InteractionCallbackData, InteractionTypes, MessageFlags } from "discordeno"; import type { InteractionRef } from '#/discord'
import { InteractionRef } from "#/discord"; 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: 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 { const { ref, type, options } = props
ref,
type,
options,
} = props;
let data: InteractionCallbackData = options; const data: InteractionCallbackData = options
if (options?.isPrivate) { if (options?.isPrivate) {
data.flags = MessageFlags.Ephemeral; data.flags = MessageFlags.Ephemeral
} }
if(ref.acknowledged) { if (ref.acknowledged) {
return await bot.helpers.sendFollowupMessage(ref.token, data); return await bot.helpers.sendFollowupMessage(ref.token, data)
} }
return await bot.helpers.sendInteractionResponse(ref.id, ref.token, return await bot.helpers.sendInteractionResponse(ref.id, ref.token, { type, data }, { withResponse: options?.withResponse })
{ type, data }, { withResponse: options?.withResponse })
} }

View File

@ -1,25 +1,25 @@
import { c } from "#/di"; import { type } from 'arktype'
import { WapiV3GuildOverview } from "#/lib/wynn/types"; import { parseDate } from 'chrono-node'
import { WApi } from "#/lib/wynn/wapi"; import { c } from '#/di'
import { PG } from "#/services/pg"; import { WapiV3GuildOverview } from '#/lib/wynn/types'
import { type } from "arktype"; import { WApi } from '#/lib/wynn/wapi'
import {parseDate} from "chrono-node"; import { PG } from '#/services/pg'
export async function update_all_guilds() { export async function update_all_guilds() {
const api = await c.getAsync(WApi) const api = await c.getAsync(WApi)
const ans = await api.get('/v3/guild/list/guild') const ans = await api.get('/v3/guild/list/guild')
if(ans.status !== 200){ if (ans.status !== 200) {
throw new Error('Failed to get guild list from wapi') throw new Error('Failed to get guild list from wapi')
} }
const parsed = type({ const parsed = type({
"[string]": { '[string]': {
uuid: "string", uuid: 'string',
prefix: "string", prefix: 'string',
} },
}).assert(ans.data) }).assert(ans.data)
const { sql } = await c.getAsync(PG) const { sql } = await c.getAsync(PG)
for(const [guild_name, guild] of Object.entries(parsed)){ for (const [guild_name, guild] of Object.entries(parsed)) {
await sql`insert into wynn.guild_info await sql`insert into wynn.guild_info
(uid, name, prefix) (uid, name, prefix)
values values
@ -32,13 +32,13 @@ export async function update_all_guilds() {
} }
export async function update_guild({ export async function update_guild({
guild_name guild_name,
}:{ }: {
guild_name: string guild_name: string
}) { }) {
const api = await c.getAsync(WApi) const api = await c.getAsync(WApi)
const ans = await api.get(`/v3/guild/${guild_name}`) const ans = await api.get(`/v3/guild/${guild_name}`)
if(ans.status !== 200){ if (ans.status !== 200) {
throw new Error('Failed to get guild into from wapi') throw new Error('Failed to get guild into from wapi')
} }
const parsed = WapiV3GuildOverview.assert(ans.data) const parsed = WapiV3GuildOverview.assert(ans.data)
@ -59,9 +59,9 @@ export async function update_guild({
wars = EXCLUDED.wars, wars = EXCLUDED.wars,
created = EXCLUDED.created created = EXCLUDED.created
` `
const {total, ...rest} = parsed.members const { total, ...rest } = parsed.members
for(const [rank_name, rank] of Object.entries(rest)){ for (const [rank_name, rank] of Object.entries(rest)) {
for(const [userName, member] of Object.entries(rank)) { for (const [userName, member] of Object.entries(rank)) {
await sql`insert into wynn.guild_members await sql`insert into wynn.guild_members
(guild_id, member_id, rank, joined_at, contributed) values (guild_id, member_id, rank, joined_at, contributed) values
(${parsed.uuid}, ${member.uuid}, ${rank_name}, ${parseDate(member.joined)}, ${member.contributed}) (${parsed.uuid}, ${member.uuid}, ${rank_name}, ${parseDate(member.joined)}, ${member.contributed})

View File

@ -1,13 +1,13 @@
import { c } from "#/di"; import { type } from 'arktype'
import { PG } from "#/services/pg"; import type { CreateMessageOptions, InteractionCallbackOptions } from 'discordeno'
import { CreateMessageOptions, InteractionCallbackOptions } from "discordeno"; import * as md from 'ts-markdown-builder'
import { type } from "arktype"; import { c } from '#/di'
import { TabWriter } from "#/lib/util/tabwriter"; import { TabWriter } from '#/lib/util/tabwriter'
import { RANK_EMOJIS, getRankEmoji, formatNumber } from "#/lib/util/wynnfmt"; import { RANK_EMOJIS, formatNumber, getRankEmoji } from '#/lib/util/wynnfmt'
import * as md from 'ts-markdown-builder'; import { PG } from '#/services/pg'
export async function formGuildInfoMessage(guild_id: string): Promise<CreateMessageOptions & InteractionCallbackOptions> { export async function formGuildInfoMessage(guild_id: string): Promise<CreateMessageOptions & InteractionCallbackOptions> {
const { sql } = await c.getAsync(PG); const { sql } = await c.getAsync(PG)
const result = await sql` const result = await sql`
with ranked as ( with ranked as (
@ -25,15 +25,15 @@ with ranked as (
) )
select * from ranked select * from ranked
where ranked.uid = ${guild_id} where ranked.uid = ${guild_id}
`; `
if (result.length == 0) { if (result.length == 0) {
return { return {
content: "No guild found.", content: 'No guild found.',
}; }
} }
const guild = result[0]; const guild = result[0]
const output = [ const output = [
`# 🏰 Guild Information`, `# 🏰 Guild Information`,
@ -41,18 +41,18 @@ where ranked.uid = ${guild_id}
`### 📊 Statistics`, `### 📊 Statistics`,
`> **Level:** \`${guild.level}\``, `> **Level:** \`${guild.level}\``,
`> **Total XP:** \`${formatNumber(guild.xp)}\``, `> **Total XP:** \`${formatNumber(guild.xp)}\``,
`> **XP Rank:** \`#${guild.xp_rank >= 1000 ? "1000+" : guild.xp_rank}\``, `> **XP Rank:** \`#${guild.xp_rank >= 1000 ? '1000+' : guild.xp_rank}\``,
`> **Territories:** \`${guild.territories}\``, `> **Territories:** \`${guild.territories}\``,
`> **Wars:** \`${guild.wars.toLocaleString()}\``, `> **Wars:** \`${guild.wars.toLocaleString()}\``,
].join("\n"); ].join('\n')
return { return {
content: output, content: output,
}; }
} }
export async function formGuildOnlineMessage(guild_id: string): Promise<CreateMessageOptions & InteractionCallbackOptions> { export async function formGuildOnlineMessage(guild_id: string): Promise<CreateMessageOptions & InteractionCallbackOptions> {
const { sql } = await c.getAsync(PG); const { sql } = await c.getAsync(PG)
const result = await sql`select const result = await sql`select
gi.name as guild_name, gi.name as guild_name,
@ -68,67 +68,73 @@ export async function formGuildOnlineMessage(guild_id: string): Promise<CreateMe
on gi.uid = gm.guild_id on gi.uid = gm.guild_id
where minecraft.user.server is not null where minecraft.user.server is not null
and gm.guild_id = ${guild_id} and gm.guild_id = ${guild_id}
`; `
const members = type({ const members = type({
guild_name: "string", guild_name: 'string',
guild_prefix: "string", guild_prefix: 'string',
name: "string", name: 'string',
rank: "string", rank: 'string',
contributed: "string", contributed: 'string',
server: "string", server: 'string',
}).array().assert(result); })
.array()
.assert(result)
if (members.length == 0) { if (members.length == 0) {
return { return {
content: "😴 No guild members are currently online.", content: '😴 No guild members are currently online.',
}; }
} }
// Get guild info // Get guild info
const guildName = members[0].guild_name; const guildName = members[0].guild_name
const guildPrefix = members[0].guild_prefix; const guildPrefix = members[0].guild_prefix
// Sort by contribution // Sort by contribution
members.sort((a, b) => Number(b.contributed) - Number(a.contributed)); members.sort((a, b) => Number(b.contributed) - Number(a.contributed))
// Group members by server // Group members by server
const membersByServer = members.reduce((acc, member) => { const membersByServer = members.reduce(
if (acc[member.server] == undefined) { (acc, member) => {
acc[member.server] = []; if (acc[member.server] == undefined) {
} acc[member.server] = []
acc[member.server].push(member); }
return acc; acc[member.server].push(member)
}, {} as Record<string, typeof members>); return acc
},
{} as Record<string, typeof members>
)
// Sort servers by player count // Sort servers by player count
const sortedServers = Object.entries(membersByServer) const sortedServers = Object.entries(membersByServer).sort(([, a], [, b]) => b.length - a.length)
.sort(([, a], [, b]) => b.length - a.length);
// Build server sections // Build server sections
const serverSections = sortedServers.map(([server, serverMembers]) => { const serverSections = sortedServers.map(([server, serverMembers]) => {
const memberList = serverMembers.map(m => { const memberList = serverMembers
const emoji = getRankEmoji(m.rank); .map((m) => {
return `${emoji} ${m.name}`; const emoji = getRankEmoji(m.rank)
}).join(", "); return `${emoji} ${m.name}`
})
.join(', ')
return `### 🌐 ${server} (${serverMembers.length} player${serverMembers.length > 1 ? 's' : ''})\n> ${memberList}`; return `### 🌐 ${server} (${serverMembers.length} player${serverMembers.length > 1 ? 's' : ''})\n> ${memberList}`
}); })
const output = [ const output = [
`# 🟢 Online Guild Members`, `# 🟢 Online Guild Members`,
`**[${guildPrefix}] ${guildName}**\n`, `**[${guildPrefix}] ${guildName}**\n`,
`📊 **Total Online:** \`${members.length}\` members across \`${sortedServers.length}\` servers\n`, `📊 **Total Online:** \`${members.length}\` members across \`${sortedServers.length}\` servers\n`,
...serverSections ...serverSections,
].join("\n"); ].join('\n')
return { return {
content: output, content: output,
}; }
} }
export async function formGuildLeaderboardMessage(guild_id: string): Promise<CreateMessageOptions & InteractionCallbackOptions> { export async function formGuildLeaderboardMessage(guild_id: string): Promise<CreateMessageOptions & InteractionCallbackOptions> {
const { sql } = await c.getAsync(PG); const { sql } = await c.getAsync(PG)
const result = await sql`select const result = await sql`select
gi.name as guild_name, gi.name as guild_name,
@ -142,65 +148,59 @@ export async function formGuildLeaderboardMessage(guild_id: string): Promise<Cre
inner join wynn.guild_info gi inner join wynn.guild_info gi
on gi.uid = gm.guild_id on gi.uid = gm.guild_id
where gm.guild_id = ${guild_id} where gm.guild_id = ${guild_id}
`; `
const members = type({ const members = type({
guild_name: "string", guild_name: 'string',
guild_prefix: "string", guild_prefix: 'string',
name: "string", name: 'string',
rank: "string", rank: 'string',
contributed: "string", contributed: 'string',
}).array().assert(result); })
.array()
.assert(result)
if (members.length === 0) { if (members.length === 0) {
return { return {
content: "No guild members found.", content: 'No guild members found.',
}; }
} }
// Sort by contribution // Sort by contribution
members.sort((a, b) => Number(b.contributed) - Number(a.contributed)); members.sort((a, b) => Number(b.contributed) - Number(a.contributed))
const topMembers = members.slice(0, 10); const topMembers = members.slice(0, 10)
// Get guild info from first member (all have same guild info) // Get guild info from first member (all have same guild info)
const guildName = members[0].guild_name; const guildName = members[0].guild_name
const guildPrefix = members[0].guild_prefix; const guildPrefix = members[0].guild_prefix
// Calculate total guild XP // Calculate total guild XP
const totalXP = members.reduce((sum, m) => sum + Number(m.contributed), 0); const totalXP = members.reduce((sum, m) => sum + Number(m.contributed), 0)
// Build the leaderboard with proper alignment // Build the leaderboard with proper alignment
const tw = new TabWriter(2); const tw = new TabWriter(2)
// Add header row // Add header row
tw.add(["#", "Rank", "Player", "XP", "%"]); tw.add(['#', 'Rank', 'Player', 'XP', '%'])
tw.add(["───", "────────────", "────────────────", "──────────", "──────"]); // Separator line tw.add(['───', '────────────', '────────────────', '──────────', '──────']) // Separator line
topMembers.forEach((member, index) => { topMembers.forEach((member, index) => {
const position = index + 1; const position = index + 1
const posStr = position === 1 ? "🥇" : position === 2 ? "🥈" : position === 3 ? "🥉" : `${position}.`; const posStr = position === 1 ? '🥇' : position === 2 ? '🥈' : position === 3 ? '🥉' : `${position}.`
const rankEmoji = getRankEmoji(member.rank); const rankEmoji = getRankEmoji(member.rank)
const contribution = Number(member.contributed); const contribution = Number(member.contributed)
const percentage = ((contribution / totalXP) * 100).toFixed(1); const percentage = ((contribution / totalXP) * 100).toFixed(1)
// Use formatNumber for consistent formatting // Use formatNumber for consistent formatting
const contribFormatted = contribution >= 10_000 const contribFormatted = contribution >= 10_000 ? formatNumber(contribution) : contribution.toLocaleString()
? formatNumber(contribution)
: contribution.toLocaleString();
tw.add([ tw.add([posStr, `${rankEmoji} ${member.rank}`, member.name, contribFormatted, `${percentage}%`])
posStr, })
`${rankEmoji} ${member.rank}`,
member.name,
contribFormatted,
`${percentage}%`
]);
});
const leaderboardTable = tw.build(); const leaderboardTable = tw.build()
// Create summary stats // Create summary stats
const avgContribution = Math.floor(totalXP / members.length); const avgContribution = Math.floor(totalXP / members.length)
const output = [ const output = [
`# 📊 Guild XP Leaderboard`, `# 📊 Guild XP Leaderboard`,
@ -209,13 +209,13 @@ export async function formGuildLeaderboardMessage(guild_id: string): Promise<Cre
`👥 **Total Members:** \`${members.length}\``, `👥 **Total Members:** \`${members.length}\``,
`📊 **Average Contribution:** \`${avgContribution.toLocaleString()}\`\n`, `📊 **Average Contribution:** \`${avgContribution.toLocaleString()}\`\n`,
`### Top Contributors`, `### Top Contributors`,
"```", '```',
leaderboardTable, leaderboardTable,
"```", '```',
`*Showing top ${Math.min(members.length, 10)} of ${members.length} members*` `*Showing top ${Math.min(members.length, 10)} of ${members.length} members*`,
].join("\n"); ].join('\n')
return { return {
content: output, content: output,
}; }
} }

View File

@ -2,9 +2,9 @@
* @file Automatically generated by barrelsby. * @file Automatically generated by barrelsby.
*/ */
export * from "./database"; export * from './database'
export * from "./discord"; export * from './discord'
export * from "./guild"; export * from './guild'
export * from "./guild_messages"; export * from './guild_messages'
export * from "./leaderboards"; export * from './leaderboards'
export * from "./players"; export * from './players'

View File

@ -1,25 +1,25 @@
import { c } from "#/di"; import { type } from 'arktype'
import { WApi } from "#/lib/wynn/wapi"; import { c } from '#/di'
import { PG } from "#/services/pg"; import { WApi } from '#/lib/wynn/wapi'
import { type } from "arktype"; import { PG } from '#/services/pg'
export async function update_guild_levels() { export async function update_guild_levels() {
const api = await c.getAsync(WApi) const api = await c.getAsync(WApi)
const ans = await api.get('/v3/leaderboards/guildLevel', {resultLimit: 1000}) const ans = await api.get('/v3/leaderboards/guildLevel', { resultLimit: 1000 })
if(ans.status !== 200){ if (ans.status !== 200) {
throw new Error('Failed to get guild list from wapi') throw new Error('Failed to get guild list from wapi')
} }
const parsed = type({ const parsed = type({
"[string]": { '[string]': {
uuid: "string", uuid: 'string',
name: "string", name: 'string',
prefix: "string", prefix: 'string',
xp: "number", xp: 'number',
level: "number", level: 'number',
} },
}).assert(ans.data) }).assert(ans.data)
const { sql } = await c.getAsync(PG) const { sql } = await c.getAsync(PG)
for(const [_, guild] of Object.entries(parsed)){ for (const [_, guild] of Object.entries(parsed)) {
await sql`insert into wynn.guild_info await sql`insert into wynn.guild_info
(uid, name, prefix, xp, level) (uid, name, prefix, xp, level)
values values

View File

@ -1,90 +1,91 @@
import { c } from "#/di" import { log } from '@temporalio/activity'
import { WApi } from "#/lib/wynn/wapi" import { type } from 'arktype'
import { PG } from "#/services/pg" import axios from 'axios'
import { log } from "@temporalio/activity" import { c } from '#/di'
import { type } from "arktype" import { WApi } from '#/lib/wynn/wapi'
import axios from "axios" import { PG } from '#/services/pg'
const playerSchemaFail = type({ const playerSchemaFail = type({
code: "string", code: 'string',
message: "string", message: 'string',
data: type({ data: type({
player: { player: {
meta: { meta: {
cached_at: "number" cached_at: 'number',
}, },
username: "string", username: 'string',
id: "string", id: 'string',
raw_id: "string", raw_id: 'string',
avatar: "string", avatar: 'string',
skin_texture: "string", skin_texture: 'string',
properties: [{ properties: [
name: "string", {
value: "string", name: 'string',
signature: "string" value: 'string',
}], signature: 'string',
name_history: "unknown[]" },
} ],
name_history: 'unknown[]',
},
}), }),
success: "false" success: 'false',
}) })
const playerSchemaSuccess = type({ const playerSchemaSuccess = type({
code: "string", code: 'string',
message: "string", message: 'string',
data: type({ data: type({
player: { player: {
meta: { meta: {
cached_at: "number" cached_at: 'number',
}, },
username: "string", username: 'string',
id: "string", id: 'string',
raw_id: "string", raw_id: 'string',
avatar: "string", avatar: 'string',
skin_texture: "string", skin_texture: 'string',
properties: [{ properties: [
name: "string", {
value: "string", name: 'string',
signature: "string" value: 'string',
}], signature: 'string',
name_history: "unknown[]" },
} ],
name_history: 'unknown[]',
},
}), }),
success: "true" success: 'true',
}) })
const playerSchema = playerSchemaFail.or(playerSchemaSuccess) const playerSchema = playerSchemaFail.or(playerSchemaSuccess)
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')
const onlineList = type({ const onlineList = type({
total: "number", total: 'number',
players: { players: {
"[string]": "string | null", '[string]': 'string | null',
} },
}).assert(raw.data) }).assert(raw.data)
const { sql } = await c.getAsync(PG) const { sql } = await c.getAsync(PG)
for(const [playerName, server] of Object.entries(onlineList.players)){ for (const [playerName, server] of Object.entries(onlineList.players)) {
// we do this optimistically without a tx, because temporal will probably handle // we do this optimistically without a tx, because temporal will probably handle
// the race, and the worst case is we do extra requests. // the race, and the worst case is we do extra requests.
const ans = await sql`select uid from minecraft.user where name = ${playerName} limit 1` const ans = await sql`select uid from minecraft.user where name = ${playerName} limit 1`
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 resp = await axios.get(`https://playerdb.co/api/player/minecraft/${playerName}`, {
headers: { headers: {
"User-Agent": "lil-robot-guy (a@tuxpa.in)", 'User-Agent': 'lil-robot-guy (a@tuxpa.in)',
} },
}) })
const parsedPlayer = playerSchema.assert(resp.data) const parsedPlayer = playerSchema.assert(resp.data)
if(!parsedPlayer.success){ if (!parsedPlayer.success) {
log.warn(`failed to get uuid for ${playerName}`, { log.warn(`failed to get uuid for ${playerName}`, {
"payload": parsedPlayer, payload: parsedPlayer,
}) })
continue continue
} }
@ -95,22 +96,22 @@ export const scrape_online_players = async()=>{
name = EXCLUDED.name, name = EXCLUDED.name,
server = EXCLUDED.server server = EXCLUDED.server
` `
}catch(e) { } catch (e) {
log.warn(`failed to get uuid for ${playerName}`, { log.warn(`failed to get uuid for ${playerName}`, {
"err": e, err: e,
}) })
continue continue
} }
} }
} }
await sql.begin(async (sql)=>{ await sql.begin(async (sql) => {
await sql`update minecraft.user set server = null` await sql`update minecraft.user set server = null`
for(const [playerName, server] of Object.entries(onlineList.players)){ for (const [playerName, server] of Object.entries(onlineList.players)) {
try { try {
await sql`update minecraft.user set server = ${server} where name = ${playerName}` await sql`update minecraft.user set server = ${server} where name = ${playerName}`
}catch(e) { } catch (e) {
log.warn(`failed to update server for ${playerName}`, { log.warn(`failed to update server for ${playerName}`, {
"err": e, err: e,
}) })
continue continue
} }

View File

@ -1,49 +1,54 @@
import { initContract } from "@ts-rest/core/src"; import { initContract } from '@ts-rest/core'
import { type } from "arktype"; import { type } from 'arktype'
const con = initContract(); const con = initContract()
const ingameauth = con.router({ const ingameauth = con.router(
challenge: { {
description: "generate a challenge for the client to solve", challenge: {
method: "GET", description: 'generate a challenge for the client to solve',
path: "/challenge", method: 'GET',
responses: { path: '/challenge',
200: type({ responses: {
challenge: "string.uuid", 200: type({
challenge: 'string.uuid',
}),
},
query: type({
uuid: 'string.uuid',
}), }),
}, },
query: type({ solve: {
uuid: "string.uuid", description: 'attempt to solve the challenge and get the token for the challenge',
}), method: 'POST',
path: '/solve',
body: type({
challenge: 'string.uuid',
uuid: 'string.uuid',
}),
responses: {
200: type({
success: 'true',
challenge: 'string.uuid',
uuid: 'string.uuid',
}),
401: type({
success: 'false',
reason: 'string',
}),
},
},
}, },
solve: { { pathPrefix: '/ingame' }
description: "attempt to solve the challenge and get the token for the challenge", )
method: "POST",
path: "/solve",
body: type({
challenge: "string.uuid",
uuid: "string.uuid",
}),
responses: {
200: type({
success: "true",
challenge: "string.uuid",
uuid: "string.uuid",
}),
401: type({
success: "false",
reason: "string",
}),
},
}
}, {pathPrefix: "/ingame"})
export const api = con.router({ export const api = con.router(
"ingameauth": ingameauth, {
}, {pathPrefix: "/api/v1"}) ingameauth: ingameauth,
},
{ pathPrefix: '/api/v1' }
)
export const contract = con.router({ export const contract = con.router({
api: api api: api,
}) })

View File

@ -1,36 +1,28 @@
import { Command } from 'clipanion'; import { Command } from 'clipanion'
// di // di
import "#/services/pg" import '#/services/pg'
import { DISCORD_GUILD_ID } from '#/constants'; import { config } from '#/config'
import { Bot } from '#/discord/bot'; import { DISCORD_GUILD_ID } from '#/constants'
import { events } from '#/discord/botevent/handler'; import { c } from '#/di'
import { SLASH_COMMANDS } from '#/discord/botevent/slash_commands'; import { Bot } from '#/discord/bot'
import { c } from '#/di'; import { events } from '#/discord/botevent/handler'
import { config } from '#/config'; import { SLASH_COMMANDS } from '#/discord/botevent/slash_commands'
export class BotCommand extends Command { export class BotCommand extends Command {
static paths = [['bot']]; static paths = [['bot']]
async execute() { async execute() {
if(!config.DISCORD_TOKEN) { if (!config.DISCORD_TOKEN) {
throw new Error('no discord token found. bot cant start'); throw new Error('no discord token found. bot cant start')
} }
const bot = await c.getAsync(Bot) const bot = await c.getAsync(Bot)
bot.events = events() bot.events = events()
console.log('registring slash commands'); console.log('registring slash commands')
await bot.rest.upsertGuildApplicationCommands(DISCORD_GUILD_ID, SLASH_COMMANDS).catch(console.error) await bot.rest.upsertGuildApplicationCommands(DISCORD_GUILD_ID, SLASH_COMMANDS).catch(console.error)
await bot.rest.upsertGuildApplicationCommands("547828454972850196", SLASH_COMMANDS).catch(console.error) await bot.rest.upsertGuildApplicationCommands('547828454972850196', SLASH_COMMANDS).catch(console.error)
console.log('connecting bot to gateway')
console.log('connecting bot to gateway'); await bot.start()
await bot.start(); console.log('bot connected')
console.log('bot connected');
} }
} }

View File

@ -1,24 +1,21 @@
import { Command } from 'clipanion'; import { Command } from 'clipanion'
import { c } from '#/di'; import { c } from '#/di'
// di // di
import "#/services/temporal" import '#/services/temporal'
import { NativeConnection, Worker } from '@temporalio/worker'; import path from 'path'
import * as activities from '../activities'; import { Client, ScheduleNotFoundError, type ScheduleOptions, ScheduleOverlapPolicy } from '@temporalio/client'
import path from 'path'; import { NativeConnection, Worker } from '@temporalio/worker'
import { Client, ScheduleNotFoundError, ScheduleOptions, ScheduleOverlapPolicy } from '@temporalio/client'; import { PG } from '#/services/pg'
import { workflowSyncAllGuilds, workflowSyncGuilds, workflowSyncOnline, workflowSyncGuildLeaderboardInfo } from '#/workflows'; import { workflowSyncAllGuilds, workflowSyncGuildLeaderboardInfo, workflowSyncGuilds, workflowSyncOnline } from '#/workflows'
import { PG } from '#/services/pg'; import * as activities from '../activities'
import { config } from '#/config';
import { config } from '#/config'
const schedules: ScheduleOptions[] = [ const schedules: ScheduleOptions[] = [
{ {
scheduleId: "update-guild-players", scheduleId: 'update-guild-players',
action: { action: {
type: 'startWorkflow', type: 'startWorkflow',
workflowType: workflowSyncGuilds, workflowType: workflowSyncGuilds,
@ -28,13 +25,15 @@ const schedules: ScheduleOptions[] = [
overlap: ScheduleOverlapPolicy.SKIP, overlap: ScheduleOverlapPolicy.SKIP,
}, },
spec: { spec: {
intervals: [{ intervals: [
every: '15 minutes', {
}] every: '15 minutes',
},
],
}, },
}, },
{ {
scheduleId: "update_guild_leaderboards", scheduleId: 'update_guild_leaderboards',
action: { action: {
type: 'startWorkflow', type: 'startWorkflow',
workflowType: workflowSyncGuildLeaderboardInfo, workflowType: workflowSyncGuildLeaderboardInfo,
@ -44,13 +43,15 @@ const schedules: ScheduleOptions[] = [
overlap: ScheduleOverlapPolicy.SKIP, overlap: ScheduleOverlapPolicy.SKIP,
}, },
spec: { spec: {
intervals: [{ intervals: [
every: '5 minutes', {
}] every: '5 minutes',
},
],
}, },
}, },
{ {
scheduleId: "update-all-guilds", scheduleId: 'update-all-guilds',
action: { action: {
type: 'startWorkflow', type: 'startWorkflow',
workflowType: workflowSyncAllGuilds, workflowType: workflowSyncAllGuilds,
@ -60,13 +61,15 @@ const schedules: ScheduleOptions[] = [
overlap: ScheduleOverlapPolicy.SKIP, overlap: ScheduleOverlapPolicy.SKIP,
}, },
spec: { spec: {
intervals: [{ intervals: [
every: '1 hour', {
}] every: '1 hour',
},
],
}, },
}, },
{ {
scheduleId: "update-online-players", scheduleId: 'update-online-players',
action: { action: {
type: 'startWorkflow', type: 'startWorkflow',
workflowType: workflowSyncOnline, workflowType: workflowSyncOnline,
@ -76,38 +79,39 @@ const schedules: ScheduleOptions[] = [
overlap: ScheduleOverlapPolicy.SKIP, overlap: ScheduleOverlapPolicy.SKIP,
}, },
spec: { spec: {
intervals: [{ intervals: [
every: '31 seconds', {
}] every: '31 seconds',
},
],
}, },
}, },
] ]
const addSchedules = async (c: Client) => { const addSchedules = async (c: Client) => {
for(const o of schedules) { for (const o of schedules) {
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) 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)
}else { } else {
throw e; throw e
} }
} }
} }
} }
export class WorkerCommand extends Command { export class WorkerCommand extends Command {
static paths = [['worker']]; static paths = [['worker']]
async execute() { async execute() {
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
await addSchedules(client); await addSchedules(client)
const connection = await NativeConnection.connect({ const connection = await NativeConnection.connect({
address: config.TEMPORAL_HOSTPORT, address: config.TEMPORAL_HOSTPORT,
@ -116,33 +120,28 @@ export class WorkerCommand extends Command {
connection, connection,
namespace: config.TEMPORAL_NAMESPACE, namespace: config.TEMPORAL_NAMESPACE,
workflowsPath: require.resolve('../workflows'), workflowsPath: require.resolve('../workflows'),
dataConverter: { dataConverter: {
payloadConverterPath: require.resolve('../payload_converter'), payloadConverterPath: require.resolve('../payload_converter'),
}, },
bundlerOptions: { bundlerOptions: {
webpackConfigHook: (config)=>{ webpackConfigHook: (config) => {
if(!config.resolve) config.resolve = {}; if (!config.resolve) config.resolve = {}
if(!config.resolve.alias) config.resolve.alias = {}; if (!config.resolve.alias) config.resolve.alias = {}
config.resolve!.alias = { config.resolve!.alias = {
"#":path.resolve(process.cwd(),'src/'), '#': path.resolve(process.cwd(), 'src/'),
...config.resolve!.alias, ...config.resolve!.alias,
} }
return config; return config
}}, },
},
taskQueue: 'wynn-worker-ts', taskQueue: 'wynn-worker-ts',
stickyQueueScheduleToStartTimeout: 5 * 1000, stickyQueueScheduleToStartTimeout: 5 * 1000,
activities activities,
}); })
await worker.run(); await worker.run()
console.log('worked.run exited')
console.log("worked.run exited"); await db.end()
await db.end(); await connection.close()
await connection.close();
} }
} }

View File

@ -1,7 +1,7 @@
import { z } from 'zod'; import { config as dotenvConfig } from 'dotenv'
import { parseEnv} from 'znv'; import { parseEnv } from 'znv'
import {config as dotenvConfig} from 'dotenv'; import { z } from 'zod'
dotenvConfig(); dotenvConfig()
const schemaConfig = { const schemaConfig = {
DISCORD_TOKEN: z.string().optional(), DISCORD_TOKEN: z.string().optional(),
@ -17,9 +17,8 @@ const schemaConfig = {
PG_PORT: z.number().int().optional(), PG_PORT: z.number().int().optional(),
PG_SSLMODE: z.string().optional(), PG_SSLMODE: z.string().optional(),
WAPI_URL: z.string().default("https://api.wynncraft.com/"), WAPI_URL: z.string().default('https://api.wynncraft.com/'),
REDIS_URL: z.string().optional(), REDIS_URL: z.string().optional(),
}; }
export const config = parseEnv(process.env, schemaConfig) export const config = parseEnv(process.env, schemaConfig)

View File

@ -1,3 +1,3 @@
export const DISCORD_GUILD_ID = "1340213134949875835"; export const DISCORD_GUILD_ID = '1340213134949875835'
export const WYNN_GUILD_NAME = "less than three" export const WYNN_GUILD_NAME = 'less than three'
export const WYNN_GUILD_ID = "2b717c60-ae61-4073-9d4f-c9c4583afed5"; export const WYNN_GUILD_ID = '2b717c60-ae61-4073-9d4f-c9c4583afed5'

View File

@ -1,5 +1,5 @@
import { Container, InjectionToken } from "@needle-di/core"; import { Container, InjectionToken } from '@needle-di/core'
import { Sql } from "postgres"; import type { Sql } from 'postgres'
export const c = new Container(); export const c = new Container()
export const T_PG = new InjectionToken<Sql>("T_PG") export const T_PG = new InjectionToken<Sql>('T_PG')

View File

@ -1,24 +1,24 @@
import { config } from "#/config"; import { InjectionToken } from '@needle-di/core'
import { c } from "#/di"; import { createBot } from 'discordeno'
import { InjectionToken } from "@needle-di/core"; import { config } from '#/config'
import { createBot, } from "discordeno"; import { c } from '#/di'
import { BotType, createBotParameters } from "./index"; import { type BotType, createBotParameters } from './index'
const createBotWithToken = (token: string) => { const createBotWithToken = (token: string) => {
return createBot({ return createBot({
...createBotParameters, ...createBotParameters,
token, token,
}) })
} }
export const Bot = new InjectionToken<BotType>("DISCORD_BOT") export const Bot = new InjectionToken<BotType>('DISCORD_BOT')
c.bind({ c.bind({
provide: Bot, provide: Bot,
useFactory: () => { useFactory: () => {
let token = config.DISCORD_TOKEN const token = config.DISCORD_TOKEN
if(!token) { if (!token) {
throw new Error('no discord token found. bot cant start'); throw new Error('no discord token found. bot cant start')
} }
const bot = createBotWithToken(token) const bot = createBotWithToken(token)
return bot return bot
}, },
}) })

View File

@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest' import { ApplicationCommandOptionTypes, ApplicationCommandTypes, type CreateApplicationCommand } from '@discordeno/types'
import { ApplicationCommandOptionTypes, ApplicationCommandTypes, CreateApplicationCommand } from '@discordeno/types' import { describe, expect, it, vi } from 'vitest'
import { createCommandHandler, ExtractCommands } from './command_parser'
import type { InteractionData } from '..' import type { InteractionData } from '..'
import { type ExtractCommands, createCommandHandler } from './command_parser'
// Test command definitions // Test command definitions
const TEST_COMMANDS = [ const TEST_COMMANDS = [
@ -83,9 +83,7 @@ describe('createCommandHandler', () => {
const interactionData: InteractionData = { const interactionData: InteractionData = {
name: 'simple', name: 'simple',
options: [ options: [{ name: 'message', type: ApplicationCommandOptionTypes.String, value: 'Hello world' }],
{ name: 'message', type: ApplicationCommandOptionTypes.String, value: 'Hello world' },
],
} }
await handler(interactionData) await handler(interactionData)

View File

@ -1,28 +1,22 @@
import { ApplicationCommandOptionTypes, CreateApplicationCommand, DiscordInteractionDataOption} from "@discordeno/types"; import { ApplicationCommandOptionTypes, type CreateApplicationCommand, type DiscordInteractionDataOption } from '@discordeno/types'
import { SLASH_COMMANDS } from "./slash_commands"; import type { InteractionData } from '..'
import { InteractionData } from ".."; import type { SLASH_COMMANDS } from './slash_commands'
// Map option types to their TypeScript types // Map option types to their TypeScript types
type OptionTypeMap = { type OptionTypeMap = {
[ApplicationCommandOptionTypes.String]: string; [ApplicationCommandOptionTypes.String]: string
[ApplicationCommandOptionTypes.Integer]: number; [ApplicationCommandOptionTypes.Integer]: number
[ApplicationCommandOptionTypes.Boolean]: boolean; [ApplicationCommandOptionTypes.Boolean]: boolean
[ApplicationCommandOptionTypes.User]: string; // user ID [ApplicationCommandOptionTypes.User]: string // user ID
[ApplicationCommandOptionTypes.Channel]: string; // channel ID [ApplicationCommandOptionTypes.Channel]: string // channel ID
[ApplicationCommandOptionTypes.Role]: string; // role ID [ApplicationCommandOptionTypes.Role]: string // role ID
[ApplicationCommandOptionTypes.Number]: number; [ApplicationCommandOptionTypes.Number]: number
[ApplicationCommandOptionTypes.Mentionable]: string; // ID [ApplicationCommandOptionTypes.Mentionable]: string // ID
[ApplicationCommandOptionTypes.Attachment]: string; // attachment ID [ApplicationCommandOptionTypes.Attachment]: string // attachment ID
}; }
// Helper type to get option by name // Helper type to get option by name
type GetOption<Options, Name> = Options extends readonly any[] type GetOption<Options, Name> = Options extends readonly any[] ? (Options[number] extends infer O ? (O extends { name: Name } ? O : never) : never) : never
? Options[number] extends infer O
? O extends { name: Name }
? O
: never
: never
: never;
// Extract the argument types from command options // Extract the argument types from command options
export type ExtractArgs<Options extends readonly any[]> = { export type ExtractArgs<Options extends readonly any[]> = {
@ -32,11 +26,11 @@ export type ExtractArgs<Options extends readonly any[]> = {
? OptionTypeMap[T] ? OptionTypeMap[T]
: OptionTypeMap[T] | undefined : OptionTypeMap[T] | undefined
: never : never
: never; : never
}; }
// Handler function type that accepts typed arguments // Handler function type that accepts typed arguments
type HandlerFunction<Args = {}> = (args: Args) => Promise<void> | void; type HandlerFunction<Args = {}> = (args: Args) => Promise<void> | void
// Get subcommand by name // Get subcommand by name
type GetSubcommand<Options, Name> = Options extends readonly any[] type GetSubcommand<Options, Name> = Options extends readonly any[]
@ -45,21 +39,17 @@ type GetSubcommand<Options, Name> = Options extends readonly any[]
? O ? O
: never : never
: never : never
: never; : never
// Check if all options are subcommands // Check if all options are subcommands
type HasOnlySubcommands<Options extends readonly any[]> = type HasOnlySubcommands<Options extends readonly any[]> = Options[number] extends { type: ApplicationCommandOptionTypes.SubCommand } ? true : false
Options[number] extends { type: ApplicationCommandOptionTypes.SubCommand }
? true
: false;
// Extract subcommand names from options // Extract subcommand names from options
type SubcommandNames<Options extends readonly any[]> = type SubcommandNames<Options extends readonly any[]> = Options[number] extends { name: infer N; type: ApplicationCommandOptionTypes.SubCommand }
Options[number] extends { name: infer N; type: ApplicationCommandOptionTypes.SubCommand } ? N extends string
? N extends string ? N
? N : never
: never : never
: never;
// Type to extract subcommand handlers // Type to extract subcommand handlers
export type SubcommandHandlers<Options extends readonly any[]> = { export type SubcommandHandlers<Options extends readonly any[]> = {
@ -68,14 +58,10 @@ export type SubcommandHandlers<Options extends readonly any[]> = {
? HandlerFunction<ExtractArgs<SubOpts>> ? HandlerFunction<ExtractArgs<SubOpts>>
: HandlerFunction<{}> : HandlerFunction<{}>
: HandlerFunction<{}> : HandlerFunction<{}>
}; }
// Get command by name from array // Get command by name from array
type GetCommand<Commands extends readonly any[], Name> = Commands[number] extends infer C type GetCommand<Commands extends readonly any[], Name> = Commands[number] extends infer C ? (C extends { name: Name } ? C : never) : never
? C extends { name: Name }
? C
: never
: never;
// Main type to extract command handlers from slash commands // Main type to extract command handlers from slash commands
export type ExtractCommands<T extends readonly any[]> = { export type ExtractCommands<T extends readonly any[]> = {
@ -86,76 +72,75 @@ export type ExtractCommands<T extends readonly any[]> = {
: HandlerFunction<ExtractArgs<Options>> : HandlerFunction<ExtractArgs<Options>>
: HandlerFunction<{}> : HandlerFunction<{}>
: HandlerFunction<{}> : HandlerFunction<{}>
}; }
// The actual command handler type based on SLASH_COMMANDS // The actual command handler type based on SLASH_COMMANDS
export type CommandHandlers = ExtractCommands<typeof SLASH_COMMANDS>; export type CommandHandlers = ExtractCommands<typeof SLASH_COMMANDS>
// Helper function to parse option values from interaction data // Helper function to parse option values from interaction data
function parseOptions(options?: DiscordInteractionDataOption[]): Record<string, any> { function parseOptions(options?: DiscordInteractionDataOption[]): Record<string, any> {
if (!options) return {}; if (!options) return {}
const args: Record<string, any> = {}; const args: Record<string, any> = {}
for (const option of options) { for (const option of options) {
if (option.type === ApplicationCommandOptionTypes.SubCommand || if (option.type === ApplicationCommandOptionTypes.SubCommand || option.type === ApplicationCommandOptionTypes.SubCommandGroup) {
option.type === ApplicationCommandOptionTypes.SubCommandGroup) { continue
continue;
} }
args[option.name] = option.value; args[option.name] = option.value
} }
return args; return args
} }
// Helper function to create command handlers with type safety // Helper function to create command handlers with type safety
export function createCommandHandler<T extends readonly CreateApplicationCommand[]>( export function createCommandHandler<T extends readonly CreateApplicationCommand[]>({
{handler, notFoundHandler}:{ handler,
notFoundHandler,
}: {
handler: ExtractCommands<T> handler: ExtractCommands<T>
notFoundHandler: HandlerFunction<{}> notFoundHandler: HandlerFunction<{}>
}) { }) {
return async (data: InteractionData): Promise<void> => { return async (data: InteractionData): Promise<void> => {
if (!data || !data.name) { if (!data || !data.name) {
await notFoundHandler({}); await notFoundHandler({})
return; return
} }
const commandName = data.name as keyof typeof handler; const commandName = data.name as keyof typeof handler
const commandHandler = handler[commandName]; const commandHandler = handler[commandName]
if (!commandHandler) { if (!commandHandler) {
await notFoundHandler({}); await notFoundHandler({})
return; return
} }
// Check if it's a direct command or has subcommands // Check if it's a direct command or has subcommands
if (typeof commandHandler === 'function') { if (typeof commandHandler === 'function') {
// Parse arguments from top-level options // Parse arguments from top-level options
const args = parseOptions(data.options); const args = parseOptions(data.options)
await commandHandler(args); await commandHandler(args)
} else { } else {
// Handle subcommands // Handle subcommands
const subcommand = data.options?.find( const subcommand = data.options?.find(
opt => opt.type === ApplicationCommandOptionTypes.SubCommand || (opt) => opt.type === ApplicationCommandOptionTypes.SubCommand || opt.type === ApplicationCommandOptionTypes.SubCommandGroup
opt.type === ApplicationCommandOptionTypes.SubCommandGroup )
);
if (!subcommand) { if (!subcommand) {
await notFoundHandler({}); await notFoundHandler({})
return; return
} }
const subHandler = commandHandler[subcommand.name as keyof typeof commandHandler]; const subHandler = commandHandler[subcommand.name as keyof typeof commandHandler]
if (!subHandler || typeof subHandler !== 'function') { if (!subHandler || typeof subHandler !== 'function') {
await notFoundHandler({}); await notFoundHandler({})
return; return
} }
// Parse arguments from subcommand options // Parse arguments from subcommand options
const args = parseOptions(subcommand.options); const args = parseOptions(subcommand.options)
await (subHandler as HandlerFunction<any>)(args); await (subHandler as HandlerFunction<any>)(args)
} }
} }
} }

View File

@ -1,54 +1,58 @@
import { Bot } from "#/discord/bot" import { Client } from '@temporalio/client'
import { ActivityTypes, InteractionTypes } from "discordeno" import { ActivityTypes, InteractionTypes } from 'discordeno'
import { c } from "#/di" import { c } from '#/di'
import { Client } from "@temporalio/client" import type { BotType } from '#/discord'
import { workflowHandleInteractionCreate } from "#/workflows" import { Bot } from '#/discord/bot'
import { BotType } from "#/discord" import { workflowHandleInteractionCreate } from '#/workflows'
export const events = () => {return { export const events = () => {
interactionCreate: async (interaction) => { return {
if(interaction.acknowledged) { interactionCreate: async (interaction) => {
return if (interaction.acknowledged) {
} return
if(interaction.type !== InteractionTypes.ApplicationCommand) { }
return if (interaction.type !== InteractionTypes.ApplicationCommand) {
} return
}
const temporalClient = await c.getAsync(Client); const temporalClient = await c.getAsync(Client)
// Start the workflow to handle the interaction // Start the workflow to handle the interaction
const handle = await temporalClient.workflow.start(workflowHandleInteractionCreate, { const handle = await temporalClient.workflow.start(workflowHandleInteractionCreate, {
args: [{ args: [
ref: { {
id: interaction.id, ref: {
token: interaction.token, id: interaction.id,
type: interaction.type, token: interaction.token,
acknowledged: interaction.acknowledged, type: interaction.type,
}, acknowledged: interaction.acknowledged,
data: interaction.data, },
}], data: interaction.data,
workflowId: `discord-interaction-${interaction.id}`,
taskQueue: 'wynn-worker-ts',
});
// Wait for the workflow to complete
await handle.result();
return
},
ready: async ({shardId}) => {
const bot = await c.getAsync(Bot)
await bot.gateway.editShardStatus(shardId, {
status: 'online',
activities: [
{
name: 'im frog',
type: ActivityTypes.Playing,
timestamps: {
start: Date.now(),
}, },
}, ],
], workflowId: `discord-interaction-${interaction.id}`,
}) taskQueue: 'wynn-worker-ts',
} })
} as BotType['events']}
// Wait for the workflow to complete
await handle.result()
return
},
ready: async ({ shardId }) => {
const bot = await c.getAsync(Bot)
await bot.gateway.editShardStatus(shardId, {
status: 'online',
activities: [
{
name: 'im frog',
type: ActivityTypes.Playing,
timestamps: {
start: Date.now(),
},
},
],
})
},
} as BotType['events']
}

View File

@ -1,60 +1,59 @@
import { ApplicationCommandOptionTypes, ApplicationCommandTypes, CreateApplicationCommand } from "@discordeno/types" import { ApplicationCommandOptionTypes, ApplicationCommandTypes, type CreateApplicationCommand } from '@discordeno/types'
export const SLASH_COMMANDS = [ export const SLASH_COMMANDS = [
{ {
name: `guild`, name: `guild`,
description: "guild commands", description: 'guild commands',
type: ApplicationCommandTypes.ChatInput, type: ApplicationCommandTypes.ChatInput,
options: [ options: [
{ {
name: "leaderboard", name: 'leaderboard',
description: "view the current leaderboard", description: 'view the current leaderboard',
type: ApplicationCommandOptionTypes.SubCommand, type: ApplicationCommandOptionTypes.SubCommand,
}, },
{ {
name: "info", name: 'info',
description: "view guild information", description: 'view guild information',
type: ApplicationCommandOptionTypes.SubCommand, type: ApplicationCommandOptionTypes.SubCommand,
}, },
{ {
name: "online", name: 'online',
description: "show online players", description: 'show online players',
type: ApplicationCommandOptionTypes.SubCommand, type: ApplicationCommandOptionTypes.SubCommand,
}, },
], ],
}, },
{ {
name: "admin", name: 'admin',
description: "admin commands", description: 'admin commands',
type: ApplicationCommandTypes.ChatInput, type: ApplicationCommandTypes.ChatInput,
defaultMemberPermissions: [ defaultMemberPermissions: ['ADMINISTRATOR'],
"ADMINISTRATOR",
],
options: [ options: [
{ {
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,
}, },
], ],
},{ },
name: "player", {
description: "player commands", name: 'player',
description: 'player commands',
type: ApplicationCommandTypes.ChatInput, type: ApplicationCommandTypes.ChatInput,
options: [ options: [
{ {
name: "lookup", name: 'lookup',
description: "view player information", description: 'view player information',
type: ApplicationCommandOptionTypes.SubCommand, type: ApplicationCommandOptionTypes.SubCommand,
options: [ options: [
{ {
name: "player", name: 'player',
description: "player name", description: 'player name',
type: ApplicationCommandOptionTypes.String, type: ApplicationCommandOptionTypes.String,
required: true, required: true,
}, },
], ],
} },
], ],
} },
] as const satisfies CreateApplicationCommand[] ] as const satisfies CreateApplicationCommand[]

View File

@ -1,5 +1,4 @@
import {BotType} from "#/discord" import type { BotType } from '#/discord'
export type BotEventsType = BotType['events'] export type BotEventsType = BotType['events']
export type InteractionHandler = NonNullable<BotType['events']['interactionCreate']> export type InteractionHandler = NonNullable<BotType['events']['interactionCreate']>

View File

@ -1,67 +1,66 @@
import { Intents, InteractionTypes } from "@discordeno/types"; import { Intents, type InteractionTypes } from '@discordeno/types'
import type { Bot, DesiredPropertiesBehavior, CompleteDesiredProperties } from "discordeno"; import type { Bot, CompleteDesiredProperties, DesiredPropertiesBehavior } from 'discordeno'
export const intents = [ export const intents = [
Intents.GuildModeration , Intents.GuildModeration,
Intents.GuildWebhooks , Intents.GuildWebhooks,
Intents.GuildExpressions , Intents.GuildExpressions,
Intents.GuildScheduledEvents , Intents.GuildScheduledEvents,
Intents.GuildMessagePolls , Intents.GuildMessagePolls,
Intents.GuildIntegrations , Intents.GuildIntegrations,
Intents.GuildInvites , Intents.GuildInvites,
Intents.GuildMessageReactions , Intents.GuildMessageReactions,
Intents.GuildPresences , Intents.GuildPresences,
Intents.DirectMessages , Intents.DirectMessages,
Intents.DirectMessageReactions , Intents.DirectMessageReactions,
Intents.GuildMembers , Intents.GuildMembers,
Intents.Guilds , Intents.Guilds,
Intents.GuildInvites , Intents.GuildInvites,
Intents.GuildMessages, Intents.GuildMessages,
] as const ] as const
export const createBotParameters = { export const createBotParameters = {
intents: intents.reduce((acc, curr) => acc | curr, Intents.Guilds), intents: intents.reduce((acc, curr) => acc | curr, Intents.Guilds),
desiredProperties: { desiredProperties: {
interaction: { interaction: {
id: true, id: true,
data: true, data: true,
type: true, type: true,
token: true, token: true,
message: true, message: true,
channelId: true, channelId: true,
channel: true, channel: true,
guildId: true, guildId: true,
guild: true, guild: true,
user: true, user: true,
member: true, member: true,
}, },
message: { message: {
id: true, id: true,
member: true, member: true,
guildId: true, guildId: true,
}, },
} },
} as const } as const
// Extract the type of desired properties from our parameters // Extract the type of desired properties from our parameters
type ExtractedDesiredProperties = typeof createBotParameters.desiredProperties; type ExtractedDesiredProperties = typeof createBotParameters.desiredProperties
// The BotType uses the CompleteDesiredProperties helper to fill in the missing properties // The BotType uses the CompleteDesiredProperties helper to fill in the missing properties
export type BotType = Bot<CompleteDesiredProperties<ExtractedDesiredProperties>, DesiredPropertiesBehavior.RemoveKey>; 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: bigint; id: bigint
token: string; token: string
type: InteractionTypes; type: InteractionTypes
acknowledged?: boolean; acknowledged?: boolean
} }
// Type for the interaction data payload // Type for the interaction data payload
export type InteractionData = Parameters<NonNullable<BotType['events']['interactionCreate']>>[0]['data']; export type InteractionData = Parameters<NonNullable<BotType['events']['interactionCreate']>>[0]['data']
// Type for the complete interaction handling payload // Type for the complete interaction handling payload
export interface InteractionCreatePayload { export interface InteractionCreatePayload {
ref: InteractionRef; ref: InteractionRef
data: InteractionData; data: InteractionData
} }

View File

@ -1,9 +1,5 @@
import { c } from "#/di"; import { c } from '#/di'
export class EventMux { export class EventMux {
constructor() { constructor() {}
}
} }

View File

@ -1,14 +1,13 @@
import crypto from "node:crypto"; import crypto from 'node:crypto'
import {IDataType, xxhash128} from "hash-wasm"; import { type IDataType, xxhash128 } from 'hash-wasm'
export function sha1Hash(data: crypto.BinaryLike) { export function sha1Hash(data: crypto.BinaryLike) {
const hash = crypto.createHash('sha1'); const hash = crypto.createHash('sha1')
hash.update(data); hash.update(data)
return hash.digest('hex'); return hash.digest('hex')
} }
export async function fastHashFileV1(data: IDataType): Promise<string> {
export async function fastHashFileV1(data: IDataType):Promise<string> {
const hash = xxhash128(data) const hash = xxhash128(data)
return hash return hash
} }

View File

@ -1,39 +1,34 @@
export class TabWriter { export class TabWriter {
columns: string[][]
columns: string[][]; constructor(private readonly spacing: number = 2) {
constructor(
private readonly spacing: number = 2
) {
this.columns = [] this.columns = []
} }
add(row: string[]) { add(row: string[]) {
if(this.columns.length == 0) { if (this.columns.length == 0) {
this.columns = new Array(row.length).fill(0).map(() => []); this.columns = new Array(row.length).fill(0).map(() => [])
} }
if(row.length != this.columns.length) { if (row.length != this.columns.length) {
throw new Error(`Row length ${row.length} does not match columns length ${this.columns.length}`); throw new Error(`Row length ${row.length} does not match columns length ${this.columns.length}`)
} }
for(let i = 0; i < row.length; i++) { for (let i = 0; i < row.length; i++) {
this.columns[i].push(row[i]); this.columns[i].push(row[i])
} }
} }
build() { build() {
let out = "" let out = ''
if(this.columns.length == 0) { if (this.columns.length == 0) {
return ""; return ''
} }
const columnWidths = this.columns.map(col => col.reduce((a, b) => Math.max(a, b.length+this.spacing), 0)); const columnWidths = this.columns.map((col) => col.reduce((a, b) => Math.max(a, b.length + this.spacing), 0))
for(let i = 0; i < this.columns[0].length; i++) { for (let i = 0; i < this.columns[0].length; i++) {
if (i > 0) out += "\n"; if (i > 0) out += '\n'
for(let j = 0; j < this.columns.length; j++) { for (let j = 0; j < this.columns.length; j++) {
out+= this.columns[j][i].padEnd(columnWidths[j]); out += this.columns[j][i].padEnd(columnWidths[j])
} }
} }
return out; return out
} }
} }

View File

@ -6,13 +6,13 @@
* Mapping of Wynncraft guild ranks to their corresponding emojis * Mapping of Wynncraft guild ranks to their corresponding emojis
*/ */
export const RANK_EMOJIS = { export const RANK_EMOJIS = {
"OWNER": "👑", OWNER: '👑',
"CHIEF": "⭐", CHIEF: '⭐',
"STRATEGIST": "🎯", STRATEGIST: '🎯',
"CAPTAIN": "⚔️", CAPTAIN: '⚔️',
"RECRUITER": "📢", RECRUITER: '📢',
"RECRUIT": "🌱", RECRUIT: '🌱',
} as const; } as const
/** /**
* Get the emoji for a given guild rank * Get the emoji for a given guild rank
@ -20,7 +20,7 @@ export const RANK_EMOJIS = {
* @returns The corresponding emoji or a default bullet point * @returns The corresponding emoji or a default bullet point
*/ */
export function getRankEmoji(rank: string): string { export function getRankEmoji(rank: string): string {
return RANK_EMOJIS[rank as keyof typeof RANK_EMOJIS] || "•"; return RANK_EMOJIS[rank as keyof typeof RANK_EMOJIS] || '•'
} }
/** /**
@ -29,7 +29,7 @@ export function getRankEmoji(rank: string): string {
* @returns Formatted string with appropriate suffix * @returns Formatted string with appropriate suffix
*/ */
export function formatNumber(num: number): string { export function formatNumber(num: number): string {
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`; if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`
return num.toLocaleString(); return num.toLocaleString()
} }

View File

@ -1,29 +1,29 @@
import { type } from "arktype" import { type } from 'arktype'
export const WynnGuildOverviewMember = type({ export const WynnGuildOverviewMember = type({
uuid: "string", uuid: 'string',
online: "boolean", online: 'boolean',
server: "null | string", server: 'null | string',
contributed: "number", contributed: 'number',
contributionRank: "number", contributionRank: 'number',
joined: "string" joined: 'string',
}) })
const WapiV3GuildMembers = type({ const WapiV3GuildMembers = type({
"[string]": WynnGuildOverviewMember '[string]': WynnGuildOverviewMember,
}) })
export const WapiV3GuildOverview = type({ export const WapiV3GuildOverview = type({
uuid: "string", uuid: 'string',
name: "string", name: 'string',
prefix: "string", prefix: 'string',
level: "number", level: 'number',
xpPercent: "number", xpPercent: 'number',
territories: "number", territories: 'number',
wars: "number", wars: 'number',
created: "string", created: 'string',
members: { members: {
total: "number", total: 'number',
owner: WapiV3GuildMembers, owner: WapiV3GuildMembers,
chief: WapiV3GuildMembers, chief: WapiV3GuildMembers,
strategist: WapiV3GuildMembers, strategist: WapiV3GuildMembers,
@ -31,242 +31,233 @@ export const WapiV3GuildOverview = type({
recruiter: WapiV3GuildMembers, recruiter: WapiV3GuildMembers,
recruit: WapiV3GuildMembers, recruit: WapiV3GuildMembers,
}, },
online: "number", online: 'number',
banner: { banner: {
base: "string", base: 'string',
tier: "number", tier: 'number',
structure: "string", structure: 'string',
layers: type({ colour: "string", pattern: "string" }).array(), layers: type({ colour: 'string', pattern: 'string' }).array(),
}, },
seasonRanks: { seasonRanks: {
"[string]": { '[string]': {
rating: "number", rating: 'number',
finalTerritories: "number" finalTerritories: 'number',
} },
} },
}) })
const WynnItemRarity = type.enumerated("common", "fabled", "legendary", "mythic", "rare", "set", "unique") const WynnItemRarity = type.enumerated('common', 'fabled', 'legendary', 'mythic', 'rare', 'set', 'unique')
const WynnSkills = type.enumerated( const WynnSkills = type.enumerated('alchemism', 'armouring', 'cooking', 'jeweling', 'scribing', 'tailoring', 'weaponsmithing', 'woodworking')
"alchemism",
"armouring",
"cooking",
"jeweling",
"scribing",
"tailoring",
"weaponsmithing",
"woodworking",
)
const WynnDropMeta = type("object") const WynnDropMeta = type('object')
const WynnDropRestriction = type.enumerated("normal", "never", "dungeon", "lootchest") const WynnDropRestriction = type.enumerated('normal', 'never', 'dungeon', 'lootchest')
const WynnItemRestrictions = type.enumerated("untradable", "quest item") const WynnItemRestrictions = type.enumerated('untradable', 'quest item')
const WynnEquipRequirements = type({ const WynnEquipRequirements = type({
level: "number", level: 'number',
"classRequirement?": "string", 'classRequirement?': 'string',
"intelligence?": "number", 'intelligence?': 'number',
"strength?": "number", 'strength?': 'number',
"dexterity?": "number", 'dexterity?': 'number',
"defence?": "number", 'defence?': 'number',
"agility?": "number", 'agility?': 'number',
"skills?": WynnSkills.array(), 'skills?': WynnSkills.array(),
}) })
const WynnBaseStats = type({ const WynnBaseStats = type({
"[string]": type("number").or({ '[string]': type('number').or({
min: "number", min: 'number',
raw: "number", raw: 'number',
max: "number", max: 'number',
}) }),
}) })
const WynnIdentifications = type({ const WynnIdentifications = type({
"[string]": type("number").or({ '[string]': type('number').or({
min: "number", min: 'number',
raw: "number", raw: 'number',
max: "number", max: 'number',
}) }),
}) })
const WynnItemIcon = type({ const WynnItemIcon = type({
format: "string", format: 'string',
value: "unknown" value: 'unknown',
}) })
export const WapiV3ItemTool = type({ export const WapiV3ItemTool = type({
internalName: "string", internalName: 'string',
type: '"tool"', type: '"tool"',
toolType: type.enumerated("axe", "pickaxe", "rod", "scythe"), toolType: type.enumerated('axe', 'pickaxe', 'rod', 'scythe'),
"identified?": "boolean", 'identified?': 'boolean',
gatheringSpeed: "number", gatheringSpeed: 'number',
requirements: { requirements: {
level: "number", level: 'number',
}, },
icon: WynnItemIcon, icon: WynnItemIcon,
rarity: WynnItemRarity rarity: WynnItemRarity,
}) })
export const WapiV3ItemTome = type({ export const WapiV3ItemTome = type({
internalName: "string", internalName: 'string',
tomeType: type.enumerated( tomeType: type.enumerated('guild_tome', 'marathon_tome', 'mysticism_tome', 'weapon_tome', 'armour_tome', 'expertise_tome', 'lootrun_tome'),
"guild_tome", raidReward: 'boolean',
"marathon_tome",
"mysticism_tome",
"weapon_tome",
"armour_tome",
"expertise_tome",
"lootrun_tome",
),
raidReward: "boolean",
type: '"tome"', type: '"tome"',
"restrictions?": WynnItemRestrictions, 'restrictions?': WynnItemRestrictions,
"dropMeta?": WynnDropMeta, 'dropMeta?': WynnDropMeta,
dropRestriction: WynnDropRestriction, dropRestriction: WynnDropRestriction,
requirements: WynnEquipRequirements, requirements: WynnEquipRequirements,
"lore?": "string", 'lore?': 'string',
icon: WynnItemIcon, icon: WynnItemIcon,
"base?": WynnBaseStats, 'base?': WynnBaseStats,
rarity: WynnItemRarity rarity: WynnItemRarity,
}) })
export const WapiV3ItemCharm = type({ export const WapiV3ItemCharm = type({
internalName: "string", internalName: 'string',
type: '"charm"', type: '"charm"',
"restrictions?": WynnItemRestrictions, 'restrictions?': WynnItemRestrictions,
"dropMeta?": WynnDropMeta, 'dropMeta?': WynnDropMeta,
dropRestriction: WynnDropRestriction, dropRestriction: WynnDropRestriction,
requirements: WynnEquipRequirements, requirements: WynnEquipRequirements,
"lore?": "string", 'lore?': 'string',
icon: WynnItemIcon, icon: WynnItemIcon,
base: WynnBaseStats, base: WynnBaseStats,
rarity: WynnItemRarity rarity: WynnItemRarity,
}) })
export const WapiV3ItemAccessory = type({ export const WapiV3ItemAccessory = type({
internalName: "string", internalName: 'string',
type: '"accessory"', type: '"accessory"',
"identified?": "boolean", 'identified?': 'boolean',
accessoryType: type.enumerated("ring", "necklace", "bracelet"), accessoryType: type.enumerated('ring', 'necklace', 'bracelet'),
"majorIds?": { 'majorIds?': {
"[string]": "string" '[string]': 'string',
}, },
"restrictions?": WynnItemRestrictions, 'restrictions?': WynnItemRestrictions,
"dropMeta?": WynnDropMeta, 'dropMeta?': WynnDropMeta,
dropRestriction: WynnDropRestriction, dropRestriction: WynnDropRestriction,
requirements: WynnEquipRequirements, requirements: WynnEquipRequirements,
"lore?": "string", 'lore?': 'string',
icon: WynnItemIcon, icon: WynnItemIcon,
"identifications?": WynnIdentifications, 'identifications?': WynnIdentifications,
"base?": WynnBaseStats, 'base?': WynnBaseStats,
rarity: WynnItemRarity rarity: WynnItemRarity,
}) })
export const WapiV3ItemIngredient = type({ export const WapiV3ItemIngredient = type({
internalName: "string", internalName: 'string',
type: '"ingredient"', type: '"ingredient"',
requirements: { requirements: {
level: "number", level: 'number',
skills: WynnSkills.array(), skills: WynnSkills.array(),
}, },
icon: WynnItemIcon, icon: WynnItemIcon,
"identifications?": WynnIdentifications, 'identifications?': WynnIdentifications,
tier: "number", tier: 'number',
consumableOnlyIDs: { consumableOnlyIDs: {
"[string]": "number" '[string]': 'number',
}, },
ingredientPositionModifiers: { ingredientPositionModifiers: {
"[string]": "number" '[string]': 'number',
}, },
itemOnlyIDs: { itemOnlyIDs: {
"[string]": "number" '[string]': 'number',
}, },
"droppedBy?": type({ 'droppedBy?': type({
name: "string", name: 'string',
coords: type("boolean | null") coords: type('boolean | null').or(type('number[] == 4')).or(type('number[] == 4').array()),
.or(type("number[] == 4")) }).array(),
.or(type("number[] == 4").array())
}).array()
}) })
export const WapiV3ItemMaterial = type({ export const WapiV3ItemMaterial = type({
internalName: "string", internalName: 'string',
type: '"material"', type: '"material"',
identified: "boolean", identified: 'boolean',
requirements: { requirements: {
level: "number", level: 'number',
}, },
craftable: type.enumerated( craftable: type
"potions", "food", "scrolls", .enumerated(
"helmets", "chestplates", "rings", "bracelets", 'potions',
"necklaces", "boots", "leggings", "bows", "wands", "spears", 'food',
"daggers", "chestplates", "helmets" 'scrolls',
).array(), 'helmets',
'chestplates',
'rings',
'bracelets',
'necklaces',
'boots',
'leggings',
'bows',
'wands',
'spears',
'daggers',
'chestplates',
'helmets'
)
.array(),
icon: WynnItemIcon, icon: WynnItemIcon,
tier: "number" tier: 'number',
}) })
export const WapiV3ItemWeapon = type({ export const WapiV3ItemWeapon = type({
internalName: "string", internalName: 'string',
type: '"weapon"', type: '"weapon"',
"identified?": "boolean", 'identified?': 'boolean',
"allowCraftsman?": "boolean", 'allowCraftsman?': 'boolean',
weaponType: type.enumerated("bow", "relik", "wand", "dagger", "spear"), weaponType: type.enumerated('bow', 'relik', 'wand', 'dagger', 'spear'),
attackSpeed: type.enumerated( attackSpeed: type.enumerated('super_slow', 'very_slow', 'slow', 'normal', 'fast', 'very_fast', 'super_fast'),
"super_slow", "very_slow", "slow", "normal", "fast", "very_fast", "super_fast" 'powderSlots?': 'number',
), 'averageDps?': 'number',
"powderSlots?": "number", 'restrictions?': WynnItemRestrictions,
"averageDps?": "number", 'dropMeta?': WynnDropMeta,
"restrictions?": WynnItemRestrictions, 'dropRestriction?': WynnDropRestriction,
"dropMeta?": WynnDropMeta,
"dropRestriction?": WynnDropRestriction,
requirements: WynnEquipRequirements, requirements: WynnEquipRequirements,
"majorIds?": { 'majorIds?': {
"[string]": "string" '[string]': 'string',
}, },
"lore?": "string", 'lore?': 'string',
icon: WynnItemIcon, icon: WynnItemIcon,
"identifications?": WynnIdentifications, 'identifications?': WynnIdentifications,
"base?": WynnBaseStats, 'base?': WynnBaseStats,
rarity: WynnItemRarity rarity: WynnItemRarity,
}) })
export const WapiV3ItemArmour = type({ export const WapiV3ItemArmour = type({
internalName: "string", internalName: 'string',
type: '"armour"', type: '"armour"',
armourType: "string", armourType: 'string',
"armourMaterial?": "string", 'armourMaterial?': 'string',
"armourColor?": "string", 'armourColor?': 'string',
"identified?": "boolean", 'identified?': 'boolean',
"allowCraftsman?": "boolean", 'allowCraftsman?': 'boolean',
"restrictions?": WynnItemRestrictions, 'restrictions?': WynnItemRestrictions,
dropRestriction: WynnDropRestriction, dropRestriction: WynnDropRestriction,
"dropMeta?": WynnDropMeta, 'dropMeta?': WynnDropMeta,
"icon?": WynnItemIcon, 'icon?': WynnItemIcon,
requirements: WynnEquipRequirements, requirements: WynnEquipRequirements,
"majorIds?": { 'majorIds?': {
"[string]": "string" '[string]': 'string',
}, },
"powderSlots?": "number", 'powderSlots?': 'number',
"lore?": "string", 'lore?': 'string',
"identifications?": WynnIdentifications, 'identifications?': WynnIdentifications,
"base?": WynnBaseStats, 'base?': WynnBaseStats,
rarity: WynnItemRarity rarity: WynnItemRarity,
}) })
export const WApiV3Item = WapiV3ItemMaterial export const WApiV3Item = WapiV3ItemMaterial.or(WapiV3ItemWeapon)
.or(WapiV3ItemWeapon) .or(WapiV3ItemArmour)
.or(WapiV3ItemArmour) .or(WapiV3ItemIngredient)
.or(WapiV3ItemIngredient) .or(WapiV3ItemAccessory)
.or(WapiV3ItemAccessory) .or(WapiV3ItemCharm)
.or(WapiV3ItemCharm) .or(WapiV3ItemTome)
.or(WapiV3ItemTome) .or(WapiV3ItemTool)
.or(WapiV3ItemTool)
export const WApiV3ItemDatabase = type({ export const WApiV3ItemDatabase = type({
"[string]": WApiV3Item '[string]': WApiV3Item,
}) })

View File

@ -6,10 +6,9 @@ export const WynnGuildOverviewMember = z.object({
server: z.null(), server: z.null(),
contributed: z.number(), contributed: z.number(),
contributionRank: z.number(), contributionRank: z.number(),
joined: z.string() joined: z.string(),
}) })
const WapiV3GuildMembers = z.record(z.string(), WynnGuildOverviewMember) const WapiV3GuildMembers = z.record(z.string(), WynnGuildOverviewMember)
export const WapiV3GuildOverview = z.object({ export const WapiV3GuildOverview = z.object({
@ -35,22 +34,16 @@ export const WapiV3GuildOverview = z.object({
base: z.string(), base: z.string(),
tier: z.number(), tier: z.number(),
structure: z.string(), structure: z.string(),
layers: z.array(z.object({ colour: z.string(), pattern: z.string() })) layers: z.array(z.object({ colour: z.string(), pattern: z.string() })),
}), }),
seasonRanks: z.record(z.string(),z.object({ rating: z.number(), finalTerritories: z.number() })) seasonRanks: z.record(z.string(), z.object({ rating: z.number(), finalTerritories: z.number() })),
}) })
const WynnItemRarity = z.enum(['common', 'fabled', 'legendary', 'mythic', 'rare', 'set', 'unique'])
const WynnItemRarity = z.enum([
"common","fabled","legendary","mythic","rare","set","unique",
])
const WynnDropMeta = z.any() const WynnDropMeta = z.any()
const WynnDropRestriction = z.enum(["normal","never","dungeon", "lootchest"]) const WynnDropRestriction = z.enum(['normal', 'never', 'dungeon', 'lootchest'])
const WynnItemRestrictions = z.enum([ const WynnItemRestrictions = z.enum(['untradable', 'quest item'])
"untradable", "quest item",
])
const WynnEquipRequirements = z.object({ const WynnEquipRequirements = z.object({
level: z.number(), level: z.number(),
@ -62,23 +55,29 @@ const WynnEquipRequirements = z.object({
agility: z.number().optional(), agility: z.number().optional(),
}) })
const WynnBaseStats = z.record(z.string(),z.union([ const WynnBaseStats = z.record(
z.number(), z.string(),
z.object({ z.union([
min: z.number(), z.number(),
raw: z.number(), z.object({
max: z.number(), min: z.number(),
}) raw: z.number(),
])) max: z.number(),
}),
])
)
const WynnIdentifications = z.record(z.string(), z.union([ const WynnIdentifications = z.record(
z.number(), z.string(),
z.object({ z.union([
min: z.number(), z.number(),
raw: z.number(), z.object({
max: z.number(), min: z.number(),
}) raw: z.number(),
])) max: z.number(),
}),
])
)
const WynnItemIcon = z.object({ const WynnItemIcon = z.object({
format: z.string(), format: z.string(),
@ -109,29 +108,19 @@ const WynnItemIcon = z.object({
export const WapiV3ItemTool = z.object({ export const WapiV3ItemTool = z.object({
internalName: z.string(), internalName: z.string(),
type: z.literal('tool'), type: z.literal('tool'),
toolType: z.enum([ toolType: z.enum(['axe', 'pickaxe', 'rod', 'scythe']),
"axe","pickaxe","rod","scythe",
]),
identified: z.boolean().optional(), identified: z.boolean().optional(),
gatheringSpeed: z.number(), gatheringSpeed: z.number(),
requirements: z.object({ requirements: z.object({
level: z.number(), level: z.number(),
}), }),
icon: WynnItemIcon, icon: WynnItemIcon,
rarity:WynnItemRarity, rarity: WynnItemRarity,
}) })
export const WapiV3ItemTome = z.object({ export const WapiV3ItemTome = z.object({
internalName: z.string(), internalName: z.string(),
tomeType: z.enum([ tomeType: z.enum(['guild_tome', 'marathon_tome', 'mysticism_tome', 'weapon_tome', 'armour_tome', 'expertise_tome', 'lootrun_tome']),
"guild_tome",
"marathon_tome",
"mysticism_tome",
"weapon_tome",
"armour_tome",
"expertise_tome",
"lootrun_tome",
]),
raidReward: z.boolean(), raidReward: z.boolean(),
type: z.literal('tome'), type: z.literal('tome'),
restrictions: WynnItemRestrictions.optional(), restrictions: WynnItemRestrictions.optional(),
@ -141,7 +130,7 @@ export const WapiV3ItemTome = z.object({
lore: z.string().optional(), lore: z.string().optional(),
icon: WynnItemIcon, icon: WynnItemIcon,
base: WynnBaseStats.optional(), base: WynnBaseStats.optional(),
rarity:WynnItemRarity, rarity: WynnItemRarity,
}) })
export const WapiV3ItemCharm = z.object({ export const WapiV3ItemCharm = z.object({
@ -154,139 +143,153 @@ export const WapiV3ItemCharm = z.object({
lore: z.string().optional(), lore: z.string().optional(),
icon: WynnItemIcon, icon: WynnItemIcon,
base: WynnBaseStats, base: WynnBaseStats,
rarity:WynnItemRarity, rarity: WynnItemRarity,
}) })
export const WapiV3ItemAccessory = z.object({ export const WapiV3ItemAccessory = z
internalName: z.string(), .object({
type: z.literal('accessory'), internalName: z.string(),
identified: z.boolean().optional(), type: z.literal('accessory'),
accessoryType: z.enum([ identified: z.boolean().optional(),
"ring","necklace","bracelet", accessoryType: z.enum(['ring', 'necklace', 'bracelet']),
]), majorIds: z.record(z.string(), z.string()).optional(),
majorIds: z.record(z.string(), z.string()).optional(), restrictions: WynnItemRestrictions.optional(),
restrictions: WynnItemRestrictions.optional(), dropMeta: WynnDropMeta.optional(),
dropMeta: WynnDropMeta.optional(), dropRestriction: WynnDropRestriction,
dropRestriction: WynnDropRestriction, requirements: WynnEquipRequirements,
requirements:WynnEquipRequirements, lore: z.string().optional(),
lore: z.string().optional(), icon: WynnItemIcon,
icon: WynnItemIcon, identifications: WynnIdentifications.optional(),
identifications: WynnIdentifications.optional(), base: WynnBaseStats.optional(),
base: WynnBaseStats.optional(), rarity: WynnItemRarity,
rarity:WynnItemRarity, })
}).strict() .strict()
export const WapiV3ItemIngredient = z.object({ export const WapiV3ItemIngredient = z
internalName: z.string(), .object({
type: z.literal('ingredient'), internalName: z.string(),
requirements: z.object({ type: z.literal('ingredient'),
level: z.number(), requirements: z.object({
skills: z.array(z.enum([ level: z.number(),
"alchemism", skills: z.array(z.enum(['alchemism', 'armouring', 'cooking', 'jeweling', 'scribing', 'tailoring', 'weaponsmithing', 'woodworking'])),
"armouring", }),
"cooking", icon: WynnItemIcon,
"jeweling", identifications: WynnIdentifications.optional(),
"scribing", tier: z.number(),
"tailoring", consumableOnlyIDs: z.record(z.string(), z.number()),
"weaponsmithing", ingredientPositionModifiers: z.record(z.string(), z.number()),
"woodworking", itemOnlyIDs: z.record(z.string(), z.number()),
])), droppedBy: z
}), .array(
icon: WynnItemIcon, z.object({
identifications: WynnIdentifications.optional(), name: z.string(),
tier: z.number(), coords: z.union([z.boolean(), z.array(z.number()).length(4), z.array(z.array(z.number()).length(4))]).nullable(),
consumableOnlyIDs: z.record(z.string(), z.number()), })
ingredientPositionModifiers: z.record(z.string(), z.number()), )
itemOnlyIDs: z.record(z.string(), z.number()), .optional(),
droppedBy: z.array(z.object({ })
name: z.string(), .strict()
coords: z.union([
z.boolean(),
z.array(z.number()).length(4),
z.array(z.array(z.number()).length(4)),
]).nullable()
})).optional()
}).strict()
export const WapiV3ItemMaterial = z.object({ export const WapiV3ItemMaterial = z
internalName: z.string(), .object({
type: z.literal('material'), internalName: z.string(),
identified: z.boolean(), type: z.literal('material'),
requirements: z.object({ identified: z.boolean(),
level: z.number(), requirements: z.object({
}), level: z.number(),
craftable: z.array(z.enum([ }),
'potions','food','scrolls', craftable: z.array(
'helmets','chestplates','rings','bracelets', z.enum([
'necklaces','boots','leggings','bows','wands','spears', 'potions',
'daggers','chestplates','helmets'])), 'food',
icon: WynnItemIcon, 'scrolls',
tier: z.number(), 'helmets',
}).strict() 'chestplates',
'rings',
'bracelets',
'necklaces',
'boots',
'leggings',
'bows',
'wands',
'spears',
'daggers',
'chestplates',
'helmets',
])
),
icon: WynnItemIcon,
tier: z.number(),
})
.strict()
export const WapiV3ItemWeapon = z.object({ export const WapiV3ItemWeapon = z
internalName: z.string(), .object({
type: z.literal('weapon'), internalName: z.string(),
identified: z.boolean().optional(), type: z.literal('weapon'),
allowCraftsman: z.boolean().optional(), identified: z.boolean().optional(),
weaponType: z.enum([ allowCraftsman: z.boolean().optional(),
"bow","relik","wand","dagger","spear" weaponType: z.enum(['bow', 'relik', 'wand', 'dagger', 'spear']),
]), attackSpeed: z.enum(['super_slow', 'very_slow', 'slow', 'normal', 'fast', 'very_fast', 'super_fast']),
attackSpeed: z.enum([ powderSlots: z.number().optional(),
"super_slow", "very_slow", "slow","normal","fast", "very_fast","super_fast" averageDps: z.number().optional(),
]), restrictions: WynnItemRestrictions.optional(),
powderSlots: z.number().optional(), dropMeta: WynnDropMeta.optional(),
averageDps: z.number().optional(), dropRestriction: WynnDropRestriction.optional(),
restrictions: WynnItemRestrictions.optional(), requirements: WynnEquipRequirements,
dropMeta: WynnDropMeta.optional(), majorIds: z.record(z.string(), z.string()).optional(),
dropRestriction: WynnDropRestriction.optional(), lore: z.string().optional(),
requirements: WynnEquipRequirements, icon: WynnItemIcon,
majorIds: z.record(z.string(), z.string()).optional(), identifications: WynnIdentifications.optional(),
lore: z.string().optional(), base: WynnBaseStats.optional(),
icon: WynnItemIcon, rarity: WynnItemRarity,
identifications: WynnIdentifications.optional(), })
base: WynnBaseStats.optional(), .strict()
rarity:WynnItemRarity,
}).strict()
export const WapiV3ItemArmour = z
.object({
internalName: z.string(),
type: z.literal('armour'),
armourType: z.string(),
armourMaterial: z.string().optional(),
armourColor: z.string().optional(),
identified: z.boolean().optional(),
allowCraftsman: z.boolean().optional(),
restrictions: WynnItemRestrictions.optional(),
dropRestriction: WynnDropRestriction,
dropMeta: WynnDropMeta.optional(),
icon: WynnItemIcon.optional(),
requirements: z.object({
level: z.number(),
classRequirement: z.string().optional(),
intelligence: z.number().optional(),
strength: z.number().optional(),
dexterity: z.number().optional(),
defence: z.number().optional(),
agility: z.number().optional(),
}),
majorIds: z.record(z.string(), z.string()).optional(),
powderSlots: z.number().optional(),
lore: z.string().optional(),
identifications: z
.record(
z.string(),
z.union([
z.number(),
z.object({
min: z.number(),
raw: z.number(),
max: z.number(),
}),
])
)
.optional(),
base: WynnBaseStats.optional(),
rarity: WynnItemRarity,
})
.strict()
export const WapiV3ItemArmour = z.object({ export const WApiV3Item = z.discriminatedUnion('type', [
internalName: z.string(),
type: z.literal('armour'),
armourType: z.string(),
armourMaterial: z.string().optional(),
armourColor: z.string().optional(),
identified: z.boolean().optional(),
allowCraftsman: z.boolean().optional(),
restrictions: WynnItemRestrictions.optional(),
dropRestriction: WynnDropRestriction,
dropMeta: WynnDropMeta.optional(),
icon: WynnItemIcon.optional(),
requirements: z.object({
level: z.number(),
classRequirement: z.string().optional(),
intelligence: z.number().optional(),
strength: z.number().optional(),
dexterity: z.number().optional(),
defence: z.number().optional(),
agility: z.number().optional(),
}),
majorIds: z.record(z.string(), z.string()).optional(),
powderSlots: z.number().optional(),
lore: z.string().optional(),
identifications: z.record(z.string(), z.union([
z.number(),
z.object({
min: z.number(),
raw: z.number(),
max: z.number(),
})
])).optional(),
base: WynnBaseStats.optional(),
rarity:WynnItemRarity,
}).strict()
export const WApiV3Item = z.discriminatedUnion("type",[
WapiV3ItemMaterial, WapiV3ItemMaterial,
WapiV3ItemWeapon, WapiV3ItemWeapon,
WapiV3ItemArmour, WapiV3ItemArmour,
@ -296,4 +299,4 @@ export const WApiV3Item = z.discriminatedUnion("type",[
WapiV3ItemTome, WapiV3ItemTome,
WapiV3ItemTool, WapiV3ItemTool,
]) ])
export const WApiV3ItemDatabase= z.record(z.string(), WApiV3Item) export const WApiV3ItemDatabase = z.record(z.string(), WApiV3Item)

View File

@ -1,12 +1,11 @@
import { config } from "#/config"; import { inject, injectable } from '@needle-di/core'
import { inject, injectable } from "@needle-di/core"; import axios, { type AxiosInstance } from 'axios'
import axios, { AxiosInstance } from "axios"; import { buildStorage, canStale, setupCache } from 'axios-cache-interceptor'
import { buildStorage, canStale, setupCache } from 'axios-cache-interceptor'; import { BentoCache } from 'bentocache'
import { BentoCache } from "bentocache"; import { config } from '#/config'
import "#/services/bento";
import { logger } from "#/logger";
import '#/services/bento'
import { logger } from '#/logger'
@injectable() @injectable()
export class WApi { export class WApi {
@ -14,51 +13,42 @@ export class WApi {
private readonly log = logger.child({ module: 'wapi' }) private readonly log = logger.child({ module: 'wapi' })
constructor( constructor(private readonly bento = inject(BentoCache)) {
private readonly bento = inject(BentoCache)
) {
const c = axios.create({ const c = axios.create({
baseURL: config.WAPI_URL, baseURL: config.WAPI_URL,
headers: { headers: {
"User-Agent": "lil-robot-guy (a@tuxpa.in)", 'User-Agent': 'lil-robot-guy (a@tuxpa.in)',
}, },
}) })
const store = this.bento.namespace('wapi-cache') const store = this.bento.namespace('wapi-cache')
const self = this
setupCache(c, { setupCache(c, {
interpretHeader: true, interpretHeader: true,
ttl: 5000, ttl: 5000,
storage: buildStorage({ storage: buildStorage({
async find(key, currentRequest) { async find(key, currentRequest) {
const value = await store.get({key}) const value = await store.get({ key })
if(!value) { if (!value) {
return; return
} }
return JSON.parse(value) return JSON.parse(value)
}, },
async remove(key, req) { async remove(key, req) {
await store.delete({key}) await store.delete({ key })
}, },
async set(key, value, req) { async set(key, value, req) {
let expireTime = value.state === 'loading' const expireTime =
? Date.now() + value.state === 'loading'
(req?.cache && typeof req.cache.ttl === 'number' ? Date.now() + (req?.cache && typeof req.cache.ttl === 'number' ? req.cache.ttl : 3000)
? req.cache.ttl : // When a stale state has a determined value to expire, we can use it.
: // Or if the cached value cannot enter in stale state.
3000) (value.state === 'stale' && value.ttl) || (value.state === 'cached' && !canStale(value))
: // When a stale state has a determined value to expire, we can use it. ? value.createdAt + value.ttl!
// Or if the cached value cannot enter in stale state. : // otherwise, we can't determine when it should expire, so we keep
(value.state === 'stale' && value.ttl) || // it indefinitely.
(value.state === 'cached' && !canStale(value)) undefined
?
value.createdAt + value.ttl!
: // otherwise, we can't determine when it should expire, so we keep
// it indefinitely.
undefined
let ttl: number | undefined let ttl: number | undefined
if(expireTime) { if (expireTime) {
ttl = expireTime - Date.now() ttl = expireTime - Date.now()
} }
await store.set({ await store.set({
@ -70,20 +60,18 @@ export class WApi {
async clear() { async clear() {
await store.clear({}) await store.clear({})
}, },
}) }),
}); })
this.c = c; this.c = c
} }
async get(path:string, params?: any) { async get(path: string, params?: any) {
return this.c.get(path, { return this.c.get(path, {
params, params,
headers: { headers: {
'Accept': 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} },
}) })
} }
} }

View File

@ -1,11 +1,10 @@
import {pino} from 'pino' import { pino } from 'pino'
export const logger = pino({
export const logger = pino({
transport: { transport: {
target: 'pino-logfmt', target: 'pino-logfmt',
}, },
level: process.env.PINO_LOG_LEVEL || 'info', level: process.env.PINO_LOG_LEVEL || 'info',
redact: [], // prevent logging of sensitive data redact: [], // prevent logging of sensitive data
}); })

View File

@ -1,10 +1,6 @@
import { runExit } from "clipanion"; import { runExit } from 'clipanion'
import { WorkerCommand } from "#/cmd/worker"; import { BotCommand } from '#/cmd/bot'
import { BotCommand } from "#/cmd/bot"; import { WorkerCommand } from '#/cmd/worker'
runExit([WorkerCommand, BotCommand])
runExit([
WorkerCommand,
BotCommand,
])

View File

@ -1,37 +1,27 @@
import { import { type EncodingType, METADATA_ENCODING_KEY, type Payload, PayloadConverterError, type PayloadConverterWithEncoding } from '@temporalio/common'
EncodingType, import { decode, encode } from '@temporalio/common/lib/encoding'
METADATA_ENCODING_KEY, import { errorMessage } from '@temporalio/common/lib/type-helpers'
Payload, import superjson from 'superjson'
PayloadConverterError,
PayloadConverterWithEncoding,
} from '@temporalio/common';
import { decode, encode } from '@temporalio/common/lib/encoding';
import { errorMessage } from '@temporalio/common/lib/type-helpers';
import superjson from 'superjson';
/** /**
* Converts between values and [superjson](https://github.com/flightcontrolhq/superjson) Payloads. * Converts between values and [superjson](https://github.com/flightcontrolhq/superjson) Payloads.
*/ */
export class SuperJsonPayloadConverter implements PayloadConverterWithEncoding { export class SuperJsonPayloadConverter implements PayloadConverterWithEncoding {
// Use 'json/plain' so that Payloads are displayed in the UI // Use 'json/plain' so that Payloads are displayed in the UI
public encodingType = 'json/plain' as EncodingType; public encodingType = 'json/plain' as EncodingType
public toPayload(value: unknown): Payload | undefined { public toPayload(value: unknown): Payload | undefined {
if (value === undefined) return undefined; if (value === undefined) return undefined
let ejson; let ejson
try { try {
ejson = superjson.stringify(value); ejson = superjson.stringify(value)
} catch (e) { } catch (e) {
throw new UnsupportedSuperJsonTypeError( throw new UnsupportedSuperJsonTypeError(
`Can't run superjson.stringify on this value: ${value}. Either convert it (or its properties) to superjson-serializable values (see https://docs.meteor.com/api/ejson.html ), or create a custom data converter. superjson.stringify error message: ${ `Can't run superjson.stringify on this value: ${value}. Either convert it (or its properties) to superjson-serializable values (see https://docs.meteor.com/api/ejson.html ), or create a custom data converter. superjson.stringify error message: ${errorMessage(
errorMessage( e
e, )}`,
) e as Error
}`, )
e as Error,
);
} }
return { return {
@ -41,21 +31,21 @@ export class SuperJsonPayloadConverter implements PayloadConverterWithEncoding {
format: encode('extended'), format: encode('extended'),
}, },
data: encode(ejson), data: encode(ejson),
}; }
} }
public fromPayload<T>(content: Payload): T { public fromPayload<T>(content: Payload): T {
return content.data ? superjson.parse<T>(decode(content.data)) : {} as T; return content.data ? superjson.parse<T>(decode(content.data)) : ({} as T)
} }
} }
export class UnsupportedSuperJsonTypeError extends PayloadConverterError { export class UnsupportedSuperJsonTypeError extends PayloadConverterError {
public readonly name: string = 'UnsupportedJsonTypeError'; public readonly name: string = 'UnsupportedJsonTypeError'
constructor( constructor(
message: string | undefined, message: string | undefined,
public readonly cause?: Error, public readonly cause?: Error
) { ) {
super(message ?? undefined); super(message ?? undefined)
} }
} }

View File

@ -1,10 +1,4 @@
import { import { CompositePayloadConverter, UndefinedPayloadConverter } from '@temporalio/common'
CompositePayloadConverter, import { SuperJsonPayloadConverter } from './adapter'
UndefinedPayloadConverter,
} from '@temporalio/common';
import { SuperJsonPayloadConverter } from './adapter';
export const payloadConverter = new CompositePayloadConverter( export const payloadConverter = new CompositePayloadConverter(new UndefinedPayloadConverter(), new SuperJsonPayloadConverter())
new UndefinedPayloadConverter(),
new SuperJsonPayloadConverter(),
);

View File

@ -1,31 +1,29 @@
import { config } from '#/config'
import { c } from '#/di'
import { BentoCache, bentostore } from 'bentocache' import { BentoCache, bentostore } from 'bentocache'
import { memoryDriver } from 'bentocache/drivers/memory' import { memoryDriver } from 'bentocache/drivers/memory'
import { redisDriver } from 'bentocache/drivers/redis' import { redisDriver } from 'bentocache/drivers/redis'
import IORedis from 'ioredis' import IORedis from 'ioredis'
import { config } from '#/config'
import { c } from '#/di'
c.bind({ c.bind({
provide: BentoCache, provide: BentoCache,
useFactory: () => { useFactory: () => {
const defaultStore = bentostore() const defaultStore = bentostore()
defaultStore.useL1Layer(memoryDriver({ maxSize: '32mb' })) defaultStore.useL1Layer(memoryDriver({ maxSize: '32mb' }))
if(config.REDIS_URL) { if (config.REDIS_URL) {
defaultStore.useL2Layer(redisDriver({ defaultStore.useL2Layer(
connection: new IORedis(config.REDIS_URL), redisDriver({
prefix: 'wynn-bento', connection: new IORedis(config.REDIS_URL),
})) prefix: 'wynn-bento',
})
)
} }
const bento = new BentoCache({ const bento = new BentoCache({
default: 'cache', default: 'cache',
stores: { stores: {
cache: defaultStore, cache: defaultStore,
} },
}) })
return bento return bento
} },
}) })

View File

@ -1,10 +1,10 @@
import { config } from "#/config"; import { injectable } from '@needle-di/core'
import { injectable } from "@needle-di/core"; import postgres, { type Sql } from 'postgres'
import postgres, { Sql } from "postgres"; import { config } from '#/config'
@injectable() @injectable()
export class PG { export class PG {
readonly db: Sql; readonly db: Sql
get sql() { get sql() {
return this.db return this.db
} }
@ -13,10 +13,10 @@ export class PG {
const opts = { const opts = {
onnotice: () => {}, onnotice: () => {},
} }
let db: Sql; let db: Sql
if(config.PG_URL) { if (config.PG_URL) {
db = postgres(config.PG_URL, opts); db = postgres(config.PG_URL, opts)
}else { } else {
db = postgres({ db = postgres({
host: config.PG_HOST, host: config.PG_HOST,
port: config.PG_PORT, port: config.PG_PORT,
@ -30,4 +30,3 @@ export class PG {
this.db = db this.db = db
} }
} }

View File

@ -1,6 +1,6 @@
import { config } from "#/config"; import { Client, Connection } from '@temporalio/client'
import { c } from "#/di"; import { config } from '#/config'
import { Client, Connection} from '@temporalio/client'; import { c } from '#/di'
c.bind({ c.bind({
provide: Client, provide: Client,
@ -15,11 +15,11 @@ c.bind({
dataConverter: { dataConverter: {
payloadConverterPath: require.resolve('../../payload_converter'), payloadConverterPath: require.resolve('../../payload_converter'),
}, },
}); })
process.on('exit', () => { process.on('exit', () => {
console.log('closing temporal client'); console.log('closing temporal client')
client.connection.close(); client.connection.close()
}); })
return client return client
}, },
}); })

View File

@ -1,21 +1,19 @@
import { proxyActivities, startChild, workflowInfo } from '@temporalio/workflow'; import { InteractionTypes } from '@discordeno/types'
import type * as activities from '#/activities'; import { proxyActivities, startChild, workflowInfo } from '@temporalio/workflow'
import { InteractionTypes } from '@discordeno/types'; import type * as activities from '#/activities'
import { handleCommandGuildInfo, handleCommandGuildOnline, handleCommandGuildLeaderboard } from './guild_messages'; import type { InteractionCreatePayload } from '#/discord'
import { handleCommandPlayerLookup } from './player_messages'; import { createCommandHandler } from '#/discord/botevent/command_parser'
import { createCommandHandler } from '#/discord/botevent/command_parser'; import type { SLASH_COMMANDS } from '#/discord/botevent/slash_commands'
import { SLASH_COMMANDS } from '#/discord/botevent/slash_commands'; import { handleCommandGuildInfo, handleCommandGuildLeaderboard, handleCommandGuildOnline } from './guild_messages'
import { InteractionCreatePayload} from '#/discord'; import { handleCommandPlayerLookup } from './player_messages'
const { reply_to_interaction } = proxyActivities<typeof activities>({ const { reply_to_interaction } = proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute', startToCloseTimeout: '1 minute',
}); })
// Define command handlers with type safety // Define command handlers with type safety
const workflowHandleApplicationCommand = async ( const workflowHandleApplicationCommand = async (payload: InteractionCreatePayload) => {
payload: InteractionCreatePayload, const { ref, data } = payload
) => {
const { ref, data } = payload;
const notFoundHandler = async (content: string) => { const notFoundHandler = async (content: string) => {
await reply_to_interaction({ await reply_to_interaction({
@ -24,52 +22,52 @@ const workflowHandleApplicationCommand = async (
options: { options: {
content: content, content: content,
isPrivate: true, isPrivate: true,
} },
}); })
} }
if (!data || !data.name) { if (!data || !data.name) {
await notFoundHandler(`Invalid command data`); await notFoundHandler(`Invalid command data`)
return return
} }
const commandHandler = createCommandHandler<typeof SLASH_COMMANDS>({ const commandHandler = createCommandHandler<typeof SLASH_COMMANDS>({
notFoundHandler: async () => { notFoundHandler: async () => {
await notFoundHandler(`command not found`); await notFoundHandler(`command not found`)
}, },
handler: { handler: {
player: { player: {
lookup: async (args) => { lookup: async (args) => {
const { workflowId } = workflowInfo(); const { workflowId } = workflowInfo()
const handle = await startChild(handleCommandPlayerLookup, { const handle = await startChild(handleCommandPlayerLookup, {
args: [{ ref, args }], args: [{ ref, args }],
workflowId: `${workflowId}-player-lookup`, workflowId: `${workflowId}-player-lookup`,
}); })
await handle.result(); await handle.result()
}, },
}, },
guild: { guild: {
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 }],
workflowId: `${workflowId}-guild-info`, workflowId: `${workflowId}-guild-info`,
}); })
await handle.result(); await handle.result()
}, },
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 }],
workflowId: `${workflowId}-guild-online`, workflowId: `${workflowId}-guild-online`,
}); })
await handle.result(); await handle.result()
}, },
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 }],
workflowId: `${workflowId}-guild-leaderboard`, workflowId: `${workflowId}-guild-leaderboard`,
}); })
await handle.result(); await handle.result()
}, },
}, },
admin: { admin: {
@ -78,24 +76,22 @@ const workflowHandleApplicationCommand = async (
ref, ref,
type: 4, type: 4,
options: { options: {
content: "Not implemented yet", content: 'Not implemented yet',
isPrivate: true, isPrivate: true,
} },
}); })
}, },
}, },
} },
}); })
await commandHandler(data); await commandHandler(data)
} }
export const workflowHandleInteractionCreate = async ( export const workflowHandleInteractionCreate = async (payload: InteractionCreatePayload) => {
payload: InteractionCreatePayload, const { ref, data } = payload
) => {
const {ref, data} = payload
if(ref.type === InteractionTypes.ApplicationCommand) { if (ref.type === InteractionTypes.ApplicationCommand) {
await workflowHandleApplicationCommand(payload) await workflowHandleApplicationCommand(payload)
} }
} }

View File

@ -1,47 +1,42 @@
import { proxyActivities } from "@temporalio/workflow"; 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 { InteractionRef } from "#/discord"; import type { InteractionRef } from '#/discord'
const { const { formGuildInfoMessage, formGuildOnlineMessage, formGuildLeaderboardMessage, reply_to_interaction } = proxyActivities<typeof activities>({
formGuildInfoMessage,
formGuildOnlineMessage,
formGuildLeaderboardMessage,
reply_to_interaction
} = proxyActivities<typeof activities>({
startToCloseTimeout: '30 seconds', startToCloseTimeout: '30 seconds',
}); })
interface CommandPayload { interface CommandPayload {
ref: InteractionRef; ref: InteractionRef
} }
export async function handleCommandGuildInfo(payload: CommandPayload): Promise<void> { export async function handleCommandGuildInfo(payload: CommandPayload): Promise<void> {
const { ref } = payload; const { ref } = payload
const msg = await formGuildInfoMessage(WYNN_GUILD_ID); const msg = await formGuildInfoMessage(WYNN_GUILD_ID)
await reply_to_interaction({ await reply_to_interaction({
ref, ref,
type: 4, type: 4,
options: msg, options: msg,
}); })
} }
export async function handleCommandGuildOnline(payload: CommandPayload): Promise<void> { export async function handleCommandGuildOnline(payload: CommandPayload): Promise<void> {
const { ref } = payload; const { ref } = payload
const msg = await formGuildOnlineMessage(WYNN_GUILD_ID); const msg = await formGuildOnlineMessage(WYNN_GUILD_ID)
await reply_to_interaction({ await reply_to_interaction({
ref, ref,
type: 4, type: 4,
options: msg, options: msg,
}); })
} }
export async function handleCommandGuildLeaderboard(payload: CommandPayload): Promise<void> { export async function handleCommandGuildLeaderboard(payload: CommandPayload): Promise<void> {
const { ref } = payload; const { ref } = payload
const msg = await formGuildLeaderboardMessage(WYNN_GUILD_ID); const msg = await formGuildLeaderboardMessage(WYNN_GUILD_ID)
await reply_to_interaction({ await reply_to_interaction({
ref, ref,
type: 4, type: 4,
options: msg, options: msg,
}); })
} }

View File

@ -1,29 +1,25 @@
import { proxyActivities } from '@temporalio/workflow'
import { proxyActivities } from '@temporalio/workflow'; import type * as activities from '#/activities'
import type * as activities from '#/activities';
const { update_guild, update_all_guilds, update_guild_levels } = proxyActivities<typeof activities>({ const { update_guild, update_all_guilds, update_guild_levels } = proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute', startToCloseTimeout: '1 minute',
}); })
export const workflowSyncAllGuilds = async() => { export const workflowSyncAllGuilds = async () => {
await update_all_guilds() await update_all_guilds()
} }
export const workflowSyncGuildLeaderboardInfo = async() => { export const workflowSyncGuildLeaderboardInfo = async () => {
await update_guild_levels() await update_guild_levels()
} }
export const workflowSyncGuilds = async() => { export const workflowSyncGuilds = async () => {
// TODO side effect // TODO side effect
const guildNames = [ const guildNames = ['less than three']
'less than three', for (const guildName of guildNames) {
]
for(const guildName of guildNames) {
// update the guild // update the guild
await update_guild({ await update_guild({
guild_name: guildName, guild_name: guildName,
}) })
} }
} }

View File

@ -2,9 +2,9 @@
* @file Automatically generated by barrelsby. * @file Automatically generated by barrelsby.
*/ */
export * from "./discord"; export * from './discord'
export * from "./guild_messages"; export * from './guild_messages'
export * from "./guilds"; export * from './guilds'
export * from "./items"; export * from './items'
export * from "./player_messages"; export * from './player_messages'
export * from "./players"; export * from './players'

View File

@ -1,15 +1,13 @@
import { proxyActivities } from '@temporalio/workflow'
import { proxyActivities } from '@temporalio/workflow'; import type * as activities from '#/activities'
import type * as activities from '#/activities';
const { update_wynn_items } = proxyActivities<typeof activities>({ const { update_wynn_items } = proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute', startToCloseTimeout: '1 minute',
retry: { retry: {
maximumAttempts: 1, maximumAttempts: 1,
} },
}); })
export const workflowSyncItemDatabase = async() => {
const {found_new} = await update_wynn_items();
export const workflowSyncItemDatabase = async () => {
const { found_new } = await update_wynn_items()
} }

View File

@ -1,23 +1,21 @@
import { proxyActivities } from "@temporalio/workflow"; import { proxyActivities } from '@temporalio/workflow'
import type * as activities from "#/activities"; import type * as activities from '#/activities'
import { InteractionRef } from "#/discord"; import type { InteractionRef } from '#/discord'
const { const { reply_to_interaction } = proxyActivities<typeof activities>({
reply_to_interaction
} = proxyActivities<typeof activities>({
startToCloseTimeout: '30 seconds', startToCloseTimeout: '30 seconds',
}); })
interface CommandPayload { interface CommandPayload {
ref: InteractionRef; ref: InteractionRef
args: { args: {
player: string; player: string
}; }
} }
export async function handleCommandPlayerLookup(payload: CommandPayload): Promise<void> { export async function handleCommandPlayerLookup(payload: CommandPayload): Promise<void> {
const { ref, args } = payload; const { ref, args } = payload
const playerName = args.player; const playerName = args.player
try { try {
// For now, we'll send a simple response // For now, we'll send a simple response
@ -29,7 +27,7 @@ export async function handleCommandPlayerLookup(payload: CommandPayload): Promis
content: `Looking up player: **${playerName}**\n\n*Player lookup functionality coming soon!*`, content: `Looking up player: **${playerName}**\n\n*Player lookup functionality coming soon!*`,
isPrivate: false, isPrivate: false,
}, },
}); })
} catch (error) { } catch (error) {
await reply_to_interaction({ await reply_to_interaction({
ref, ref,
@ -38,6 +36,6 @@ export async function handleCommandPlayerLookup(payload: CommandPayload): Promis
content: `Error looking up player: ${playerName}`, content: `Error looking up player: ${playerName}`,
isPrivate: true, isPrivate: true,
}, },
}); })
} }
} }

View File

@ -1,14 +1,13 @@
import { proxyActivities } from '@temporalio/workflow'
import { proxyActivities } from '@temporalio/workflow'; import type * as activities from '#/activities'
import type * as activities from '#/activities';
const { scrape_online_players } = proxyActivities<typeof activities>({ const { scrape_online_players } = proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute', startToCloseTimeout: '1 minute',
retry: { retry: {
maximumAttempts: 1, maximumAttempts: 1,
} },
}); })
export const workflowSyncOnline = async() => { export const workflowSyncOnline = async () => {
await scrape_online_players(); await scrape_online_players()
} }

View File

@ -21,6 +21,97 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@biomejs/biome@npm:1.9.4":
version: 1.9.4
resolution: "@biomejs/biome@npm:1.9.4"
dependencies:
"@biomejs/cli-darwin-arm64": "npm:1.9.4"
"@biomejs/cli-darwin-x64": "npm:1.9.4"
"@biomejs/cli-linux-arm64": "npm:1.9.4"
"@biomejs/cli-linux-arm64-musl": "npm:1.9.4"
"@biomejs/cli-linux-x64": "npm:1.9.4"
"@biomejs/cli-linux-x64-musl": "npm:1.9.4"
"@biomejs/cli-win32-arm64": "npm:1.9.4"
"@biomejs/cli-win32-x64": "npm:1.9.4"
dependenciesMeta:
"@biomejs/cli-darwin-arm64":
optional: true
"@biomejs/cli-darwin-x64":
optional: true
"@biomejs/cli-linux-arm64":
optional: true
"@biomejs/cli-linux-arm64-musl":
optional: true
"@biomejs/cli-linux-x64":
optional: true
"@biomejs/cli-linux-x64-musl":
optional: true
"@biomejs/cli-win32-arm64":
optional: true
"@biomejs/cli-win32-x64":
optional: true
bin:
biome: bin/biome
checksum: 10c0/b5655c5aed9a6fffe24f7d04f15ba4444389d0e891c9ed9106fab7388ac9b4be63185852cc2a937b22940dac3e550b71032a4afd306925cfea436c33e5646b3e
languageName: node
linkType: hard
"@biomejs/cli-darwin-arm64@npm:1.9.4":
version: 1.9.4
resolution: "@biomejs/cli-darwin-arm64@npm:1.9.4"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@biomejs/cli-darwin-x64@npm:1.9.4":
version: 1.9.4
resolution: "@biomejs/cli-darwin-x64@npm:1.9.4"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@biomejs/cli-linux-arm64-musl@npm:1.9.4":
version: 1.9.4
resolution: "@biomejs/cli-linux-arm64-musl@npm:1.9.4"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@biomejs/cli-linux-arm64@npm:1.9.4":
version: 1.9.4
resolution: "@biomejs/cli-linux-arm64@npm:1.9.4"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@biomejs/cli-linux-x64-musl@npm:1.9.4":
version: 1.9.4
resolution: "@biomejs/cli-linux-x64-musl@npm:1.9.4"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@biomejs/cli-linux-x64@npm:1.9.4":
version: 1.9.4
resolution: "@biomejs/cli-linux-x64@npm:1.9.4"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@biomejs/cli-win32-arm64@npm:1.9.4":
version: 1.9.4
resolution: "@biomejs/cli-win32-arm64@npm:1.9.4"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@biomejs/cli-win32-x64@npm:1.9.4":
version: 1.9.4
resolution: "@biomejs/cli-win32-x64@npm:1.9.4"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@boringnode/bus@npm:^0.7.1": "@boringnode/bus@npm:^0.7.1":
version: 0.7.1 version: 0.7.1
resolution: "@boringnode/bus@npm:0.7.1" resolution: "@boringnode/bus@npm:0.7.1"
@ -1168,27 +1259,25 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@ts-rest/core@https://gitpkg.vercel.app/aidant/ts-rest/libs/ts-rest/core?feat-standard-schema": "@ts-rest/core@npm:^3.53.0-rc.1":
version: 3.52.0 version: 3.53.0-rc.1
resolution: "@ts-rest/core@https://gitpkg.vercel.app/aidant/ts-rest/libs/ts-rest/core?feat-standard-schema" resolution: "@ts-rest/core@npm:3.53.0-rc.1"
peerDependencies: peerDependencies:
"@types/node": ^18.18.7 || >=20.8.4 "@types/node": ^18.18.7 || >=20.8.4
zod: ^3.24.0
peerDependenciesMeta: peerDependenciesMeta:
"@types/node": "@types/node":
optional: true optional: true
zod: checksum: 10c0/a1a8c304f797da016ad968878a47c75fb0dcb7811c6f8e2ae81a3f9f3dedba30c5b5c8b14668437c136c9eca9b361e7048117478330bd449a2fbbc53f84f73cb
optional: true
checksum: 10c0/fba55ca7d1a5161d3ec1af850c9c98d34efd1b4373ad8ca4108737c2d75fa11ef033da6e9cb657cd5078465c09ff6761c029053f9c59022075b8d3e128f298a5
languageName: node languageName: node
linkType: hard linkType: hard
"@ts-rest/fastify@https://gitpkg.vercel.app/aidant/ts-rest/libs/ts-rest/fastify?feat-standard-schema": "@ts-rest/fastify@npm:^3.53.0-rc.1":
version: 3.52.0 version: 3.53.0-rc.1
resolution: "@ts-rest/fastify@https://gitpkg.vercel.app/aidant/ts-rest/libs/ts-rest/fastify?feat-standard-schema" resolution: "@ts-rest/fastify@npm:3.53.0-rc.1"
peerDependencies: peerDependencies:
"@ts-rest/core": 3.53.0-rc.1
fastify: ^4.0.0 fastify: ^4.0.0
checksum: 10c0/8e8a31fda5a49c4fc976962df29129a24ad3c9c4896af38a6deb95bf60e36943c29bef56ff601bb63cab883d845095c7793ae266e92876cf74a7a05d238ba00f checksum: 10c0/5d935903743e457873036dc943538b8603081e1d4a1f28bb7b79b0f1a6cee8ddcd4aef839318f5f00c7b24f38491e68977aa8ef41b2665f4702d47eeba0cee9e
languageName: node languageName: node
linkType: hard linkType: hard
@ -1799,6 +1888,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "backend@workspace:." resolution: "backend@workspace:."
dependencies: dependencies:
"@biomejs/biome": "npm:1.9.4"
"@discordeno/types": "npm:^21.0.0" "@discordeno/types": "npm:^21.0.0"
"@needle-di/core": "npm:^0.10.1" "@needle-di/core": "npm:^0.10.1"
"@temporalio/activity": "npm:^1.11.7" "@temporalio/activity": "npm:^1.11.7"
@ -1806,8 +1896,8 @@ __metadata:
"@temporalio/common": "npm:^1.11.7" "@temporalio/common": "npm:^1.11.7"
"@temporalio/worker": "npm:^1.11.7" "@temporalio/worker": "npm:^1.11.7"
"@temporalio/workflow": "npm:^1.11.7" "@temporalio/workflow": "npm:^1.11.7"
"@ts-rest/core": "https://gitpkg.vercel.app/aidant/ts-rest/libs/ts-rest/core?feat-standard-schema" "@ts-rest/core": "npm:^3.53.0-rc.1"
"@ts-rest/fastify": "https://gitpkg.vercel.app/aidant/ts-rest/libs/ts-rest/fastify?feat-standard-schema" "@ts-rest/fastify": "npm:^3.53.0-rc.1"
"@types/node": "npm:^22.13.4" "@types/node": "npm:^22.13.4"
"@types/object-hash": "npm:^3" "@types/object-hash": "npm:^3"
"@vitest/runner": "npm:^3.2.3" "@vitest/runner": "npm:^3.2.3"