This commit is contained in:
a 2025-02-26 21:56:30 -06:00
commit 01eae7fec7
No known key found for this signature in database
GPG Key ID: 2F22877AA4DFDADB
47 changed files with 5310 additions and 0 deletions

10
.editorconfig Normal file
View File

@ -0,0 +1,10 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
[*.{js,json,yml}]
charset = utf-8
indent_style = space
indent_size = 2

4
.gitattributes vendored Normal file
View File

@ -0,0 +1,4 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated

View File

@ -0,0 +1,40 @@
name: commit-tag
on:
push
jobs:
commit-tag-image:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
env:
DOCKER_LATEST: nightly
RUNNER_TOOL_CACHE: /toolcache
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
registry: https://tuxpa.in
username: actions_token
password: ${{ secrets.ACTIONS_TOKEN }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: |
linux/amd64
linux/arm64
push: true
tags: |
tuxpa.in/a/wynn:${{ steps.meta.outputs.REPO_VERSION }}

View File

@ -0,0 +1,42 @@
name: release-tag
on:
push:
tags:
- 'v*'
jobs:
release-image:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
env:
DOCKER_LATEST: nightly
RUNNER_TOOL_CACHE: /toolcache
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: https://tuxpa.in
username: actions_token
password: ${{ secrets.ACTIONS_TOKEN }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
platforms: |
linux/amd64
linux/arm64
push: true
tags: |
tuxpa.in/a/wynn:${{ gitea.ref_name }}

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Swap the comments on the following lines if you wish to use zero-installs
# In that case, don't forget to run `yarn config set enableGlobalCache false`!
# Documentation here: https://yarnpkg.com/features/caching#zero-installs
#!.yarn/cache
.pnp.*
node_modules/
.env

1
.yarnrc.yml Normal file
View File

@ -0,0 +1 @@
nodeLinker: node-modules

13
Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM node:23
WORKDIR /app
RUN npm i -g tsx
COPY package.json yarn.lock .yarnrc.yml .
RUN corepack yarn install
COPY . .
ENTRYPOINT ["/app/cli"]

1
README.md Normal file
View File

@ -0,0 +1 @@
# wynn

11
barrelsby.json Normal file
View File

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

2
cli Executable file
View File

@ -0,0 +1,2 @@
#!/usr/bin/env tsx
import "./src/main.ts";

9
docker-compose.yaml Normal file
View File

@ -0,0 +1,9 @@
version: '3'
services:
db:
image: postgres
restart: always
environment:
POSTGRES_PASSWORD: postgres
ports:
- 54327:5432

39
package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "backend",
"packageManager": "yarn@4.6.0",
"scripts": {
"barrels": "barrelsby -c barrelsby.json --delete"
},
"devDependencies": {
"@types/object-hash": "^3",
"barrelsby": "^2.8.1",
"rollup": "^4.34.8",
"typescript": "5.7.3"
},
"dependencies": {
"@needle-di/core": "^0.10.1",
"@temporalio/activity": "^1.11.7",
"@temporalio/client": "^1.11.7",
"@temporalio/common": "^1.11.7",
"@temporalio/worker": "^1.11.7",
"@temporalio/workflow": "^1.11.7",
"@types/node": "^22.13.4",
"any-date-parser": "^2.0.3",
"arktype": "2.0.4",
"axios": "^1.7.9",
"chrono-node": "^2.7.8",
"clipanion": "^4.0.0-rc.4",
"cloudevents": "^8.0.2",
"discordeno": "^21.0.0",
"dotenv": "^16.4.7",
"hash-wasm": "^4.12.0",
"json-stable-stringify": "^1.2.1",
"object-hash": "^3.0.0",
"postgres": "^3.4.5",
"ts-markdown-builder": "^0.4.0",
"why-is-node-running": "^3.2.2",
"znv": "^0.4.0",
"zod": "^3.24.1",
"zod-config": "^0.1.2"
}
}

21
scratch/test.ts Normal file
View File

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

View File

@ -0,0 +1,18 @@
import { WApiV3ItemDatabase } from "#/lib/types";
import { WApi } from "#/lib/wapi";
import { ArkError, ArkErrors } from "arktype";
export async function update_wynn_items() {
const api = new WApi()
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){
console.log(parsed[0].problem)
console.log(parsed[0].path)
console.log(parsed[0].data)
}
}

File diff suppressed because one or more lines are too long

95
src/activities/guild.ts Normal file
View File

