osu-leaderboard/index.ts

1448 lines
49 KiB
TypeScript
Raw Normal View History

2024-06-30 14:09:50 +00:00
import type { User } from "@slack/web-api/dist/response/UsersInfoResponse";
2024-06-30 10:28:18 +00:00
const { App, ExpressReceiver } = (await import("@slack/bolt"));
import postgres from "postgres";
import "dotenv/config";
import bcrypt from "bcrypt";
2024-06-30 15:18:21 +00:00
import type { StaticSelectAction } from "@slack/bolt";
import { inspect } from "node:util";
import { scheduleJob } from "node-schedule";
2024-06-30 09:18:57 +00:00
2025-02-17 21:20:02 +00:00
// @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";
2024-06-30 10:28:18 +00:00
const sql = postgres({
host: '/var/run/postgresql',
database: 'haroon_osu',
username: 'haroon'
})
2024-06-30 09:18:57 +00:00
2024-06-30 10:28:18 +00:00
const receiver = new ExpressReceiver({ signingSecret: process.env.SLACK_SIGNING_SECRET! })
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
receiver,
installerOptions: {
port: 41691
}
});
2025-02-17 21:20:02 +00:00
const ordr = new Client(process.env.ORDR_TOKEN!);
2024-06-30 10:28:18 +00:00
const states = new Map();
app.command("/osu-link", async (ctx) => {
await ctx.ack();
2024-06-30 15:45:48 +00:00
const [exists = null] = await sql`SELECT osu_id FROM links WHERE slack_id = ${ctx.context.userId!}`;
2024-06-30 10:28:18 +00:00
if (exists) {
return ctx.respond({
text: "This slack account is already linked to an osu! account.",
unfurl_links: true,
blocks: [
{
type: 'section',
text: {
type: "mrkdwn",
text: `This slack account is already linked to an <https://osu.ppy.sh/users/${exists.osu_id}/|osu! account>.`
2024-06-30 09:18:57 +00:00
}
2024-06-30 10:28:18 +00:00
}
2024-06-30 11:33:54 +00:00
]
2024-06-30 09:18:57 +00:00
2024-06-30 10:28:18 +00:00
})
return;
}
const verifCode = `OSULEADERBOARD-${ctx.context.userId}-${Date.now()}`;
states.set(ctx.context.userId, verifCode);
const encodedCode = await bcrypt.hash(verifCode, 10);
ctx.respond({
replace_original: true,
text: "View this message in your client to verify!",
blocks: [
{
type: 'section',
text: {
type: "mrkdwn",
text: `Hey <@${ctx.context.userId}>! To link your osu! account to your Slack account, click this button:`
},
"accessory": {
"type": "button",
"text": {
"type": "plain_text",
"text": "Link account",
"emoji": true
},
"value": "link",
2024-07-02 21:43:49 +00:00
"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`,
2024-06-30 10:28:18 +00:00
"action_id": "link"
}
2024-06-30 09:18:57 +00:00
}
2024-06-30 10:28:18 +00:00
]
})
})
receiver.router.get("/osu/callback", async (req, res) => {
res.contentType("text/html")
if (req.query.error) {
return res.send(`Something went wrong: <br><br>${req.query.error_description} (${req.query.error})<br><br>This has been reported.`)
}
2024-06-30 10:28:18 +00:00
const code = req.query.code as string;
const state = req.query.state as string;
let _userId
2024-07-02 23:01:43 +00:00
2024-06-30 10:28:18 +00:00
try {
const [userId, hash] = state.split(':');
2024-06-30 10:28:18 +00:00
const isValid = await bcrypt.compare(states.get(userId), hash);
if (!isValid) {
throw new Error();
2024-06-30 09:18:57 +00:00
}
_userId = userId
states.delete(userId);
2024-06-30 10:28:18 +00:00
} catch (err) {
return res.send(`Something went wrong: <br><br>Your state was invalid. Please re-authenticate. (invalid_state)<br><br>This has been reported.`)
}
2024-06-30 09:18:57 +00:00
2024-06-30 10:28:18 +00:00
const data = await fetch("https://osu.ppy.sh/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
2024-07-02 21:43:49 +00:00
body: `client_id=33126&client_secret=${encodeURIComponent(process.env.CLIENT_SECRET!)}&code=${code}&grant_type=authorization_code&scope=public&redirect_uri=${encodeURIComponent("https://osu.haroon.hackclub.app/osu/callback")}`
2024-06-30 10:28:18 +00:00
}).then(res => res.json());
if (data.error) {
console.log(data)
return res.send(`Something went wrong: <br><br>${data.message} (${data.error})<br><br>This has been reported.`)
} else {
const user = await fetch("https://osu.ppy.sh/api/v2/me", {
headers: {
"Authorization": `Bearer ${data.access_token}`
}
}).then(res => res.json());
// {user.id} - osu! user ID
// userId - slack user ID
2024-07-02 21:43:49 +00:00
await sql`INSERT INTO links VALUES (${user.id}, ${_userId}, ${data.refresh_token})`
2024-06-30 10:28:18 +00:00
2024-07-02 21:43:49 +00:00
cacheStuff();
2024-06-30 15:44:24 +00:00
return res.send(`Your osu! account (${user.id}) has been successfully linked to your Slack account (${_userId})!`)
2024-06-30 09:18:57 +00:00
}
2024-06-30 10:28:18 +00:00
})
2024-06-30 14:09:50 +00:00
let _token: string | null;
async function getTemporaryToken(): Promise<string> {
if (_token) return _token;
2024-06-30 11:33:54 +00:00
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=client_credentials&scope=public`
}).then(res => res.json());
2024-06-30 14:09:50 +00:00
_token = data.access_token;
setTimeout(() => {
_token = null;
}, data.expires_in)
2024-06-30 11:33:54 +00:00
return data.access_token;
}
async function getAccessToken(slack_id: string): Promise<string | null> {
2024-07-02 21:43:49 +00:00
const user = await sql`SELECT * FROM links WHERE slack_id = ${slack_id}`;
if (!user.length) return null
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());
2024-07-02 21:43:49 +00:00
await sql`UPDATE links SET refresh_token = ${data.refresh_token} WHERE slack_id = ${slack_id}`;
return data.access_token;
} catch (err) {
console.error(err)
return null
}
2024-07-02 21:43:49 +00:00
}
async function sendGET<T>(path: string, token?: string): Promise<T> {
const _token = token || await getTemporaryToken();
const data = await fetch(`https://osu.ppy.sh/api/v2/${path}`, {
headers: {
'Authorization': `Bearer ${_token}`
}
}).then(res => res.json());
return data as T
}
2024-06-30 11:33:54 +00:00
/// GENERATED ///
function splitArray<T>(arr: T[], maxElements: number): T[][] {
const result: T[][] = [];
for (let i = 0; i < arr.length; i += maxElements) {
result.push(arr.slice(i, i + maxElements));
}
return result;
}
/// GENERATED ///
2025-02-17 21:20:02 +00:00
type CacheUser = {
username: string;
id: number;
slackId: string;
2024-06-30 11:33:54 +00:00
score: {
2025-02-17 21:20:02 +00:00
osu: number;
taiko: number;
fruits: number;
mania: number;
};
}
const cache: CacheUser[] = []
2024-06-30 11:33:54 +00:00
2024-07-02 23:01:43 +00:00
const multiplayerRoundCache: any[] = [];
2024-07-02 21:43:49 +00:00
2025-02-17 21:20:02 +00:00
const sentWarningDM = {
ref: false
}
2024-07-02 21:43:49 +00:00
async function cacheStuff(): Promise<void> {
2024-06-30 11:33:54 +00:00
const token = await getTemporaryToken();
const users = await sql`SELECT * FROM links`;
2024-06-30 14:09:50 +00:00
let lb: {
username: string,
id: number,
slackId: string,
score: {
osu: number,
taiko: number
fruits: number,
mania: number
}
}[] = [];
2024-06-30 11:33:54 +00:00
2024-06-30 14:09:50 +00:00
const osuUsers: string[][] = users.map(user => [user.osu_id, user.slack_id]);
2024-06-30 11:33:54 +00:00
2024-06-30 14:09:50 +00:00
for (let list of splitArray<string[]>(osuUsers, 50)) {
const query = list.map((user) => `ids[]=${user[0]}`).join("&");
2024-06-30 11:33:54 +00:00
2024-07-02 21:43:49 +00:00
const data = await sendGET(`users?${query}`)
2024-06-30 11:33:54 +00:00
2024-06-30 15:45:48 +00:00
// @ts-ignore i can't be bothered to type this rn
2024-06-30 11:33:54 +00:00
lb.push(...data.users.map(user => ({
username: user.username,
id: user.id,
2024-06-30 14:09:50 +00:00
slackId: osuUsers.find(v => v[0] == user.id)![1],
2024-06-30 11:33:54 +00:00
score: {
2024-06-30 15:18:21 +00:00
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,
2024-06-30 11:33:54 +00:00
}
})))
}
2024-06-30 15:18:21 +00:00
cache.length = 0;
cache.push(...lb);
2024-07-02 21:43:49 +00:00
// Multiplayer games
multiplayerRoundCache.length = 0;
2024-06-30 14:09:50 +00:00
const tohken = await getAccessToken("U06TBP41C3E") as string;
2025-02-17 21:20:02 +00:00
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
};
2024-07-02 23:01:43 +00:00
2024-07-02 21:43:49 +00:00
const rooms = await fetch(`https://osu.ppy.sh/api/v2/rooms?category=realtime`, {
headers: {
2024-07-02 23:01:43 +00:00
'Authorization': `Bearer ${tohken}`
2024-07-02 21:43:49 +00:00
}
}).then(res => res.json());
multiplayerRoundCache.push(...rooms);
2024-06-30 11:33:54 +00:00
}
2024-06-30 14:09:50 +00:00
async function generateProfile(slackProfile: User) {
const token = await getTemporaryToken();
const osuProfile = await fetch(`https://osu.ppy.sh/api/v2/users/${cache.find(user => user.slackId == slackProfile.id)!.id}?key=id`, {
headers: {
'Authorization': `Bearer ${token}`
}
}).then(res => res.json());
return [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": `*Slack Username*: <https://hackclub.slack.com/team/${slackProfile.id}|${slackProfile.profile!.display_name_normalized}>\n*osu! username:* <https://osu.ppy.sh/users/${osuProfile.id}|${osuProfile.username}>`
},
"accessory": {
"type": "image",
"image_url": osuProfile.avatar_url,
"alt_text": `${osuProfile.username}'s osu profile picture`
}
}
]
}
app.command('/osu-profile', async (ctx) => {
await ctx.ack();
const userProfile = (await ctx.client.users.info({ user: ctx.context.userId! })).user!.profile!;
const arg = ctx.command.text.slice();
let match;
if (match = arg.match(/\<\@(.+)\|(.+)>/)) {
// Slack user
const mentionedUser = match[1];
const slackProfile = (await ctx.client.users.info({ user: mentionedUser })).user!;
if (!cache.find(u => u.slackId == slackProfile.id)) {
return ctx.respond({
2024-06-30 16:39:38 +00:00
response_type: 'in_channel',
text: `${userProfile.display_name_normalized} ran /osu-profile @${slackProfile.profile!.display_name_normalized}`,
blocks: [
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": `<@${ctx.context.userId}> ran \`/osu-profile\` | Matched by slack user`
}
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": `*Slack Username*: <https://hackclub.slack.com/team/${slackProfile.id}|${slackProfile.profile!.display_name_normalized}>\n*osu! username:* Not linked`
},
"accessory": {
"type": "image",
"image_url": 'https://osu.ppy.sh/images/layout/avatar-guest@2x.png',
"alt_text": `default osu profile picture`
}
}
]
2024-06-30 14:09:50 +00:00
})
}
return ctx.respond({
response_type: 'in_channel',
text: `${userProfile.display_name_normalized} ran /osu-profile @${slackProfile.profile!.display_name_normalized}`,
blocks: [
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": `<@${ctx.context.userId}> ran \`/osu-profile\` | Matched by slack user`
}
]
},
...await generateProfile(slackProfile)
]
})
} else if (arg) {
// osu! user
const cached = cache.find(u => u.username.toLowerCase() == arg.toLowerCase())
if (!cached) {
2024-06-30 16:39:38 +00:00
const token = await getTemporaryToken();
const osuProfile = await fetch(`https://osu.ppy.sh/api/v2/users/${arg}?key=username`, {
headers: {
'Authorization': `Bearer ${token}`
}
}).then(res => res.json());
2024-06-30 14:09:50 +00:00
return ctx.respond({
2024-06-30 16:39:38 +00:00
response_type: 'in_channel',
text: `${userProfile.display_name_normalized} ran /osu-profile ${arg}`,
blocks: [
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": `<@${ctx.context.userId}> ran \`/osu-profile\` | Matched by osu! username`
}
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": `*Slack Username*: Not linked\n*osu! username:* <https://osu.ppy.sh/users/${osuProfile.id}|${osuProfile.username}>`
},
"accessory": {
"type": "image",
"image_url": osuProfile.avatar_url,
"alt_text": `${osuProfile.username}'s osu profile picture`
}
}
]
2024-06-30 14:09:50 +00:00
})
}
const slackProfile = (await ctx.client.users.info({ user: cached.slackId })).user!;
return ctx.respond({
response_type: 'in_channel',
text: `${userProfile.display_name_normalized} ran /osu-profile ${arg}`,
blocks: [
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": `<@${ctx.context.userId}> ran \`/osu-profile\` | Matched by osu! username`
}
]
},
...await generateProfile(slackProfile)
]
})
2024-06-30 15:18:21 +00:00
} else {
// User's own profile
const mentionedUser = ctx.context.userId!;
const slackProfile = (await ctx.client.users.info({ user: mentionedUser })).user!;
if (!cache.find(u => u.slackId == slackProfile.id)) {
return ctx.respond({
2024-07-02 21:43:49 +00:00
response_type: 'in_channel',
text: `${userProfile.display_name_normalized} ran /osu-profile`,
blocks: [
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": `<@${ctx.context.userId}> ran \`/osu-profile\` | Matched by no input`
}
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": `*Slack Username*: <https://hackclub.slack.com/team/${slackProfile.id}|${slackProfile.profile!.display_name_normalized}>\n*osu! username:* Not linked`
},
"accessory": {
"type": "image",
"image_url": 'https://osu.ppy.sh/images/layout/avatar-guest@2x.png',
"alt_text": `default osu profile picture`
}
}
]
2024-06-30 15:18:21 +00:00
})
}
return ctx.respond({
response_type: 'in_channel',
text: `${userProfile.display_name_normalized} ran /osu-profile`,
blocks: [
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": `<@${ctx.context.userId}> ran \`/osu-profile\` | Matched by no input`
}
]
},
...await generateProfile(slackProfile)
]
})
2024-06-30 14:09:50 +00:00
}
})
2024-06-30 15:18:21 +00:00
app.command('/osu-leaderboard', async (ctx) => {
await ctx.ack();
2025-02-17 21:20:02 +00:00
const cached = splitArray<CacheUser>(cache.sort((a, b) => {
2024-06-30 15:18:21 +00:00
return b.score.osu - a.score.osu
}), 10);
2024-06-30 15:18:21 +00:00
const users = [];
2025-02-17 21:20:02 +00:00
for (let i in cached[0]) {
try {
2025-02-17 21:20:02 +00:00
const cachedU = cached[0][i];
const slackInfo = await ctx.client.users.info({ user: cachedU.slackId })
const slackProfile = slackInfo.user!;
users.push(`${users.length + 1}. <https://hackclub.slack.com/team/${slackProfile.id}|${slackProfile.profile!.display_name_normalized}> / <https://osu.ppy.sh/users/${cachedU.id}|${cachedU.username}> - ${cachedU.score.osu.toLocaleString()}`)
} catch (e) {
console.error(e)
continue;
}
2024-06-30 15:18:21 +00:00
}
ctx.respond({
response_type: 'in_channel',
blocks: [
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": `<@${ctx.context.userId}> ran \`/osu-leaderboard\``
}
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": users.join('\n')
}
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": "*Current leaderboard:* :osu-standard: osu!standard"
}
]
},
{
"type": "section",
"block_id": "select",
"text": {
"type": "mrkdwn",
"text": "Change leaderboard:"
},
"accessory": {
"type": "static_select",
"placeholder": {
"type": "plain_text",
"text": "Choose ruleset...",
"emoji": true
},
"options": [
{
"text": {
"type": "plain_text",
"text": ":osu-standard: osu!standard",
"emoji": true
},
"value": "osu"
},
{
"text": {
"type": "plain_text",
"text": ":osu-taiko: osu!taiko",
"emoji": true
},
"value": "taiko"
},
{
"text": {
"type": "plain_text",
"text": ":osu-catch: osu!catch",
"emoji": true
},
"value": "fruits"
},
{
"text": {
"type": "plain_text",
"text": ":osu-mania: osu!mania",
"emoji": true
},
"value": "mania"
}
],
2024-06-30 16:39:38 +00:00
"action_id": "change-leaderboard|" + ctx.context.userId
2024-06-30 15:18:21 +00:00
}
}
]
})
})
app.action("link", ({ ack }) => ack())
app.action(/change-leaderboard\|.+/, async (ctx) => {
await ctx.ack();
const action = ctx.action as StaticSelectAction
const [_, userId] = action.action_id.split('|');
if (userId != ctx.context.userId) {
return ctx.respond({ replace_original: false, response_type: "ephemeral", text: `This leaderboard was initialised by <@${userId}>. Only they can manage it.` })
}
2025-02-17 21:20:02 +00:00
const selected = action.selected_option.value as "osu" | "taiko" | "fruits" | "mania";
2024-06-30 15:18:21 +00:00
const cached = splitArray<any>(cache.sort((a, b) => {
2024-06-30 15:18:21 +00:00
return b.score[selected] - a.score[selected]
}), 10)[0];
2024-06-30 15:18:21 +00:00
const users = [];
for (let i in cached) {
try {
const cachedU = cached[i];
const slackProfile = (await ctx.client.users.info({ user: cachedU.slackId })).user!;
users.push(`${users.length + 1}. <https://hackclub.slack.com/team/${slackProfile.id}|${slackProfile.profile!.display_name_normalized}> / <https://osu.ppy.sh/users/${cachedU.id}|${cachedU.username}> - ${cachedU.score[selected].toLocaleString()}`)
} catch (e) {
console.error(e)
continue;
}
2024-06-30 15:18:21 +00:00
}
ctx.respond({
response_type: 'in_channel',
blocks: [
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": `<@${ctx.context.userId}> ran \`/osu-leaderboard\``
}
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": users.join('\n')
}
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": `*Current leaderboard:* ${action.selected_option.text.text}`
}
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Change leaderboard:"
},
"accessory": {
"type": "static_select",
"placeholder": {
"type": "plain_text",
"text": "Choose ruleset...",
"emoji": true
},
"options": [
{
"text": {
"type": "plain_text",
"text": ":osu-standard: osu!standard",
"emoji": true
},
"value": "osu"
},
{
"text": {
"type": "plain_text",
"text": ":osu-taiko: osu!taiko",
"emoji": true
},
"value": "taiko"
},
{
"text": {
"type": "plain_text",
"text": ":osu-catch: osu!catch",
"emoji": true
},
"value": "fruits"
},
{
"text": {
"type": "plain_text",
"text": ":osu-mania: osu!mania",
"emoji": true
},
"value": "mania"
}
],
2024-06-30 16:39:38 +00:00
"action_id": "change-leaderboard|" + userId
2024-06-30 15:18:21 +00:00
}
}
]
})
})
2024-07-02 21:43:49 +00:00
app.command("/osu-multiplayer-invite", async (ctx) => {
await ctx.ack();
const me = cache.find(user => user.slackId == ctx.context.userId);
if (!me) {
return ctx.respond({
response_type: 'ephemeral',
text: `Hey <@${ctx.context.userId}>, you haven't linked your osu! account to your Slack account. Run /osu-link and then run this command.`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `Hey <@${ctx.context.userId}>, you haven't linked your osu! account to your Slack account. Run \`/osu-link\` and then run this command.`
}
}
]
});
}
2024-07-02 23:01:43 +00:00
const ownedRoom = multiplayerRoundCache.find(room => room.host.id == me.id && room.active);
2024-07-02 21:43:49 +00:00
2024-07-02 23:01:43 +00:00
if (!ownedRoom) {
2024-07-02 21:43:49 +00:00
return ctx.respond({
response_type: 'ephemeral',
2024-07-02 23:01:43 +00:00
text: `Hey <@${ctx.context.userId}>, you aren't in a multiplayer room. If you are, make sure you're the host of the room, and you're on osu!lazer. If this is still happening, my cache may need reloading. Wait about a minute before running the command again.`,
2024-07-02 21:43:49 +00:00
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
2024-07-02 23:01:43 +00:00
text: `Hey <@${ctx.context.userId}>, you aren't in a multiplayer room. If you are, make sure you're the host of the room, and you're on osu!lazer. If this is still happening, my cache may need reloading. Wait about a minute before running the command again.`,
2024-07-02 21:43:49 +00:00
}
}
]
});
2024-07-02 23:01:43 +00:00
}
const ratings = ownedRoom.playlist.map((x: any) => x.beatmap.difficulty_rating);
const min = Math.min(...ratings);
const max = Math.max(...ratings);
const currentSong = ownedRoom.playlist.find((x: any) => !x.expired)
const ruleset = [":osu-standard: osu!standard", ":osu-taiko: osu!taiko", ":osu-catch: osu!catch", ":osu-mania: osu!mania"][ownedRoom.playlist[0].ruleset_id]
return ctx.respond({
response_type: 'in_channel',
"blocks": [
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": `<@${ctx.context.userId}> ran \`/osu-multiplayer-invite\` | ${ruleset}${ownedRoom.has_password ? " | Password required" : ""}`
}
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": `*Room*: ${ownedRoom.name}\n*Star Rating*: ${min} - ${max}\n\n*Currently playing:* <https://osu.ppy.sh/beatmapsets/${currentSong.beatmap.beatmapset_id}#osu/${currentSong.beatmap.id}|${currentSong.beatmap.beatmapset.title_unicode} - ${currentSong.beatmap.beatmapset.artist_unicode} (${currentSong.beatmap.difficulty_rating})>`
},
"accessory": {
"type": "image",
"image_url": ownedRoom.host.avatar_url,
"alt_text": `${ownedRoom.host.username}'s osu profile picture`
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Join this lobby:"
},
"accessory": {
"type": "button",
"text": {
"type": "plain_text",
"text": "Join",
"emoji": true
},
"value": "link",
"url": `osu://mp/${ownedRoom.id}`,
"action_id": "link"
}
}
]
})
2024-07-02 21:43:49 +00:00
})
2024-06-30 18:22:41 +00:00
receiver.router.get('/osu/news.rss', async (req, res) => {
const news = await fetch('https://osu.ppy.sh/api/v2/news').then(res => res.json());
const posts = news.news_posts;
const out = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>osu!news</title>
<link>https://osu.haroon.hackclub.app/home/news</link>
<atom:link rel="self" type="application/rss+xml" href="https://osu.haroon.hackclub.app/osu/news.rss" />
<description>Latest news on osu!</description>
<language>en-us</language>
<ttl>60</ttl>
<image>
<url>https://raw.githubusercontent.com/ppy/osu-web/master/public/images/favicon/favicon-32x32.png</url>
<title>osu!news</title>
<link>https://osu.haroon.hackclub.app/home/news</link>
</image>
2024-07-02 23:01:43 +00:00
${posts.map((post: any) =>
`<item>
2024-06-30 18:22:41 +00:00
<title>${post.title}</title>
<link>https://osu.haroon.hackclub.app/home/news/${post.slug}</link>
<guid isPermaLink="false">${post.id}</guid>
2024-07-02 23:01:43 +00:00
<pubDate>${new Date(post.published_at).toLocaleString('en-GB', { timeZone: 'UTC', hour12: false, weekday: 'short', year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }).replace(/(?:(\d),)/, '$1') + ' GMT'}</pubDate>
2024-06-30 18:22:41 +00:00
<description>${post.preview}</description>
2024-07-02 21:43:49 +00:00
<enclosure url="${post.first_image}" type="image/jpg"/>
2024-06-30 18:22:41 +00:00
</item>`
2024-07-02 23:01:43 +00:00
).join('\n ')}
2024-06-30 18:22:41 +00:00
</channel>
</rss>`;
res.contentType("application/rss+xml")
res.send(out)
2024-07-03 22:01:21 +00:00
});
app.command('/osu-eval', async (ctx) => {
await ctx.ack();
if (ctx.context.userId != 'U06TBP41C3E') return;
const resp = inspect(await eval(ctx.body.text), undefined, 1)
2024-07-03 22:01:21 +00:00
ctx.respond({
text: resp,
response_type: 'ephemeral',
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: "```" + resp + "```"
}
}
]
})
})
app.command('/osu-search', async (ctx) => {
await ctx.ack();
2024-07-04 22:26:38 +00:00
const me = cache.find(user => user.slackId == ctx.context.userId);
if (!me) {
return ctx.respond({
response_type: 'ephemeral',
text: `Hey <@${ctx.context.userId}>, you haven't linked your osu! account to your Slack account. Run /osu-link and then run this command.`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `Hey <@${ctx.context.userId}>, you haven't linked your osu! account to your Slack account. Run \`/osu-link\` and then run this command.`
}
}
]
});
}
2025-02-17 21:20:02 +00:00
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"
}
},
]
});
}
2024-07-03 22:01:21 +00:00
ctx.client.views.open({
trigger_id: ctx.payload.trigger_id,
view: {
"type": "modal",
2024-07-04 22:26:38 +00:00
"callback_id": "search",
private_metadata: ctx.payload.channel_id,
2024-07-03 22:01:21 +00:00
"title": {
"type": "plain_text",
"text": "Search for a beatmap",
"emoji": true
},
"submit": {
"type": "plain_text",
"text": "Search",
"emoji": true
},
"close": {
"type": "plain_text",
"text": "Cancel",
"emoji": true
},
"blocks": [
{
"type": "input",
block_id: "keywords",
"element": {
"type": "plain_text_input",
"action_id": "keywords"
},
"label": {
"type": "plain_text",
"text": "Keywords",
"emoji": true
},
optional: true
},
{
"type": "input",
"block_id": "rating",
"element": {
"type": "number_input",
"is_decimal_allowed": true,
"action_id": "rating"
},
"label": {
"type": "plain_text",
"text": "Rating",
"emoji": true
},
optional: true
},
{
"type": "input",
"block_id": "gamemode",
"element": {
"type": "static_select",
"placeholder": {
"type": "plain_text",
"text": "Choose an item",
"emoji": true
},
"options": [
{
"text": {
"type": "plain_text",
"text": "osu!standard",
"emoji": true
},
"value": "osu"
},
{
"text": {
"type": "plain_text",
"text": "osu!taiko",
"emoji": true
},
"value": "taiko"
},
{
"text": {
"type": "plain_text",
"text": "osu!catch",
"emoji": true
},
"value": "fruits"
},
{
"text": {
"type": "plain_text",
"text": "osu!mania",
"emoji": true
},
"value": "mania"
2024-07-04 22:26:38 +00:00
}
2024-07-03 22:01:21 +00:00
],
"action_id": "gamemode"
},
"label": {
"type": "plain_text",
"text": "Gamemode",
"emoji": true
}
},
// @ts-expect-error initial_option does exist and work.
{
"type": "actions",
2024-07-04 22:26:38 +00:00
"block_id": "ranked",
2024-07-03 22:01:21 +00:00
"elements": [
{
"type": "radio_buttons",
"initial_option": {
"text": {
"type": "mrkdwn",
2024-07-04 22:26:38 +00:00
"text": "*Ranked*"
2024-07-03 22:01:21 +00:00
},
"description": {
"type": "mrkdwn",
2024-07-04 22:26:38 +00:00
"text": "Maps that will give you Performance Points"
2024-07-03 22:01:21 +00:00
},
2024-07-04 22:26:38 +00:00
"value": "ranked"
2024-07-03 22:01:21 +00:00
},
"options": [
{
"text": {
"type": "mrkdwn",
2024-07-04 22:26:38 +00:00
"text": "*Graveyard*"
2024-07-03 22:01:21 +00:00
},
"description": {
"type": "mrkdwn",
2024-07-04 22:26:38 +00:00
"text": "Maps that were previously ranked and gave you Performance Points"
2024-07-03 22:01:21 +00:00
},
2024-07-04 22:26:38 +00:00
"value": "graveyard"
2024-07-03 22:01:21 +00:00
},
{
"text": {
"type": "mrkdwn",
"text": "*Ranked*"
},
"description": {
"type": "mrkdwn",
"text": "Maps that will give you Performance Points"
},
"value": "ranked"
},
{
"text": {
"type": "mrkdwn",
"text": "*Loved*"
},
"description": {
"type": "mrkdwn",
"text": "Maps that will not give you Performance Points but suggested by the community"
},
"value": "loved"
}
],
2024-07-04 22:26:38 +00:00
"action_id": "ranked"
2024-07-03 22:01:21 +00:00
}
]
}
]
}
})
2024-06-30 18:22:41 +00:00
})
2024-07-04 22:26:38 +00:00
app.view("search", async (ctx) => {
await ctx.ack();
const options = {
keywords: ctx.payload.state.values["keywords"]["keywords"]?.value || "",
rating: ctx.payload.state.values["rating"]["rating"]?.value || "",
gamemode: ctx.payload.state.values["gamemode"]["gamemode"].selected_option!.value,
ranked: ctx.payload.state.values["ranked"]["ranked"].selected_option!.value,
}
const url = new URL("https://osu.ppy.sh/api/v2/beatmapsets/search");
url.searchParams.set("e", "")
url.searchParams.set("c", "")
url.searchParams.set("g", "")
url.searchParams.set("l", "")
url.searchParams.set("m", ["osu", "taiko", "fruits", "mania"].indexOf(options.gamemode).toString())
url.searchParams.set("nsfw", "")
url.searchParams.set("played", "")
url.searchParams.set("q", options.keywords)
url.searchParams.set("r", "")
url.searchParams.set("sort", "")
url.searchParams.set("s", options.ranked);
const data = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${await getAccessToken(ctx.context.userId!)}`
}
}).then(res => res.json());
const set = data.beatmapsets[0];
2025-02-17 21:20:02 +00:00
ctx.client.chat.postMessage({
2024-07-04 22:26:38 +00:00
channel: ctx.view.private_metadata,
"blocks": [
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": `<@${ctx.context.userId}> searched for a map`
}
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": `*Beatmap:* ${set.title_unicode} - ${set.artist_unicode}\n*Mapper:* ${set.creator}\n\n<https://osu.ppy.sh/beatmapsets/${set.id}|*View this set on the osu! website*>`
}
},
{
"type": "image",
"image_url": set.covers.card,
"alt_text": "beatmap card"
}
]
})
})
2024-06-30 17:36:38 +00:00
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"
}
2024-06-30 14:09:50 +00:00
async function debugDailyChallenge() {
// Daily Challenge!!
2024-06-30 14:09:50 +00:00
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": `<https://osu.ppy.sh/beatmapsets/${currentSong.beatmap.beatmapset_id
}#osu/${currentSong.beatmap.id
}|${currentSong.beatmap.beatmapset.title
} - ${currentSong.beatmap.beatmapset.artist
} (${currentSong.beatmap.difficulty_rating
})>
*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
})
}
2025-02-17 21:20:02 +00:00
type QueueJob = {
md5: string,
playerName: string,
ts: string,
userId: string
}
const queue: QueueJob[] = []
// key is renderID
const waiting = new Map<number, QueueJob>()
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'
})
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);
}
};
2025-02-17 21:20:02 +00:00
const socket = io('https://apis.issou.best', {
path: '/ordr/ws',
autoConnect: false
})
2025-02-17 21:20:02 +00:00
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();
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();
2025-02-17 21:20:02 +00:00
setInterval(cacheStuff, 60 * 1000) // Cache every minute. Ratelimit is 1200 req/m anyways.
2024-06-30 10:28:18 +00:00
2025-02-17 21:20:02 +00:00
scheduleJob('30 5 0 * * *', debugDailyChallenge)
})();