UNPKG

@haelp/teto

Version:

A typescript-based controllable TETR.IO client.

389 lines (350 loc) 11.1 kB
import type { Types } from "../.."; import type { Events, Game as GameTypes } from "../../types"; import { API, CONSTANTS, parseToken } from "../../utils"; import { Game } from "../game"; import { Ribbon } from "../ribbon"; import { Room } from "../room"; import { Social } from "../social"; import { ClientUtils } from "../utils"; import type { ClientOptions, ClientUser } from "./types"; export type * from "./types"; export class Client { /** User information */ public user: ClientUser; /** Whether the client has been disconnected. If true, the client needs to be reconnected with `.reconnect()` or destroyed */ public disconnected: boolean = false; /** * Utils for the client. * @deprecated - functionality has been moved to other sections. This may be removed in the future. */ public utils: ClientUtils; /** The client's token */ public token: string; /** @hidden */ private _handling: GameTypes.Handling; /** Raw ribbon client, the backbone of TETR.IO multiplayer. You probably don't want to touch this unless you know what you are doing. */ public ribbon: Ribbon; /** A helpful manager for all things social on TETR.IO (friends, dms etc.) */ public social!: Social; /** The room the client is in (if it is in a room). You can make it non-nullable with `client.room!` */ public room?: Room; /** The game the client is currently in if it is in a game. */ public game?: Game; /** Useful for connecting to the main game API when none of the client helpers have the request you want to send. You can use `client.api.get` and `client.api.post` to easily send GET and POST requests. */ public api: API; public rooms: { list(): ReturnType<API["rooms"]>; join(id: string): Promise<Room>; create(type?: "public" | "private"): Promise<Room>; }; /** * Raw ribbon handler. * @example * client.on('social.dm', () => console.log('DM received!')); */ public on: typeof this.ribbon.emitter.on; /** Raw ribbon handler. */ public off: typeof this.ribbon.emitter.off; /** Raw ribbon handler. You likely want to use `client.wait` instead. */ public once: typeof this.ribbon.emitter.once; /** Raw ribbon handler for sending messages. */ public emit: typeof this.ribbon.emit; /** @hideconstructor */ private constructor( token: string, sessionID: string, ribbon: Ribbon, me: Awaited<ReturnType<API["users"]["me"]>>, userAgent: string = CONSTANTS.userAgent, handling: GameTypes.Handling ) { this.token = token; this.ribbon = ribbon; this.on = ribbon.emitter.on.bind(ribbon.emitter); this.off = ribbon.emitter.off.bind(ribbon.emitter); this.once = ribbon.emitter.once.bind(ribbon.emitter); this.emit = ribbon.emit.bind(ribbon); this.user = { id: me._id, role: me.role, sessionID, username: me.username, userAgent }; this._handling = handling; this.utils = new ClientUtils(this); this.api = new API({ token: this.token, userAgent }); this.rooms = { list: () => this.api.rooms(), join: async (id: string) => { return await this.wrap( "room.join", id.toUpperCase(), "client.room.join" ); }, create: async (type = "private") => { return await this.wrap( "room.create", type === "public", "client.room.join" ); } }; this.init(); } /** * Create a new client * @example * const client = await Client.connect({ token: 'your.jwt.token' }); * @example * const client = await Client.connect({ username: 'halp', password: 'password' }); * @example * // If playing games, pass in handling * const client = await Client.connect({ * // ...login info * handling: { * arr: 0, * cancel: false, * das: 5, * dcd: 0, * safelock: false, * may20g: true, * sdf: 41 * }; * }); * @example * // You can pass in a custom user agent * const client = await Client.connect({ * // ...login info * userAgent: "v8/001" * }); */ static async connect(options: ClientOptions) { const api = new API(); if (options.userAgent) { api.update({ userAgent: options.userAgent }); } if (options.turnstile) { api.update({ turnstile: options.turnstile }); } const sessionID = `SESS-${Math.floor( Math.random() * Number.MAX_SAFE_INTEGER )}`; let self: { id: string; token: string }; if ("token" in options) { if (!options.token) throw new Error("No valid token was provided"); self = { token: options.token, id: parseToken(options.token) }; } else if ("username" in options) { self = await api.users.authenticate(options.username, options.password); } else { throw new Error("Invalid client options"); } const token = self.token; api.update({ token }); const me = api.users.me(); const handling: Types.Game.Handling = options.handling || { arr: 0, cancel: false, das: 5, dcd: 0, safelock: false, may20g: true, sdf: 41, irs: "tap", ihs: "tap" }; const ribbon = await Ribbon.create({ token, handling, userAgent: options.userAgent || CONSTANTS.userAgent, ...(options.ribbon ?? {}) }); const data = await new Promise<Events.in.Client["client.ready"]>( (resolve, reject) => { const t = setTimeout(() => { ribbon.destroy(); reject("Failed to connect"); }, 5000); ribbon.emitter.once("client.ready", (d) => { if (d) { clearTimeout(t); resolve(d); } }); } ); const client = new Client( token, sessionID, ribbon, await me, options.userAgent || CONSTANTS.userAgent, handling ); client.social = await Social.create(client, data.social); return client; } /** * Wait for an event to occur. Wraps `client.once` into a typed Promise. * @returns the data from the event * @example * // wait for a notification (although you probably want to use `client.on` for this instead) * console.log(await client.wait('social.notification')); */ wait<T extends keyof Events.in.all>(event: T) { return new Promise<Events.in.all[T]>((resolve) => this.once(event, resolve) ); } /** * Send a message and then wait for another message. Throws an error if a 'err' message is received before the response message * @param event - the `command` of the event to send * @param data - the data to send along with the command. For void (no) data, just pass in `undefined` * @param listen - the event to wait for before resolving. * @param error - a list of custom error events to listen for. Defaults to `[client.error]`. * @returns the data sent by the `listen` event * @throws an error if the error event provided (or `client.error`) is received from TETR.IO * @example * // This is just for example, use `client.room!.chat` instead * await client.wrap('room.chat', { content: 'Hello', pinned: false }, 'room.chat'); */ wrap<O extends keyof Events.out.all, I extends keyof Events.in.all>( event: O, data: Events.out.all[O], listen: I, error: (keyof Events.in.all)[] = ["client.error"] ) { return new Promise<Events.in.all[I]>((resolve, reject) => { const disband = () => { this.off(listen, rs); error.forEach((err) => this.off(err, rj)); }; const rs = (data: Events.in.all[I]) => { disband(); resolve(data); }; const rj = (error: string) => { disband(); reject(error); }; this.on(listen, rs); error.forEach((err) => this.on(err, rj)); // @ts-expect-error this.emit(event, data); }); } /** @hidden */ private init() { this.on("room.join", async () => { const data = await this.wait("room.update"); this.room = new Room(this, data); this.emit("client.room.join", this.room!); }); this.on("notify", (notif) => { if (typeof notif === "string") { return this.emit("client.notify", { msg: notif }); } else if ("type" in notif) { switch (notif.type) { case "deny": this.emit("client.notify", { msg: notif.msg, color: "#FF2200", icon: "denied", timeout: notif.timeout }); break; case "warn": this.emit("client.notify", { msg: notif.msg, color: "#FFF43C", icon: "warning" }); break; case "err": this.emit("client.error", notif.msg); this.emit("client.notify", { msg: notif.msg, color: "#FF4200", icon: "error" }); break; case "announce": this.emit("client.notify", { msg: notif.msg, color: "#FFCC00", icon: "announcement", timeout: 1e4 }); break; case "ok": this.emit("client.notify", { msg: notif.msg, color: "#6AFF3C", icon: "ok" }); break; } } else { this.emit("client.notify", notif); } }); this.on("client.dead", async () => { this.disconnected = true; }); } /** * Reconnect the client to TETR.IO. * @throws {Error} if the client is already connected */ async reconnect() { if (!this.disconnected) { throw new Error("Client is not disconnected."); } const newRibbon = await this.ribbon.clone(); this.ribbon.destroy(); this.ribbon = newRibbon; const data = await new Promise<Events.in.Client["client.ready"]>( (resolve, reject) => { const t = setTimeout(() => { newRibbon.destroy(); reject("Failed to connect"); }, 5000); this.ribbon.emitter.once("client.ready", (d) => { if (d) { clearTimeout(t); resolve(d); } }); } ); this.social = await Social.create(this, data.social); } /** The client's current handling. */ get handling() { return this._handling; } /** Change the client's current handling (do not use while in a room) */ set handling(handling: GameTypes.Handling) { if (this.room) throw new Error( "Do not set the handling in a room (you will be banned)!" ); this._handling = handling; this.emit("config.handling", handling); } /** Clean up the client. Leaves any rooms first. */ async destroy() { if (this.room) { try { await this.room.leave(); } catch (e) {} } this.ribbon.destroy(); if (this.room) delete this.room; if (this.game) delete this.game; } }