@ -0,0 +1,95 @@
import { container, T_PG } from "#/di";
import { WapiV3GuildOverview } from "#/lib/types";
import { WApi } from "#/lib/wapi";
import { type } from "arktype";
import {parseDate} from "chrono-node";
export async function update_all_guilds() {
const api = new WApi()
const ans = await api.get('/v3/guild/list/guild')
if(ans.status !== 200){
throw new Error('Failed to get guild list from wapi')
}
const parsed = type({
"[string]": {
uuid: "string",
prefix: "string",
}
}).assert(ans.data)
const db = container.get(T_PG)
await db.begin(async (sql) => {
for(const [guild_name, guild] of Object.entries(parsed)){
await sql`insert into wynn_guild_info
(uid, name, prefix)
values
(${guild.uuid}, ${guild_name}, ${guild.prefix})
on conflict (uid) do update set
name = EXCLUDED.name,
prefix = EXCLUDED.prefix
`
}
})
}
export async function update_guild({
guild_name
}:{
guild_name: string
}) {
const api = new WApi()
const ans = await api.get(`/v3/guild/${guild_name}`)
if(ans.status !== 200){
throw new Error('Failed to get guild into from wapi')
}
const parsed = WapiV3GuildOverview.assert(ans.data)
const db = container.get(T_PG)
await db.begin(async (sql) => {
await sql`insert into wynn_guild_info
(uid, name, prefix, level, xp_percent, territories, wars, created)
values
(${parsed.uuid}, ${parsed.name}, ${parsed.prefix}, ${parsed.level}, ${parsed.xpPercent}, ${parsed.territories}, ${parsed.wars}, ${parseDate(parsed.created)})
on conflict (uid) do update set
name = EXCLUDED.name,
prefix = EXCLUDED.prefix,
level = EXCLUDED.level,
xp_percent = EXCLUDED.xp_percent,
territories = EXCLUDED.territories,
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)) {
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})
on conflict (guild_id, member_id) do update set
rank = EXCLUDED.rank,
joined_at = EXCLUDED.joined_at,
contributed = EXCLUDED.contributed
`
await sql`insert into minecraft_user
(uid, name, server) values (${member.uuid}, ${userName}, ${member.server})
on conflict (uid) do update set
name = EXCLUDED.name,
server = EXCLUDED.server
`
}
}
for (const [season, seasonData] of Object.entries(parsed.seasonRanks)) {
await sql`insert into wynn_guild_season_results
(guild_id, season, rating, territories) values
(${parsed.uuid}, ${season}, ${seasonData.rating}, ${seasonData.finalTerritories})
on conflict (guild_id, season) do update set
rating = EXCLUDED.rating,
territories = EXCLUDED.territories
`
}
})
}

7
src/activities/index.ts Normal file
View File

@ -0,0 +1,7 @@
/**
* @file Automatically generated by barrelsby.
*/
export * from "./database";
export * from "./guild";
export * from "./players";

119
src/activities/players.ts Normal file
View File

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

90
src/bot/common/guild.ts Normal file
View File

@ -0,0 +1,90 @@
import { Sql } from "postgres";
import { CreateMessageOptions, Embed, InteractionCallbackOptions } from "discordeno"
import {type} from "arktype"
import { TabWriter } from "#/lib/tabwriter"
import * as md from 'ts-markdown-builder';
export const formGuildOnlineMessage = async (guild_id: string, sql:Sql): Promise<CreateMessageOptions & InteractionCallbackOptions> => {
const result = await sql`select
name,
rank,
contributed,
minecraft_user.server as server
from wynn_guild_members inner join minecraft_user
on minecraft_user.uid = wynn_guild_members.member_id
where minecraft_user.server is not null
and wynn_guild_members.guild_id = ${guild_id}
`
const members = type({
name: "string",
rank: "string",
contributed: "string",
server: "string",
}).array().assert(result)
if(members.length == 0) {
return {
content: "nobody is online :(",
}
}
members.sort((a, b) => Number(b.contributed) - Number(a.contributed))
// group members by server
const membersByServer= members.reduce((acc, member) => {
if(acc[member.server] == undefined) {
acc[member.server] = []
}
acc[member.server].push(member)
return acc
}, {} as Record<string, typeof members>)
const output = Object.entries(membersByServer).map(([server, mx]) => {
return `**[${server}]** (${mx.length}): ${mx.map(m => m.name).join(", ")}`
}).join(", ")
return {
content: `**total**: ${members.length} \n` + output,
}
}
export const formGuildLeaderboardMessage = async (guild_id: string, sql:Sql): Promise<CreateMessageOptions & InteractionCallbackOptions> => {
const result = await sql`select
name,
rank,
contributed
from wynn_guild_members inner join minecraft_user
on minecraft_user.uid = wynn_guild_members.member_id
where wynn_guild_members.guild_id = ${guild_id}
`
const members = type({
name: "string",
rank: "string",
contributed: "string",
}).array().assert(result)
const tw = new TabWriter()
members.sort((a, b) => Number(b.contributed) - Number(a.contributed))
let idx = 1
for (const member of members.slice(0,10)) {
tw.add([
`${idx}.`,
member.rank,
member.name,
Number(member.contributed).toLocaleString(),
])
idx = idx + 1
}
const built = tw.build()
const output = [
md.heading("Guild Exp:"),
md.codeBlock(built),
].join("\n\n")
return {
content: output,
}
}

