This package has been archived, and as such it is read-only.
Built and signed on GitHub ActionsBuilt and signed on GitHub Actions
Built and signed on GitHub Actions
latest
mybearworld/roarbotA library for creating bots for the Meower platform.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856/** * 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. * * > [!NOTE] * > Make sure to always use `await` when possible within commands in order for * > potential errors to not make your bot crash. * * ```ts * const bot = new RoarBot(); * bot.command("greet", { * description: "Greet someone!", * args: [ * { name: "whom", type: "string" }, * { name: "greeting", type: "full" }, * ], * fn: async (reply, [whom, greeting]) => { * await reply(`${greeting || "Hello"}, ${whom}!`); * }, * }); * bot.login("BearBot", "········"); * * // @BearBot help * // @BearBot greet Josh * // @BearBot greet Josh Hello there * ``` * * ```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: async (reply, [n1, n2]) => { * await reply((n1 + n2).toString()); * }, * }); * }; * * // ==== ./commands/ping.ts ==== * import type { RoarBot } from "../mod.ts"; * * export default (bot: RoarBot) => { * bot.command("ping", { * args: [], * fn: async (reply) => { * await reply("Pong"); * }, * }); * }; * ``` * * @module */ import { JSR_UPDATE, AUTH_PACKET_SCHEMA, LOGIN_SCHEMA, API_POST_SCHEMA, POST_PACKET_SCHEMA, UPDATE_POST_PACKET_SCHEMA, DELETE_POST_PACKET_SCHEMA, UPLOADS_ATTACHMENT_SCHEMA, API_USER_SCHEMA, type UploadsAttachment, type User, } from "./types.ts"; import { type Pattern, type ResolvePattern, parseArgs, stringifyPatternType, } from "./patterns.ts"; import { RichPost } from "./rich/post.ts"; export type { Post, UploadsAttachment, Attachment, User } from "./types.ts"; export * from "./patterns.ts"; export * from "./rich/post.ts"; const ATTACMHENT_MAX_SIZE = 25 << 20; const version = "1.8.2"; const logTimeFormat = new Intl.DateTimeFormat("en-US", { timeStyle: "medium", hour12: false, }); /** * A bot connecting to Meower. */ export class RoarBot { private _events: { [K in keyof Events]: Events[K][] } = { login: [], post: [], updatePost: [], deletePost: [], }; private _commands: Command[] = []; private _username?: string; private _token?: string; private _admins: string[]; private _banned: string[]; private _ws?: WebSocket; private _messages: Messages; private _foundUpdate = false; private _loggingLevel: LoggingLevel; /** * 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._loggingLevel = options?.loggingLevel ?? "base"; this._messages = { noCommand: (command) => `The command ${command} doesn't exist!`, helpDescription: "Shows this message.", helpOptional: "(optional)", helpCommands: "## Commands", banned: "You are banned from using this bot.", adminLocked: "You can't use this command as it is limited to administrators.", error: "💥 Something exploded. Check the console for more info!", argsMissing: (name) => `Missing ${name}.`, argsNotInSet: (string, set) => `${string} has to be one of ${set}.`, argNan: (string) => `${string} is not a number.`, tooManyArgs: "You have too many arguments/replies.", ...options?.messages, }; this._checkForUpdates(); setInterval( () => { this._checkForUpdates(); }, 1000 * 60 * 60, ); this.on("post", (reply, post) => { const split = post.p.split(" "); if ( split[0].toLowerCase() === `@${this._username}`.toLowerCase() && split[1] && !this._commands.find( (command) => command.name === split[1] || command.aliases.some((alias) => alias === split[1]), ) ) { reply(this._messages.noCommand(JSON.stringify(split[1]))); } }); if (!(options?.help ?? true)) { return; } this.command("help", { description: "Shows this message.", args: [], fn: async (reply) => { const commands = Object.entries( Object.groupBy(this._commands, (command) => command.category), ) .map( ([name, commands]) => (name === "None" ? "" : `### ${name}\n`) + (commands ?? []) .map((command) => { const pattern = command.pattern .map((patternType) => ( typeof patternType === "object" && !Array.isArray(patternType) ) ? (patternType.optional ? "[" : "<") + (("name" in patternType ? `${patternType.name}: ` : "") + stringifyPatternType(patternType.type)) + (patternType.optional ? "]" : ">") : `(${stringifyPatternType(patternType)})`, ) .join(" "); return ( (command.admin ? "🔒 " : "") + `@${this.username} ${command.name} ${pattern}` + (command.description ? `\n_${command.description}_` : "") + (command.aliases.length !== 0 ? `\n_Aliases: ${command.aliases.join(", ")}_\n` : "\n") ); }) .join("\n"), ) .join("\n"); await reply(`${this._messages.helpCommands}\n${commands}`); }, }); } private _log( level: "ws" | "info" | "error" | "success", msg: string, // deno-lint-ignore no-explicit-any -- console.log uses `any[]` as well ...other: any[] ) { if ( this._loggingLevel !== "none" && !(level === "ws" && this._loggingLevel !== "ws") ) { console.log( `\x1b[1;90m[${logTimeFormat.format(Date.now())}]\x1b[1;0m`, (level === "info" || level === "ws" ? "\x1b[1;90m" : level === "error" ? "\x1b[1;31m" : "\x1b[1;36m") + msg, ...other, "\x1b[0m", ); } } private async _checkForUpdates() { if (this._foundUpdate) { return; } this._log("info", "Checking for RoarBot updates..."); try { const response = JSR_UPDATE.parse( await (await fetch("https://jsr.io/@mbw/roarbot/meta.json")).json(), ); if (version !== response.latest) { console.log( `A new RoarBot version is available! ${version} → ${response.latest}\nSee the changelog for the changes: https://github.com/mybearworld/roarbot/blob/main/CHANGELOG.md`, ); } this._foundUpdate = true; } catch { this._log( "error", "Failed to check for RoarBot updates. Ensure that you're on a recent version!", ); } } /** * 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) { this._log("info", `Trying to log into ${username}...`); 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!`, ); } this._log("success", "Recieved initial token."); this._log("info", "Connecting to Meower..."); this._connectWebSocket(username, response.token); } private _connectWebSocket(username: string, initialToken: string) { const ws = new WebSocket( `https://server.meower.org?v=1&token=${initialToken}`, ); this._ws = ws; ws.addEventListener("message", ({ data }) => { this._log("ws", data); }); ws.addEventListener("message", ({ data }) => { const parsed = AUTH_PACKET_SCHEMA.safeParse(JSON.parse(data)); if (!parsed.success) { return; } const token = parsed.data.val.token; this._log("success", "Recieved token. Logged in successfully!"); 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) => { const post = new RichPost(parsed.data.val, this); callback(post.reply.bind(post), post); }); }); ws.addEventListener("message", ({ data }) => { const parsed = UPDATE_POST_PACKET_SCHEMA.safeParse(JSON.parse(data)); if (!parsed.success) { return; } this._events.updatePost.forEach((callback) => { const post = new RichPost(parsed.data.val, this); callback(post.reply.bind(post), post); }); }); ws.addEventListener("message", ({ data }) => { const parsed = DELETE_POST_PACKET_SCHEMA.safeParse(JSON.parse(data)); if (!parsed.success) { return; } this._events.deletePost.forEach((callback) => callback(parsed.data.val.post_id), ); }); ws.addEventListener("close", () => { this._log("info", "Disconnected. Attempting to reconnect..."); if (!this._token) { this._log("error", "No token available to reconnect."); return; } this._connectWebSocket(username, this._token); }); } /** * 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<RichPost> { 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 new RichPost(response, this); } /** * Get the profile of a user. * @param username The username to get the profile of. * @returns The user profile. * @throws If the API returns an error. */ async user(username: string): Promise<User> { const response = API_USER_SCHEMA.parse( await ( await fetch( `https://api.meower.org/users/${encodeURIComponent(username)}`, ) ).json(), ); if (response.error) { throw new Error(`Couldn't get user. Error: ${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._username || !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, bucket: "attachments", claimed: false, hash: "", uploaded_at: Math.floor(Date.now() / 1000), uploaded_by: this._username, }; } /** * 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 response = 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, }), }); if (response.ok) { return; } throw new Error( `Failed to set account settings. The server responded with ${response.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>, ) { const validateName = (name: string) => { if (/\s/.test(name)) { throw new Error("A command name cannot include whitespace."); } if (this._commands.some((command) => command.name === name)) { throw new Error( `A command with the name of ${JSON.stringify(name)} already exists.`, ); } }; validateName(name); options.aliases?.forEach((alias) => { validateName(alias); }); this._commands.push({ name: name, aliases: options.aliases ?? [], description: options.description ?? null, category: options.category ?? "None", pattern: options.args, admin: options.admin ?? false, }); this._log("success", `Registered command ${JSON.stringify(name)}.`); this.on("post", async (reply, post) => { if (post.username === this.username) { return; } const split = post.content.split(" "); if ( split[0].toLowerCase() !== `@${this.username}`.toLowerCase() || (split[1] !== name && !( options.aliases && options.aliases.some((alias) => alias === split[1]) )) ) { return; } const commandName = `${JSON.stringify(post.content)} by ${ post.username } in ${post.origin}`; this._log("info", `Running ${commandName}...`); const handleError = async (fn: () => void | Promise<void>) => { try { await fn(); } catch (e) { this._log( "error", `Couldn't run ${commandName} because an error occured.`, e, ); try { await reply(this._messages.error); } catch (f) { this._log( "error", "Another error occured trying to send the error.", f, ); } } }; let refuse = false; await handleError(async () => { if (this._banned.includes(post.username)) { this._log( "error", `Refused running ${commandName} as the user is banned.`, ); refuse = true; await reply(this._messages.banned); } }); if (refuse) { return; } await handleError(async () => { if (options.admin && !this._admins.includes(post.username)) { this._log( "error", `Refused running ${commandName} as the user is not an admin.`, ); refuse = true; await reply(this._messages.adminLocked); } }); if (refuse) { return; } const parsed = parseArgs( options.args, split.slice(2), this._messages, post.replyTo, ); await handleError(async () => { if (parsed.error) { this._log( "error", `Couldn't run ${commandName} because ${parsed.message}`, ); await reply(parsed.message); } else { await options.fn(reply, parsed.parsed, post); this._log("success", `Successfully ran ${commandName}.`); } }); }); } /** * 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: async (reply, [n1, n2]) => { * await reply((n1 + n2).toString()); * }, * }); * }; * * // ==== ./commands/ping.ts ==== * import type { RoarBot } from "../mod.ts"; * * export default (bot: RoarBot) => { * bot.command("ping", { * args: [], * fn: async (reply) => { * await 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: RichPost["reply"], post: RichPost) => void; updatePost: (reply: RichPost["reply"], post: RichPost) => void; deletePost: (id: string) => 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; /** * Different messages the bot might send. Each of them has a default that * will be used if none are provided here. */ messages?: Partial<Messages>; /** Whether to log messages to the console. */ loggingLevel?: LoggingLevel; }; /** * How much logging the bot should do. By default, this is `"base"`. * - `none`: No logging at all * - `base`: Logging of most things. * - `ws`: Same as `base`, but also logs packets from the server. */ export type LoggingLevel = "none" | "base" | "ws"; /** * Different messgaes the bot might send. Each of them has a default that will * be used if none are provided here. */ export type Messages = { /** When a command doesn't exist. */ noCommand: (command: string) => string; /** Description of the help command. */ helpDescription: string; /** @deprecated Unused */ helpOptional: string; /** Heading for the commands in the help command. */ helpCommands: string; /** Message for when a user is banned. */ banned: string; /** Message for when someone tries to run an admin-locked command. */ adminLocked: string; /** Message for when something goes wrong. */ error: string; /** Message for when an argument is missing. */ argsMissing: (name: string) => string; /** Message for when a string is not in the expected set of strings. */ argsNotInSet: (string: string, set: string) => string; /** Message for when something is not a number. */ argNan: (string: string) => string; /** Message for when there are too many arguments. */ tooManyArgs: string; }; /** * Options that can be passed into {@link RoarBot.prototype.command}. */ export type CommandOptions<TPattern extends Pattern> = { /** Alternate names of the command. */ aliases?: string[]; /** The description of the command. This is shown in the help message. */ description?: string; /** The category the command is in. This is shown in the help message. */ category?: 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: RichPost["reply"], args: ResolvePattern<TPattern>, post: RichPost, ) => void | Promise<void>; }; /** A command when it has been added to a bot. */ export type Command = { /** The name of the command. */ name: string; /** Alternate names of the command. */ aliases: string[]; /** The category of the command. */ category: 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[]; };