Skip to content

Commit

Permalink
API properties, Multiplayer.Room interfaces
Browse files Browse the repository at this point in the history
  • Loading branch information
TTTaevas committed Oct 28, 2024
1 parent 2851cb0 commit 31eb8de
Show file tree
Hide file tree
Showing 9 changed files with 114 additions and 95 deletions.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,16 @@ async function logUserTopPlayBeatmap(username: string) {
// It's more convenient to use `osu.API.createAsync()` instead of `new osu.API()` as it doesn't require you to directly provide an access_token!
// In a proper application, you'd use this function as soon as the app starts so you can use that object everywhere
// (or if it acts as a user, you'd use this function at the end of the authorization flow)
const api = await osu.API.createAsync({id: "<client_id>", secret: "<client_secret>"})
const api = await osu.API.createAsync("<client_id>", "<client_secret>") // with id as a number

const user = await api.getUser(username) // We need to get the id of the user in order to request what we want
const score = (await api.getUserScores(user, "best", osu.Ruleset.osu, {lazer: false}, {limit: 1}))[0] // Specifying the Ruleset is optional
const beatmapDifficulty = await api.getBeatmapDifficultyAttributesOsu(score.beatmap, score.mods) // Specifying the mods so the SR is adapted to them

const x = `${score.beatmapset.artist} - ${score.beatmapset.title} [${score.beatmap.version}]`
const y = `+${score.mods.toString()} (${beatmapDifficulty.star_rating.toFixed(2)}*)`
const y = `+${score.mods.map((m) => m.acronym).toString()} (${beatmapDifficulty.star_rating.toFixed(2)}*)`
console.log(`${username}'s top play is on: ${x} ${y}`)
// Doomsday fanboy's top play is on: Yamajet feat. Hiura Masako - Sunglow [Harmony] +DT (8.72*)
// Doomsday fanboy's top play is on: Erio o Kamattechan - os-Uchuujin(Asterisk Makina Remix) [Mattress Actress] +DT,CL (8.85*)
}