47
src/bot/index.ts Normal file
View File

@ -0,0 +1,47 @@
import { config } from "#/config";
import {createBot, Intents} from "discordeno"
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.GuildMessages,
]
export const bot = createBot({
intents: intents.reduce((acc, curr) => acc | curr, Intents.Guilds),
token: config.DISCORD_TOKEN,
desiredProperties: {
interaction: {
id: true,
data: true,
type: true,
token: true,
message: true,
channelId: true,
channel: true,
guildId: true,
guild: true,
user: true,
member: true,
},
message: {
id: true,
member: true,
guildId: true,
},
}
})

77
src/botevent/index.ts Normal file
View File

@ -0,0 +1,77 @@
import {bot} from "#/bot"
import { ActivityTypes, ApplicationCommandOptionTypes, InteractionTypes } from "discordeno"
import { InteractionHandler, MuxHandler, SlashHandler } from "./types"
import { root } from "./slash_commands"
import { uuid4 } from "@temporalio/workflow"
export const slashHandler: InteractionHandler = async (interaction) => {
if(!interaction.data) {
return
}
if(!interaction.data.name) {
return
}
const commandPath: string[] = [interaction.data.name]
if(interaction.data.options) {
for(const option of interaction.data.options) {
if(option.type === ApplicationCommandOptionTypes.SubCommand) {
commandPath.push(option.name)
}
}
}
let rootHandler: SlashHandler = root
let cur: SlashHandler | MuxHandler<any> = rootHandler
for(let i = 0; i < commandPath.length; i++) {
const handlerName = commandPath[i]
if(typeof cur === 'function') {
return interaction.respond(`oh no a bug. router: got function instead of next handler`)
}
let next: SlashHandler | MuxHandler<any> | undefined = cur[handlerName]
if(!cur) {
return interaction.respond(`command not implemented at ${handlerName}`, {isPrivate: true})
}
cur = next
}
if(typeof cur === 'function') {
// yay, we found command with handler, run the command
try {
await cur(interaction)
return
}catch(e) {
const errId = uuid4()
console.error("invokation exception",errId, commandPath, e)
return interaction.respond(`invokation exception: ${errId}`, {isPrivate: true})
}
}
// ok now we need to go down the handler tree.
}
export const events = {
interactionCreate: async (interaction) => {
if(interaction.acknowledged) {
return
}
if(interaction.type !== InteractionTypes.ApplicationCommand) {
return
}
await slashHandler(interaction)
return
},
ready: async ({shardId}) => {
await bot.gateway.editShardStatus(shardId, {
status: 'online',
activities: [
{
name: 'im frog',
type: ActivityTypes.Playing,
timestamps: {
start: Date.now(),
},
},
],
})
}
} as typeof bot.events

View File

@ -0,0 +1,39 @@
import { formGuildLeaderboardMessage, formGuildOnlineMessage } from "#/bot/common/guild"
import { WYNN_GUILD_ID } from "#/constants"
import { container, T_PG } from "#/di"
import { SlashHandler } from "./types"
export const root: SlashHandler = {
guild: {
info: async (interaction) => {
interaction.respond("TODO: guild info")
},
online: async (interaction) => {
const db = container.get(T_PG)
const msg = await formGuildOnlineMessage(
WYNN_GUILD_ID,
db,
)
await interaction.respond(msg, {
withResponse: true,
})
},
leaderboard: async (interaction) => {
const db = container.get(T_PG)
const leaderboard = await formGuildLeaderboardMessage(
WYNN_GUILD_ID,
db,
)
await interaction.respond(leaderboard, {
withResponse: true,
})
},
},
admin: {
set_wynn_guild: async (interaction) => {
const db = container.get(T_PG)
},
}
}

