diff --git a/.gitignore b/.gitignore index 9b1ee42..168cbee 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,5 @@ dist # Finder (MacOS) folder config .DS_Store + +replay-*.osr \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index fdde9d8..a8f06bf 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/index.ts b/index.ts index e771046..18363b4 100644 --- a/index.ts +++ b/index.ts @@ -6,6 +6,14 @@ import "dotenv/config"; import bcrypt from "bcrypt"; import type { StaticSelectAction } from "@slack/bolt"; import { inspect } from "node:util"; +import { scheduleJob } from "node-schedule"; + +// @ts-expect-error No typings :))))))))))) +import osr from "node-osr"; +import { Client, Events } from "ordr.js"; + +import io from "socket.io-client"; +import fs from "fs"; const sql = postgres({ host: '/var/run/postgresql', @@ -24,6 +32,8 @@ const app = new App({ } }); +const ordr = new Client(process.env.ORDR_TOKEN!); + const states = new Map(); app.command("/osu-link", async (ctx) => { @@ -162,22 +172,27 @@ async function getTemporaryToken(): Promise { return data.access_token; } -async function getAccessToken(slack_id: string): Promise { +async function getAccessToken(slack_id: string): Promise { const user = await sql`SELECT * FROM links WHERE slack_id = ${slack_id}`; if (!user.length) return null - const data = await fetch("https://osu.ppy.sh/oauth/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded" - }, - body: `client_id=33126&client_secret=${encodeURIComponent(process.env.CLIENT_SECRET!)}&grant_type=refresh_token&refresh_token=${user[0].refresh_token}&scope=public` - }).then(res => res.json()); + try { + const data = await fetch("https://osu.ppy.sh/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: `client_id=33126&client_secret=${encodeURIComponent(process.env.CLIENT_SECRET!)}&grant_type=refresh_token&refresh_token=${user[0].refresh_token}&scope=public` + }).then(res => res.json()); - await sql`UPDATE links SET refresh_token = ${data.refresh_token} WHERE slack_id = ${slack_id}`; + await sql`UPDATE links SET refresh_token = ${data.refresh_token} WHERE slack_id = ${slack_id}`; - return data.access_token; + return data.access_token; + } catch (err) { + console.error(err) + return null + } } async function sendGET(path: string, token?: string): Promise { @@ -202,20 +217,26 @@ function splitArray(arr: T[], maxElements: number): T[][] { } /// GENERATED /// -const cache: { - username: string, - id: number, - slackId: string, +type CacheUser = { + username: string; + id: number; + slackId: string; score: { - osu: number, - taiko: number - fruits: number, - mania: number - } -}[] = [] + osu: number; + taiko: number; + fruits: number; + mania: number; + }; +} + +const cache: CacheUser[] = [] const multiplayerRoundCache: any[] = []; +const sentWarningDM = { + ref: false +} + async function cacheStuff(): Promise { const token = await getTemporaryToken(); @@ -246,10 +267,10 @@ async function cacheStuff(): Promise { id: user.id, slackId: osuUsers.find(v => v[0] == user.id)![1], score: { - osu: user.statistics_rulesets.osu?.total_score || 0, - taiko: user.statistics_rulesets.taiko?.total_score || 0, - fruits: user.statistics_rulesets.fruits?.total_score || 0, - mania: user.statistics_rulesets.mania?.total_score || 0, + osu: Math.floor(user.statistics_rulesets.osu?.pp) || 0, + taiko: Math.floor(user.statistics_rulesets.taiko?.pp) || 0, + fruits: Math.floor(user.statistics_rulesets.fruits?.pp) || 0, + mania: Math.floor(user.statistics_rulesets.mania?.pp) || 0, } }))) } @@ -264,7 +285,49 @@ async function cacheStuff(): Promise { const tohken = await getAccessToken("U06TBP41C3E") as string; - if (!tohken) return; + if (!tohken) { + const verifCode = `OSULEADERBOARD-U06TBP41C3E-${Date.now()}`; + + states.set('U06TBP41C3E', verifCode); + + const encodedCode = await bcrypt.hash(verifCode, 10); + + await app.client.chat.postMessage({ + channel: "U06TBP41C3E", + text: "uh oh, your token seems to have expired!! multiplayer round fetching + daily challenges are disabled.", + blocks: [ + { + type: 'section', + text: { + type: "mrkdwn", + text: `uh oh, your token seems to have expired!! multiplayer round fetching + daily challenges are disabled.` + } + }, + { + type: 'section', + text: { + type: "mrkdwn", + text: `Please re-authenticate to generate it by clicking the Reauthenticate button.` + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Reauthenticate", + "emoji": true + }, + "value": "link", + "url": `https://osu.ppy.sh/oauth/authorize?client_id=33126&redirect_uri=https://osu.haroon.hackclub.app/osu/callback&response_type=code&state=${encodeURIComponent("U06TBP41C3E:" + encodedCode)}&scope=public`, + "action_id": "link" + } + }, + ] + }) + + sentWarningDM.ref = true; + + return + }; const rooms = await fetch(`https://osu.ppy.sh/api/v2/rooms?category=realtime`, { headers: { @@ -480,17 +543,23 @@ app.command('/osu-profile', async (ctx) => { app.command('/osu-leaderboard', async (ctx) => { await ctx.ack(); - const cached = splitArray(cache, 10)[0].sort((a, b) => { + const cached = splitArray(cache.sort((a, b) => { return b.score.osu - a.score.osu - }); + }), 10); const users = []; - for (let i in cached) { - const cachedU = cached[i]; - const slackProfile = (await ctx.client.users.info({ user: cachedU.slackId })).user!; + for (let i in cached[0]) { + try { + const cachedU = cached[0][i]; + const slackInfo = await ctx.client.users.info({ user: cachedU.slackId }) + const slackProfile = slackInfo.user!; - users.push(`${parseInt(i) + 1}. / - ${cachedU.score.osu.toLocaleString()}`) + users.push(`${users.length + 1}. / - ${cachedU.score.osu.toLocaleString()}`) + } catch (e) { + console.error(e) + continue; + } } ctx.respond({ @@ -588,19 +657,24 @@ app.action(/change-leaderboard\|.+/, async (ctx) => { return ctx.respond({ replace_original: false, response_type: "ephemeral", text: `This leaderboard was initialised by <@${userId}>. Only they can manage it.` }) } - const selected = action.selected_option.value; + const selected = action.selected_option.value as "osu" | "taiko" | "fruits" | "mania"; - const cached = splitArray(cache, 10)[0].sort((a, b) => { + const cached = splitArray(cache.sort((a, b) => { return b.score[selected] - a.score[selected] - }); + }), 10)[0]; const users = []; for (let i in cached) { - const cachedU = cached[i]; - const slackProfile = (await ctx.client.users.info({ user: cachedU.slackId })).user!; + try { + const cachedU = cached[i]; + const slackProfile = (await ctx.client.users.info({ user: cachedU.slackId })).user!; - users.push(`${parseInt(i) + 1}. / - ${cachedU.score[selected].toLocaleString()}`) + users.push(`${users.length + 1}. / - ${cachedU.score[selected].toLocaleString()}`) + } catch (e) { + console.error(e) + continue; + } } ctx.respond({ @@ -859,6 +933,41 @@ app.command('/osu-search', async (ctx) => { }); } + const accessToken = await getAccessToken(ctx.context.userId!); + + if (!accessToken) { + const verifCode = `OSULEADERBOARD-${ctx.context.userId}-${Date.now()}`; + + states.set(ctx.context.userId, verifCode); + + const encodedCode = await bcrypt.hash(verifCode, 10); + + return ctx.respond({ + response_type: 'ephemeral', + text: `Hey <@${ctx.context.userId}>, your token has expired. Please re-authenticate to generate it by clicking the Reauthenticate button.`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `Hey <@${ctx.context.userId}>, your token has expired. Please re-authenticate to generate it by clicking the \`Reauthenticate\` button.` + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Reauthenticate", + "emoji": true + }, + "value": "link", + "url": `https://osu.ppy.sh/oauth/authorize?client_id=33126&redirect_uri=https://osu.haroon.hackclub.app/osu/callback&response_type=code&state=${encodeURIComponent(ctx.context.userId + ":" + encodedCode)}&scope=public`, + "action_id": "link" + } + }, + ] + }); + } + ctx.client.views.open({ trigger_id: ctx.payload.trigger_id, view: { @@ -1057,7 +1166,7 @@ app.view("search", async (ctx) => { const set = data.beatmapsets[0]; - return ctx.client.chat.postMessage({ + ctx.client.chat.postMessage({ channel: ctx.view.private_metadata, "blocks": [ { @@ -1090,12 +1199,255 @@ receiver.router.get('*', (req, res) => { res.redirect(`https://osu.ppy.sh${req.path}`) }) +enum Mods { + EZ = "Easy", + NF = "No Fail", + HT = "Half Time", + HR = "Hard Rock", + SD = "Sudden Death", + PF = "Perfect", + DT = "Double Time", + NC = "Nightcore", + HD = "Hidden", + FI = "Fade In", + FL = "Flashlight", + RL = "Relax", + AP = "Autopilot", + SO = "Spun Out", + "1K" = "One Key", + "2K" = "Two Keys", + "3K" = "Three Keys", + "4K" = "Four Keys", + "5K" = "Five Keys", + "6K" = "Six Keys", + "7K" = "Seven Keys", + "8K" = "Eight Keys", + "9K" = "Nine Keys", + "10K" = "Ten Keys" +} + +async function debugDailyChallenge() { + // Daily Challenge!! + + const tohken = await getAccessToken("U06TBP41C3E"); + + const rooms: any[] = await fetch(`https://osu.ppy.sh/api/v2/rooms`, { + headers: { + 'Authorization': `Bearer ${tohken}`, + 'X-Api-Version': '20240529' + } + }).then(res => res.json()); + + const dailyChallenge = rooms.find(room => room.host.id == 3 && room.active && room.category == "daily_challenge"); + + const currentSong = dailyChallenge.current_playlist_item + + const ruleset = [":osu-standard: osu!standard", ":osu-taiko: osu!taiko", ":osu-catch: osu!catch", ":osu-mania: osu!mania"][currentSong.ruleset_id] + + return app.client.chat.postMessage({ + channel: "C165V7XT9", + text: "A new daily challenge has started!", + "blocks": [ + { + "type": "header", + text: { + text: ruleset.split(' ').shift() + " A new daily challenge has started!", + emoji: true, + type: "plain_text" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ` + +*Ruleset:* ${ruleset} +*Required mods:* ${currentSong.required_mods.length === 0 ? "None" : currentSong.required_mods.map((mod: any) => + // @ts-ignore I HATE THIS + Mods[mod.acronym] || mod.acronym + ).join(', ')}` + }, + "accessory": { + "type": "image", + "image_url": dailyChallenge.host.avatar_url, + "alt_text": `${dailyChallenge.host.username}'s osu profile picture` + } + } + ], + unfurl_links: true + }) +} + +type QueueJob = { + md5: string, + playerName: string, + ts: string, + userId: string +} + +const queue: QueueJob[] = [] +// key is renderID +const waiting = new Map() + +const processQueue = async () => { + if (queue.length > 0) { + const job = queue.shift()!; + + setTimeout(processQueue, 5000) + + app.client.reactions.add({ + channel: "C165V7XT9", + name: "thinkspin", + timestamp: job.ts + }) + + const render = await ordr.sendRender({ + replay: `replay-${job.md5}.osr`, + skin: 'default', + username: job.playerName, + showDanserLogo: false, + resolution: '1280x720', + introBGDim: 100, + inGameBGDim: 100, + breakBGDim: 100 + }) + + console.log(render) + + // @ts-ignore Error code 0 DOES exist: https://ordr.issou.best/docs/#section/Error-codes + if (render.errorCode !== 0) { + app.client.chat.postEphemeral({ + channel: "C165V7XT9", + user: job.userId, + text: `Hey: it looks like you posted a replay! Unfortunately, I couldn't generate a video of it: "${render.message}"` + }) + return + } + + waiting.set(render.renderID!, job); + } +}; + +const socket = io('https://apis.issou.best', { + path: '/ordr/ws', + autoConnect: false +}) + +socket.on('connect', () => { + console.log('Connected to ordr websocket!') +}) + +socket.on('disconnect', reason => { + if (reason == "io server disconnect") { + socket.connect() + } +}) + +socket.on('render_done_json', async (render) => { + const job = waiting.get(render.renderID!); + + if (!job) return; + + app.client.chat.postMessage({ + channel: 'C165V7XT9', + thread_ts: job.ts, + reply_broadcast: true, + text: `<${render.videoUrl}|replay-${job.md5}.mp4>`, + unfurl_media: true + }) + + app.client.reactions.remove({ + channel: "C165V7XT9", + name: "thinkspin", + timestamp: job.ts + }) + + waiting.delete(render.renderID!); +}) + +const addToQueue = (job: QueueJob) => { + queue.push(job); + if (queue.length === 1) { + processQueue(); + } +}; + +app.event("message", async (ctx) => { + if (ctx.event.channel != "C165V7XT9") return; + if (ctx.event.subtype != "file_share") return; + const ts = ctx.event.ts; + + const history = await ctx.client.conversations.history({ + channel: "C165V7XT9", + latest: ts, + limit: 1, + inclusive: true + }) + + if (!(history.messages && history.messages.length > 0)) { + return; + } + + const message = history.messages[0]; + + if (!message.files) return; + if (message.files.length === 0) return; + + const replay = message.files.find(file => file.name?.endsWith(".osr")); + + if (!replay) return; + + const replayData = await fetch(replay.url_private_download!, { + headers: { + 'Authorization': `Bearer ${process.env.SLACK_BOT_TOKEN}` + } + }).then(res => res.arrayBuffer()); + + const replayBuffer = Buffer.from(replayData); + + const _replay = await osr.read(replayBuffer); + + if (_replay.gameMode !== 0) { + ctx.client.chat.postEphemeral({ + channel: "C165V7XT9", + user: ctx.context.userId!, + text: "Hey: it looks like you posted a replay! Unfortunately, it's not an :osu-standard: osu!standard replay, and so I can't generate a video of it. Sorry!" + }) + return; + } + + const replayFile = fs.createWriteStream(`replay-${_replay.replayMD5}.osr`); + + replayFile.write(replayBuffer); + replayFile.end(); + + + replayFile.on('finish', () => { + addToQueue({ + md5: _replay.replayMD5, + playerName: _replay.playerName, + ts: ts, + userId: ctx.context.userId! + }) + }); +}) + ; (async () => { await app.start(41691); console.log('⚡️ Bolt app is running!'); + socket.connect(); + cacheStuff(); setInterval(cacheStuff, 60 * 1000) // Cache every minute. Ratelimit is 1200 req/m anyways. + + scheduleJob('30 5 0 * * *', debugDailyChallenge) })(); diff --git a/package.json b/package.json index f6dc48d..43328d4 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,13 @@ "dependencies": { "@slack/bolt": "^4.0.0", "@types/bcrypt": "^5.0.2", + "@types/node-schedule": "^2.1.7", "bcrypt": "^5.1.1", "dotenv": "^16.4.5", - "postgres": "^3.4.4" + "node-osr": "^1.2.1", + "node-schedule": "^2.1.1", + "ordr.js": "^4.0.0", + "postgres": "^3.4.4", + "socket.io-client": "^4.8.1" } } \ No newline at end of file