logUserTopPlayBeatmap("Doomsday fanboy")
Expand All @@ -68,7 +68,7 @@ When a user authorizes your application, they get redirected to your `Applicatio

With this code, you're able to create your `api` object:
```typescript
const api = await osu.API.createAsync({id: "<client_id>", secret: "<client_secret>"}, {code: "<code>", redirect_uri: "<application_callback_url>"})
const api = await osu.API.createAsync("<client_id>", "<client_secret>", {code: "<code>", redirect_uri: "<application_callback_url>"})
```

#### The part where you make it so your application works without the user saying okay every 2 minutes
Expand Down Expand Up @@ -125,7 +125,7 @@ async function readChat() {
// Somehow get the code so the application can read the messages as your osu! user
const url = osu.generateAuthorizationURL(id, redirect_uri, ["public", "chat.read"]) // "chat.read" is 100% needed in our case
const code = await getCode(url)
const api = await osu.API.createAsync({id, secret}, {code, redirect_uri}, {verbose: "errors"})
const api = await osu.API.createAsync(id, secret, {code, redirect_uri}, {verbose: "errors"})

// Get a WebSocket object to interact with and get messages from
const socket = api.generateWebSocket()
Expand Down
4 changes: 2 additions & 2 deletions lib/Beatmapset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,13 +392,13 @@ export namespace Beatmapset {
}

/** @group Kudosu Change */
export interface KudosuGain extends WithUserid, WithOptionalBeatmapset, WithDiscussion {
export interface KudosuGain extends WithUserid, WithOptionalBeatmapset, WithOptionalDiscussion {
type: "kudosu_gain"
comment: Comment.WithDiscussionidPostidNewvotevotes
}

/** @group Kudosu Change */
export interface KudosuLost extends WithUserid, WithOptionalBeatmapset, WithDiscussion {
export interface KudosuLost extends WithUserid, WithOptionalBeatmapset, WithOptionalDiscussion {
type: "kudosu_lost"
comment: Comment.WithDiscussionidPostidNewvotevotes
}
Expand Down
24 changes: 19 additions & 5 deletions lib/Multiplayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export namespace Multiplayer {
export interface Room {
id: number
name: string
category: string
type: string
category: "normal" | "spotlight" | "daily_challenge"
type: "head_to_head" | "team_versus" | "playlists"
user_id: User["id"]
starts_at: Date
ends_at: Date | null
Expand All @@ -16,11 +16,18 @@ export namespace Multiplayer {
channel_id: Chat.Channel["channel_id"]
active: boolean
has_password: boolean
queue_mode: string
queue_mode: "all_players" | "all_players_round_robin" | "host_only"
auto_skip: boolean
host: User.WithCountry
playlist: Room.PlaylistItem[]
recent_participants: User[]
current_playlist_item?: Room.PlaylistItem.WithBeatmap | null
playlist?: Room.PlaylistItem.WithComplexBeatmap[]
playlist_item_stats?: {
count_active: number
count_total: number
ruleset_ids: Ruleset[]
}
difficulty_range?: {min: Beatmap["difficulty_rating"], max: Beatmap["difficulty_rating"]}
/** Only exists if the authorized user has played */
current_user_score?: {
/** In a format where `96.40%` would be `0.9640` (with some numbers after the zero) */
Expand Down Expand Up @@ -53,10 +60,17 @@ export namespace Multiplayer {
playlist_order: number | null
/** @remarks Should be null if the room isn't the realtime multiplayer kind */
played_at: Date | null
beatmap: Beatmap.WithBeatmapsetChecksumMaxcombo
}

export namespace PlaylistItem {
export interface WithBeatmap extends PlaylistItem {
beatmap: Beatmap.WithBeatmapset
}

export interface WithComplexBeatmap extends PlaylistItem {
beatmap: Beatmap.WithBeatmapsetChecksumMaxcombo
}

export interface Score extends ScoreImport.WithUser {
playlist_item_id: PlaylistItem["id"]
room_id: Room["id"]
Expand Down
18 changes: 9 additions & 9 deletions lib/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,6 @@ export namespace User {
}[]
}

/** @obtainableFrom {@link API.getFriends} */
export interface Friend {
target_id: User["id"]
relation_type: "friend" | "block"
mutual: boolean
target: WithCountryCoverGroupsStatisticsSupport
}

export interface WithCountryCoverGroupsStatisticsSupport extends WithCountryCover, WithGroups {
statistics: Statistics
support_level: number
Expand Down Expand Up @@ -250,6 +242,14 @@ export namespace User {
}
}

/** @obtainableFrom {@link API.getFriends} */
export interface Relation {
target_id: User["id"]
relation_type: "friend" | "block"
mutual: boolean
target: WithCountryCoverGroupsStatisticsSupport
}

/** @obtainableFrom {@link API.getUserKudosu} */
export interface KudosuHistory {
id: number
Expand Down Expand Up @@ -371,7 +371,7 @@ export namespace User {
* @scope {@link Scope"friends.read"}
* @remarks The Statistics will be of the authorized user's favourite gamemode, not the friend's!
*/
export async function getFriends(this: API): Promise<User.Friend[]> {
export async function getFriends(this: API): Promise<User.Relation[]> {
return await this.request("get", "friends")
}
}
72 changes: 38 additions & 34 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,8 @@ export class API {
* @returns A promise with an API instance
*/
public static async createAsync(
client: {
id: number,
secret: string
},
client_id: API["client_id"],
client_secret: API["client_secret"],
user?: {
/** The Application Callback URL; Where the User has been redirected to after saying "okay" to your application doing stuff */
redirect_uri: string,
Expand All @@ -137,41 +135,43 @@ export class API {
settings?: Partial<API>
): Promise<API> {
const new_api = new API({
client,
client_id,
client_secret,
...settings
})

return user ?
await new_api.getAndSetToken({client_id: client.id, client_secret: client.secret, grant_type: "authorization_code",
redirect_uri: user.redirect_uri, code: user.code}, new_api) :
await new_api.getAndSetToken({client_id: client.id, client_secret: client.secret, grant_type: "client_credentials", scope: "public"}, new_api)
await new_api.getAndSetToken({client_id, client_secret, grant_type: "authorization_code", redirect_uri: user.redirect_uri, code: user.code}, new_api) :
await new_api.getAndSetToken({client_id, client_secret, grant_type: "client_credentials", scope: "public"}, new_api)
}


// CLIENT INFO

private _client: {
id: number
secret: string
} = {id: 0, secret: ""}
/** The details of your client, which you've got from https://osu.ppy.sh/home/account/edit#oauth */
get client() {return this._client}
set client(client) {this._client = client}
private _client_id: number = 0
/** The ID of your client, which you can get on https://osu.ppy.sh/home/account/edit#oauth */
get client_id() {return this._client_id}
set client_id(client_id) {this._client_id = client_id}

private _client_secret: string = ""
/** The Secret of your client, which you can get or reset on https://osu.ppy.sh/home/account/edit#oauth */
get client_secret() {return this._client_secret}
set client_secret(client_secret) {this._client_secret = client_secret}

private _server: string = "https://osu.ppy.sh"
/** The base url of the server where the requests should land (defaults to **https://osu.ppy.sh**) */
get server() {return this._server}
set server(server) {this._server = server}

private _routes: {
/** Used by practically every method to interact with the {@link API.server} */
normal: string
/** Used for getting an {@link API.access_token} and using your {@link API.refresh_token} */
token_obtention: string
} = {normal: "api/v2", token_obtention: "oauth/token"}
/** What follows the {@link API.server} and preceeds the individual endpoints used by each request */
get routes() {return this._routes}
set routes(routes) {this._routes = routes}
private _route_api: string = "api/v2"
/** Used by practically every method to interact with the {@link API.server} (defaults to **api/v2**) */
get route_api() {return this._route_api}
set route_api(route_api) {this._route_api = route_api}

private _route_token: string = "oauth/token"
/** Used for getting an {@link API.access_token} and using your {@link API.refresh_token} (defaults to **oauth/token**) */
get route_token() {return this._route_token}
set route_token(route_token) {this._route_token = route_token}

private _user?: User["id"]
/** The osu! user id of the user who went through the Authorization Code Grant */
Expand Down Expand Up @@ -266,7 +266,7 @@ export class API {
code?: string
refresh_token?: string
}, api: API): Promise<API> {
const response = await fetch(`${this.server}/${this.routes.token_obtention}`, {
const response = await fetch(`${this.server}/${this.route_token}`, {
method: "post",
headers: {
"Accept": "application/json",
Expand All @@ -277,13 +277,13 @@ export class API {
signal: this.timeout > 0 ? AbortSignal.timeout(this.timeout * 1000) : undefined
})
.catch((e) => {
throw new APIError("Failed to fetch a token", this.server, "post", this.routes.token_obtention, body, undefined, e)
throw new APIError("Failed to fetch a token", this.server, "post", this.route_token, body, undefined, e)
})

const json: any = await response.json()
if (!json.access_token) {
this.log(true, "Unable to obtain a token! Here's what was received from the API:", json)
throw new APIError("No token obtained", this.server, "post", this.routes.token_obtention, body, response.status)
throw new APIError("No token obtained", this.server, "post", this.route_token, body, response.status)
}
api.token_type = json.token_type
if (json.refresh_token) {api.refresh_token = json.refresh_token}
Expand Down Expand Up @@ -385,7 +385,7 @@ export class API {
const old_token = this.access_token
try {
await this.getAndSetToken(
{client_id: this.client.id, client_secret: this.client.secret, grant_type: "refresh_token", refresh_token: this.refresh_token}, this)
{client_id: this.client_id, client_secret: this.client_secret, grant_type: "refresh_token", refresh_token: this.refresh_token}, this)
if (old_token !== this.access_token) {this.log(false, "The token has been refreshed!")}
} catch(e) {
this.log(true, "Failed to refresh the token :(", e)
Expand Down Expand Up @@ -443,8 +443,8 @@ export class API {
if (settings?.signal) signals.push(settings.signal)
if (this.timeout > 0) signals.push(AbortSignal.timeout(this.timeout * 1000))

const second_slash = this.routes.normal.length ? "/" : "" // if the server **is** the route, don't have `//` between the server and the endpoint
let url = `${this.server}/${this.routes.normal}${second_slash}${endpoint}`
const second_slash = this.route_api.length ? "/" : "" // if the server **is** the route, don't have `//` between the server and the endpoint
let url = `${this.server}/${this.route_api}${second_slash}${endpoint}`