11
src/botevent/types.ts Normal file
View File

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

28
src/cmd/bot.ts Normal file
View File

@ -0,0 +1,28 @@
import { Command } from 'clipanion';
import * as BotCommands from "#/slashcommands";
// di
import "#/services/pg"
import { DISCORD_GUILD_ID } from '#/constants';
import { bot } from '#/bot';
import { events } from '#/botevent';
export class BotCommand extends Command {
static paths = [['bot']];
async execute() {
bot.events = events
console.log('registring slash commands');
await bot.rest.upsertGuildApplicationCommands(DISCORD_GUILD_ID, Object.values(BotCommands))
console.log('connecting bot to gateway');
await bot.start();
console.log('bot connected');
}
}

133
src/cmd/worker.ts Normal file
View File

@ -0,0 +1,133 @@
import { Command } from 'clipanion';
import { container, T_PG } from '#/di';
import { runMigrations } from '#/services/pg/migrations';
// di
import "#/services/pg"
import "#/services/temporal"
import { NativeConnection, Worker } from '@temporalio/worker';
import { config } from '#/config';
import * as activities from '../activities';
import path from 'path';
import { Client, ScheduleNotFoundError, ScheduleOptions, ScheduleOverlapPolicy } from '@temporalio/client';
import { workflowSyncAllGuilds, workflowSyncGuilds, workflowSyncOnline } from '#/workflows';
const schedules: ScheduleOptions[] = [
{
scheduleId: "update-guild-players",
action: {
type: 'startWorkflow',
workflowType: workflowSyncGuilds,
taskQueue: 'wynn-worker',
},
policies: {
overlap: ScheduleOverlapPolicy.SKIP,
},
spec: {
intervals: [{
every: '15 minutes',
}]
},
},
{
scheduleId: "update-all-guilds",
action: {
type: 'startWorkflow',
workflowType: workflowSyncAllGuilds,
taskQueue: 'wynn-worker',
},
policies: {
overlap: ScheduleOverlapPolicy.SKIP,
},
spec: {
intervals: [{
every: '1 hour',
}]
},
},
{
scheduleId: "update-online-players",
action: {
type: 'startWorkflow',
workflowType: workflowSyncOnline,
taskQueue: 'wynn-worker',
},
policies: {
overlap: ScheduleOverlapPolicy.SKIP,
},
spec: {
intervals: [{
every: '31 seconds',
}]
},
},
]
const addSchedules = async (c: Client) => {
for(const o of schedules) {
const handle = c.schedule.getHandle(o.scheduleId)
try {
const desc = await handle.describe();
console.log(desc)
}catch(e: any){
if(e instanceof ScheduleNotFoundError) {
await c.schedule.create(o)
}else {
throw e;
}
}
}
}
export class WorkerCommand extends Command {
static paths = [['worker']];
async execute() {
const pg = container.get(T_PG);
await runMigrations(pg);
const client = await container.getAsync(Client);
// schedules
await addSchedules(client);
const connection = await NativeConnection.connect({
address: config.TEMPORAL_HOSTPORT,
})
const worker = await Worker.create({
connection,
namespace: config.TEMPORAL_NAMESPACE,
workflowsPath: require.resolve('../workflows'),
bundlerOptions: {
webpackConfigHook: (config)=>{
if(!config.resolve) config.resolve = {};
if(!config.resolve.alias) config.resolve.alias = {};
config.resolve!.alias = {
"#":path.resolve(process.cwd(),'src/'),
...config.resolve!.alias,
}
return config;
}},
taskQueue: 'wynn-worker',
stickyQueueScheduleToStartTimeout: 5 * 1000,
activities
});
await worker.run();
console.log("worked.run exited");
await pg.end();
await connection.close();
}
}

20
src/cmd/wynn.ts Normal file
View File

@ -0,0 +1,20 @@
import { Command } from 'clipanion';
import { container, T_PG } from '#/di';
import { runMigrations } from '#/services/pg/migrations';
// di
import "#/services/pg"
export const WynnCommands = [
class extends Command {
static paths = [['wynn', 'refetch']];
async execute() {
const pg = container.get(T_PG);
await runMigrations(pg);
await pg.end()
}
}
]

21
src/config/index.ts Normal file
View File

