This release is 8 versions behind 1.6.1 — the latest version of @mbw/roarbot. Jump to latest
Built and signed on GitHub ActionsBuilt and signed on GitHub Actions
Built and signed on GitHub Actions
A library for creating bots for the Meower platform.
This package works with Deno, Bun, Browsers
JSR Score
100%
Published
2 months ago (1.3.0)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691/** * RoarBot is a library for creating bots for the [Meower](https://meower.org) * platform. It comes with an easy way to connect to Meower and parse commands. * * ```ts * const bot = new RoarBot(); * bot.command("greet", { * description: "Greet someone!", * args: [ * { name: "whom", type: "string" }, * { name: "greeting", type: "full" }, * ], * fn: (reply, [whom, greeting]) => { * reply(`${greeting || "Hello"}, ${whom}!`); * }, * }); * bot.login("BearBot", "········"); * * // @BearBot help * // @BearBot greet Josh * // @BearBot greet Josh Hello there * ``` * * @module */ import { AUTH_PACKET_SCHEMA, LOGIN_SCHEMA, API_POST_SCHEMA, POST_PACKET_SCHEMA, UPLOADS_ATTACHMENT_SCHEMA, type Post, type UploadsAttachment, } from "./types.ts"; export type { Post, UploadsAttachment, Attachment } from "./types.ts"; const ATTACMHENT_MAX_SIZE = 25 << 20; /** * A bot connecting to Meower. */ export class RoarBot { private _events: { [K in keyof Events]: Events[K][] } = { login: [], post: [], }; private _commands: Command[] = []; private _username?: string; private _token?: string; private _admins: string[]; private _banned: string[]; private _ws?: WebSocket; /** * Create a bot. * @param options Some options. See {@link RoarBotOptions} for more details. */ constructor(options?: RoarBotOptions) { this._admins = options?.admins ?? []; this._banned = options?.banned ?? []; this.on("post", (reply, post) => { const split = post.p.split(" "); if ( split[0] === `@${this._username}` && !this._commands.find((command) => command.name === split[1]) ) { reply(`The command ${JSON.stringify(split[1])} doesn't exist!`); } }); if (!(options?.help ?? true)) { return; } this.command("help", { description: "Shows this message.", args: [], fn: (reply) => { const commands = this._commands .map((command) => { const pattern = command.pattern .map((patternType) => typeof patternType === "object" && !Array.isArray(patternType) ? "(" + (("name" in patternType ? `${patternType.name}: ` : "") + stringifyPatternType(patternType.type) + (patternType.optional ? " (optional)" : "")) + ")" : `(${stringifyPatternType(patternType)})`, ) .join(" "); return ( (command.admin ? "🔒 " : "") + `@${this.username} ${command.name}` + (command.description ? ` - ${command.description}` : "") + (pattern ? ` - ${pattern}` : "") ); }) .join("\n"); reply(`**Commands:**\n${commands}`); }, }); } /** * Log into an account and start the bot. * @param username The username of the account the bot should log into. * @param password The password of the account the bot should log into. This can also be a token that will get invalidated when the login succeeds. * @throws When the login fails. * @throws When the bot is already logged in. * @example * ```js * const bot = new RoarBot(); * bot.login("BearBot", "12345678"); * ``` * > [!NOTE] * > In a real scenario, the password should not be in plain text like this, * > but in an environment variable. */ async login(username: string, password: string) { if (this._token) { throw new Error("This bot is already logged in."); } const response = LOGIN_SCHEMA.parse( await ( await fetch(`https://api.meower.org/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }) ).json(), ); if (response.error) { throw new Error( `Couldn't log in: ${response.type}. Ensure that you have the correct password!`, ); } const ws = new WebSocket( `https://server.meower.org?v=1&token=${response.token}`, ); this._ws = ws; ws.addEventListener("message", ({ data }) => { const parsed = AUTH_PACKET_SCHEMA.safeParse(JSON.parse(data)); if (!parsed.success) { return; } const token = parsed.data.val.token; this._username = username; this._token = token; this._events.login.forEach((callback) => callback(token)); }); ws.addEventListener("message", ({ data }) => { const parsed = POST_PACKET_SCHEMA.safeParse(JSON.parse(data)); if (!parsed.success) { return; } this._events.post.forEach((callback) => callback((content, options) => { return this.post(content, { replies: [parsed.data.val.post_id], chat: parsed.data.val.post_origin, ...options, }); }, parsed.data.val), ); }); } /** * Listen to an event that occurs. * @param event The event to listen for. * @param callback The callback to execute when the event fires. * @example * ```ts * bot.on("login", () => console.log("Hooray!")); * ``` */ on<TEvent extends keyof Events>(event: TEvent, callback: Events[TEvent]) { this._events[event].push(callback); } /** * Create a new post. * @param content The content of the post. * @param options More parameters of the post. See {@link PostOptions} for * details. * @throws If the bot is not logged in. * @throws If the API returns an error. * @throws If {@link RoarBot.prototype.uploadFile} fails. * @returns The resulting post. This might be returned later than the post * will be appearing via the socket. */ async post(content: string, options?: PostOptions): Promise<Post> { if (!this._token) { throw new Error("The bot is not logged in."); } const response = API_POST_SCHEMA.parse( await ( await fetch( `https://api.meower.org/${ !options?.chat || options?.chat === "home" ? "home" : `posts/${options?.chat}` }`, { method: "POST", headers: { "Content-Type": "application/json", Token: this._token, }, body: JSON.stringify({ content, reply_to: options?.replies, attachments: await Promise.all( (options?.attachments ?? []).map((attachment) => typeof attachment === "string" ? attachment : ( this.upload(attachment).then((attachment) => attachment.id) ), ), ), }), }, ) ).json(), ); if (response.error) { throw new Error(`Couldn't post: ${response.type}`); } return response; } /** * Upload an attachment to Meower for use in posts. * @param file The file to upload. * @returns The uploaded file returned from the API. * @throws If the bot is not logged in. * @throws If the file is too large. * @throws If the API returns an error. */ async upload(file: Blob): Promise<UploadsAttachment> { if (!this._token) { throw new Error("The bot is not logged in."); } if (file.size > ATTACMHENT_MAX_SIZE) { throw new Error( `The file is too large. Keep it at or under ${ATTACMHENT_MAX_SIZE}B`, ); } const form = new FormData(); form.set("file", file); const response = UPLOADS_ATTACHMENT_SCHEMA.parse( await ( await fetch("https://uploads.meower.org/attachments", { method: "POST", body: form, headers: { Authorization: this._token }, }) ).json(), ); return response; } /** * Sets the account settings of the account. * @param options The options to set. * @throws If the bot is not logged in. * @throws If the API returns an error. */ async setAccountSettings(options: SetAccountSettingsOptions) { if (!this._token) { throw new Error("The bot is not logged in."); } const status = ( await fetch("https://api.meower.org/me/config", { method: "PATCH", headers: { Token: this._token, "Content-Type": "application/json", }, body: JSON.stringify({ ...options, avatar_color: options.avatarColor, unread_inbox: options.unreadInbox, hide_blocked_users: options.hideBlockedUsers, favorited_chats: options.favoritedChats, }), }) ).status; if (status === 200) { return; } throw new Error( `Failed to set account settings. The server responded with ${status}`, ); } /** * Register a new command. * @param name The name of the command. * @param options Some options. See {@link CommandOptions} for details. * @throws If a command with that name is already present. */ command<const TPattern extends Pattern>( name: string, options: CommandOptions<TPattern>, ) { if (this._commands.some((command) => command.name === name)) { throw new Error( `A command with the name of ${JSON.stringify(name)} already exists.`, ); } this._commands.push({ name: name, description: options.description ?? null, pattern: options.args, admin: options.admin ?? false, }); this.on("post", async (reply, post) => { if (post.u === this.username) { return; } const split = post.p.split(" "); if (split[0] !== `@${this.username}` || split[1] !== name) { return; } if (this._banned.includes(post.u)) { reply("You are banned from using this bot."); return; } if (options.admin && !this._admins.includes(post.u)) { reply("You can't use this command as it is limited to administrators."); return; } const parsed = parseArgs(options.args, split.slice(2)); if (parsed.error) { reply(parsed.message); } else { try { await options.fn(reply, parsed.parsed, post); } catch (e) { reply("💥 Something exploded. Check the console for more info!"); console.error(e); } } }); } /** * Passes the bot to different modules. This should be used to separate * different bits of functionality, like commands, into different files. * @param modules An array of dynamically imported modules with a default * export that gets in the bot. * * @example * ```ts * const bot = new RoarBot(); * bot.run( * import("./commands/add.ts"), * import("./commands/ping.ts"), * ); * bot.login("BearBot", "········"); * * // ==== ./commands/add.ts ==== * import type { RoarBot } from "../mod.ts"; * * export default (bot: RoarBot) => { * bot.command("add", { * args: ["number", "number"], * fn: (reply, [n1, n2]) => reply((n1 + n2).toString()), * }); * }; * * // ==== ./commands/ping.ts ==== * import type { RoarBot } from "../mod.ts"; * * export default (bot: RoarBot) => { * bot.command("ping", { * args: [], * fn: (reply) => reply("Pong"), * }); * }; * ``` */ async run(...modules: Promise<{ default: (bot: RoarBot) => void }>[]) { const awaitedModules = await Promise.all(modules); awaitedModules.forEach((module) => module.default(this)); } /** * The username of the account the bot is logged into. If the bot isn't logged * in, this is `undefined`. */ get username(): string | undefined { return this._username; } /** * The token of the account the bot is logged into. If the bot isn't logged * in, this is `undefined`. */ get token(): string | undefined { return this._token; } /** The used commands. */ get commands(): Command[] { return [...this._commands]; } /** * The open WebSocket connection. This is `undefined` if the bot is not * logged in. */ get ws(): WebSocket | undefined { return this._ws; } } /** * A mapping of events to their respective callbacks. */ export type Events = { login: (token: string) => void; post: ( reply: ( content: string, options?: Omit<PostOptions, "replies" | "chat">, ) => Promise<Post>, post: Post, ) => void; }; /** Options that can be passed into {@link RoarBot}. */ export type RoarBotOptions = { /** The administrators of this bot. They can use admin commands. */ admins?: string[]; /** * Users banned from using the bot. Any commands they try to run won't be executed. */ banned?: string[]; /** * Whether to have a generated help command. By default, this is true. */ help?: boolean; }; /** * Options that can be passed into {@link RoarBot.prototype.command}. */ export type CommandOptions<TPattern extends Pattern> = { /** The description of the command. This is shown in the help message. */ description?: string; /** The argument pattern of the command. */ args: TPattern; /** Whether this command is only usable by administrators. */ admin?: boolean; /** The callback to be called when the command gets executed. */ fn: ( reply: ( content: string, options?: Omit<PostOptions, "replies" | "chat">, ) => Promise<Post>, args: ResolvePattern<TPattern>, post: Post, ) => void | Promise<void>; }; /** A command when it has been added to a bot. */ export type Command = { /** The name of the command. */ name: string; /** The description of the command. */ description: string | null; /** The pattern the arguments use. */ pattern: Pattern; /** Whether the command is limited to administrators. */ admin: boolean; }; /** * Options that can be passed into {@link RoarBot.prototype.post}. */ export type PostOptions = { /** Post IDs that this post is replying to. */ replies?: string[]; /** * The attachments to upload with a post. These can either be attachment IDs * or blobs that are passed to {@link RoarBot.prototype.upload} */ attachments?: (string | Blob)[]; /** * The chat to post to. If this is not specified, the post will be posted to * home. The available special chats are: * - `home` * - `livechat` */ chat?: string; }; /** * Options that can be passed into {@link RoarBot.prototype.setAccountSettings} * to modify. */ export type SetAccountSettingsOptions = { /** A default profile picture. */ pfp?: number; /** An uploaded profile picture. TODO: Make uploading icons possible */ avatar?: string; /** The profile color. */ avatarColor?: string; /** The quote. */ quote?: string; /** Whether the account has unread messages in their inbox. */ unreadInbox?: boolean; /** The theme the account uses on Meower Svelte. */ theme?: string; /** The layout the account uses on Meower Svelte. */ layout?: string; /** Whether the account has sound effects enabled on Meower Svelte. */ sfx?: boolean; /** Whether the account has background music enabled on Meower Svelte. */ bgm?: boolean; /** The song the account uses as background music on Meower Svelte. */ bgmSong?: number; /** Whether the account has debug mode enabled on Meower Svelte. */ debug?: boolean; /** * Whether the account is not recieving posts from blocked users. * > [!NOTE] * > For this to take effect, the account has to log in again. */ hideBlockedUsers?: boolean; /** The chats the user has favorited. */ favoritedChats?: string[]; }; /** * Possible types of patterns to a command. * - `"string"`: Any string * - `"number"`: Any floating point number * - `"full"`: A string that matches until the end of the command. * - `string[]`: One of the specified strings */ export type PatternType = "string" | "number" | "full" | string[]; /** * A list of arguments types. This is a list of objects formatted like this: * - type: A {@link PatternType}. * - optional: Whether it's optional or not. After an optional argument can only * be other optional arguments. * - name: The name of the argument. * If both the name and optional aren't given, the type can be standalone * without a wrapper object. * * @example Basic * ```js * ["number", "string"] * // @Bot cmd 2 4 → [2, "4"] * ``` * @example `full` * ```js * [ * { type: "number", name: "amount" }, * { type: "full", name: "string" } * ] * // @Bot cmd 7 Hello, world! → [7, "Hello, world!"] * ``` * @example Optionals * ```js * [ * { type: "string", name: "person to greet" }, * { type: "string", optional: true, name: "greeting to use" } * ] * // @Bot cmd Josh → ["Josh"] * // @Bot cmd Josh G'day → ["Josh", "G'day"] * ``` */ export type Pattern = ( | PatternType | { type: PatternType; name?: string; optional?: boolean } )[]; /** * Converts the passed in `TPattern` to its corresponding TypeScript type. */ export type ResolvePattern<TPattern extends Pattern> = { [K in keyof TPattern]: K extends `${number}` ? TPattern[K] extends PatternType ? ResolvePatternType<TPattern[K]> : TPattern[K] extends { type: PatternType } ? TPattern[K] extends { optional: true } ? ResolvePatternType<TPattern[K]["type"]> | undefined : ResolvePatternType<TPattern[K]["type"]> : never : TPattern[K]; }; type ResolvePatternType<TArgument extends PatternType> = TArgument extends "string" ? string : TArgument extends "number" ? number : TArgument extends "full" ? string : TArgument extends string[] ? TArgument[number] : never; const parseArgs = <const TPattern extends Pattern>( pattern: TPattern, args: string[], ): | { error: true; message: string } | { error: false; parsed: ResolvePattern<TPattern> } => { const parsed = []; let hadOptionals = false; let hadFull = false; for (const [i, slice] of pattern.entries()) { const isObject = typeof slice === "object" && "type" in slice; const type = isObject ? slice.type : slice; const optional = isObject && !!slice.optional; if (hadOptionals && !optional) { return { error: true, message: "In this command's pattern, there is an optional argument following a non-optional one.\nThis is an issue with the bot, not your command.", }; } hadOptionals ||= optional; const name = isObject && !!slice.name; const repr = name ? `${slice.name} (${type})` : `${type}`; const current = args[i]; if (!current) { if (optional) { continue; } else if (type !== "full") { return { error: true, message: `Missing ${repr}.` }; } } if (Array.isArray(type)) { if (!type.includes(current)) { return { error: true, message: `${JSON.stringify(current)} has to be one of ${type .map((t) => JSON.stringify(t)) .join(", ")}.`, }; } parsed.push(current); continue; } switch (type) { case "string": { parsed.push(current); break; } case "number": { const number = Number(current); if (Number.isNaN(number)) { return { error: true, message: `${JSON.stringify(current)} is not a number.`, }; } parsed.push(number); break; } case "full": { if (pattern[i + 1]) { return { error: true, message: "In this command's pattern, there is an argument following a `full` argument.\nThis is an issue with the bot, not your command.", }; } hadFull = true; parsed.push(args.slice(i).join(" ")); break; } default: (type) satisfies never; } } if (!hadFull && args.length !== parsed.length) { return { error: true, message: "You have too many arguments." }; } return { error: false, parsed: parsed as ResolvePattern<TPattern> }; }; const stringifyPatternType = (patternType: PatternType) => { return ( typeof patternType === "string" ? patternType === "full" ? "full string" : patternType : patternType.map((option) => JSON.stringify(option)).join(" | ") ); };