@haelp/teto
Version:
A typescript-based controllable TETR.IO client.
389 lines (350 loc) • 11.1 kB
text/typescript
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;
}
}