@ -0,0 +1,21 @@
import { z } from 'zod';
import { parseEnv} from 'znv';
import {config as dotenvConfig} from 'dotenv';
dotenvConfig();
const schemaConfig = {
DISCORD_TOKEN: z.string(),
TEMPORAL_HOSTPORT: z.string().default('localhost:7233'),
TEMPORAL_NAMESPACE: z.string().default('default'),
PG_URL: z.string().optional(),
PG_USER: z.string().optional(),
PG_HOST: z.string().optional(),
PG_PASSWORD: z.string().optional(),
PG_DATABASE: z.string().optional(),
PG_PORT: z.number().int().optional(),
};
export const config = parseEnv(process.env, schemaConfig)

3
src/constants/index.ts Normal file
View File

@ -0,0 +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";

6
src/di/index.ts Normal file
View File

@ -0,0 +1,6 @@
import { Container, InjectionToken } from "@needle-di/core";
import { Bot } from "discordeno";
import { Sql } from "postgres";
export const container = new Container();
export const T_PG = new InjectionToken<Sql>("T_PG")

40
src/lib/tabwriter.ts Normal file
View File

@ -0,0 +1,40 @@
export class TabWriter {
columns: string[][];
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(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]);
}
}
build() {
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));
console.log(columnWidths)
for(let i = 0; i < this.columns[0].length; i++) {
for(let j = 0; j < this.columns.length; j++) {
out+= this.columns[j][i].padEnd(columnWidths[j]);
}
out+= "\n";
}
return out;
}
}

272
src/lib/types.ts Normal file
View File

@ -0,0 +1,272 @@
import { type } from "arktype"
export const WynnGuildOverviewMember = type({
uuid: "string",
online: "boolean",
server: "null | string",
contributed: "number",
contributionRank: "number",
joined: "string"
})
const WapiV3GuildMembers = type({
"[string]": WynnGuildOverviewMember
})
export const WapiV3GuildOverview = type({
uuid: "string",
name: "string",
prefix: "string",
level: "number",
xpPercent: "number",
territories: "number",
wars: "number",
created: "string",
members: {
total: "number",
owner: WapiV3GuildMembers,
chief: WapiV3GuildMembers,
strategist: WapiV3GuildMembers,
captain: WapiV3GuildMembers,
recruiter: WapiV3GuildMembers,
recruit: WapiV3GuildMembers,
},
online: "number",
banner: {
base: "string",
tier: "number",
structure: "string",
layers: type({ colour: "string", pattern: "string" }).array(),
},
seasonRanks: {
"[string]": {
rating: "number",
finalTerritories: "number"
}
}
})
const WynnItemRarity = type.enumerated("common", "fabled", "legendary", "mythic", "rare", "set", "unique")
const WynnSkills = type.enumerated(
"alchemism",
"armouring",
"cooking",
"jeweling",
"scribing",
"tailoring",
"weaponsmithing",
"woodworking",
)
const WynnDropMeta = type("object")
const WynnDropRestriction = type.enumerated("normal", "never", "dungeon", "lootchest")
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(),
})
const WynnBaseStats = type({
"[string]": type("number").or({
min: "number",
raw: "number",
max: "number",
})
})
const WynnIdentifications = type({
"[string]": type("number").or({
min: "number",
raw: "number",
max: "number",
})
})
const WynnItemIcon = type({
format: "string",
value: "unknown"
})
export const WapiV3ItemTool = type({
internalName: "string",
type: '"tool"',
toolType: type.enumerated("axe", "pickaxe", "rod", "scythe"),
"identified?": "boolean",
gatheringSpeed: "number",
requirements: {
level: "number",
},
icon: WynnItemIcon,
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",
type: '"tome"',
"restrictions?": WynnItemRestrictions,
"dropMeta?": WynnDropMeta,
dropRestriction: WynnDropRestriction,
requirements: WynnEquipRequirements,
"lore?": "string",
icon: WynnItemIcon,
"base?": WynnBaseStats,
rarity: WynnItemRarity
})
export const WapiV3ItemCharm = type({
internalName: "string",
type: '"charm"',
"restrictions?": WynnItemRestrictions,
"dropMeta?": WynnDropMeta,
dropRestriction: WynnDropRestriction,
requirements: WynnEquipRequirements,
"lore?": "string",
icon: WynnItemIcon,
base: WynnBaseStats,
rarity: WynnItemRarity
})
export const WapiV3ItemAccessory = type({
internalName: "string",
type: '"accessory"',
"identified?": "boolean",
accessoryType: type.enumerated("ring", "necklace", "bracelet"),
"majorIds?": {
"[string]": "string"
},
"restrictions?": WynnItemRestrictions,
"dropMeta?": WynnDropMeta,
dropRestriction: WynnDropRestriction,
requirements: WynnEquipRequirements,
"lore?": "string",
icon: WynnItemIcon,
"identifications?": WynnIdentifications,
"base?": WynnBaseStats,
rarity: WynnItemRarity
})
export const WapiV3ItemIngredient = type({
internalName: "string",
type: '"ingredient"',
requirements: {
level: "number",
skills: WynnSkills.array(),
},
icon: WynnItemIcon,
"identifications?": WynnIdentifications,
tier: "number",
consumableOnlyIDs: {
"[string]": "number"
},
ingredientPositionModifiers: {
"[string]": "number"
},
itemOnlyIDs: {
"[string]": "number"
},
"droppedBy?": type({
name: "string",
coords: type("boolean | null")
.or(type("number[] == 4"))
.or(type("number[] == 4").array())
}).array()
})
export const WapiV3ItemMaterial = type({
internalName: "string",
type: '"material"',
identified: "boolean",
requirements: {
level: "number",
},
craftable: type.enumerated(
"potions", "food", "scrolls",
"helmets", "chestplates", "rings", "bracelets",
"necklaces", "boots", "leggings", "bows", "wands", "spears",
"daggers", "chestplates", "helmets"
).array(),
icon: WynnItemIcon,
tier: "number"
})
export const WapiV3ItemWeapon = type({
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,
requirements: WynnEquipRequirements,
"majorIds?": {
"[string]": "string"
},
"lore?": "string",
icon: WynnItemIcon,
"identifications?": WynnIdentifications,
"base?": WynnBaseStats,
rarity: WynnItemRarity
})
export const WapiV3ItemArmour = type({
internalName: "string",
type: '"armour"',
armourType: "string",
"armourMaterial?": "string",
"armourColor?": "string",
"identified?": "boolean",
"allowCraftsman?": "boolean",
"restrictions?": WynnItemRestrictions,
dropRestriction: WynnDropRestriction,
"dropMeta?": WynnDropMeta,
"icon?": WynnItemIcon,
requirements: WynnEquipRequirements,
"majorIds?": {
"[string]": "string"
},
"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 WApiV3ItemDatabase = type({
"[string]": WApiV3Item
})

