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,
"directory": [
"./src/activities",
"./src/workflows"
],
"exclude": [
"types.ts"
]
"directory": ["./src/activities", "./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"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@types/node": "^22.13.4",
"@types/object-hash": "^3",
"@vitest/runner": "^3.2.3",
@ -29,8 +30,8 @@
"@temporalio/common": "^1.11.7",
"@temporalio/worker": "^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/fastify": "https://gitpkg.vercel.app/aidant/ts-rest/libs/ts-rest/fastify?feat-standard-schema",
"@ts-rest/core": "^3.53.0-rc.1",
"@ts-rest/fastify": "^3.53.0-rc.1",
"any-date-parser": "^2.0.3",
"arktype": "2.1.1",
"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 unionType = tupleType.or(tupleArrayType)
// good
tupleType.assert([1,"2"])
tupleType.assert([1, '2'])
// good
tupleArrayType.assert([[1,"2"]])
tupleArrayType.assert([[1, '2']])
// no good!
const resp = unionType([[1,"2"]])
if(resp instanceof ArkErrors) {
const resp = unionType([[1, '2']])
if (resp instanceof ArkErrors) {
const err = resp[0]
console.log(err.data)
console.log(err.problem)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,90 +1,91 @@
import { c } from "#/di"
import { WApi } from "#/lib/wynn/wapi"
import { PG } from "#/services/pg"
import { log } from "@temporalio/activity"
import { type } from "arktype"
import axios from "axios"
import { log } from '@temporalio/activity'
import { type } from 'arktype'
import axios from 'axios'
import { c } from '#/di'
import { WApi } from '#/lib/wynn/wapi'
import { PG } from '#/services/pg'
const playerSchemaFail = type({
code: "string",
message: "string",
code: 'string',
message: 'string',
data: type({
player: {
meta: {
cached_at: "number"
cached_at: 'number',
},
username: 'string',
id: 'string',
raw_id: 'string',
avatar: 'string',
skin_texture: 'string',
properties: [
{
name: 'string',
value: 'string',
signature: 'string',
},
],
name_history: 'unknown[]',
},
username: "string",
id: "string",
raw_id: "string",
avatar: "string",
skin_texture: "string",
properties: [{
name: "string",
value: "string",
signature: "string"
}],
name_history: "unknown[]"
}
}),
success: "false"
success: 'false',
})
const playerSchemaSuccess = type({
code: "string",
message: "string",
code: 'string',
message: 'string',
data: type({
player: {
meta: {
cached_at: "number"
cached_at: 'number',
},
username: 'string',
id: 'string',
raw_id: 'string',
avatar: 'string',
skin_texture: 'string',
properties: [
{
name: 'string',
value: 'string',
signature: 'string',
},
],
name_history: 'unknown[]',
},
username: "string",
id: "string",
raw_id: "string",
avatar: "string",
skin_texture: "string",
properties: [{
name: "string",
value: "string",
signature: "string"
}],
name_history: "unknown[]"
}
}),
success: "true"
success: 'true',
})
const playerSchema = playerSchemaFail.or(playerSchemaSuccess)
export const scrape_online_players = async()=>{
export const scrape_online_players = async () => {
const api = await c.getAsync(WApi)
const raw = await api.get('/v3/player')
const onlineList = type({
total: "number",
total: 'number',
players: {
"[string]": "string | null",
}
'[string]': 'string | null',
},
}).assert(raw.data)
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
// 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`
if(ans.length === 0){
if (ans.length === 0) {
// the user doesn't exist, so we need to grab their uuid
try {
const resp = await axios.get(`https://playerdb.co/api/player/minecraft/${playerName}`, {
headers: {
"User-Agent": "lil-robot-guy (a@tuxpa.in)",
}
'User-Agent': 'lil-robot-guy (a@tuxpa.in)',
},
})
const parsedPlayer = playerSchema.assert(resp.data)
if(!parsedPlayer.success){
if (!parsedPlayer.success) {
log.warn(`failed to get uuid for ${playerName}`, {
"payload": parsedPlayer,
payload: parsedPlayer,
})
continue
}
@ -95,22 +96,22 @@ export const scrape_online_players = async()=>{
name = EXCLUDED.name,
server = EXCLUDED.server
`
}catch(e) {
} catch (e) {
log.warn(`failed to get uuid for ${playerName}`, {
"err": e,
err: e,
})
continue
}
}
}
await sql.begin(async (sql)=>{
await sql.begin(async (sql) => {
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 {
await sql`update minecraft.user set server = ${server} where name = ${playerName}`
}catch(e) {
} catch (e) {
log.warn(`failed to update server for ${playerName}`, {
"err": e,
err: e,
})
continue
}

View File

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

View File

@ -1,36 +1,28 @@
import { Command } from 'clipanion';
import { Command } from 'clipanion'
// di
import "#/services/pg"
import { DISCORD_GUILD_ID } from '#/constants';
import { Bot } from '#/discord/bot';
import { events } from '#/discord/botevent/handler';
import { SLASH_COMMANDS } from '#/discord/botevent/slash_commands';
import { c } from '#/di';
import { config } from '#/config';
import '#/services/pg'
import { config } from '#/config'
import { DISCORD_GUILD_ID } from '#/constants'
import { c } from '#/di'
import { Bot } from '#/discord/bot'
import { events } from '#/discord/botevent/handler'
import { SLASH_COMMANDS } from '#/discord/botevent/slash_commands'
export class BotCommand extends Command {
static paths = [['bot']];
static paths = [['bot']]
async execute() {
if(!config.DISCORD_TOKEN) {
throw new Error('no discord token found. bot cant start');
if (!config.DISCORD_TOKEN) {
throw new Error('no discord token found. bot cant start')
}
const bot = await c.getAsync(Bot)
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("547828454972850196", SLASH_COMMANDS).catch(console.error)
await bot.rest.upsertGuildApplicationCommands('547828454972850196', SLASH_COMMANDS).catch(console.error)
console.log('connecting bot to gateway');
await bot.start();
console.log('bot connected');
console.log('connecting bot to gateway')
await bot.start()
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
import "#/services/temporal"
import { NativeConnection, Worker } from '@temporalio/worker';
import * as activities from '../activities';
import path from 'path';
import { Client, ScheduleNotFoundError, ScheduleOptions, ScheduleOverlapPolicy } from '@temporalio/client';
import { workflowSyncAllGuilds, workflowSyncGuilds, workflowSyncOnline, workflowSyncGuildLeaderboardInfo } from '#/workflows';
import { PG } from '#/services/pg';
import { config } from '#/config';
import '#/services/temporal'
import path from 'path'
import { Client, ScheduleNotFoundError, type ScheduleOptions, ScheduleOverlapPolicy } from '@temporalio/client'
import { NativeConnection, Worker } from '@temporalio/worker'
import { PG } from '#/services/pg'
import { workflowSyncAllGuilds, workflowSyncGuildLeaderboardInfo, workflowSyncGuilds, workflowSyncOnline } from '#/workflows'
import * as activities from '../activities'
import { config } from '#/config'
const schedules: ScheduleOptions[] = [
{
scheduleId: "update-guild-players",
scheduleId: 'update-guild-players',
action: {
type: 'startWorkflow',
workflowType: workflowSyncGuilds,
@ -28,13 +25,15 @@ const schedules: ScheduleOptions[] = [
overlap: ScheduleOverlapPolicy.SKIP,
},
spec: {
intervals: [{
intervals: [
{
every: '15 minutes',
}]
},
],
},
},
{
scheduleId: "update_guild_leaderboards",
scheduleId: 'update_guild_leaderboards',
action: {
type: 'startWorkflow',
workflowType: workflowSyncGuildLeaderboardInfo,
@ -44,13 +43,15 @@ const schedules: ScheduleOptions[] = [
overlap: ScheduleOverlapPolicy.SKIP,
},
spec: {
intervals: [{
intervals: [
{
every: '5 minutes',
}]
},
],
},
},
{
scheduleId: "update-all-guilds",
scheduleId: 'update-all-guilds',
action: {
type: 'startWorkflow',
workflowType: workflowSyncAllGuilds,
@ -60,13 +61,15 @@ const schedules: ScheduleOptions[] = [
overlap: ScheduleOverlapPolicy.SKIP,
},
spec: {
intervals: [{
intervals: [
{
every: '1 hour',
}]
},
],
},
},
{
scheduleId: "update-online-players",
scheduleId: 'update-online-players',
action: {
type: 'startWorkflow',
workflowType: workflowSyncOnline,
@ -76,38 +79,39 @@ const schedules: ScheduleOptions[] = [
overlap: ScheduleOverlapPolicy.SKIP,
},
spec: {
intervals: [{
intervals: [
{
every: '31 seconds',
}]
},
],
},
},
]
const addSchedules = async (c: Client) => {
for(const o of schedules) {
for (const o of schedules) {
const handle = c.schedule.getHandle(o.scheduleId)
try {
const desc = await handle.describe();
const desc = await handle.describe()
console.log(desc)
}catch(e: any){
if(e instanceof ScheduleNotFoundError) {
} catch (e: any) {
if (e instanceof ScheduleNotFoundError) {
await c.schedule.create(o)
}else {
throw e;
} else {
throw e
}
}
}
}
export class WorkerCommand extends Command {
static paths = [['worker']];
static paths = [['worker']]
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
await addSchedules(client);
await addSchedules(client)
const connection = await NativeConnection.connect({
address: config.TEMPORAL_HOSTPORT,
@ -120,29 +124,24 @@ export class WorkerCommand extends Command {
payloadConverterPath: require.resolve('../payload_converter'),
},
bundlerOptions: {
webpackConfigHook: (config)=>{
if(!config.resolve) config.resolve = {};
if(!config.resolve.alias) config.resolve.alias = {};
webpackConfigHook: (config) => {
if (!config.resolve) config.resolve = {}
if (!config.resolve.alias) config.resolve.alias = {}
config.resolve!.alias = {
"#":path.resolve(process.cwd(),'src/'),
'#': path.resolve(process.cwd(), 'src/'),
...config.resolve!.alias,
}
return config;
}},
return config
},
},
taskQueue: 'wynn-worker-ts',
stickyQueueScheduleToStartTimeout: 5 * 1000,
activities
});
await worker.run();
activities,
})
await worker.run()
console.log("worked.run exited");
await db.end();
await connection.close();
console.log('worked.run exited')
await db.end()
await connection.close()
}
}

View File

@ -1,7 +1,7 @@
import { z } from 'zod';
import { parseEnv} from 'znv';
import {config as dotenvConfig} from 'dotenv';
dotenvConfig();
import { config as dotenvConfig } from 'dotenv'
import { parseEnv } from 'znv'
import { z } from 'zod'
dotenvConfig()
const schemaConfig = {
DISCORD_TOKEN: z.string().optional(),
@ -17,9 +17,8 @@ const schemaConfig = {
PG_PORT: z.number().int().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(),
};
}
export const config = parseEnv(process.env, schemaConfig)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,24 +1,26 @@
import { Bot } from "#/discord/bot"
import { ActivityTypes, InteractionTypes } from "discordeno"
import { c } from "#/di"
import { Client } from "@temporalio/client"
import { workflowHandleInteractionCreate } from "#/workflows"
import { BotType } from "#/discord"
import { Client } from '@temporalio/client'
import { ActivityTypes, InteractionTypes } from 'discordeno'
import { c } from '#/di'
import type { BotType } from '#/discord'
import { Bot } from '#/discord/bot'
import { workflowHandleInteractionCreate } from '#/workflows'
export const events = () => {return {
export const events = () => {
return {
interactionCreate: async (interaction) => {
if(interaction.acknowledged) {
if (interaction.acknowledged) {
return
}
if(interaction.type !== InteractionTypes.ApplicationCommand) {
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
const handle = await temporalClient.workflow.start(workflowHandleInteractionCreate, {
args: [{
args: [
{
ref: {
id: interaction.id,
token: interaction.token,
@ -26,17 +28,18 @@ export const events = () => {return {
acknowledged: interaction.acknowledged,
},
data: interaction.data,
}],
},
],
workflowId: `discord-interaction-${interaction.id}`,
taskQueue: 'wynn-worker-ts',
});
})
// Wait for the workflow to complete
await handle.result();
await handle.result()
return
},
ready: async ({shardId}) => {
ready: async ({ shardId }) => {
const bot = await c.getAsync(Bot)
await bot.gateway.editShardStatus(shardId, {
status: 'online',
@ -50,5 +53,6 @@ export const events = () => {return {
},
],
})
}
} as BotType['events']}
},
} 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 = [
{
name: `guild`,
description: "guild commands",
description: 'guild commands',
type: ApplicationCommandTypes.ChatInput,
options: [
{
name: "leaderboard",
description: "view the current leaderboard",
name: 'leaderboard',
description: 'view the current leaderboard',
type: ApplicationCommandOptionTypes.SubCommand,
},
{
name: "info",
description: "view guild information",
name: 'info',
description: 'view guild information',
type: ApplicationCommandOptionTypes.SubCommand,
},
{
name: "online",
description: "show online players",
name: 'online',
description: 'show online players',
type: ApplicationCommandOptionTypes.SubCommand,
},
],
},
{
name: "admin",
description: "admin commands",
name: 'admin',
description: 'admin commands',
type: ApplicationCommandTypes.ChatInput,
defaultMemberPermissions: [
"ADMINISTRATOR",
],
defaultMemberPermissions: ['ADMINISTRATOR'],
options: [
{
name: "set_wynn_guild",
description: "set the default wynncraft guild for the server",
name: 'set_wynn_guild',
description: 'set the default wynncraft guild for the server',
type: ApplicationCommandOptionTypes.SubCommand,
},
],
},{
name: "player",
description: "player commands",
},
{
name: 'player',
description: 'player commands',
type: ApplicationCommandTypes.ChatInput,
options: [
{
name: "lookup",
description: "view player information",
name: 'lookup',
description: 'view player information',
type: ApplicationCommandOptionTypes.SubCommand,
options: [
{
name: "player",
description: "player name",
name: 'player',
description: 'player name',
type: ApplicationCommandOptionTypes.String,
required: true,
},
],
}
},
],
}
},
] 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 InteractionHandler = NonNullable<BotType['events']['interactionCreate']>

View File

@ -1,20 +1,20 @@
import { Intents, InteractionTypes } from "@discordeno/types";
import type { Bot, DesiredPropertiesBehavior, CompleteDesiredProperties } from "discordeno";
import { Intents, type InteractionTypes } from '@discordeno/types'
import type { Bot, CompleteDesiredProperties, DesiredPropertiesBehavior } from 'discordeno'
export const intents = [
Intents.GuildModeration ,
Intents.GuildWebhooks ,
Intents.GuildExpressions ,
Intents.GuildScheduledEvents ,
Intents.GuildMessagePolls ,
Intents.GuildIntegrations ,
Intents.GuildInvites ,
Intents.GuildMessageReactions ,
Intents.GuildPresences ,
Intents.DirectMessages ,
Intents.DirectMessageReactions ,
Intents.GuildMembers ,
Intents.Guilds ,
Intents.GuildInvites ,
Intents.GuildModeration,
Intents.GuildWebhooks,
Intents.GuildExpressions,
Intents.GuildScheduledEvents,
Intents.GuildMessagePolls,
Intents.GuildIntegrations,
Intents.GuildInvites,
Intents.GuildMessageReactions,
Intents.GuildPresences,
Intents.DirectMessages,
Intents.DirectMessageReactions,
Intents.GuildMembers,
Intents.Guilds,
Intents.GuildInvites,
Intents.GuildMessages,
] as const
@ -39,29 +39,28 @@ export const createBotParameters = {
member: true,
guildId: true,
},
}
},
} as const
// 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
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
export interface InteractionRef {
id: bigint;
token: string;
type: InteractionTypes;
acknowledged?: boolean;
id: bigint
token: string
type: InteractionTypes
acknowledged?: boolean
}
// 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
export interface InteractionCreatePayload {
ref: InteractionRef;
data: InteractionData;
ref: InteractionRef
data: InteractionData
}

View File

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

View File

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

View File

@ -1,39 +1,34 @@
export class TabWriter {
columns: string[][]
columns: string[][];
constructor(
private readonly spacing: number = 2
) {
constructor(private readonly spacing: number = 2) {
this.columns = []
}
add(row: string[]) {
if(this.columns.length == 0) {
this.columns = new Array(row.length).fill(0).map(() => []);
if (this.columns.length == 0) {
this.columns = new Array(row.length).fill(0).map(() => [])
}
if(row.length != this.columns.length) {
throw new Error(`Row length ${row.length} does not match columns 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}`)
}
for(let i = 0; i < row.length; i++) {
this.columns[i].push(row[i]);
for (let i = 0; i < row.length; i++) {
this.columns[i].push(row[i])
}
}
build() {
let out = ""
if(this.columns.length == 0) {
return "";
let out = ''
if (this.columns.length == 0) {
return ''
}
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++) {
if (i > 0) out += "\n";
for(let j = 0; j < this.columns.length; j++) {
out+= this.columns[j][i].padEnd(columnWidths[j]);
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++) {
if (i > 0) out += '\n'
for (let j = 0; j < this.columns.length; 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
*/
export const RANK_EMOJIS = {
"OWNER": "👑",
"CHIEF": "⭐",
"STRATEGIST": "🎯",
"CAPTAIN": "⚔️",
"RECRUITER": "📢",
"RECRUIT": "🌱",
} as const;
OWNER: '👑',
CHIEF: '⭐',
STRATEGIST: '🎯',
CAPTAIN: '⚔️',
RECRUITER: '📢',
RECRUIT: '🌱',
} as const
/**
* 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
*/
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
*/
export function formatNumber(num: number): string {
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`;
return num.toLocaleString();
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`
return num.toLocaleString()
}

View File

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

View File

@ -6,10 +6,9 @@ export const WynnGuildOverviewMember = z.object({
server: z.null(),
contributed: z.number(),
contributionRank: z.number(),
joined: z.string()
joined: z.string(),
})
const WapiV3GuildMembers = z.record(z.string(), WynnGuildOverviewMember)
export const WapiV3GuildOverview = z.object({
@ -35,22 +34,16 @@ export const WapiV3GuildOverview = z.object({
base: z.string(),
tier: z.number(),
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 WynnDropRestriction = z.enum(["normal","never","dungeon", "lootchest"])
const WynnDropRestriction = z.enum(['normal', 'never', 'dungeon', 'lootchest'])
const WynnItemRestrictions = z.enum([
"untradable", "quest item",
])
const WynnItemRestrictions = z.enum(['untradable', 'quest item'])
const WynnEquipRequirements = z.object({
level: z.number(),
@ -62,23 +55,29 @@ const WynnEquipRequirements = z.object({
agility: z.number().optional(),
})
const WynnBaseStats = z.record(z.string(),z.union([
const WynnBaseStats = z.record(
z.string(),
z.union([
z.number(),
z.object({
min: z.number(),
raw: z.number(),
max: z.number(),
})
]))
}),
])
)
const WynnIdentifications = z.record(z.string(), z.union([
const WynnIdentifications = z.record(
z.string(),
z.union([
z.number(),
z.object({
min: z.number(),
raw: z.number(),
max: z.number(),
})
]))
}),
])
)
const WynnItemIcon = z.object({
format: z.string(),
@ -109,29 +108,19 @@ const WynnItemIcon = z.object({
export const WapiV3ItemTool = z.object({
internalName: z.string(),
type: z.literal('tool'),
toolType: z.enum([
"axe","pickaxe","rod","scythe",
]),
toolType: z.enum(['axe', 'pickaxe', 'rod', 'scythe']),
identified: z.boolean().optional(),
gatheringSpeed: z.number(),
requirements: z.object({
level: z.number(),
}),
icon: WynnItemIcon,
rarity:WynnItemRarity,
rarity: WynnItemRarity,
})
export const WapiV3ItemTome = z.object({
internalName: z.string(),
tomeType: z.enum([
"guild_tome",
"marathon_tome",
"mysticism_tome",
"weapon_tome",
"armour_tome",
"expertise_tome",
"lootrun_tome",
]),
tomeType: z.enum(['guild_tome', 'marathon_tome', 'mysticism_tome', 'weapon_tome', 'armour_tome', 'expertise_tome', 'lootrun_tome']),
raidReward: z.boolean(),
type: z.literal('tome'),
restrictions: WynnItemRestrictions.optional(),
@ -141,7 +130,7 @@ export const WapiV3ItemTome = z.object({
lore: z.string().optional(),
icon: WynnItemIcon,
base: WynnBaseStats.optional(),
rarity:WynnItemRarity,
rarity: WynnItemRarity,
})
export const WapiV3ItemCharm = z.object({
@ -154,43 +143,35 @@ export const WapiV3ItemCharm = z.object({
lore: z.string().optional(),
icon: WynnItemIcon,
base: WynnBaseStats,
rarity:WynnItemRarity,
rarity: WynnItemRarity,
})
export const WapiV3ItemAccessory = z.object({
export const WapiV3ItemAccessory = z
.object({
internalName: z.string(),
type: z.literal('accessory'),
identified: z.boolean().optional(),
accessoryType: z.enum([
"ring","necklace","bracelet",
]),
accessoryType: z.enum(['ring', 'necklace', 'bracelet']),
majorIds: z.record(z.string(), z.string()).optional(),
restrictions: WynnItemRestrictions.optional(),
dropMeta: WynnDropMeta.optional(),
dropRestriction: WynnDropRestriction,
requirements:WynnEquipRequirements,
requirements: WynnEquipRequirements,
lore: z.string().optional(),
icon: WynnItemIcon,
identifications: WynnIdentifications.optional(),
base: WynnBaseStats.optional(),
rarity:WynnItemRarity,
}).strict()
rarity: WynnItemRarity,
})
.strict()
export const WapiV3ItemIngredient = z.object({
export const WapiV3ItemIngredient = z
.object({
internalName: z.string(),
type: z.literal('ingredient'),
requirements: z.object({
level: z.number(),
skills: z.array(z.enum([
"alchemism",
"armouring",
"cooking",
"jeweling",
"scribing",
"tailoring",
"weaponsmithing",
"woodworking",
])),
skills: z.array(z.enum(['alchemism', 'armouring', 'cooking', 'jeweling', 'scribing', 'tailoring', 'weaponsmithing', 'woodworking'])),
}),
icon: WynnItemIcon,
identifications: WynnIdentifications.optional(),
@ -198,43 +179,58 @@ export const WapiV3ItemIngredient = z.object({
consumableOnlyIDs: z.record(z.string(), z.number()),
ingredientPositionModifiers: z.record(z.string(), z.number()),
itemOnlyIDs: z.record(z.string(), z.number()),
droppedBy: z.array(z.object({
droppedBy: z
.array(
z.object({
name: z.string(),
coords: z.union([
z.boolean(),
z.array(z.number()).length(4),
z.array(z.array(z.number()).length(4)),
]).nullable()
})).optional()
}).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
.object({
internalName: z.string(),
type: z.literal('material'),
identified: z.boolean(),
requirements: z.object({
level: z.number(),
}),
craftable: z.array(z.enum([
'potions','food','scrolls',
'helmets','chestplates','rings','bracelets',
'necklaces','boots','leggings','bows','wands','spears',
'daggers','chestplates','helmets'])),
craftable: z.array(
z.enum([
'potions',
'food',
'scrolls',
'helmets',
'chestplates',
'rings',
'bracelets',
'necklaces',
'boots',
'leggings',
'bows',
'wands',
'spears',
'daggers',
'chestplates',
'helmets',
])
),
icon: WynnItemIcon,
tier: z.number(),
}).strict()
})
.strict()
export const WapiV3ItemWeapon = z.object({
export const WapiV3ItemWeapon = z
.object({
internalName: z.string(),
type: z.literal('weapon'),
identified: z.boolean().optional(),
allowCraftsman: z.boolean().optional(),
weaponType: z.enum([
"bow","relik","wand","dagger","spear"
]),
attackSpeed: z.enum([
"super_slow", "very_slow", "slow","normal","fast", "very_fast","super_fast"
]),
weaponType: z.enum(['bow', 'relik', 'wand', 'dagger', 'spear']),
attackSpeed: z.enum(['super_slow', 'very_slow', 'slow', 'normal', 'fast', 'very_fast', 'super_fast']),
powderSlots: z.number().optional(),
averageDps: z.number().optional(),
restrictions: WynnItemRestrictions.optional(),
@ -246,11 +242,12 @@ export const WapiV3ItemWeapon = z.object({
icon: WynnItemIcon,
identifications: WynnIdentifications.optional(),
base: WynnBaseStats.optional(),
rarity:WynnItemRarity,
}).strict()
rarity: WynnItemRarity,
})
.strict()
export const WapiV3ItemArmour = z.object({
export const WapiV3ItemArmour = z
.object({
internalName: z.string(),
type: z.literal('armour'),
armourType: z.string(),
@ -274,19 +271,25 @@ export const WapiV3ItemArmour = z.object({
majorIds: z.record(z.string(), z.string()).optional(),
powderSlots: z.number().optional(),
lore: z.string().optional(),
identifications: z.record(z.string(), z.union([
identifications: z
.record(
z.string(),
z.union([
z.number(),
z.object({
min: z.number(),
raw: z.number(),
max: z.number(),
})
])).optional(),
}),
])
)
.optional(),
base: WynnBaseStats.optional(),
rarity:WynnItemRarity,
}).strict()
rarity: WynnItemRarity,
})
.strict()
export const WApiV3Item = z.discriminatedUnion("type",[
export const WApiV3Item = z.discriminatedUnion('type', [
WapiV3ItemMaterial,
WapiV3ItemWeapon,
WapiV3ItemArmour,
@ -296,4 +299,4 @@ export const WApiV3Item = z.discriminatedUnion("type",[
WapiV3ItemTome,
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 axios, { AxiosInstance } from "axios";
import { buildStorage, canStale, setupCache } from 'axios-cache-interceptor';
import { BentoCache } from "bentocache";
import "#/services/bento";
import { logger } from "#/logger";
import { inject, injectable } from '@needle-di/core'
import axios, { type AxiosInstance } from 'axios'
import { buildStorage, canStale, setupCache } from 'axios-cache-interceptor'
import { BentoCache } from 'bentocache'
import { config } from '#/config'
import '#/services/bento'
import { logger } from '#/logger'
@injectable()
export class WApi {
@ -14,51 +13,42 @@ export class WApi {
private readonly log = logger.child({ module: 'wapi' })
constructor(
private readonly bento = inject(BentoCache)
) {
constructor(private readonly bento = inject(BentoCache)) {
const c = axios.create({
baseURL: config.WAPI_URL,
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 self = this
setupCache(c, {
interpretHeader: true,
ttl: 5000,
storage: buildStorage({
async find(key, currentRequest) {
const value = await store.get({key})
if(!value) {
return;
const value = await store.get({ key })
if (!value) {
return
}
return JSON.parse(value)
},
async remove(key, req) {
await store.delete({key})
await store.delete({ key })
},
async set(key, value, req) {
let expireTime = value.state === 'loading'
? Date.now() +
(req?.cache && typeof req.cache.ttl === 'number'
? req.cache.ttl
:
3000)
const expireTime =
value.state === 'loading'
? Date.now() + (req?.cache && typeof req.cache.ttl === 'number' ? req.cache.ttl : 3000)
: // When a stale state has a determined value to expire, we can use it.
// Or if the cached value cannot enter in stale state.
(value.state === 'stale' && value.ttl) ||
(value.state === 'cached' && !canStale(value))
?
value.createdAt + value.ttl!
(value.state === 'stale' && value.ttl) || (value.state === 'cached' && !canStale(value))
? value.createdAt + value.ttl!
: // otherwise, we can't determine when it should expire, so we keep
// it indefinitely.
undefined
let ttl: number | undefined
if(expireTime) {
if (expireTime) {
ttl = expireTime - Date.now()
}
await store.set({
@ -70,20 +60,18 @@ export class WApi {
async 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, {
params,
headers: {
'Accept': 'application/json',
Accept: 'application/json',
'Content-Type': 'application/json',
}
},
})
}
}

View File

@ -1,5 +1,4 @@
import {pino} from 'pino'
import { pino } from 'pino'
export const logger = pino({
transport: {
@ -8,4 +7,4 @@ export const logger = pino({
level: process.env.PINO_LOG_LEVEL || 'info',
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 {
EncodingType,
METADATA_ENCODING_KEY,
Payload,
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';
import { type EncodingType, METADATA_ENCODING_KEY, type Payload, PayloadConverterError, type 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.
*/
export class SuperJsonPayloadConverter implements PayloadConverterWithEncoding {
// 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 {
if (value === undefined) return undefined;
let ejson;
if (value === undefined) return undefined
let ejson
try {
ejson = superjson.stringify(value);
ejson = superjson.stringify(value)
} catch (e) {
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: ${
errorMessage(
e,
`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(
e
)}`,
e as Error
)
}`,
e as Error,
);
}
return {
@ -41,21 +31,21 @@ export class SuperJsonPayloadConverter implements PayloadConverterWithEncoding {
format: encode('extended'),
},
data: encode(ejson),
};
}
}
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 {
public readonly name: string = 'UnsupportedJsonTypeError';
public readonly name: string = 'UnsupportedJsonTypeError'
constructor(
message: string | undefined,
public readonly cause?: Error,
public readonly cause?: Error
) {
super(message ?? undefined);
super(message ?? undefined)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,13 @@
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from '#/activities';
import { proxyActivities } from '@temporalio/workflow'
import type * as activities from '#/activities'
const { update_wynn_items } = proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute',
retry: {
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 type * as activities from "#/activities";
import { InteractionRef } from "#/discord";
import { proxyActivities } from '@temporalio/workflow'
import type * as activities from '#/activities'
import type { InteractionRef } from '#/discord'
const {
reply_to_interaction
} = proxyActivities<typeof activities>({
const { reply_to_interaction } = proxyActivities<typeof activities>({
startToCloseTimeout: '30 seconds',
});
})
interface CommandPayload {
ref: InteractionRef;
ref: InteractionRef
args: {
player: string;
};
player: string
}
}
export async function handleCommandPlayerLookup(payload: CommandPayload): Promise<void> {
const { ref, args } = payload;
const playerName = args.player;
const { ref, args } = payload
const playerName = args.player
try {
// 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!*`,
isPrivate: false,
},
});
})
} catch (error) {
await reply_to_interaction({
ref,
@ -38,6 +36,6 @@ export async function handleCommandPlayerLookup(payload: CommandPayload): Promis
content: `Error looking up player: ${playerName}`,
isPrivate: true,
},
});
})
}
}

View File

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

View File

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