From 8ab9e7edfd50aed852f41eb2074d45dfa719fc8e Mon Sep 17 00:00:00 2001 From: PartMan Date: Mon, 11 Aug 2025 19:02:04 +0530 Subject: [PATCH] feat: Try to use JSDoc types within the code --- .eslintignore | 1 + chat.d.ts | 5 +- chat.js | 9 +- classes/common.d.ts | 10 -- classes/message.d.ts | 154 ++++++++++++---- classes/message.js | 132 +++++++++++++- classes/room.d.ts | 105 ++++++----- classes/room.js | 140 +++++++++++++-- classes/user.d.ts | 122 ++++++++----- classes/user.js | 130 ++++++++++++-- client.d.ts | 321 +++++++++++++++------------------ client.js | 218 ++++++++++++++++++----- client.test.js | 10 -- data.d.ts | 394 ++++------------------------------------- data.js | 45 ++++- mocks/fetch.js | 4 +- mocks/ws.js | 6 + package-lock.json | 302 ++++++++++++++++++++++++++++++- package.json | 8 +- showdown/colors.json | 4 +- tools.d.ts | 112 +++++------- tools.js | 215 +++++++++++++++------- tsconfig.json | 13 ++ types/client-opts.d.ts | 119 +++++++++++++ types/common.d.ts | 16 ++ types/data.d.ts | 357 +++++++++++++++++++++++++++++++++++++ 26 files changed, 2033 insertions(+), 919 deletions(-) delete mode 100644 classes/common.d.ts create mode 100644 tsconfig.json create mode 100644 types/client-opts.d.ts create mode 100644 types/common.d.ts create mode 100644 types/data.d.ts diff --git a/.eslintignore b/.eslintignore index 027cc69..2eb42bb 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ showdown/* +*.d.ts diff --git a/chat.d.ts b/chat.d.ts index 3477781..fc042a6 100644 --- a/chat.d.ts +++ b/chat.d.ts @@ -1,4 +1 @@ -/** - * @warning Use `formatText` from Tools instead! - */ -export default function formatText(input: string): string; +export function formatText(str: string, isTrusted?: boolean | undefined, replaceLinebreaks?: boolean | undefined): string; diff --git a/chat.js b/chat.js index fe88b9f..54ecc03 100644 --- a/chat.js +++ b/chat.js @@ -9,6 +9,8 @@ * @license MIT */ +const toID = text => text.toLowerCase().replace(/[^a-z0-9]/g, ''); + const linkRegex = /(?:(?:https?:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*|www\.[a-z0-9-]+(?:\.[a-z0-9-]+)+|\b[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:(?:com?|org|net|edu|info|us|jp)\b|[a-z]{2,3}(?=:[0-9]|\/)))(?::[0-9]+)?(?:\/(?:(?:[^\s()&<>]|&|"|\((?:[^\\s()<>&]|&)*\))*(?:[^\s()[\]{}".,!?;:&<>*`^~\\]|\((?:[^\s()<>&]|&)*\)))?)?|[a-z0-9.]+@[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,})(?![^ ]*>)/gi; @@ -420,7 +422,12 @@ class TextFormatter { /** * Takes a string and converts it to HTML by replacing standard chat formatting with the appropriate HTML tags. + * @warning Use `formatText` from Tools instead! + * @param str {string} + * @param isTrusted {boolean=} + * @param replaceLinebreaks {boolean=} + * @returns string */ -module.exports = function formatText(str, isTrusted = false, replaceLinebreaks = false) { +exports.formatText = function formatText(str, isTrusted = false, replaceLinebreaks = false) { return new TextFormatter(str, isTrusted, replaceLinebreaks).get(); }; diff --git a/classes/common.d.ts b/classes/common.d.ts deleted file mode 100644 index 972955c..0000000 --- a/classes/common.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -import Room from './room'; - -export type HTMLopts = { - name?: string; - rank?: '+' | '%' | '*' | '@' | '#' | '§' | '&'; - change?: boolean; - notransform?: boolean; - // Only used for User#HTML methods. - room?: string | Room; -}; diff --git a/classes/message.d.ts b/classes/message.d.ts index fa7113c..057fb7a 100644 --- a/classes/message.d.ts +++ b/classes/message.d.ts @@ -1,59 +1,135 @@ -import type { HTMLopts } from './common.d.ts'; -import type { Client } from '../client.d.ts'; -import type Room from './room.d.ts'; -import type User from './user.d.ts'; - -type MessageOpts = { - by: string; - text: string; - type: 'chat' | 'pm'; - target: string; - raw: string; - isIntro: boolean; - parent: Client; - time: void | number; -}; - -export default class Message { +/** + * @import { Ranks, HTML, HTMLOpts } from '../types/common.d.ts'; + * @import { Client } from '../client.js'; + */ +export class Message { + /** + * @constructor + * @param input {{ + * by: string; + * text: string; + * type: 'chat' | 'pm'; + * target: string; + * raw: string; + * isIntro: boolean; + * parent: Client; + * isHidden?: boolean; + * time?: number; + * }} + */ + constructor(input: { + by: string; + text: string; + type: 'chat' | 'pm'; + target: string; + raw: string; + isIntro: boolean; + parent: Client; + isHidden?: boolean; + time?: number; + }); + /** + * Author of the message. + * @type User + * @example User(PartMan) + */ author: User; + /** + * Full message content. + * @type string + * @example 'Hi!' + */ content: string; + /** + * Message string, as-received from the server. + * @type string + * @example '|c|+PartMan|Hi!' + */ raw: string; + /** + * Client that received the message. + * @type Client + */ parent: Client; - msgRank: HTMLopts['rank'] | ' '; + /** + * The rank of the author that sent the message. + * @type {Ranks | ' '} + * @example '+' + */ + msgRank: Ranks | ' '; + /** + * The command of the message. '/botmsg' will only be set as the command if no other command was used. + * @type {string | null} + * @example '!dt' + */ + command: string | null; + /** + * Whether the message was received before joining (eg: via history). These messages + * will not be emitted if scrollback is not explicitly enabled. + * @type boolean + */ isIntro: boolean; + /** + * Whether this message fulfilled a waiting condition. See User/Room's `waitFor` for more info. + * @see {User.waitFor} + * @see {Room.waitFor} + * @type boolean + */ awaited: boolean; + /** + * UNIX timestamp that the message was received at. + * @type number + */ time: number; - + /** + * Chatrooms have 'chat', while PMs have 'pm'. Some methods/properties change accordingly. + * @type { 'chat' | 'pm' } + */ type: 'chat' | 'pm'; - target: 'chat' extends this['type'] ? Room : 'pm' extends this['type'] ? User : never; - isHidden: 'pm' extends this['type'] ? boolean : never; - - constructor(input: MessageOpts); - /** - * Responds to the message - * @param text The text to respond (to the message) with - * @returns A promise that resolves when the message is sent successfully + * The room / DM in which the message was sent. For PMs, this is always the non-client + * User, not necessarily the author of the message! + * @type { Room | User | never } + */ + target: Room | User | never; + /** + * Whether the message is hidden (eg: `/botmsg`). + * @type {boolean} + */ + isHidden: boolean; + /** + * Responds to the message. + * @param text {string} The text to respond (to the message) with. + * @returns {Promise} A promise that resolves when the message is sent successfully. */ reply(text: string): Promise; - /** - * Privately responds to the message - * @param text The text to privately respond (to the message) with + * Privately responds to the message. + * @param text {string} The text to privately respond (to the message) with. + * @returns void */ privateReply(text: string): void; - /** - * Sends HTML in the message context (chatroom for 'chat', PMs for 'pm') - * @param html The HTML to send - * @param opts HTML options. If a string is passed, it is used as HTMLopts.name. + * Sends HTML in the message context (chatroom for 'chat', PMs for 'pm'). + * @param html {HTML} The HTML to send. + * @param opts {HTMLOpts} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {HTML | string} Returns HTML only if `opts.notransform` is true. */ - sendHTML(html: any, opts?: HTMLopts | string): boolean; - + sendHTML(html: HTML, opts: HTMLOpts): HTML | string; /** * Privately sends HTML in the message context (chatroom for 'chat', PMs for 'pm') - * @param html The HTML to send - * @param opts HTML options. If a string is passed, it is used as HTMLopts.name. + * @param html {HTML} The HTML to send + * @param opts {HTMLOpts} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {HTML | string} Returns HTML only if `opts.notransform` is true. */ - replyHTML(html: any, opts?: HTMLopts | string): boolean; + replyHTML(html: HTML, opts: HTMLOpts): HTML | string; + [customInspectSymbol](depth: any, options: any, inspect: any): any; } +import { User } from './user.js'; +import type { Client } from '../client.js'; +import type { Ranks } from '../types/common.d.ts'; +import { Room } from './room.js'; +import type { HTML } from '../types/common.d.ts'; +import type { HTMLOpts } from '../types/common.d.ts'; +declare const customInspectSymbol: unique symbol; +export {}; diff --git a/classes/message.js b/classes/message.js index a46be1a..d4dae4c 100644 --- a/classes/message.js +++ b/classes/message.js @@ -1,12 +1,106 @@ +// @ts-check 'use strict'; const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom'); + const { toID } = require('../tools.js'); +const { User } = require('./user.js'); +const { Room } = require('./room.js'); + +/** + * @import { Ranks, HTML, HTMLOpts } from '../types/common.d.ts'; + * @import { Client } from '../client.js'; + */ class Message { + /** + * Author of the message. + * @type User + * @example User(PartMan) + */ + author; + /** + * Full message content. + * @type string + * @example 'Hi!' + */ + content; + /** + * Message string, as-received from the server. + * @type string + * @example '|c|+PartMan|Hi!' + */ + raw; + /** + * Client that received the message. + * @type Client + */ + parent; + /** + * The rank of the author that sent the message. + * @type {Ranks | ' '} + * @example '+' + */ + msgRank; + /** + * The command of the message. '/botmsg' will only be set as the command if no other command was used. + * @type {string | null} + * @example '!dt' + */ + command; + /** + * Whether the message was received before joining (eg: via history). These messages + * will not be emitted if scrollback is not explicitly enabled. + * @type boolean + */ + isIntro; + /** + * Whether this message fulfilled a waiting condition. See User/Room's `waitFor` for more info. + * @see {User.waitFor} + * @see {Room.waitFor} + * @type boolean + */ + awaited; + /** + * UNIX timestamp that the message was received at. + * @type number + */ + time; + /** + * Chatrooms have 'chat', while PMs have 'pm'. Some methods/properties change accordingly. + * @type { 'chat' | 'pm' } + */ + type; + /** + * The room / DM in which the message was sent. For PMs, this is always the non-client + * User, not necessarily the author of the message! + * @type { Room | User | never } + */ + target; + /** + * Whether the message is hidden (eg: `/botmsg`). + * @type {boolean} + */ + isHidden; + + /** + * @constructor + * @param input {{ + * by: string; + * text: string; + * type: 'chat' | 'pm'; + * target: string; + * raw: string; + * isIntro: boolean; + * parent: Client; + * isHidden?: boolean; + * time?: number; + * }} + */ constructor(input) { const { by, text, type, target, raw, isIntro, parent, time, isHidden } = input; - const msgRank = by[0]; + + const msgRank = /** @type {Ranks} */ (by[0]); const byId = toID(by); if (byId && !parent.users.get(byId)) { parent.addUser(by); @@ -15,7 +109,7 @@ class Message { this.content = text; const match = text.match(/^[/!][^ ]+/); if (match) this.command = match[0]; - else this.command = false; + else this.command = null; this.msgRank = msgRank; this.raw = raw; this.parent = parent; @@ -36,26 +130,48 @@ class Message { this.parent.handle(new Error(`Message: Expected type chat/pm; got ${this.type}`)); } } + /** + * Responds to the message. + * @param text {string} The text to respond (to the message) with. + * @returns {Promise} A promise that resolves when the message is sent successfully. + */ reply(text) { return this.target.send(text); } + /** + * Privately responds to the message. + * @param text {string} The text to privately respond (to the message) with. + * @returns void + */ privateReply(text) { - if (this.type !== 'chat') this.reply(text); - else { + if (this.target instanceof User) this.reply(text); + else if (this.target instanceof Room) { const privateSend = this.target.privateSend(this.author.userid, text); if (privateSend === false) this.author.send(text); } } + /** + * Sends HTML in the message context (chatroom for 'chat', PMs for 'pm'). + * @param html {HTML} The HTML to send. + * @param opts {HTMLOpts} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {HTML | string} Returns HTML only if `opts.notransform` is true. + */ sendHTML(html, opts) { return this.target.sendHTML(html, opts); } + /** + * Privately sends HTML in the message context (chatroom for 'chat', PMs for 'pm') + * @param html {HTML} The HTML to send + * @param opts {HTMLOpts} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {HTML | string} Returns HTML only if `opts.notransform` is true. + */ replyHTML(html, opts) { - if (this.type === 'pm') return this.target.sendHTML(html, opts); - if (this.type === 'chat') return this.target.privateHTML(this.author.userid, html, opts); + if (this.target instanceof User) return this.target.sendHTML(html, opts); + if (this.target instanceof Room) return this.target.privateHTML(this.author.userid, html, opts); return ''; } [customInspectSymbol](depth, options, inspect) { - if (depth < 1) return options.stylize(`${this.title} [PS-Message]`, 'special'); + if (depth < 1) return options.stylize(`${this.content} [PS-Message]`, 'special'); const logObj = {}; const keys = ['content', 'type', 'raw', 'time', 'author', 'target', 'command', 'parent', 'isIntro', 'awaited']; keys.forEach(key => (logObj[key] = this[key])); @@ -63,4 +179,4 @@ class Message { } } -module.exports = Message; +exports.Message = Message; diff --git a/classes/room.d.ts b/classes/room.d.ts index 77b362f..3caccab 100644 --- a/classes/room.d.ts +++ b/classes/room.d.ts @@ -1,107 +1,124 @@ -import type { HTMLopts } from './common.d.ts'; -import type { Client } from '../client.d.ts'; -import type Message from './message.d.ts'; -import type User from './user.d.ts'; - -export default class Room { +export type Users = User[] | string[] | User | string; +export class Room { + /** + * @param name {string} Name of the room. + * @param parent {Client} Client associated with the room. + */ + constructor(name: string, parent: Client); /** * Formatted name of the room. + * @type string * @example Bot Development */ title: string; /** * Room ID. - * @example botdevelopment + * @type string + * @example 'botdevelopment' */ roomid: string; /** * Room ID. - * @example botdevelopment + * @type string + * @example 'botdevelopment' */ id: string; /** * The Bot that this room is registered to. + * @type Client */ parent: Client; /** * Whether the room is a chatroom or a battleroom. + * @type {'chat' | 'battle'} */ type: 'chat' | 'battle'; /** * Room visibility. + * @type {'public' | 'hidden' | 'secret' | 'private'} */ - visibility: 'public' | 'secret'; + visibility: 'public' | 'hidden' | 'secret' | 'private'; /** - * Current modchat level + * Current modchat level. + * @type {?string} */ - modchat?: string; + modchat: string | null; /** * Can be undefined if no auth is defined in the room. + * @type {Record | undefined} * @example { '*': ['partbot'] } */ - auth?: { [key: string]: string[] }; + auth: Record | undefined; /** * List of all users currently online, formatted as shown in chat. + * @type string[] * @example ['#PartMan@!', '*PartBot'] */ users: string[]; - - constructor(name: string, parent: Client); - + _waits: any[]; /** * Sends a message to the room. - * @param text The text to send - * @returns A promise that resolves when the message is sent successfully + * @param text {string} The text to send. + * @returns {Promise} A promise that resolves when the message is sent successfully. */ send(text: string): Promise; - /** * Privately sends a message to a user in the room. - * @param user The user to send the text to - * @param text The text to privately send + * @param user {User | string} The user to send the text to. + * @param text {string} The text to privately send. + * @returns {string | false} */ privateSend(user: User | string, text: string): string | false; - /** * Sends HTML in the room. - * @param html The HTML to send - * @param opts HTML options. If a string is passed, it is used as HTMLopts.name. + * @param html {HTML} The HTML to send. + * @param opts {HTMLOpts=} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {string | HTML} Returns HTML only if `opts.notransform` is true. */ - sendHTML(html: any, opts?: HTMLopts | string): string | false; - + sendHTML(html: HTML, opts?: HTMLOpts | undefined): string | HTML; /** * Privately sends HTML in the room - * @param user The user to send the HTML to - * @param html The HTML to send - * @param opts HTML options. If a string is passed, it is used as HTMLopts.name. + * @param userList {Users} The user to send the HTML to. + * @param html {HTML} The HTML to send. + * @param opts {HTMLOpts} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {string | HTML} Returns HTML only if `opts.notransform` is true. */ - privateHTML(user: User | string | (User | string)[], html: any, opts?: HTMLopts | string): string | false; - + privateHTML(userList: Users, html: HTML, opts?: HTMLOpts): string | HTML; /** * Sends HTML pages to multiple users from the room. - * @param user The user to send the HTML to - * @param html The HTML to send - * @param opts HTML options. If a string is passed, it is used as HTMLopts.name. + * @param userList {Users} The user to send the HTML to + * @param html {HTML} The HTML to send + * @param opts {HTMLOpts} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {string | HTML} Returns HTML only if `opts.notransform` is true. */ - pageHTML(user: User | string | (User | string)[], html: any, opts?: HTMLopts | string): string | false; - + pageHTML(userList: Users, html: HTML, opts?: HTMLOpts): string | HTML; /** * Alias for User#sendHTML() that passes opts.room. - * @param user The user to send the HTML to - * @param html The HTML to send - * @param opts HTML options. If a string is passed, it is used as HTMLopts.name. + * @param user {User | string} The user to send the HTML to. + * @param html {HTML} The HTML to send. + * @param opts {HTMLOpts} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {string | HTML} Returns HTML only if `opts.notransform` is true. */ - pmHTML(user: User | string, html: any, opts?: HTMLopts | string): string | false; - + pmHTML(user: User | string, html: HTML, opts?: HTMLOpts): string | HTML; /** * Waits for the first message in the room that fulfills the given condition. - * @param condition A function to run on the message. Return a truthy value to satisfy - * @param time The time (in ms) to wait before rejecting as a timeout + * @param condition {(message: Message) => boolean} A function to run on the message. Return a truthy value to satisfy. + * @param time {number=} The time (in ms) to wait before rejecting as a timeout. Defaults to 60s. + * @throws Error If timed out. + * @return Promise */ - waitFor(condition: (message: Message) => boolean, time?: number): Promise; - + waitFor(condition: (message: Message) => boolean, time?: number | undefined): Promise; /** * Re-fetch the room's details from the server. + * @returns void */ update(): void; + [customInspectSymbol](depth: any, options: any, inspect: any): any; } +import type { User } from './user.js'; +import type { Client } from '../client.js'; +import type { Message } from './message.js'; +import type { HTML } from '../types/common.d.ts'; +import type { HTMLOpts } from '../types/common.d.ts'; +declare const customInspectSymbol: unique symbol; +export {}; diff --git a/classes/room.js b/classes/room.js index 86681f7..77f2b52 100644 --- a/classes/room.js +++ b/classes/room.js @@ -1,9 +1,24 @@ +// @ts-check 'use strict'; -const { toID, formatText } = require('../tools.js'); +const { toID, toRoomID, formatText } = require('../tools.js'); const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom'); +/** + * @import { HTMLOpts, HTML } from '../types/common.d.ts'; + * @import { Message } from './message.js'; + * @import { User } from './user.js'; + * @import { Client } from '../client.js'; + */ + +/** @typedef {User[] | string[] | User | string} Users */ + +/** + * Maps an array to user IDs. + * @param userList {Users} + * @returns {string[]} + */ function getUserIds(userList) { const arr = Array.isArray(userList) ? userList : [userList]; return arr @@ -17,26 +32,98 @@ function getUserIds(userList) { } class Room { + /** + * Formatted name of the room. + * @type string + * @example Bot Development + */ + title; + /** + * Room ID. + * @type string + * @example 'botdevelopment' + */ + roomid; + /** + * Room ID. + * @type string + * @example 'botdevelopment' + */ + id; + /** + * The Bot that this room is registered to. + * @type Client + */ + parent; + /** + * Whether the room is a chatroom or a battleroom. + * @type {'chat' | 'battle'} + */ + type; + /** + * Room visibility. + * @type {'public' | 'hidden' | 'secret' | 'private'} + */ + visibility; + /** + * Current modchat level. + * @type {?string} + */ + modchat; + /** + * Can be undefined if no auth is defined in the room. + * @type {Record | undefined} + * @example { '*': ['partbot'] } + */ + auth; + /** + * List of all users currently online, formatted as shown in chat. + * @type string[] + * @example ['#PartMan@!', '*PartBot'] + */ + users; + /** + * @param name {string} Name of the room. + * @param parent {Client} Client associated with the room. + */ constructor(name, parent) { - this.roomid = name.toLowerCase().replace(/[^a-z0-9-]/g, ''); + this.roomid = toRoomID(name); this.parent = parent; this._waits = []; + this.users = []; } + /** + * Sends a message to the room. + * @param text {string} The text to send. + * @returns {Promise} A promise that resolves when the message is sent successfully. + */ send(text) { return new Promise((resolve, reject) => { text = `${this.roomid}|${text?.toString() || String(text)}`; this.parent.sendQueue(text, resolve, reject); }); } + /** + * Privately sends a message to a user in the room. + * @param user {User | string} The user to send the text to. + * @param text {string} The text to privately send. + * @returns {string | false} + */ privateSend(user, text) { if (!['*', '#', '&'].includes(this.users.find(u => toID(u) === this.parent.status.userid)?.charAt(0))) return false; - user = this.parent.getUser(user); - if (!user) return ''; + const target = typeof user === 'string' ? this.parent.getUser(user) : user; + if (!target) return ''; const formatted = formatText(text); - this.send(`/sendprivatehtmlbox ${user.userid}, ${formatted}`); + this.send(`/sendprivatehtmlbox ${target.userid}, ${formatted}`); return formatted; } - sendHTML(html, opts = {}) { + /** + * Sends HTML in the room. + * @param html {HTML} The HTML to send. + * @param opts {HTMLOpts=} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {string | HTML} Returns HTML only if `opts.notransform` is true. + */ + sendHTML(html, opts) { if (!['*', '#', '&'].includes(this.users.find(u => toID(u) === this.parent.status.userid)?.charAt(0))) return false; if (!html) throw new Error('Missing HTML argument'); if (typeof opts === 'string') opts = { name: opts }; @@ -47,6 +134,13 @@ class Room { this.send(`/${command} ${opts.rank ? `${opts.rank}, ` : ''}${opts.name ?? fallbackName}, ${formatted}`); return formatted; } + /** + * Privately sends HTML in the room + * @param userList {Users} The user to send the HTML to. + * @param html {HTML} The HTML to send. + * @param opts {HTMLOpts} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {string | HTML} Returns HTML only if `opts.notransform` is true. + */ privateHTML(userList, html, opts = {}) { if (!['*', '#', '&'].includes(this.users.find(u => toID(u) === this.parent.status.userid)?.charAt(0))) return false; const users = getUserIds.bind(this)(userList); @@ -60,6 +154,13 @@ class Room { this.send(`/${command} ${users.join('|')}, ${opts.name ?? fallbackName}, ${formatted}`); return formatted; } + /** + * Sends HTML pages to multiple users from the room. + * @param userList {Users} The user to send the HTML to + * @param html {HTML} The HTML to send + * @param opts {HTMLOpts} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {string | HTML} Returns HTML only if `opts.notransform` is true. + */ pageHTML(userList, html, opts = {}) { if (!['*', '#', '&'].includes(this.users.find(u => toID(u) === this.parent.status.userid)?.charAt(0))) return false; const users = getUserIds.bind(this)(userList); @@ -72,13 +173,26 @@ class Room { this.send(`/sendhtmlpage ${users.join('|')}, ${opts.name ?? fallbackName}, ${formatted}`); return formatted; } + /** + * Alias for User#sendHTML() that passes opts.room. + * @param user {User | string} The user to send the HTML to. + * @param html {HTML} The HTML to send. + * @param opts {HTMLOpts} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {string | HTML} Returns HTML only if `opts.notransform` is true. + */ pmHTML(user, html, opts = {}) { if (typeof opts === 'string') opts = { name: opts }; - user = this.parent.addUser(user); - return user?.sendHTML(html, { ...opts, room: this }); + const target = this.parent.addUser(user); + return target?.sendHTML(html, { ...opts, room: this }); } - waitFor(condition, time) { - if (!time && typeof time !== 'number') time = 60 * 1000; + /** + * Waits for the first message in the room that fulfills the given condition. + * @param condition {(message: Message) => boolean} A function to run on the message. Return a truthy value to satisfy. + * @param time {number=} The time (in ms) to wait before rejecting as a timeout. Defaults to 60s. + * @throws Error If timed out. + * @return Promise + */ + waitFor(condition, time = 60 * 1000) { if (typeof condition !== 'function') throw new TypeError('Condition must be a function.'); const room = this; return new Promise((resolve, reject) => { @@ -100,6 +214,10 @@ class Room { room._waits.push(waitObj); }); } + /** + * Re-fetch the room's details from the server. + * @returns void + */ update() { this.parent.send(`|/cmd roominfo ${this.roomid}`); } @@ -133,4 +251,4 @@ class Room { } } -module.exports = Room; +exports.Room = Room; diff --git a/classes/user.d.ts b/classes/user.d.ts index 5fbffb7..0319fc3 100644 --- a/classes/user.d.ts +++ b/classes/user.d.ts @@ -1,86 +1,118 @@ -import type { HTMLopts } from './common.d.ts'; -import type { Client } from '../client.d.ts'; -import type Message from './message.d.ts'; - -export default class User { +/** + * @import { HTMLOpts, HTMLOptsObject, HTML } from '../types/common.d.ts'; + * @import { Message } from './message.js'; + * @import { Room } from './room.js'; + * @import { Client } from '../client.js'; + */ +export class User { /** - * Formatted name of the user - * @example PartBot~ + * @constructor + * @param init {{ name: string; userid: string }} + * @param parent {Client} + */ + constructor( + init: { + name: string; + userid: string; + }, + parent: Client + ); + /** + * Formatted name of the user. + * @type string + * @example 'PartBot~' */ name: string; /** - * User ID (lowercase, all non-alphanumeric characters stripped) - * @example partbot + * User ID (lowercase, all non-alphanumeric characters stripped). + * @type string + * @example 'partbot' */ userid: string; /** - * User ID (lowercase, all non-alphanumeric characters stripped) - * @example partbot + * User ID (lowercase, all non-alphanumeric characters stripped). + * @type string + * @example 'partbot' */ id: string; /** - * Global rank - * @example * + * Global rank. + * @type string + * @example '*' */ group: string; /** - * Relative avatar URL - * @example supernerd + * Relative avatar URL. + * @type string + * @example 'supernerd' */ avatar: string; /** - * Whether the user is autoconfirmed + * Whether the user is autoconfirmed. + * @type boolean */ autoconfirmed: boolean; /** - * User's current status, if set + * User's current status, if set. + * @type {string | undefined} */ - status?: string; + status: string | undefined; /** - * Known list of usernames with a direct rename to/from this user + * Known list of usernames with a direct rename to/from this user. + * @type Set */ alts: Set; /** - * List of rooms the user is currently in + * List of rooms the user is currently in. + * @type {{ [roomId: string]: { isPrivate?: true } } | null} */ - rooms: { [key: string]: { isPrivate?: true } } | false; + rooms: { + [roomId: string]: { + isPrivate?: true; + }; + } | null; /** - * The Bot this user is attached to + * The Bot this user is attached to. + * @type Client */ parent: Client; - - constructor(init: object, parent: Client); - + _waits: any[]; /** - * Sends a PM to the user - * @param text The text to send - * @returns A promise that resolves when the message is sent successfully + * Sends a PM to the user. + * @param text {string} The text to send. + * @returns {Promise} A promise that resolves when the message is sent successfully */ send(text: string): Promise; - /** - * Sends HTML to the user - * @param html The HTML to send - * @param opts HTML options. If a string is passed, it is used as HTMLopts.name. + * Sends HTML to the user. + * @param html {HTML} The HTML to send. + * @param opts {HTMLOpts} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {string | HTML} Returns HTML only if `opts.notransform` is true. */ - sendHTML(html: any, opts?: HTMLopts | string): string; - + sendHTML(html: HTML, opts?: HTMLOpts): string | HTML; /** - * Sends an HTML page to the user - * @param html The HTML to send - * @param opts HTML options. If a string is passed, it is used as HTMLopts.name. + * Sends an HTML page to the user. + * @param html {HTML} The HTML to send. + * @param opts {HTMLOpts} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {string | HTML} Returns HTML only if `opts.notransform` is true. */ - pageHTML(html: any, opts?: HTMLopts | string): string; - + pageHTML(html: HTML, opts?: HTMLOpts): string | HTML; /** - * Waits for the first message in the room that fulfills the given condition - * @param condition A function to run on incoming messages. Return a truthy value to satisfy - * @param time The time (in ms) to wait before rejecting as a timeout + * Waits for the first message in the room that fulfills the given condition. + * @param condition {(message: Message) => boolean} A function to run on the message. Return a truthy value to satisfy. + * @param time {number} The time (in ms) to wait before rejecting as a timeout. Defaults to 60s. + * @throws Error If timed out. + * @return Promise */ - waitFor(condition: (message: Message) => boolean, time?: number): Promise; - + waitFor(condition: (message: Message) => boolean, time?: number): Promise; /** * Re-fetch the user's details from the server and resolve with the updated user when complete + * @returns Promise */ - update(): Promise; + update(): Promise; + #private; } +import type { Client } from '../client.js'; +import type { Message } from './message.js'; +import type { HTML } from '../types/common.d.ts'; +import type { HTMLOpts } from '../types/common.d.ts'; diff --git a/classes/user.js b/classes/user.js index 5d97753..e548a96 100644 --- a/classes/user.js +++ b/classes/user.js @@ -1,8 +1,77 @@ +// @ts-check 'use strict'; const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom'); +/** + * @import { HTMLOpts, HTMLOptsObject, HTML } from '../types/common.d.ts'; + * @import { Message } from './message.js'; + * @import { Room } from './room.js'; + * @import { Client } from '../client.js'; + */ + class User { + /** + * Formatted name of the user. + * @type string + * @example 'PartBot~' + */ + name; + /** + * User ID (lowercase, all non-alphanumeric characters stripped). + * @type string + * @example 'partbot' + */ + userid; + /** + * User ID (lowercase, all non-alphanumeric characters stripped). + * @type string + * @example 'partbot' + */ + id; + /** + * Global rank. + * @type string + * @example '*' + */ + group; + /** + * Relative avatar URL. + * @type string + * @example 'supernerd' + */ + avatar; + /** + * Whether the user is autoconfirmed. + * @type boolean + */ + autoconfirmed; + /** + * User's current status, if set. + * @type {string | undefined} + */ + status; + /** + * Known list of usernames with a direct rename to/from this user. + * @type Set + */ + alts; + /** + * List of rooms the user is currently in. + * @type {{ [roomId: string]: { isPrivate?: true } } | null} + */ + rooms; + /** + * The Bot this user is attached to. + * @type Client + */ + parent; + + /** + * @constructor + * @param init {{ name: string; userid: string }} + * @param parent {Client} + */ constructor(init, parent) { Object.keys(init).forEach(key => (this[key] = init[key])); this.id ??= init.userid; @@ -10,13 +79,21 @@ class User { this._waits = []; this.alts = new Set(); } + + /** + * Validates and unifies options. + * @param opts {HTMLOpts} + * @returns {{ opts: HTMLOptsObject, room: Room | null }} + */ #validateOpts(opts = {}) { if (typeof opts === 'string') opts = { name: opts }; if (!opts || typeof opts !== 'object') throw new TypeError('Options must be an object'); const fallbackName = this.parent.status.username + Date.now().toString(36); - let room; + /** @type {Room | null} */ + let room = null; if (opts.room) room = typeof opts.room === 'string' ? this.parent.getRoom(opts.room) : opts.room; else { + /** @type {Partial>} */ const rooms = {}; for (const room of this.parent.rooms.values()) { if (room.auth?.['*']?.includes(this.parent.status.userid) || room.auth?.['#']?.includes(this.parent.status.userid)) @@ -26,6 +103,11 @@ class User { } return { opts: opts.name ? opts : { ...opts, name: fallbackName }, room }; } + /** + * Sends a PM to the user. + * @param text {string} The text to send. + * @returns {Promise} A promise that resolves when the message is sent successfully + */ send(text) { const user = this; return new Promise(function (resolve, reject) { @@ -33,22 +115,41 @@ class User { user.parent.sendQueue(text, resolve, reject); }); } - sendHTML(html, _opts = {}) { + /** + * Sends HTML to the user. + * @param html {HTML} The HTML to send. + * @param opts {HTMLOpts} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {string | HTML} Returns HTML only if `opts.notransform` is true. + */ + sendHTML(html, opts = {}) { if (!html) throw new Error('Missing HTML argument'); - const { opts, room } = this.#validateOpts(_opts); + const { opts: parsedOpts, room } = this.#validateOpts(opts); if (!room) return false; - const formatted = opts.notransform ? html : this.parent.opts.transformHTML(html, opts); - room.send(`/pmuhtml${opts.change ? 'change' : ''} ${this.userid}, ${opts.name}, ${formatted}`); + const formatted = parsedOpts.notransform ? html : this.parent.opts.transformHTML(html, parsedOpts); + room.send(`/pmuhtml${parsedOpts.change ? 'change' : ''} ${this.userid}, ${parsedOpts.name}, ${formatted}`); return formatted; } - pageHTML(html, _opts = {}) { + /** + * Sends an HTML page to the user. + * @param html {HTML} The HTML to send. + * @param opts {HTMLOpts} HTML options. If a string is passed, it is used as HTMLOpts.name. + * @returns {string | HTML} Returns HTML only if `opts.notransform` is true. + */ + pageHTML(html, opts = {}) { if (!html) throw new Error('Missing HTML argument'); - const { opts, room } = this.#validateOpts(_opts); + const { opts: parsedOpts, room } = this.#validateOpts(opts); if (!room) return false; - const formatted = opts.notransform ? html : this.parent.opts.transformHTML(html, opts); - room.send(`/sendhtmlpage ${this.userid}, ${opts.name}, ${formatted}`); + const formatted = parsedOpts.notransform ? html : this.parent.opts.transformHTML(html, parsedOpts); + room.send(`/sendhtmlpage ${this.userid}, ${parsedOpts.name}, ${formatted}`); return formatted; } + /** + * Waits for the first message in the room that fulfills the given condition. + * @param condition {(message: Message) => boolean} A function to run on the message. Return a truthy value to satisfy. + * @param time {number} The time (in ms) to wait before rejecting as a timeout. Defaults to 60s. + * @throws Error If timed out. + * @return Promise + */ waitFor(condition, time = 60_000) { if (typeof condition !== 'function') throw new TypeError('Condition must be a function.'); const user = this; @@ -71,8 +172,13 @@ class User { user._waits.push(waitObj); }); } - update() { - return this.parent.getUserDetails(this.userid).then(() => this); + /** + * Re-fetch the user's details from the server and resolve with the updated user when complete + * @returns Promise + */ + async update() { + await this.parent.getUserDetails(this.userid); + return this; } [customInspectSymbol](depth, options, inspect) { if (depth < 1) return options.stylize(`${this.name || '-'} [PS-User]`, 'special'); @@ -86,4 +192,4 @@ class User { } } -module.exports = User; +exports.User = User; diff --git a/client.d.ts b/client.d.ts index e749c0f..37b9747 100644 --- a/client.d.ts +++ b/client.d.ts @@ -1,226 +1,195 @@ -import { EventEmitter } from 'events'; -import Message from './classes/message'; -import Room from './classes/room'; -import User from './classes/user'; -import { HTMLopts } from './classes/common'; - -type UserDetails = { userid: string; [key: string]: any }; - -type ClientOpts = { +export type UserDetails = { + userid: string; + [key: string]: any; +}; +/** @typedef {{ userid: string; [key: string]: any }} UserDetails */ +/** + * The client that will connect to Showdown. + * @class Client + * @implements {ClientEvents} + */ +export class Client extends EventEmitter implements ClientEvents { /** - * The username you wish to connect to. Required parameter. + * @constructor + * @param opts {ClientOpts} */ - username: string; + constructor(opts: ClientOpts); /** - * The password for the username you're connecting to. Leave this blank if the account is unregistered. + * Final client options after applying defaults. + * @type {ClientOpts} */ - password?: string; + opts: ClientOpts; /** - * The avatar your Bot will have on connection. If not specified, PS will set one randomly. + * Information about the connection status. + * @type {{ connected: boolean; loggedIn: boolean; inited: boolean; username?: (string|null); userid?: (string|null) }} */ - avatar?: string | number; + status: { + connected: boolean; + loggedIn: boolean; + inited: boolean; + username?: string | null; + userid?: string | null; + }; /** - * The status your Bot will have on connection. + * Whether the client is trusted. + * @type {boolean} */ - status?: string; + isTrusted: boolean; /** - * An array with the strings of the rooms you want the Bot to join. + * Whether the connection is currently closed. + * @type {boolean} */ - rooms: string[]; + closed: boolean; /** - * The function you would like to run on debugs. If this is a falsy value, debug messages will not be displayed. - * If a true value is given which is not a function, the Bot simply logs messages to the console. + * Collection of rooms. + * @type {Map} */ - debug?: boolean | ((details: string) => void); + rooms: Map; /** - * Handling for internal errors. If a function is provided, this will run it with an error / string. - * To opt out of error handling (not recommended), set this to false. - * @default console.log + * Collection of users. + * @type {Map} */ - handle?: boolean | ((error: string | Error) => void); + users: Map; + _queue: any[]; + _queued: any[]; + _userdetailsQueue: any[]; + _pendingRoomJoins: any[]; + _deinitedRoomJoins: any[]; + debug: { + (...data: any[]): void; + (message?: any, ...optionalParams: any[]): void; + }; + handle: { + (...data: any[]): void; + (message?: any, ...optionalParams: any[]): void; + }; /** - * Does not populate userdetails automatically. Use `Client#getUserDetails` or `User#update` to populate a user. - * @warning * Users will not have any properties other than id, userid, name, and alts. - * @warning * `User#sendHTML` and `User#pageHTML` will be disabled. Use `Room#pmHTML` or `Room#pageHTML` instead. + * Connects to the server. + * @param retry {boolean=} Indicates whether this is a reconnect attempt. + * @returns void */ - sparse?: boolean; + connect(retry?: boolean | undefined): void; + connection: any; /** - * Enables scrollback (messages that are received from before the bot joins, such as - * chat history). Scrollback messages will be emitted with the field `isIntro: true`. - * @default false + * Disconnects from the server. + * @returns void */ - scrollback?: boolean; + disconnect(): void; + ready: boolean; /** - * Dictates whether messages throw errors by default. Set to 'false' to enable messages throwing errors. - * @default true + * Reset status after a disconnect. + * @private + * @returns void */ - noFailMessages?: boolean; + private _resetStatus; /** - * The throttle (in milliseconds) for every 'batch' of three messages. PS has a per-message throttle of - * 25ms for public roombots, 100ms for trusted users, and 600ms for regular users. + * Logs in. + * @param username {string} The username to use to log in. + * @param password {string} The password for the username. Leave blank if unregistered. + * @returns {Promise} A promise that resolves when the login message is sent. */ - throttle?: number; - // A custom HTML processor, applied on all HTML methods. Defaults to no-transform. See HTML options for more info on opts. - transformHTML?: (input: any, opts: HTMLopts) => string; + login(username: string, password: string): Promise; /** - * The time, in milliseconds, that your Bot will wait before attempting to login again after failing. - * If this is 0, it will not attempt to login again. - * @default 10_000 + * Starts sending messages in queue once the throttle is known. + * @private + * @returns void */ - retryLogin?: number; - autoReconnect?: boolean; + private _activateQueue; + throttle: number; + activatedQueue: boolean; + queueTimer: NodeJS.Timeout; /** - * The time, in milliseconds, that your Bot will wait before attempting to reconnect after a disconnect. - * If this is 0, it will not attempt to reconnect. - * @default 30_000 + * Sends a text message to the server. Unthrottled; use sendQueue for chat messages. + * @param text {string | string[]} The text to send. + * @returns void */ - autoReconnectDelay?: number; + send(text: string | string[]): void; /** - * The time, in milliseconds, after which your connection times out. - * @default 20_000 + * Schedules a message to be sent, while being throttled. + * @param text {string} The message to send. + * @param sent {((msg: Message) => void)=} The resolve method for a promise. + * @param fail {((err: { cause: string, message: string }) => void)=} The reject method for a promise. + * @returns void */ - connectionTimeout?: number; + sendQueue( + text: string, + sent?: ((msg: Message) => void) | undefined, + fail?: ((err: { cause: string; message: string }) => void) | undefined + ): void; /** - * The server to connect to. - * @default 'sim3.psim.us' + * Sends a string to a user (if the user is not already tracked, they are added). + * @param user {User | string} The user to send to. + * @param text {string} The message to send. + * @returns {Promise} A promise that resolves when the message is sent successfully. */ - server?: string; - serverid?: string; + sendUser(user: User | string, text: string): Promise; /** - * The port on which you're connecting to. Can also be specified in server as `url:port`, in which case leave this field blank. + * Maps the incoming packets into data. + * @private + * @param message {string} The received packet. + * @returns void */ - port?: number; + private receive; + lastMessage: number; /** - * The protocol used for the websocket connection. Defaults to wss, but can be changed to ws (insecure). - * @default 'ws' + * Maps the incoming data into individual lines. + * @private + * @param message {string} + * @returns void */ - serverProtocol?: 'ws' | 'wss'; + private receiveMsg; /** - * The login server. - * @default 'https://play.pokemonshowdown.com/~~showdown/action.php' + * Runs on each received line of input and emits events accordingly. + * @param room {string} The room the line was received in. + * @param message {string} The raw content of the message. + * @param isIntro {boolean=} Whether the line was received as part of an `|init|`. */ - loginServer?: string; -}; - -export interface Client { - on(event: 'packet', listener: (direction: 'in' | 'out', data: string) => void): this; - on(event: 'connect', listener: () => void): this; - on(event: 'message', listener: (message: Message) => void): this; - on(event: 'join', listener: (room: string, user: string, isIntro: boolean) => void): this; - on(event: 'leave', listener: (room: string, user: string, isIntro: boolean) => void): this; - on(event: 'name', listener: (room: string, newName: string, oldName: string, isIntro: boolean) => void): this; - on(event: 'joinRoom', listener: (room: string) => void): this; - on(event: 'leaveRoom', listener: (room: string) => void): this; - on(event: 'chatError', listener: (room: string, error: string, isIntro: boolean) => void): this; - on(event: string, listener: (room: string, line: string, isIntro: boolean) => void): this; -} - -export class Client extends EventEmitter { - opts: Required> & ClientOpts; - status: { - /** - * Whether the socket is connected. - */ - connected: boolean; - /** - * Whether the client is authenticated on PS. - */ - loggedIn: boolean; - /** - * Whether the client has inited with rooms and whatnot. - */ - inited: boolean; - username?: string | null; - userid?: string | null; + receiveLine(room: string, message: string, isIntro?: boolean | undefined): void; + challstr: { + challengekeyid: string; + challstr: string; }; - isTrusted?: boolean; - closed: boolean; - rooms: Map; - users: Map; - - constructor(opts: ClientOpts); - - /** - * Connects to the server - * @param retry Indicates whether this is a reconnect attempt - */ - connect(retry?: boolean): void; - - /** - * Disconnects from the server - */ - disconnect(): void; - - /** - * Logs in - * @param username The username to use to log in - * @param password The password for the username. LEave blank if unregistered - * @returns A promise that resolves when the login message is sent - */ - login(username: string, password?: string): Promise; - - /** - * Sends a text message to the server. Unthrottled; use sendQueue for chat messages - * @param text The text to send - */ - send(text: string): void; - - /** - * Schedules a message to be sent, while being throttled - * @param text The message to send - * @param sent The resolve method for a promise - * @param fail The reject method for a promise - * @returns A promise that resolves when the message is sent successfully - */ - sendQueue(text: string, sent: (msg: Message) => any, fail: (msg: Message) => any): void; - - /** - * Sends a string to a user (if the user is not already tracked, they are added) - * @param user The user to send to - * @param text The message to send - * @returns A promise that resolves when the message is sent successfully - */ - sendUser(user: User | string, text: string): Promise; - /** - * Adds a user to the list of tracked users on the Bot. Starts fetching userdetails in the background - * @param details The details of the user to add, or the full username of the user. - * @returns The added User + * Adds a user to the list of tracked users on the Bot. Starts fetching userdetails in the background. + * @param details {UserDetails | string} The details of the user to add, or the full username of the user. + * @returns {User} The added User. */ - addUser(details: string | UserDetails): User; - + addUser(details: UserDetails | string): User; /** - * Gets the specified user (or their current user, if they were seen on an alt) - * @param user The user to find - * @param deepSearch Whether to also look for direct alts - * @returns The user if found, otherwise false + * Gets the specified user (or their current user, if they were seen on an alt). + * @param input {User | string} The user to find. + * @param deepSearch {boolean=} Whether to also look for direct alts. + * @returns {User | null} The user if found, otherwise null. */ - getUser(user: string, deepSearch?: boolean): User | false; - + getUser(input: User | string, deepSearch?: boolean | undefined): User | null; /** * Queues a request to fetch userdetails - * @param userid The user being queried - * @returns A promise that resolves with the queried userdetails + * @param userid {string} The user being queried + * @returns {Promise} A promise that resolves with the queried userdetails */ getUserDetails(userid: string): Promise; - /** - * Gets a (cached) room from its name (aliases not supported) - * @param room The name of the room being fetched - * @returns The room being fetched + * Gets a (cached) room from its name (aliases not supported). + * @param room {string} The name of the room being fetched. + * @returns {Room | null} The room being fetched. */ - getRoom(room: string): Room; - + getRoom(room: string): Room | null; /** - * Joins a room - * @param room The room to join - * @returns A promise that resolves when the room is joined + * Joins a room. + * @param room {string} The room to join. + * @returns A promise that resolves when the room is joined. */ - joinRoom(room: string): Promise; + joinRoom(room: string): Promise; + [customInspectSymbol](depth: any, options: any, inspect: any): any; } - -export { Message, Room, User }; - -export * as Tools from './tools'; - -export * as Data from './data'; +import { Message } from './classes/message.js'; +import { User } from './classes/user.js'; +import { Room } from './classes/room.js'; +import Tools = require('./tools.js'); +import Data = require('./data.js'); +import type { ClientEvents } from './types/client-opts.d.ts'; +import EventEmitter = require('events'); +import type { ClientOpts } from './types/client-opts.d.ts'; +/** @import { ClientOpts, ClientEvents } from './types/client-opts.d.ts'; */ +declare const customInspectSymbol: unique symbol; +export { Message, User, Room, Tools, Data }; diff --git a/client.js b/client.js index 6fa715d..064a84f 100644 --- a/client.js +++ b/client.js @@ -1,20 +1,63 @@ +// @ts-check 'use strict'; const EventEmitter = require('events'); const querystring = require('querystring'); const WebSocket = require('isomorphic-ws'); +/** @import { ClientOpts, ClientEvents } from './types/client-opts.d.ts'; */ const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom'); -const User = require('./classes/user.js'); -const Room = require('./classes/room.js'); -const Message = require('./classes/message.js'); +const { User } = require('./classes/user.js'); +const { Room } = require('./classes/room.js'); +const { Message } = require('./classes/message.js'); const Tools = require('./tools.js'); const Data = require('./data.js'); +/** @typedef {{ userid: string; [key: string]: any }} UserDetails */ + +/** + * The client that will connect to Showdown. + * @class Client + * @implements {ClientEvents} + */ class Client extends EventEmitter { - constructor(opts = {}) { + /** + * Final client options after applying defaults. + * @type {ClientOpts} + */ + opts; + /** + * Information about the connection status. + * @type {{ connected: boolean; loggedIn: boolean; inited: boolean; username?: (string|null); userid?: (string|null) }} + */ + status; + /** + * Whether the client is trusted. + * @type {boolean} + */ + isTrusted; + /** + * Whether the connection is currently closed. + * @type {boolean} + */ + closed; + /** + * Collection of rooms. + * @type {Map} + */ + rooms; + /** + * Collection of users. + * @type {Map} + */ + users; + /** + * @constructor + * @param opts {ClientOpts} + */ + constructor(opts) { super(); this.opts = { server: 'sim3.psim.us', @@ -28,7 +71,6 @@ class Client extends EventEmitter { status: null, sparse: false, retryLogin: 4 * 1000, - autoReconnect: true, autoReconnectDelay: 5 * 1000, rooms: [], debug: false, @@ -67,6 +109,11 @@ class Client extends EventEmitter { } // Websocket + /** + * Connects to the server. + * @param retry {boolean=} Indicates whether this is a reconnect attempt. + * @returns void + */ connect(retry) { if (retry) this.debug('Retrying...'); if (this.status.connected) return this.handle('Already connected'); @@ -90,8 +137,8 @@ class Client extends EventEmitter { const connection = new WebSocket(websocketUrl); this.connection = connection; - connection.onopen = connection => { - this.emit('connect', connection); + connection.onopen = () => { + this.emit('connect'); this.debug(`Connected to server: ${this.opts.server}`); this.status.connected = true; }; @@ -100,7 +147,7 @@ class Client extends EventEmitter { this.handle(err); this._resetStatus(); this.emit('disconnect', err); - if (this.opts.autoReconnect) { + if (this.opts.autoReconnectDelay) { this.debug(`Retrying in ${this.opts.autoReconnectDelay / 1000} seconds`); setTimeout(() => this.connect(true), this.opts.autoReconnectDelay); } @@ -115,18 +162,27 @@ class Client extends EventEmitter { this.connection = null; this._resetStatus(); this.emit('disconnect', 0); - if (!this.closed && this.opts.autoReconnect) { + if (!this.closed && this.opts.autoReconnectDelay) { this.debug(`Retrying in ${this.opts.autoReconnectDelay / 1000} seconds.`); setTimeout(() => this.connect(true), this.opts.autoReconnectDelay); } }; } + /** + * Disconnects from the server. + * @returns void + */ disconnect() { this.closed = true; this.ready = false; clearInterval(this.queueTimer); this.connection?.close(); } + /** + * Reset status after a disconnect. + * @private + * @returns void + */ _resetStatus() { this.status = { connected: false, @@ -137,18 +193,24 @@ class Client extends EventEmitter { }; } - async login(name, pass) { + /** + * Logs in. + * @param username {string} The username to use to log in. + * @param password {string} The password for the username. Leave blank if unregistered. + * @returns {Promise} A promise that resolves when the login message is sent. + */ + async login(username, password) { this.debug('Sending login request...'); let res; - if (!pass) { + if (!password) { res = await fetch( - `${this.opts.loginServer}?${querystring.stringify({ act: 'getassertion', userid: Tools.toID(name), ...this.challstr })}` + `${this.opts.loginServer}?${querystring.stringify({ act: 'getassertion', userid: Tools.toID(username), ...this.challstr })}` ); } else { res = await fetch(this.opts.loginServer, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: querystring.stringify({ act: 'login', name: Tools.toID(name), pass, ...this.challstr }), + body: querystring.stringify({ act: 'login', name: Tools.toID(username), pass: password, ...this.challstr }), }); } const response = await res.text(); @@ -169,20 +231,24 @@ class Client extends EventEmitter { // POST (registered) login uses JSON, GET (unregistered) login uses the string directly; } this.debug('Sending login trn...'); - this.send(`|/trn ${name},0,${trnData}`); - return Promise.resolve('Received assertion successfully'); + this.send(`|/trn ${username},0,${trnData}`); } catch (err) { this.debug('Login error'); this.handle(err); if (this.opts.retryLogin) { this.debug(`Retrying login in ${this.opts.retryLogin / 1000} seconds...`); - setTimeout(() => this.login(name, pass), this.opts.retryLogin); + setTimeout(() => this.login(username, password), this.opts.retryLogin); } this.emit('loginfailure', err); } } // Sending data + /** + * Starts sending messages in queue once the throttle is known. + * @private + * @returns void + */ _activateQueue() { const throttle = this.opts.throttle || (this.isTrusted ? 300 : 1800); if (this.activatedQueue && this.throttle === throttle) return; // No need to adjust throttle @@ -196,6 +262,11 @@ class Client extends EventEmitter { this.send(Object.values(messages).map(message => message.content)); }, throttle); } + /** + * Sends a text message to the server. Unthrottled; use sendQueue for chat messages. + * @param text {string | string[]} The text to send. + * @returns void + */ send(text) { if (!text.length) return; if (!this.connection) return this.handle('Not connected!'); @@ -205,6 +276,13 @@ class Client extends EventEmitter { this.emit('packet', 'out', text); this.connection.send(text); } + /** + * Schedules a message to be sent, while being throttled. + * @param text {string} The message to send. + * @param sent {((msg: Message) => void)=} The resolve method for a promise. + * @param fail {((err: { cause: string, message: string }) => void)=} The reject method for a promise. + * @returns void + */ sendQueue(text, sent, fail) { if (!this.status.connected) return fail?.({ cause: 'Not connected.', message: text }); const multiTest = text.match(/^([a-z0-9-]*?\|(?:\/pm [^,]*?, ?)?)[^/!].*?\n/); @@ -229,6 +307,12 @@ class Client extends EventEmitter { .catch(error => fail?.(error)); } else this._queue.push({ content: text, sent, fail }); } + /** + * Sends a string to a user (if the user is not already tracked, they are added). + * @param user {User | string} The user to send to. + * @param text {string} The message to send. + * @returns {Promise} A promise that resolves when the message is sent successfully. + */ sendUser(user, text) { let userid; if (user instanceof User) userid = user.userid; @@ -239,6 +323,12 @@ class Client extends EventEmitter { } // Receiving data + /** + * Maps the incoming packets into data. + * @private + * @param message {string} The received packet. + * @returns void + */ receive(message) { this.lastMessage = Date.now(); const flag = message.substr(0, 1); @@ -251,10 +341,11 @@ class Client extends EventEmitter { } } } - /** - * - * @param {string} message + * Maps the incoming data into individual lines. + * @private + * @param message {string} + * @returns void */ receiveMsg(message) { if (!message) return; @@ -262,18 +353,34 @@ class Client extends EventEmitter { const split = message.split('\n'); let room = 'lobby'; if (split[0].charAt(0) === '>') { - room = split.shift().substr(1); + room = split.shift().substring(1); if (room === '') room = 'lobby'; } let isIntro = false; - for (const batch of split) { - if (batch.split('|')[1] === 'init') isIntro = true; - this.receiveLine(room, batch, isIntro).catch(this.handle); + for (const line of split) { + if (line.split('|')[1] === 'init') isIntro = true; + try { + this.receiveLine(room, line, isIntro); + } catch (e) { + this.handle(e); + } + } + } else + try { + this.receiveLine('lobby', message); + } catch (e) { + this.handle(e); } - } else this.receiveLine('lobby', message).catch(this.handle); } - async receiveLine(room, message, isIntro) { + + /** + * Runs on each received line of input and emits events accordingly. + * @param room {string} The room the line was received in. + * @param message {string} The raw content of the message. + * @param isIntro {boolean=} Whether the line was received as part of an `|init|`. + */ + receiveLine(room, message, isIntro) { if (!isIntro || this.opts.scrollback) this.emit('line', room, message, isIntro); const args = message.split('|'); switch (args[1]) { @@ -282,7 +389,7 @@ class Client extends EventEmitter { break; } case 'updateuser': { - this.status.username = args[2].substr(1); + this.status.username = args[2].substring(1); this.status.userid = Tools.toID(this.status.username); if (!args[2].startsWith(' Guest')) { this.debug(`Successfully logged in as '${args[2]}.'`); @@ -309,12 +416,8 @@ class Client extends EventEmitter { challstr: args[3], }; if (this.opts.username) { - try { - this.debug('Logging in'); - await this.login(this.opts.username, this.opts.password); - } catch (e) { - this.handle(e); - } + this.debug('Logging in'); + this.login(this.opts.username, this.opts.password).catch(e => this.handle(e)); } break; } @@ -551,14 +654,19 @@ class Client extends EventEmitter { } // Utility - addUser(input) { + /** + * Adds a user to the list of tracked users on the Bot. Starts fetching userdetails in the background. + * @param details {UserDetails | string} The details of the user to add, or the full username of the user. + * @returns {User} The added User. + */ + addUser(details) { let userid, name; - if (typeof input === 'string') { - userid = Tools.toID(input); - name = input.replace(/^\W/, ''); + if (typeof details === 'string') { + userid = Tools.toID(details); + name = details.replace(/^\W/, ''); } else { - userid = input?.userid; - name = input?.name; + userid = details?.userid; + name = details?.name; name ??= userid; } if (!userid) throw new Error('Input must be an object with userid or a string for new User'); @@ -568,21 +676,35 @@ class Client extends EventEmitter { this.users.set(userid, user); if (!this.opts.sparse) this.getUserDetails(userid); } - Object.keys(input).forEach(key => (user[key] = input[key])); + if (typeof details === 'object') Object.keys(details).forEach(key => (user[key] = details[key])); return user; } - getUser(str, deep = false) { - if (str instanceof User) str = str.userid; + /** + * Gets the specified user (or their current user, if they were seen on an alt). + * @param input {User | string} The user to find. + * @param deepSearch {boolean=} Whether to also look for direct alts. + * @returns {User | null} The user if found, otherwise null. + */ + getUser(input, deepSearch = false) { + /** @type {string} */ + let str; + if (typeof input === 'object' && input instanceof User) str = input.userid; + else str = input; if (typeof str !== 'string') return null; str = Tools.toID(str); if (this.users.get(str)) return this.users.get(str); - if (deep) { + if (deepSearch) { for (const user of this.users.values()) { if (user.alts?.has(str)) return user; } } - return false; + return null; } + /** + * Queues a request to fetch userdetails + * @param userid {string} The user being queried + * @returns {Promise} A promise that resolves with the queried userdetails + */ getUserDetails(userid) { userid = Tools.toID(userid); const client = this; @@ -591,10 +713,20 @@ class Client extends EventEmitter { client._userdetailsQueue.push({ id: userid, resolve: resolve }); }); } + /** + * Gets a (cached) room from its name (aliases not supported). + * @param room {string} The name of the room being fetched. + * @returns {Room | null} The room being fetched. + */ getRoom(room) { const roomid = Tools.toRoomID(room); return this.rooms.get(roomid); // Sadly there's no easy way to update aliases } + /** + * Joins a room. + * @param room {string} The room to join. + * @returns A promise that resolves when the room is joined. + */ joinRoom(room) { room = Tools.toRoomID(room); return new Promise((resolve, reject) => { diff --git a/client.test.js b/client.test.js index b578855..b8a285e 100644 --- a/client.test.js +++ b/client.test.js @@ -68,16 +68,6 @@ describe('PS-Client', () => { }); }); - it.skip('should be able to send raw HTML', () => { - return new Promise((resolve, reject) => { - Bot.getRoom('Bot Development') - .waitFor(msg => msg.author.id === 'psclient' && /Test/.test(msg.content)) - .then(resolve) - .catch(reject); - Bot.getRoom('Bot Development').sendRawHTML('
Test
'); - }); - }); - afterAll(() => { require('console').log(chalk.red('xx'), chalk.dim('Disconnecting...'), '\n'); Bot.disconnect(); diff --git a/data.d.ts b/data.d.ts index 8269d09..0ffd94c 100644 --- a/data.d.ts +++ b/data.d.ts @@ -1,28 +1,5 @@ -// Rough-ish outline of data from Showdown -// Help here in getting simpler/more accurate types would be greatly appreciated - -type Stats = 'atk' | 'def' | 'spa' | 'spd' | 'spe' | 'hp'; -type StatsTable = Record; -type IsNonstandard = 'CAP' | 'Past' | 'Future' | 'Unobtainable' | 'Gigantamax' | 'LGPE' | 'Custom' | null; -type Gender = 'M' | 'F' | 'N' | ''; - -type AbilityFlags = Partial< - Record<'breakable' | 'cantsuppress' | 'failroleplay' | 'failskillswap' | 'noentrain' | 'noreceiver' | 'notrace' | 'notransform', 1> ->; - -export type Ability = { - isNonstandard?: 'Past' | 'CAP'; - flags: AbilityFlags; - name: string; - rating: number; - num: number; - desc: string; - shortDesc: string; -}; export const abilities: Record; - export const aliases: Record; - export const formatsData: Record< string, { @@ -32,355 +9,37 @@ export const formatsData: Record< natDexTier?: string; } >; - export const formats: ( - | { section: string; column?: number } + | Format | { - name: string; - desc?: string; - mod?: string; - team?: string; - ruleset?: string[]; - gameType?: string; - challengeShow?: boolean; - tournamentShow?: boolean; - searchShow?: boolean; - rated?: boolean; - banlist?: string[]; - unbanlist?: string[]; - bestOfDefault?: boolean; - restricted?: string[]; - teraPreviewDefault?: boolean; + section: string; + column?: number; } )[]; - -export type Item = { - name: string; - desc: string; - shortDesc: string; - gen: number; - num: number; - spritenum: number; - isNonstandard?: IsNonstandard; - boosts?: StatsTable; - condition?: any; // not this - - isBerry?: boolean; - isPokeball?: boolean; - isGem?: boolean; - isChoice?: boolean; - itemUser?: string[]; - forcedForme?: string; - megaStone?: string; - megaEvolves?: string; - zMove?: string | boolean; - zMoveType?: Types; - zMoveFrom?: string; - naturalGift?: { - basePower: number; - type: Types; - }; - fling?: { basePower: number; status?: string; volatileStatus?: string }; - ignoreKlutz?: boolean; -}; export const items: Record; - -type MoveSource = `${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}${'M' | 'T' | 'L' | 'R' | 'E' | 'D' | 'S' | 'V' | 'C'}${string}`; - -type EventInfo = { - generation: number; - level?: number; - shiny?: boolean | 1; - gender?: Gender; - nature?: string; - ivs?: Partial; - perfectIVs?: number; - isHidden?: boolean; - abilities?: string[]; - maxEggMoves?: number; - moves?: string[]; - pokeball?: string; - from?: string; - japan?: boolean; - emeraldEventEgg?: boolean; -}; - -export type Learnset = { - learnset?: Record; - eventData?: EventInfo[]; - eventOnly?: boolean; - encounters?: EventInfo[]; - exists?: boolean; -}; - export const learnsets: Record; - -type MoveTarget = - | 'adjacentAlly' - | 'adjacentAllyOrSelf' - | 'adjacentFoe' - | 'all' - | 'allAdjacent' - | 'allAdjacentFoes' - | 'allies' - | 'allySide' - | 'allyTeam' - | 'any' - | 'foeSide' - | 'normal' - | 'randomNormal' - | 'scripted' - | 'self'; - -type MoveFlags = Partial< - Record< - | 'allyanim' - | 'bypasssub' - | 'bite' - | 'bullet' - | 'cantusetwice' - | 'charge' - | 'contact' - | 'dance' - | 'defrost' - | 'distance' - | 'failcopycat' - | 'failencore' - | 'failinstruct' - | 'failmefirst' - | 'failmimic' - | 'futuremove' - | 'gravity' - | 'heal' - | 'metronome' - | 'mirror' - | 'mustpressure' - | 'noassist' - | 'nonsky' - | 'noparentalbond' - | 'nosketch' - | 'nosleeptalk' - | 'pledgecombo' - | 'powder' - | 'protect' - | 'pulse' - | 'punch' - | 'recharge' - | 'reflectable' - | 'slicing' - | 'snatch' - | 'sound' - | 'wind', - 1 - > ->; - -type HitEffect = { - boosts?: Partial | null; - status?: string; - volatileStatus?: string; - sideCondition?: string; - slotCondition?: string; - pseudoWeather?: string; - terrain?: string; - weather?: string; -}; - -type SecondaryEffect = HitEffect & { - chance?: number; - ability?: Ability; - dustproof?: boolean; - kingsrock?: boolean; - self?: HitEffect; -}; - -type EffectData = { - name?: string; - desc?: string; - duration?: number; - effectType?: string; - infiltrates?: boolean; - isNonstandard?: IsNonstandard | null; - shortDesc?: string; -}; - -type Move = EffectData & - HitEffect & { - name: string; - num?: number; - condition?: { duration?: number }; - basePower: number; - accuracy: true | number; - pp: number; - category: 'Physical' | 'Special' | 'Status'; - type: Types; - priority: number; - target: MoveTarget; - flags: MoveFlags; - realMove?: string; - - damage?: number | 'level' | false | null; - contestType?: string; - noPPBoosts?: boolean; - - isZ?: boolean | string; - zMove?: { - basePower?: number; - effect?: string; - boost?: Partial; - }; - - isMax?: boolean | string; - maxMove?: { - basePower: number; - }; - - ohko?: boolean | 'Ice'; - thawsTarget?: boolean; - heal?: number[] | null; - forceSwitch?: boolean; - selfSwitch?: 'copyvolatile' | 'shedtail' | boolean; - selfBoost?: { boosts?: Partial }; - selfdestruct?: 'always' | 'ifHit' | boolean; - breaksProtect?: boolean; - - recoil?: [number, number]; - drain?: [number, number]; - mindBlownRecoil?: boolean; - stealsBoosts?: boolean; - struggleRecoil?: boolean; - secondary?: SecondaryEffect | null; - secondaries?: SecondaryEffect[] | null; - self?: SecondaryEffect | null; - hasSheerForce?: boolean; - - alwaysHit?: boolean; - critRatio?: number; - overrideOffensivePokemon?: 'target' | 'source'; - overrideOffensiveStat?: string; - overrideDefensivePokemon?: 'target' | 'source'; - overrideDefensiveStat?: string; - forceSTAB?: boolean; - ignoreAbility?: boolean; - ignoreAccuracy?: boolean; - ignoreDefensive?: boolean; - ignoreEvasion?: boolean; - ignoreImmunity?: boolean | { [typeName: string]: boolean }; - ignoreNegativeOffensive?: boolean; - ignoreOffensive?: boolean; - ignorePositiveDefensive?: boolean; - ignorePositiveEvasion?: boolean; - multiaccuracy?: boolean; - multihit?: number | number[]; - multihitType?: 'parentalbond'; - noDamageVariance?: boolean; - nonGhostTarget?: MoveTarget; - pressureTarget?: MoveTarget; - spreadModifier?: number; - sleepUsable?: boolean; - smartTarget?: boolean; - tracksTarget?: boolean; - willCrit?: boolean; - callsMove?: boolean; - - hasCrashDamage?: boolean; - isConfusionSelfHit?: boolean; - stallingMove?: boolean; - baseMove?: string; - - basePowerCallback?: true; - }; - export const moves: Record; - -export type Species = { - id: string; - name: string; - num: number; - gen?: number; - baseSpecies?: string; - forme?: string; - baseForme?: string; - cosmeticFormes?: string[]; - otherFormes?: string[]; - formeOrder?: string[]; - spriteid?: string; - abilities: { - 0: string; - 1?: string; - H?: string; - S?: string; - }; - types: Types[]; - addedType?: string; - prevo?: string; - evos?: string[]; - evoType?: 'trade' | 'useItem' | 'levelMove' | 'levelExtra' | 'levelFriendship' | 'levelHold' | 'other'; - evoCondition?: string; - evoItem?: string; - evoMove?: string; - evoRegion?: 'Alola' | 'Galar'; - evoLevel?: number; - nfe?: boolean; - eggGroups: string[]; - canHatch?: boolean; - gender?: Gender; - genderRatio?: { M: number; F: number }; - baseStats: StatsTable; - maxHP?: number; - bst: number; - weightkg: number; - weighthg?: number; - heightm: number; - color: string; - tags?: ('Mythical' | 'Restricted Legendary' | 'Sub-Legendary' | 'Ultra Beast' | 'Paradox')[]; - isNonstandard?: IsNonstandard; - unreleasedHidden?: boolean | 'Past'; - maleOnlyHidden?: boolean; - mother?: string; - isMega?: boolean; - isPrimal?: boolean; - canGigantamax?: string; - gmaxUnreleased?: boolean; - cannotDynamax?: boolean; - forceTeraType?: string; - battleOnly?: string | string[]; - requiredItem?: string; - requiredMove?: string; - requiredAbility?: string; - requiredItems?: string[]; - changesFrom?: string; - pokemonGoData?: string[]; - tier?: string; - doublesTier?: string; - natDexTier?: string; -}; - export const pokedex: Record; - -export type Types = - | 'Bug' - | 'Dark' - | 'Dragon' - | 'Electric' - | 'Fairy' - | 'Fighting' - | 'Fire' - | 'Flying' - | 'Ghost' - | 'Grass' - | 'Ground' - | 'Ice' - | 'Normal' - | 'Poison' - | 'Psychic' - | 'Rock' - | 'Steel' - | 'Stellar' - | 'Water'; - export const typechart: Record< - Lowercase, + | 'normal' + | 'psychic' + | 'bug' + | 'dark' + | 'dragon' + | 'electric' + | 'fairy' + | 'fighting' + | 'fire' + | 'flying' + | 'ghost' + | 'grass' + | 'ground' + | 'ice' + | 'poison' + | 'rock' + | 'steel' + | 'stellar' + | 'water', { damageTaken: Record & Partial>; @@ -388,3 +47,12 @@ export const typechart: Record< HPdvs?: Partial; } >; +import type { Ability } from './types/data.d.ts'; +import type { IsNonstandard } from './types/data.d.ts'; +import type { Format } from './types/data.d.ts'; +import type { Item } from './types/data.d.ts'; +import type { Learnset } from './types/data.d.ts'; +import type { Move } from './types/data.d.ts'; +import type { Species } from './types/data.d.ts'; +import type { Types } from './types/data.d.ts'; +import type { StatsTable } from './types/data.d.ts'; diff --git a/data.js b/data.js index 6412cd8..f718af7 100644 --- a/data.js +++ b/data.js @@ -1,9 +1,36 @@ -exports.abilities = require('./showdown/abilities.js').BattleAbilities; -exports.aliases = require('./showdown/aliases.js').BattleAliases; -exports.formatsData = require('./showdown/formats-data.js').BattleFormatsData; -exports.formats = require('./showdown/formats.js').Formats; -exports.items = require('./showdown/items.js').BattleItems; -exports.learnsets = require('./showdown/learnsets.js').BattleLearnsets; -exports.moves = require('./showdown/moves.json'); -exports.pokedex = require('./showdown/pokedex.json'); -exports.typechart = require('./showdown/typechart.js').BattleTypeChart; +// @ts-check +/** + * @import { Ability, IsNonstandard, Format, Item, Learnset, Move, Species, Types, StatsTable } from './types/data.d.ts'; + */ + +/** @deprecated This will be removed in a future release! */ +exports.abilities = /** @type {Record} */ (require('./showdown/abilities.js').BattleAbilities); +/** @deprecated This will be removed in a future release! */ +exports.aliases = /** @type {Record} */ (require('./showdown/aliases.js').BattleAliases); +/** @deprecated This will be removed in a future release! */ +exports.formatsData = /** @type {Record} */ (require('./showdown/formats-data.js').BattleFormatsData); +/** @deprecated This will be removed in a future release! */ +exports.formats = /** @type {({ section: string; column?: number } | Format)[]} */ (require('./showdown/formats.js').Formats); +/** @deprecated This will be removed in a future release! */ +exports.items = /** @type {Record} */ (require('./showdown/items.js').BattleItems); +/** @deprecated This will be removed in a future release! */ +exports.learnsets = /** @type {Record} */ (require('./showdown/learnsets.js').BattleLearnsets); +/** @deprecated This will be removed in a future release! */ +exports.moves = /** @type {Record} */ (/** @type {unknown} */ (require('./showdown/moves.json'))); +/** @deprecated This will be removed in a future release! */ +exports.pokedex = /** @type {Record} */ (require('./showdown/pokedex.json')); +/** @deprecated This will be removed in a future release! */ +exports.typechart = /** @type {Record< + Lowercase, + { + damageTaken: Record & + Partial>; + HPivs?: Partial; + HPdvs?: Partial; + } +>} */ (require('./showdown/typechart.js').BattleTypeChart); diff --git a/mocks/fetch.js b/mocks/fetch.js index c7e0c84..69f7ce6 100644 --- a/mocks/fetch.js +++ b/mocks/fetch.js @@ -1,6 +1,6 @@ const querystring = require('querystring'); -module.exports = (url, params) => { +module.exports = async (url, params) => { if (url === 'https://play.pokemonshowdown.com/action.php') { expect(querystring.parse(params.body)).toEqual({ act: 'login', @@ -9,6 +9,6 @@ module.exports = (url, params) => { challengekeyid: 'challstr_key', // This is actually supposed to be a number challstr: 'challstr_value', }); - return { text: async () => ']{"assertion":"challstr_value_then_other_stuff","username":"psclient"}' }; + return /** @type Response */ ({ text: async () => ']{"assertion":"challstr_value_then_other_stuff","username":"psclient"}' }); } }; diff --git a/mocks/ws.js b/mocks/ws.js index 392496a..8941eae 100644 --- a/mocks/ws.js +++ b/mocks/ws.js @@ -32,6 +32,12 @@ class Connection extends EventEmitter { } } class MockSocket extends EventEmitter { + /** @type {() => void} */ + onopen; + /** @type {(packet: { data: string }) => void} */ + onmessage; + /** @type {() => void} */ + onclose; constructor(props) { super(props); setTimeout(() => { diff --git a/package-lock.json b/package-lock.json index 908379a..2ff7e2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,13 +13,15 @@ "ws": "^8.18.0" }, "devDependencies": { + "@types/jest": "^30.0.0", "chalk": "^4.0.0", "dotenv": "^16.0.3", "eslint": "^8.37.0", "eslint-config-prettier": "^9.1.0", "husky": "^8.0.3", "jest": "^29.7.0", - "prettier": "^3.3.3" + "prettier": "^3.3.3", + "typescript": "^5.9.2" }, "engines": { "node": ">=18.0.0" @@ -49,15 +51,15 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -228,9 +230,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "dev": true, "license": "MIT", "engines": { @@ -863,6 +865,16 @@ } } }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -924,6 +936,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/get-type": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.0.1.tgz", + "integrity": "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/globals": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", @@ -940,6 +962,30 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -1286,6 +1332,230 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jest/node_modules/@jest/expect-utils": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.0.5.tgz", + "integrity": "sha512-F3lmTT7CXWYywoVUGTCmom0vXq3HTTkaZyTAzIy+bXSBizB7o5qzlC9VCtq0arOa8GqmNsbg/cE9C6HLn7Szew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/@sinclair/typebox": { + "version": "0.34.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", + "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/ci-info": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@types/jest/node_modules/expect": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.0.5.tgz", + "integrity": "sha512-P0te2pt+hHI5qLJkIR+iMvS+lYUZml8rKKsohVHAGY+uClp9XVbdyYNJOIjSRpHVp8s8YqxJCiHUkSYZGr8rtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.0.5", + "@jest/get-type": "30.0.1", + "jest-matcher-utils": "30.0.5", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-diff": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.5.tgz", + "integrity": "sha512-1UIqE9PoEKaHcIKvq2vbibrCog4Y8G0zmOxgQUVEiTqwR5hJVMCoDsN1vFvI5JvwD37hjueZ1C4l2FyGnfpE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-matcher-utils": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.0.5.tgz", + "integrity": "sha512-uQgGWt7GOrRLP1P7IwNWwK1WAQbq+m//ZY0yXygyfWp0rJlksMSLQAA4wYQC3b6wl3zfnchyTx+k3HZ5aPtCbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "jest-diff": "30.0.5", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-message-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.5.tgz", + "integrity": "sha512-NAiDOhsK3V7RU0Aa/HnrQo+E4JlbarbmI3q6Pi4KcxicdtjV82gcIUrejOtczChtVQR4kddu1E1EJlW6EN9IyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.5", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-mock": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-util": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@types/node": { "version": "22.8.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz", @@ -4365,6 +4635,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", diff --git a/package.json b/package.json index f2b4cd8..5d8889a 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,14 @@ { "name": "ps-client", + "type": "module", "homepage": "https://github.com/PartMan7/PS-Client#readme", "version": "5.0.1", + "types": "client.js", "description": "Package for connecting to Pokemon Showdown servers.", "main": "client.js", "scripts": { + "clean": "rm -f *.d.ts classes/*.d.ts || :", + "build": "npm run clean && tsc && rm -f showdown/*.d.ts", "jest": "jest --silent=false --verbose --runInBand", "lint": "eslint . && prettier --check .", "test": "npm run lint && npm run jest", @@ -26,13 +30,15 @@ "ws": "^8.18.0" }, "devDependencies": { + "@types/jest": "^30.0.0", "chalk": "^4.0.0", "dotenv": "^16.0.3", "eslint": "^8.37.0", "eslint-config-prettier": "^9.1.0", "husky": "^8.0.3", "jest": "^29.7.0", - "prettier": "^3.3.3" + "prettier": "^3.3.3", + "typescript": "^5.9.2" }, "engines": { "node": ">=18.0.0" diff --git a/showdown/colors.json b/showdown/colors.json index 4e670c0..9683eef 100644 --- a/showdown/colors.json +++ b/showdown/colors.json @@ -536,5 +536,7 @@ "setsu": "rhztxtxg", "cassiopeia": "0km", "chilletignis": "y6n3w4nx", - "zacthesunrise": "zwga99j0" + "zacthesunrise": "zwga99j0", + "bluefins": "talltremors", + "gambit": "gkreldpcgltpfldkvk" } \ No newline at end of file diff --git a/tools.d.ts b/tools.d.ts index 5745385..d9d47de 100644 --- a/tools.d.ts +++ b/tools.d.ts @@ -1,81 +1,61 @@ -type Namecolour = { +export function HSL(name: string, original?: boolean | undefined): NameColor; +export function update(...types: Updates[]): Promise; +export function uploadToPastie(text: string, callback?: ((url: string) => void) | undefined): Promise; +/** + * @overload + * @param input {PokePasteInput} + * @param output {'' | undefined | void} + * @returns {Promise} The uploaded URL. + */ +export function uploadToPokepaste(input: PokePasteInput, output: '' | undefined | void): Promise; +/** + * @overload + * @param input {PokePasteInput} + * @param output {'raw'} + * @returns {Promise} The 'raw' version of the uploaded URL. + */ +export function uploadToPokepaste(input: PokePasteInput, output: 'raw'): Promise; +/** + * @overload + * @param input {PokePasteInput} + * @param output {'html'} + * @returns {Promise} The returned HTML for the uploaded URL. + */ +export function uploadToPokepaste(input: PokePasteInput, output: 'html'): Promise; +export function escapeHTML(input: string): string; +export function unescapeHTML(input: string): string; +export const formatText: (str: string, isTrusted?: boolean | undefined, replaceLinebreaks?: boolean | undefined) => string; +export type NameColor = { source: string; hsl: [number, number, number]; - base?: Omit; + base?: Omit; }; - -type PokePaste = { - title: string; - author: string; - notes: string; - paste: string; -}; - -type UpdateType = +export type Updates = + | any | 'abilities' | 'aliases' - | 'config' | 'formatsdata' | 'formats' | 'items' | 'learnsets' | 'moves' | 'pokedex' - | 'typechart'; - -/** - * @param text The input value - * @returns The ID of the given input - */ -export function toID(text: any): string; -/** - * @param text The input value - * @returns The room ID of the given input (preserves '-') - */ -export function toRoomID(text: any): string; - -/** - * @param name - The username whose HSL value is to be calculated - * @param original - Whether the username's original colour should override the custom colour (optional) - * @returns An object with the required details (HSL values in namecolour.hsl) - */ -export function HSL(name: string, original?: boolean): Namecolour; - -/** - * @param types - A string corresponding to the datacenter you wish to update. - * If nothing is passed, this updates all available datacenters. - * @returns A promise with the name(s) of the updated datacenter(s) - */ -export function update(...types: UpdateType[]): Promise; - -/** - * @param text - The text to upload - * @param callback - An optional callback to run with the returned URL - * @returns A promise that resolves with the uploaded URL - */ -export function uploadToPastie(text: string, callback?: (url: string) => any): Promise; - -/** - * @param input - The input to upload (can be an object or a string) - * @param output - An optional string to dictate the resolution value of the promise ('raw' for the URL with the raw text, 'html' for the source HTML). Leave empty for the standard URL. - * @returns A promise with the value dictated by output - */ -export function uploadToPokepaste(input: string | PokePaste, output?: 'raw' | 'html' | void): Promise; - -/** - * @param input - The text to sanitize HTML from - * @returns The HTML-sanitized text - */ -export function escapeHTML(input: string): string; - + | 'typechart' + | 'colors'; +export type PokePasteConfig = { + title: string; + author: string; + notes: string; + paste: string; +}; +export type PokePasteInput = PokePasteConfig | string; /** - * @param input - The text to desanitize HTML from - * @returns The HTML-desanitized text + * @param text {string} The input value + * @returns {string} The ID of the given input */ -export function unescapeHTML(input: string): string; - +export function toID(text: string): string; /** - * @param input - The text to format - * @returns The formatted text + * @param text {string} The input value + * @returns {string} The room ID of the given input (preserves '-') */ -export function formatText(input: string): string; +export function toRoomID(text: string): string; diff --git a/tools.js b/tools.js index aa58457..54d3c32 100644 --- a/tools.js +++ b/tools.js @@ -1,3 +1,4 @@ +// @ts-check 'use strict'; const crypto = require('crypto'); @@ -5,8 +6,13 @@ const fs = require('fs'); const path = require('path'); const querystring = require('querystring'); -let COLORS = require('./showdown/colors.json'); +const baseColors = require('./showdown/colors.json'); +let COLORS = baseColors; +/** + * @param text {string} The input value + * @returns {string} The ID of the given input + */ function toID(text) { return String(text) .toLowerCase() @@ -14,6 +20,10 @@ function toID(text) { } exports.toID = toID; +/** + * @param text {string} The input value + * @returns {string} The room ID of the given input (preserves '-') + */ function toRoomID(text) { return String(text) .toLowerCase() @@ -21,6 +31,18 @@ function toRoomID(text) { } exports.toRoomID = toRoomID; +/** + * @typedef {{ + * source: string; + * hsl: [number, number, number]; + * base?: Omit; + * }} NameColor + */ +/** + * @param name {string} The username whose HSL value is to be calculated + * @param original {boolean=} Whether the username's original colour should override the custom colour (optional) + * @returns {NameColor} An object with the required details (HSL values in namecolour.hsl) + */ exports.HSL = function HSL(name, original) { name = toID(name); const out = { source: name, hsl: null }; @@ -87,7 +109,33 @@ exports.HSL = function HSL(name, original) { return out; }; +/** + * @typedef { + * | 'abilities' + * | 'aliases' + * | 'formatsdata' + * | 'formats' + * | 'items' + * | 'learnsets' + * | 'moves' + * | 'pokedex' + * | 'typechart' + * | 'colors' + * } Updates + */ +/** + * @deprecated This (along with PS data) will be removed in a future release! + * @param types {Updates[]} A string corresponding to the datacenter you wish to update. + * If nothing is passed, this updates all available datacenters. + * @returns {Promise} A promise with the name(s) of the updated datacenter(s) + */ exports.update = function update(...types) { + /** + * @typedef {{ url: string, path: string, name: string, key?: string; expo?: string, process?: (data: string) => string }} UpdateEntry + */ + /** + * @type {Record} + */ const links = { abilities: { url: 'https://play.pokemonshowdown.com/data/abilities.js', @@ -104,6 +152,7 @@ exports.update = function update(...types) { colors: { path: path.join(__dirname, 'showdown', 'colors.json'), name: 'Colors', + url: 'custom handling', }, formatsdata: { url: 'https://play.pokemonshowdown.com/data/formats-data.js', @@ -181,14 +230,15 @@ exports.update = function update(...types) { expo: 'BattleTypeChart', }, }; - types = types + /** @type UpdateEntry[] */ + let typeData = types .map(toID) .map(type => links[type]) .filter(type => type); - if (!types.length) types = Object.values(links); + if (!typeData.length) typeData = Object.values(links); return new Promise((resolve, reject) => { Promise.all( - types.map( + typeData.map( type => new Promise(res => { if (type.name === 'Colors') { @@ -210,7 +260,7 @@ exports.update = function update(...types) { .then(res => res.text()) .then(response => { const data = type.process ? type.process(response) : response; - const writeData = (typeof data === 'string' ? data : JSON.stringify(data)) + (type.append || ''); + const writeData = typeof data === 'string' ? data : JSON.stringify(data); fs.writeFile(type.path, writeData, err => { if (err) throw err; try { @@ -227,7 +277,7 @@ exports.update = function update(...types) { ).then(res => { if (require.cache[require.resolve('./client.js')]) { const main = require('./client.js'); - types.forEach(type => { + typeData.forEach(type => { const key = type.key || toID(type.name); if (type.expo) main.Data[key] = require(type.path)[type.expo]; else main.Data[key] = require(type.path); @@ -238,67 +288,94 @@ exports.update = function update(...types) { }); }; -exports.uploadToPastie = function uploadToPastie(text, callback) { - return new Promise((resolve, reject) => { - fetch('https://pastie.io/documents', { - method: 'POST', - headers: { - 'Content-Type': 'text/plain', - }, - body: text, - }) - .then(res => res.json()) - .then(res => { - if (callback && typeof callback === 'function') callback(`https://pastie.io/raw/${res.key}`); - resolve(`https://pastie.io/raw/${res.key}`); - }) - .catch(reject); +/** + * @param text {string} The text to upload. + * @param callback {((url: string) => void)=} An optional callback to run with the returned URL. + * @returns {Promise} A promise that resolves with the uploaded URL. + */ +exports.uploadToPastie = async function uploadToPastie(text, callback) { + const res = await fetch('https://pastie.io/documents', { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: text, }); + /** @type {{ key: string }} */ + const data = await res.json(); + + if (callback && typeof callback === 'function') callback(`https://pastie.io/raw/${data.key}`); + return `https://pastie.io/raw/${data.key}`; }; -exports.uploadToPokepaste = function uploadToPokepaste(text, output) { - return new Promise((resolve, reject) => { - switch (typeof text) { - case 'string': { - text = { - title: 'Untitled', - author: 'Anonymous', - notes: '', - paste: text.replace(/\r?\n/g, '\r\n'), - }; - break; - } - default: { - if (text.paste) break; - return reject(new Error('Invalid Paste value.')); - } +/** @typedef {{ title: string; author: string; notes: string; paste: string }} PokePasteConfig */ +/** @typedef {PokePasteConfig | string} PokePasteInput */ +/** + * @overload + * @param input {PokePasteInput} + * @param output {'' | undefined | void} + * @returns {Promise} The uploaded URL. + */ +/** + * @overload + * @param input {PokePasteInput} + * @param output {'raw'} + * @returns {Promise} The 'raw' version of the uploaded URL. + */ +/** + * @overload + * @param input {PokePasteInput} + * @param output {'html'} + * @returns {Promise} The returned HTML for the uploaded URL. + */ +/** + * @param input {PokePasteInput} + * The input to upload (can be an object or a string). + * @param output {?'' | 'raw' | 'html' | void} An optional string to dictate the resolution value of the promise + * ('raw' for the URL with the raw text, 'html' for the source HTML). Leave empty for the standard URL. + * @returns {Promise} The requested type of info. + */ +exports.uploadToPokepaste = async function uploadToPokepaste(input, output) { + /** @type {PokePasteConfig} */ + let data; + switch (typeof input) { + case 'string': { + data = { + title: 'Untitled', + author: 'Anonymous', + notes: '', + paste: input.replace(/\r?\n/g, '\r\n'), + }; + break; } - fetch('https://pokepast.es/create', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: querystring.stringify(text), - }) - .then(res => res.text().then(data => ({ data, url: res.url }))) - .then(({ data, url }) => { - if (typeof output === 'function') return output(url); - switch (toID(output)) { - case 'raw': - resolve(url.replace(/(?<=pokepast\.es)/, '/raw')); - break; - case 'html': - resolve(data); - break; - default: - resolve(url); - } - }) - .catch(reject); - }); + default: { + if (input.paste) break; + throw new Error('Invalid Paste value.'); + } + } + const { content, url } = await fetch('https://pokepast.es/create', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: querystring.stringify(data), + }).then(res => res.text().then(content => ({ content, url: res.url }))); + + switch (output) { + case 'raw': + return url.replace(/(?<=pokepast\.es)/, '/raw'); + case 'html': + return content; + default: + return url; + } }; -exports.escapeHTML = function escapeHTML(str) { - if (!str) return ''; - return String(str) +/** + * @param input {string} The text to sanitize HTML from. + * @returns {string} The HTML-sanitized text. + */ +exports.escapeHTML = function escapeHTML(input) { + if (!input) return ''; + return String(input) .replace(/&/g, '&') .replace(//g, '>') @@ -307,9 +384,13 @@ exports.escapeHTML = function escapeHTML(str) { .replace(/\//g, '/'); }; -exports.unescapeHTML = function unescapeHTML(str) { - if (!str) return ''; - return String(str) +/** + * @param input {string} The text to desanitize HTML from + * @returns {string} The HTML-desanitized text + */ +exports.unescapeHTML = function unescapeHTML(input) { + if (!input) return ''; + return String(input) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') @@ -318,4 +399,8 @@ exports.unescapeHTML = function unescapeHTML(str) { .replace(///g, '/'); }; -exports.formatText = require('./chat.js'); +/** + * @param input {string} The text to format. + * @returns {string} The formatted text. + */ +exports.formatText = require('./chat.js').formatText; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4f3b17e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "allowJs": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES6" + }, + "exclude": ["*.test.js", "mocks/*"] +} diff --git a/types/client-opts.d.ts b/types/client-opts.d.ts new file mode 100644 index 0000000..d52ff7c --- /dev/null +++ b/types/client-opts.d.ts @@ -0,0 +1,119 @@ +// These need to be declared in an actual file because JSDoc is bloody atrocious. + +import type { Message } from '../classes/message.js'; +import type { HTMLOpts } from './common.d.ts'; + +export type ClientOpts = { + /** + * The username you wish to connect to. Required parameter. + */ + username: string; + /** + * The password for the username you're connecting to. Leave this blank if the account is unregistered. + */ + password?: string; + /** + * The avatar your Bot will have on connection. If not specified, PS will set one randomly. + */ + avatar?: string | number; + /** + * The status your Bot will have on connection. + */ + status?: string; + /** + * An array with the strings of the rooms you want the Bot to join. + */ + rooms: string[]; + /** + * The function you would like to run on debugs. If this is a falsy value, debug messages will not be displayed. + * If a true value is given which is not a function, the Bot simply logs messages to the console. + */ + debug?: boolean | ((details: string) => void); + /** + * Handling for internal errors. If a function is provided, this will run it with an error / string. + * To opt out of error handling (not recommended), set this to null. + * @default console.log + */ + handle?: ((error: string | Error) => void) | null; + /** + * Does not populate userdetails automatically. Use `Client#getUserDetails` or `User#update` to populate a user. + * @warning * Users will not have any properties other than id, userid, name, and alts. + * @warning * `User#sendHTML` and `User#pageHTML` will be disabled. Use `Room#pmHTML` or `Room#pageHTML` instead. + */ + sparse?: boolean; + /** + * Enables scrollback (messages that are received from before the bot joins, such as + * chat history). Scrollback messages will be emitted with the field `isIntro: true`. + * @default false + */ + scrollback?: boolean; + /** + * Dictates whether messages throw errors by default. Set to 'false' to enable messages throwing errors. + * @default true + */ + noFailMessages?: boolean; + /** + * The throttle (in milliseconds) for every 'batch' of three messages. PS has a per-message throttle of + * 25ms for public roombots, 100ms for trusted users, and 600ms for regular users. + */ + throttle?: number; + // A custom HTML processor, applied on all HTML methods. Defaults to no-transform. See HTML options for more info on opts. + transformHTML?: (input: any, opts: HTMLOpts) => string; + /** + * The time, in milliseconds, that your Bot will wait before attempting to login again after failing. + * If this is 0, it will not attempt to login again. + * @default 10_000 + */ + retryLogin?: number; + autoReconnect?: boolean; + /** + * The time, in milliseconds, that your Bot will wait before attempting to reconnect after a disconnect. + * If this is 0, it will not attempt to reconnect. + * @default 30_000 + */ + autoReconnectDelay?: number; + /** + * The time, in milliseconds, after which your connection times out. + * @default 20_000 + */ + connectionTimeout?: number; + /** + * The server to connect to. + * @default 'sim3.psim.us' + */ + server?: string; + serverid?: string; + /** + * The port on which you're connecting to. Can also be specified in server as `url:port`, in which case leave this field blank. + */ + port?: number; + /** + * The protocol used for the websocket connection. Defaults to wss, but can be changed to ws (insecure). + * @default 'ws' + */ + serverProtocol?: 'ws' | 'wss'; + /** + * The login server. + * @default 'https://play.pokemonshowdown.com/~~showdown/action.php' + */ + loginServer?: string; + /** + * Explicitly specify whether the client is trusted (trusted users on PS + * have slightly more permissions and features). The client will try to + * infer this by default. + */ + isTrusted?: boolean; +}; + +export interface ClientEvents { + on(event: 'packet', listener: (direction: 'in' | 'out', data: string) => void): this; + on(event: 'connect', listener: () => void): this; + on(event: 'message', listener: (message: Message) => void): this; + on(event: 'join', listener: (room: string, user: string, isIntro: boolean) => void): this; + on(event: 'leave', listener: (room: string, user: string, isIntro: boolean) => void): this; + on(event: 'name', listener: (room: string, newName: string, oldName: string, isIntro: boolean) => void): this; + on(event: 'joinRoom', listener: (room: string) => void): this; + on(event: 'leaveRoom', listener: (room: string) => void): this; + on(event: 'chatError', listener: (room: string, error: string, isIntro: boolean) => void): this; + on(event: string, listener: (room: string, line: string, isIntro: boolean) => void): this; +} diff --git a/types/common.d.ts b/types/common.d.ts new file mode 100644 index 0000000..df50821 --- /dev/null +++ b/types/common.d.ts @@ -0,0 +1,16 @@ +import type { Room } from '../classes/room.d.ts'; + +export type HTML = any; + +export type Ranks = '+' | '%' | '*' | '@' | '#' | '§' | '&'; + +export type HTMLOptsObject = { + name?: string; + rank?: Ranks; + change?: boolean; + notransform?: boolean; + // Only used for User#HTML methods. + room?: string | Room; +}; + +export type HTMLOpts = HTMLOptsObject | string; diff --git a/types/data.d.ts b/types/data.d.ts new file mode 100644 index 0000000..01fefaa --- /dev/null +++ b/types/data.d.ts @@ -0,0 +1,357 @@ +// Rough-ish outline of data from Showdown +// Help here in getting simpler/more accurate types would be greatly appreciated + +type Stats = 'atk' | 'def' | 'spa' | 'spd' | 'spe' | 'hp'; +type StatsTable = Record; +type IsNonstandard = 'CAP' | 'Past' | 'Future' | 'Unobtainable' | 'Gigantamax' | 'LGPE' | 'Custom' | null; +type Gender = 'M' | 'F' | 'N' | ''; + +type AbilityFlags = Partial< + Record<'breakable' | 'cantsuppress' | 'failroleplay' | 'failskillswap' | 'noentrain' | 'noreceiver' | 'notrace' | 'notransform', 1> +>; + +export type Ability = { + isNonstandard?: 'Past' | 'CAP'; + flags: AbilityFlags; + name: string; + rating: number; + num: number; + desc: string; + shortDesc: string; +}; + +export type Item = { + name: string; + desc: string; + shortDesc: string; + gen: number; + num: number; + spritenum: number; + isNonstandard?: IsNonstandard; + boosts?: Partial; + condition?: any; // not this + + isBerry?: boolean; + isPokeball?: boolean; + isGem?: boolean; + isChoice?: boolean; + itemUser?: string[]; + forcedForme?: string; + megaStone?: string; + megaEvolves?: string; + zMove?: string | boolean; + zMoveType?: Types; + zMoveFrom?: string; + naturalGift?: { + basePower: number; + type: Types; + }; + fling?: { basePower: number; status?: string; volatileStatus?: string }; + ignoreKlutz?: boolean; +}; + +type MoveSource = `${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}${'M' | 'T' | 'L' | 'R' | 'E' | 'D' | 'S' | 'V' | 'C'}${string}`; + +type EventInfo = { + generation: number; + level?: number; + shiny?: boolean | 1; + gender?: Gender; + nature?: string; + ivs?: Partial; + perfectIVs?: number; + isHidden?: boolean; + abilities?: string[]; + maxEggMoves?: number; + moves?: string[]; + pokeball?: string; + from?: string; + japan?: boolean; + emeraldEventEgg?: boolean; +}; + +export type Learnset = { + learnset?: Record; + eventData?: EventInfo[]; + eventOnly?: boolean; + encounters?: EventInfo[]; + exists?: boolean; +}; + +type MoveTarget = + | 'adjacentAlly' + | 'adjacentAllyOrSelf' + | 'adjacentFoe' + | 'all' + | 'allAdjacent' + | 'allAdjacentFoes' + | 'allies' + | 'allySide' + | 'allyTeam' + | 'any' + | 'foeSide' + | 'normal' + | 'randomNormal' + | 'scripted' + | 'self'; + +type MoveFlags = Partial< + Record< + | 'allyanim' + | 'bypasssub' + | 'bite' + | 'bullet' + | 'cantusetwice' + | 'charge' + | 'contact' + | 'dance' + | 'defrost' + | 'distance' + | 'failcopycat' + | 'failencore' + | 'failinstruct' + | 'failmefirst' + | 'failmimic' + | 'futuremove' + | 'gravity' + | 'heal' + | 'metronome' + | 'mirror' + | 'mustpressure' + | 'noassist' + | 'nonsky' + | 'noparentalbond' + | 'nosketch' + | 'nosleeptalk' + | 'pledgecombo' + | 'powder' + | 'protect' + | 'pulse' + | 'punch' + | 'recharge' + | 'reflectable' + | 'slicing' + | 'snatch' + | 'sound' + | 'wind', + 1 + > +>; + +type HitEffect = { + boosts?: Partial | null; + status?: string; + volatileStatus?: string; + sideCondition?: string; + slotCondition?: string; + pseudoWeather?: string; + terrain?: string; + weather?: string; +}; + +type SecondaryEffect = HitEffect & { + chance?: number; + ability?: Ability; + dustproof?: boolean; + kingsrock?: boolean; + self?: HitEffect; +}; + +type EffectData = { + name?: string; + desc?: string; + duration?: number; + effectType?: string; + infiltrates?: boolean; + isNonstandard?: IsNonstandard | null; + shortDesc?: string; +}; + +type Move = EffectData & + HitEffect & { + name: string; + num?: number; + condition?: { duration?: number }; + basePower: number; + accuracy: true | number; + pp: number; + category: 'Physical' | 'Special' | 'Status'; + type: Types; + priority: number; + target: MoveTarget; + flags: MoveFlags; + realMove?: string; + + damage?: number | 'level' | false | null; + contestType?: string; + noPPBoosts?: boolean; + + isZ?: boolean | string; + zMove?: { + basePower?: number; + effect?: string; + boost?: Partial; + }; + + isMax?: boolean | string; + maxMove?: { + basePower: number; + }; + + ohko?: boolean | 'Ice'; + thawsTarget?: boolean; + heal?: number[] | null; + forceSwitch?: boolean; + selfSwitch?: 'copyvolatile' | 'shedtail' | boolean; + selfBoost?: { boosts?: Partial }; + selfdestruct?: 'always' | 'ifHit' | boolean; + breaksProtect?: boolean; + + recoil?: [number, number]; + drain?: [number, number]; + mindBlownRecoil?: boolean; + stealsBoosts?: boolean; + struggleRecoil?: boolean; + secondary?: SecondaryEffect | null; + secondaries?: SecondaryEffect[] | null; + self?: SecondaryEffect | null; + hasSheerForce?: boolean; + + alwaysHit?: boolean; + critRatio?: number; + overrideOffensivePokemon?: 'target' | 'source'; + overrideOffensiveStat?: string; + overrideDefensivePokemon?: 'target' | 'source'; + overrideDefensiveStat?: string; + forceSTAB?: boolean; + ignoreAbility?: boolean; + ignoreAccuracy?: boolean; + ignoreDefensive?: boolean; + ignoreEvasion?: boolean; + ignoreImmunity?: boolean | { [typeName: string]: boolean }; + ignoreNegativeOffensive?: boolean; + ignoreOffensive?: boolean; + ignorePositiveDefensive?: boolean; + ignorePositiveEvasion?: boolean; + multiaccuracy?: boolean; + multihit?: number | number[]; + multihitType?: 'parentalbond'; + noDamageVariance?: boolean; + nonGhostTarget?: MoveTarget; + pressureTarget?: MoveTarget; + spreadModifier?: number; + sleepUsable?: boolean; + smartTarget?: boolean; + tracksTarget?: boolean; + willCrit?: boolean; + callsMove?: boolean; + + hasCrashDamage?: boolean; + isConfusionSelfHit?: boolean; + stallingMove?: boolean; + baseMove?: string; + + basePowerCallback?: true; + }; + +export type Species = { + id: string; + name: string; + num: number; + gen?: number; + baseSpecies?: string; + forme?: string; + baseForme?: string; + cosmeticFormes?: string[]; + otherFormes?: string[]; + formeOrder?: string[]; + spriteid?: string; + abilities: { + 0: string; + 1?: string; + H?: string; + S?: string; + }; + types: Types[]; + addedType?: string; + prevo?: string; + evos?: string[]; + evoType?: 'trade' | 'useItem' | 'levelMove' | 'levelExtra' | 'levelFriendship' | 'levelHold' | 'other'; + evoCondition?: string; + evoItem?: string; + evoMove?: string; + evoRegion?: 'Alola' | 'Galar'; + evoLevel?: number; + nfe?: boolean; + eggGroups: string[]; + canHatch?: boolean; + gender?: Gender; + genderRatio?: { M: number; F: number }; + baseStats: StatsTable; + maxHP?: number; + bst: number; + weightkg: number; + weighthg?: number; + heightm: number; + color: string; + tags?: ('Mythical' | 'Restricted Legendary' | 'Sub-Legendary' | 'Ultra Beast' | 'Paradox')[]; + isNonstandard?: IsNonstandard; + unreleasedHidden?: boolean | 'Past'; + maleOnlyHidden?: boolean; + mother?: string; + isMega?: boolean; + isPrimal?: boolean; + canGigantamax?: string; + gmaxUnreleased?: boolean; + cannotDynamax?: boolean; + forceTeraType?: string; + battleOnly?: string | string[]; + requiredItem?: string; + requiredMove?: string; + requiredAbility?: string; + requiredItems?: string[]; + changesFrom?: string; + pokemonGoData?: string[]; + tier?: string; + doublesTier?: string; + natDexTier?: string; +}; + +export type Types = + | 'Bug' + | 'Dark' + | 'Dragon' + | 'Electric' + | 'Fairy' + | 'Fighting' + | 'Fire' + | 'Flying' + | 'Ghost' + | 'Grass' + | 'Ground' + | 'Ice' + | 'Normal' + | 'Poison' + | 'Psychic' + | 'Rock' + | 'Steel' + | 'Stellar' + | 'Water'; + +export type Format = { + name: string; + desc?: string; + mod?: string; + team?: string; + ruleset?: string[]; + gameType?: string; + challengeShow?: boolean; + tournamentShow?: boolean; + searchShow?: boolean; + rated?: boolean; + banlist?: string[]; + unbanlist?: string[]; + bestOfDefault?: boolean; + restricted?: string[]; + teraPreviewDefault?: boolean; +};