299
src/lib/types.zod.ts Normal file
View File

@ -0,0 +1,299 @@
import { z } from 'zod'
export const WynnGuildOverviewMember = z.object({
uuid: z.string(),
online: z.boolean(),
server: z.null(),
contributed: z.number(),
contributionRank: z.number(),
joined: z.string()
})
const WapiV3GuildMembers = z.record(z.string(), WynnGuildOverviewMember)
export const WapiV3GuildOverview = z.object({
uuid: z.string(),
name: z.string(),
prefix: z.string(),
level: z.number(),
xpPercent: z.number(),
territories: z.number(),
wars: z.number(),
created: z.string(),
members: z.object({
total: z.number(),
owner: WapiV3GuildMembers,
chief: WapiV3GuildMembers,
strategist: WapiV3GuildMembers,
captain: WapiV3GuildMembers,
recruiter: WapiV3GuildMembers,
recruit: WapiV3GuildMembers,
}),
online: z.number(),
banner: z.object({
base: z.string(),
tier: z.number(),
structure: 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() }))
})
const WynnItemRarity = z.enum([
"common","fabled","legendary","mythic","rare","set","unique",
])
const WynnDropMeta = z.any()
const WynnDropRestriction = z.enum(["normal","never","dungeon", "lootchest"])
const WynnItemRestrictions = z.enum([
"untradable", "quest item",
])
const WynnEquipRequirements = 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(),
})
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([
z.number(),
z.object({
min: z.number(),
raw: z.number(),
max: z.number(),
})
]))
const WynnItemIcon = z.object({
format: z.string(),
value: z.any(),
})
/*
"Gathering Axe T11": {
"internalName": "Gathering Axe 11",
"type": "tool",
"toolType": "axe",
"gatheringSpeed": 275,
"identified": true,
"requirements": {
"level": 95
},
"icon": {
"format": "attribute",
"value": {
"id": "minecraft:iron_horse_armor",
"customModelData": "50",
"name": "gatheringTool.axe11"
}
},
"rarity": "common"
},
*/
export const WapiV3ItemTool = z.object({
internalName: z.string(),
type: z.literal('tool'),
toolType: z.enum([
"axe","pickaxe","rod","scythe",
]),
identified: z.boolean().optional(),
gatheringSpeed: z.number(),
requirements: z.object({
level: z.number(),
}),
icon: WynnItemIcon,
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",
]),
raidReward: z.boolean(),
type: z.literal('tome'),
restrictions: WynnItemRestrictions.optional(),
dropMeta: WynnDropMeta.optional(),
dropRestriction: WynnDropRestriction,
requirements: WynnEquipRequirements,
lore: z.string().optional(),
icon: WynnItemIcon,
base: WynnBaseStats.optional(),
rarity:WynnItemRarity,
})
export const WapiV3ItemCharm = z.object({
internalName: z.string(),
type: z.literal('charm'),
restrictions: WynnItemRestrictions.optional(),
dropMeta: WynnDropMeta.optional(),
dropRestriction: WynnDropRestriction,
requirements: WynnEquipRequirements,
lore: z.string().optional(),
icon: WynnItemIcon,
base: WynnBaseStats,
rarity:WynnItemRarity,
})
export const WapiV3ItemAccessory = z.object({
internalName: z.string(),
type: z.literal('accessory'),
identified: z.boolean().optional(),
accessoryType: z.enum([
"ring","necklace","bracelet",
]),
majorIds: z.record(z.string(), z.string()).optional(),
restrictions: WynnItemRestrictions.optional(),
dropMeta: WynnDropMeta.optional(),
dropRestriction: WynnDropRestriction,
requirements:WynnEquipRequirements,
lore: z.string().optional(),
icon: WynnItemIcon,
identifications: WynnIdentifications.optional(),
base: WynnBaseStats.optional(),
rarity:WynnItemRarity,
}).strict()
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",
])),
}),
icon: WynnItemIcon,
identifications: WynnIdentifications.optional(),
tier: z.number(),
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({
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()
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'])),
icon: WynnItemIcon,
tier: z.number(),
}).strict()
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"
]),
powderSlots: z.number().optional(),
averageDps: z.number().optional(),
restrictions: WynnItemRestrictions.optional(),
dropMeta: WynnDropMeta.optional(),
dropRestriction: WynnDropRestriction.optional(),
requirements: WynnEquipRequirements,
majorIds: z.record(z.string(), z.string()).optional(),
lore: z.string().optional(),
icon: WynnItemIcon,
identifications: WynnIdentifications.optional(),
base: WynnBaseStats.optional(),
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 WApiV3Item = z.discriminatedUnion("type",[
WapiV3ItemMaterial,
WapiV3ItemWeapon,
WapiV3ItemArmour,
WapiV3ItemIngredient,
WapiV3ItemAccessory,
WapiV3ItemCharm,
WapiV3ItemTome,
WapiV3ItemTool,
])
export const WApiV3ItemDatabase= z.record(z.string(), WApiV3Item)

