commit
This commit is contained in:
commit
01eae7fec7
10
.editorconfig
Normal file
10
.editorconfig
Normal 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
4
.gitattributes
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/.yarn/** linguist-vendored
|
||||
/.yarn/releases/* binary
|
||||
/.yarn/plugins/**/* binary
|
||||
/.pnp.* binary linguist-generated
|
40
.gitea/workflows/commit_tag.yml
Normal file
40
.gitea/workflows/commit_tag.yml
Normal 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 }}
|
||||
|
42
.gitea/workflows/release_tag.yml
Normal file
42
.gitea/workflows/release_tag.yml
Normal 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
16
.gitignore
vendored
Normal 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
1
.yarnrc.yml
Normal file
@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
13
Dockerfile
Normal file
13
Dockerfile
Normal 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"]
|
11
barrelsby.json
Normal file
11
barrelsby.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"delete": true,
|
||||
"directory": [
|
||||
"./src/activities",
|
||||
"./src/workflows",
|
||||
"./src/slashcommands"
|
||||
],
|
||||
"exclude": [
|
||||
"types.ts"
|
||||
]
|
||||
}
|
9
docker-compose.yaml
Normal file
9
docker-compose.yaml
Normal 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
39
package.json
Normal 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
21
scratch/test.ts
Normal 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)
|
||||
}
|
18
src/activities/database.ts
Normal file
18
src/activities/database.ts
Normal 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)
|
||||
}
|
||||
}
|
1
src/activities/fullresult.json
Normal file
1
src/activities/fullresult.json
Normal file
File diff suppressed because one or more lines are too long
95
src/activities/guild.ts
Normal file
95
src/activities/guild.ts
Normal 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
7
src/activities/index.ts
Normal 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
119
src/activities/players.ts
Normal 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
90
src/bot/common/guild.ts
Normal 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
47
src/bot/index.ts
Normal 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
77
src/botevent/index.ts
Normal 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
|
39
src/botevent/slash_commands.ts
Normal file
39
src/botevent/slash_commands.ts
Normal 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
11
src/botevent/types.ts
Normal 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
28
src/cmd/bot.ts
Normal 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
133
src/cmd/worker.ts
Normal 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
20
src/cmd/wynn.ts
Normal 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
21
src/config/index.ts
Normal 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
3
src/constants/index.ts
Normal 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
6
src/di/index.ts
Normal 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
40
src/lib/tabwriter.ts
Normal 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
272
src/lib/types.ts
Normal 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
299
src/lib/types.zod.ts
Normal 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
26
src/lib/wapi.ts
Normal 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
12
src/main.ts
Normal 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
9
src/mux/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { container } from "#/di";
|
||||
|
||||
|
||||
|
||||
export class EventMux {
|
||||
constructor() {
|
||||
}
|
||||
}
|
||||
|
23
src/services/pg/index.ts
Normal file
23
src/services/pg/index.ts
Normal 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,
|
||||
})
|
||||
},
|
||||
});
|
121
src/services/pg/migrations.ts
Normal file
121
src/services/pg/migrations.ts
Normal 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`)
|
||||
});
|
||||
}
|
22
src/services/temporal/index.ts
Normal file
22
src/services/temporal/index.ts
Normal 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
|
||||
},
|
||||
});
|
20
src/slashcommands/admin.ts
Normal file
20
src/slashcommands/admin.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
26
src/slashcommands/guild_overview.ts
Normal file
26
src/slashcommands/guild_overview.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
6
src/slashcommands/index.ts
Normal file
6
src/slashcommands/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./admin";
|
||||
export * from "./guild_overview";
|
14
src/utils/index.ts
Normal file
14
src/utils/index.ts
Normal 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
24
src/workflows/guilds.ts
Normal 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
6
src/workflows/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./guilds";
|
||||
export * from "./players";
|
14
src/workflows/players.ts
Normal file
14
src/workflows/players.ts
Normal 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
25
tsconfig.json
Normal 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"]
|
||||
}
|
Loading…
Reference in New Issue
Block a user