if (method === "get" && parameters) {
// For GET requests specifically, requests need to be shaped in very particular ways
Expand All @@ -467,7 +467,7 @@ export class API {
"Content-Type": "application/json",
"User-Agent": "osu-api-v2-js (https://github.com/TTTaevas/osu-api-v2-js)",
"Authorization": `${this.token_type} ${this.access_token}`,
"x-api-version": "20241025",
"x-api-version": "20241027",
...settings?.headers // written that way, custom headers with (for example) only a user-agent would only overwrite the default user-agent
},
body: method !== "get" ? JSON.stringify(parameters) : undefined, // parameters are here if request is NOT GET
Expand Down Expand Up @@ -519,7 +519,7 @@ export class API {
return await this.request(method, endpoint, parameters, settings, {number_try: info.number_try + 1, just_refreshed: info.just_refreshed})
}

throw new APIError(error_message, `${this.server}/${this.routes.normal}`, method, endpoint, parameters, error_code, error_object)
throw new APIError(error_message, `${this.server}/${this.route_api}`, method, endpoint, parameters, error_code, error_object)
}

this.log(false, response.statusText, response.status, {method, endpoint, parameters})
Expand Down Expand Up @@ -830,7 +830,9 @@ export class ChildAPI extends API {
/** @hidden @deprecated use API equivalent */
get access_token() {return this.original.access_token}
/** @hidden @deprecated use API equivalent */
get client() {return this.original.client}
get client_id() {return this.original.client_id}
/** @hidden @deprecated use API equivalent */
get client_secret() {return this.original.client_secret}
/** @hidden @deprecated use API equivalent */
get expires() {return this.original.expires}
/** @hidden @deprecated use API equivalent */
Expand All @@ -852,7 +854,9 @@ export class ChildAPI extends API {
/** @hidden @deprecated use API equivalent */
get retry_on_timeout() {return this.original.retry_on_timeout}
/** @hidden @deprecated use API equivalent */
get routes() {return this.original.routes}
get route_api() {return this.original.route_api}
/** @hidden @deprecated use API equivalent */
get route_token() {return this.original.route_token}
/** @hidden @deprecated use API equivalent */
get scopes() {return this.original.scopes}
/** @hidden @deprecated use API equivalent */
Expand Down
9 changes: 5 additions & 4 deletions lib/tests/authenticated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,25 +266,25 @@ const testMultiplayer = async () => {
// PLAYLIST
const playlist_test = new Test(api.getRooms, ["playlists", "all"], "Multiplayer.Room")
await playlist_test.try()
tests.push(playlist_test)

if (playlist_test.response) {
const room = (playlist_test.response as osu.Multiplayer.Room[])[0]
tests.push(new Test(api.getRoomLeaderboard, [room], {leaderboard: "Multiplayer.Room.Leader"}))
} else {
console.warn("⚠️ Skipping multiplayer playlist tests, unable to get rooms")
tests.push(playlist_test)
}

// REALTIME
const realtime_test = new Test(api.getRooms, ["realtime", "all"], "Multiplayer.Room")
await realtime_test.try()
tests.push(realtime_test)

if (realtime_test.response) {
const room = (realtime_test.response as osu.Multiplayer.Room[])[0]
tests.push(new Test(api.getRoomLeaderboard, [room], {leaderboard: "Multiplayer.Room.Leader"}))
} else {
console.warn("⚠️ Skipping multiplayer realtime tests, unable to get rooms")
tests.push(realtime_test)
}

return tests
Expand All @@ -303,14 +303,15 @@ const testScore = () => {

const testUser = () => [
new Test(api.getResourceOwner, [], "User.Extended.WithStatisticsrulesets"),
new Test(api.getFriends, [], "User.WithCountryCoverGroupsStatisticsSupport")
new Test(api.getFriends, [], undefined,
[(r: AR<typeof api.getFriends>) => validate(r[0].target, "User.WithCountryCoverGroupsStatisticsSupport")])
]

const test = async (): Promise<void> => {
const scopes: osu.Scope[] = ["public", "chat.read", "chat.write", "chat.write_manage", "forum.write", "friends.read", "identify"]
const url = osu.generateAuthorizationURL(id, redirect_uri, scopes, server)
const code = await getCode(url)
api = await osu.API.createAsync({id, secret}, {code, redirect_uri}, {verbose: "all", timeout: 30, server, retry_on_timeout: true})
api = await osu.API.createAsync(id, secret, {code, redirect_uri}, {verbose: "all", timeout: 30, server, retry_on_timeout: true})

const tests = [
testChat,
Expand Down
10 changes: 5 additions & 5 deletions lib/tests/guest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import util from "util"
import tsj from "ts-json-schema-generator"
import ajv from "ajv"

if (process.env.ID === undefined) {throw new Error("❌ The ID has not been defined in the environment variables!")}
if (process.env.SECRET === undefined) {throw new Error("❌ The SECRET has not been defined in the environment variables!")}

let api: osu.API
const generator = tsj.createGenerator({path: "lib/index.ts", additionalProperties: true})
Expand Down Expand Up @@ -353,8 +351,8 @@ const testOther = () => [
]


const test = async (id: string, secret: string): Promise<void> => {
api = await osu.API.createAsync({id: Number(id), secret}, undefined, {verbose: "all", timeout: 30, retry_on_timeout: true})
const test = async (id: number, secret: string): Promise<void> => {
api = await osu.API.createAsync(id, secret, undefined, {verbose: "all", timeout: 30, retry_on_timeout: true})

const tests = [
testBeatmapPack,
Expand Down Expand Up @@ -402,4 +400,6 @@ const test = async (id: string, secret: string): Promise<void> => {
}
}

test(process.env.ID, process.env.SECRET)
if (process.env.ID === undefined) {throw new Error("❌ The ID has not been defined in the environment variables!")}
if (process.env.SECRET === undefined) {throw new Error("❌ The SECRET has not been defined in the environment variables!")}
test(Number(process.env.ID), process.env.SECRET)
Loading

0 comments on commit 31eb8de

Please sign in to comment.