26
src/lib/wapi.ts Normal file
View File

@ -0,0 +1,26 @@
import axios, { Axios, AxiosInstance } from "axios";
export class WApi {
c: AxiosInstance
constructor(endpoint: string = `https://api.wynncraft.com/`) {
this.c = axios.create({
baseURL: endpoint,
})
}
async get(path:string, params?: any) {
return this.c.get(path, {
params,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
}
})
}
}

12
src/main.ts Normal file
View File

@ -0,0 +1,12 @@
import { runExit } from "clipanion";
import { WorkerCommand } from "#/cmd/worker";
import { WynnCommands } from "#/cmd/wynn";
import { BotCommand } from "./cmd/bot";
runExit([
WorkerCommand,
BotCommand,
...WynnCommands,
])

9
src/mux/index.ts Normal file
View File

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

23
src/services/pg/index.ts Normal file
View File

@ -0,0 +1,23 @@
import { config } from "#/config";
import { container, T_PG } from "#/di";
import postgres from "postgres";
container.bind({
provide: T_PG,
useFactory: () => {
const opts = {
onnotice: () => {},
}
if(config.PG_URL) {
return postgres(config.PG_URL, opts);
}
return postgres({
host: config.PG_HOST,
port: config.PG_PORT,
user: config.PG_USER,
password: config.PG_PASSWORD,
db: config.PG_DATABASE,
...opts,
})
},
});

View File

@ -0,0 +1,121 @@
import { Sql, TransactionSql } from "postgres";
type MigrationFunc = (sql:TransactionSql)=>Promise<void>
interface Migration {
name: string
up: MigrationFunc
// TODO: implement down
down?: MigrationFunc
}
const migration = (name:string, up:MigrationFunc, down?:MigrationFunc)=>{
return {name, up, down}
}
const migrations: Array<Migration> = [
migration("create-api-responses", async (sql)=>{
await sql`
create table if not exists wynn_api_responses (
path text not null,
content jsonb not null,
content_hash text not null,
received_time timestamptz not null
)`
await sql`
create index wynn_api_responses_received_time on wynn_api_responses (received_time, path)
`
}),
migration("guild-info", async (sql)=>{
await sql`
create table if not exists wynn_guild_members (
guild_id UUID not null,
member_id UUID not null,
rank text not null,
joined_at timestamptz not null,
contributed bigint not null,
primary key (guild_id, member_id),
unique(member_id)
)`
await sql`
create table if not exists wynn_guild_info (
uid UUID not null,
name text not null,
prefix text not null,
level bigint ,
xp_percent bigint,
territories bigint,
wars bigint,
created timestamptz,
primary key (uid)
)`
await sql`
create table if not exists wynn_guild_season_results (
guild_id UUID not null,
season text not null,
rating bigint not null,
territories bigint not null,
primary key (guild_id, season)
)
`
}),
migration("create-user-info", async (sql)=>{
await sql`
create table if not exists minecraft_user (
uid UUID not null,
name text not null,
server text,
primary key (uid)
)
`
}),
migration("create-guild-settings", async (sql)=>{
await sql`
create table if not exists discord_guild_setting(
discord_guild text not null,
name text not null,
value jsonb not null,
primary key (discord_guild, name)
)`
})
]
export const runMigrations = async (pg: Sql) => {
await pg.begin(async (sql)=>{
await sql`
create table if not exists migration_version (version int)
`
await sql`
insert into migration_version
select 0
where 0=(select count(*) from migration_version)
`
const [{version}] = await sql`
select version from migration_version limit 1
`
const targetVersion = migrations.length
if(version == targetVersion) {
return
}
if (version > targetVersion) {
console.log(`version ${version} is greater than the latest migration ${targetVersion}, nothing to do`)
return
}
console.log(`running migrations from version ${version}`)
for (let i = version+1; i <= targetVersion; i++) {
const m = migrations[i-1]
console.log(`running migration ${i}_${m.name}`)
await m.up(sql)
await sql`
update migration_version set version=${i}
`
}
console.log(`done running db migrations`)
});
}

View File

@ -0,0 +1,22 @@
import { config } from "#/config";
import { container } from "#/di";
import { Client, Connection} from '@temporalio/client';
container.bind({
provide: Client,
async: true,
useFactory: async () => {
const connection = await Connection.connect({
address: config.TEMPORAL_HOSTPORT,
})
const client = new Client({
connection,
namespace: config.TEMPORAL_NAMESPACE,
});
process.on('exit', () => {
console.log('closing temporal client');
client.connection.close();
});
return client
},
});

View File

@ -0,0 +1,20 @@
import {ApplicationCommandOptionTypes, ApplicationCommandTypes, CreateApplicationCommand } from "discordeno";
export const AdminCommands: CreateApplicationCommand = {
name: "admin",
description: "admin commands",
type: ApplicationCommandTypes.ChatInput,
defaultMemberPermissions: [
"ADMINISTRATOR",
],
options: [
{
name: "set_wynn_guild",
description: "set the default wynncraft guild for the server",
type: ApplicationCommandOptionTypes.SubCommand,
},
],
}

View File

@ -0,0 +1,26 @@
import {ApplicationCommandOptionTypes, ApplicationCommandTypes, CreateApplicationCommand } from "discordeno";
export const GuildCommands: CreateApplicationCommand = {
name: "guild",
description: "guild commands",
type: ApplicationCommandTypes.ChatInput,
options: [
{
name: "leaderboard",
description: "view the current leaderboard",
type: ApplicationCommandOptionTypes.SubCommand,
},
{
name: "info",
description: "view guild information",
type: ApplicationCommandOptionTypes.SubCommand,
},
{
name: "online",
description: "show online players",
type: ApplicationCommandOptionTypes.SubCommand,
},
],
}

View File

@ -0,0 +1,6 @@
/**
* @file Automatically generated by barrelsby.
*/
export * from "./admin";
export * from "./guild_overview";

14
src/utils/index.ts Normal file
View File

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

24
src/workflows/guilds.ts Normal file
View File

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

6
src/workflows/index.ts Normal file
View File

@ -0,0 +1,6 @@
/**
* @file Automatically generated by barrelsby.
*/
export * from "./guilds";
export * from "./players";

14
src/workflows/players.ts Normal file
View File

@ -0,0 +1,14 @@
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();
}

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"checkJs": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"#/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

3427
yarn.lock Normal file

File diff suppressed because it is too large Load Diff