From f190b1c8a746be2b0913c89b9bfb993328ee9801 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Sun, 24 Sep 2023 11:22:26 -0500 Subject: [PATCH] Add a chat plugin for storing teams remotely (#9513) --- CODEOWNERS | 1 + databases/schemas/teams.sql | 14 + lib/postgres.ts | 3 + server/chat-plugins/teams.ts | 724 +++++++++++++++++++++++++++++++++++ 4 files changed, 742 insertions(+) create mode 100644 databases/schemas/teams.sql create mode 100644 server/chat-plugins/teams.ts diff --git a/CODEOWNERS b/CODEOWNERS index 881233eba9..3ce685ae73 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -21,6 +21,7 @@ server/chat-plugins/responder.ts @mia-pi-git server/chat-plugins/rock-paper-scissors.ts @mia-pi-git server/chat-plugins/sample-teams.ts @KrisXV server/chat-plugins/scavenger*.ts @xfix @sparkychildcharlie @PartMan7 +sever/chat-plugins/teams.ts @mia-pi-git server/chat-plugins/the-studio.ts @KrisXV server/chat-plugins/trivia/ @AnnikaCodes server/chat-plugins/username-prefixes.ts @AnnikaCodes diff --git a/databases/schemas/teams.sql b/databases/schemas/teams.sql new file mode 100644 index 0000000000..7876789393 --- /dev/null +++ b/databases/schemas/teams.sql @@ -0,0 +1,14 @@ +CREATE TABLE teams ( + teamid TEXT NOT NULL PRIMARY KEY, + ownerid TEXT NOT NULL, + team TEXT NOT NULL, + date TIMESTAMP NOT NULL, + format TEXT NOT NULL, + views INTEGER NOT NULL, + title TEXT, + private BOOLEAN +); + +CREATE INDEX owner_idx ON teams(ownerid); +CREATE INDEX format_idx ON teams(ownerid); +CREATE INDEX owner_fmt_idx ON teams(ownerid, format); diff --git a/lib/postgres.ts b/lib/postgres.ts index 43868fa9c5..9a9ca45cbc 100644 --- a/lib/postgres.ts +++ b/lib/postgres.ts @@ -26,6 +26,9 @@ export class PostgresDatabase { this.pool = null!; } } + destroy() { + return this.pool.end(); + } async query(statement: string | SQLStatement, values?: any[]) { if (!this.pool) { throw new Error(`Attempting to use postgres without 'pg' installed`); diff --git a/server/chat-plugins/teams.ts b/server/chat-plugins/teams.ts new file mode 100644 index 0000000000..837923a6c5 --- /dev/null +++ b/server/chat-plugins/teams.ts @@ -0,0 +1,724 @@ +/** + * Plugin for sharing / storing teams in a database. + * By Mia. + * @author mia-pi-git + */ + +import {PostgresDatabase, FS, Utils} from '../../lib'; +import * as crypto from 'crypto'; + +/** Maximum amount of teams a user can have stored at once. */ +const MAX_TEAMS = 200; +/** Max teams that can be viewed in a search */ +const MAX_SEARCH = 3000; + +export interface StoredTeam { + teamid: string; + team: string; + ownerid: ID; + format: ID; + title: string | null; + date: Date; + private: boolean; + views: number; +} + +interface TeamSearch { + format?: string; + owner?: string; + pokemon?: string[]; + moves?: string[]; + abilities?: string[]; + gen?: number; +} + +function refresh(context: Chat.PageContext) { + return ( + `` + ); +} + +export const TeamsHandler = new class { + database = new PostgresDatabase(); + readyPromise: Promise | null = Config.usepostgres ? (async () => { + try { + await this.database.query('SELECT * FROM teams LIMIT 1'); + } catch { + await this.database.query(FS(`databases/schemas/teams.sql`).readSync()); + } + })() : null; + destroy() { + void this.database.destroy(); + } + + async search(search: TeamSearch, user: User, count = 10, includePrivate = false) { + const args = []; + const where = []; + if (count > 500) { + throw new Chat.ErrorMessage("Cannot search more than 500 teams."); + } + if (search.format) { + where.push(`format = $${args.length + 1}`); + args.push(toID(search.format)); + } + if (search.owner) { + where.push(`ownerid = $${args.length + 1}`); + args.push(toID(search.owner)); + } + if (search.gen) { + where.push(`format LIKE 'gen${search.gen}%'`); + } + if (!includePrivate) where.push('private != true'); + + const result = await this.query( + `SELECT * FROM teams${where.length ? ` WHERE ${where.join(' AND ')}` : ''} LIMIT ${count}`, + args, + ); + return result.filter(row => { + const team = Teams.unpack(row.team)!; + if (row.private && row.ownerid !== user.id) { + return false; + } + let match = true; + if (search.pokemon?.length) { + match = search.pokemon.some( + pokemon => team.some(set => toID(set.species) === toID(pokemon)) + ); + } + if (!match) return false; + if (search.moves?.length) { + match = search.moves.some( + move => team.some(set => set.moves.some(m => toID(m) === toID(move))) + ); + } + if (!match) return false; + if (search.abilities?.length) { + match = search.abilities.some( + ability => team.some(set => toID(set.ability) === toID(ability)) + ); + } + return match; + }); + } + + async query(statement: string, values: any[] = []) { + if (this.readyPromise) await this.readyPromise; + return this.database.query(statement, values) as Promise; + } + + async save( + connection: Connection, + formatName: string, + rawTeam: string, + teamName: string | null = null, + isPrivate = false, + isUpdate?: string + ) { + this.validateAccess(connection, true); + + if (Monitor.countPrepBattle(connection.ip, connection)) { + return null; + } + const user = connection.user; + const format = Dex.formats.get(toID(formatName)); + if (!format.exists || format.team) { + connection.popup("Invalid format:\n\n" + formatName); + return null; + } + let existing = null; + if (isUpdate) { + existing = await this.get(isUpdate); + if (!existing) { + connection.popup("You're trying to edit a team that doesn't exist."); + return null; + } + } + + const team = Teams.import(rawTeam); + if (!team) { + connection.popup('Invalid team:\n\n' + rawTeam); + return null; + } + if (team.length > 24) { + connection.popup("Your team has too many Pokemon."); + } + + // now, we purge invalid nicknames and make sure it's an actual team + // gotta use the validated team so that nicknames are removed + for (const set of team) { + const namedSpecies = Dex.species.get(set.name); + // allow nicknames named after other mons - to support those OMs + if (!namedSpecies.exists) { + set.name = set.species; + } + if (!Dex.species.get(set.species).exists) { + connection.popup(`Invalid Pokemon ${set.species} in team.`); + return null; + } + if (set.moves.length > 24) { + connection.popup("Only 24 moves are allowed per set."); + return null; + } + for (const m of set.moves) { + if (!Dex.moves.get(m).exists) { + connection.popup(`Invalid move ${m} on ${set.species}.`); + return null; + } + } + if (set.ability && !Dex.abilities.get(set.ability).exists) { + connection.popup(`Invalid ability ${set.ability} on ${set.species}.`); + return null; + } + if (set.item && !Dex.items.get(set.item).exists) { + connection.popup(`Invalid item ${set.item} on ${set.species}.`); + return null; + } + if (set.nature && !Dex.natures.get(set.nature).exists) { + connection.popup(`Invalid nature ${set.nature} on ${set.species}.`); + return null; + } + if (set.teraType && !Dex.types.get(set.teraType).exists) { + connection.popup(`Invalid Tera Type ${set.nature} on ${set.species}.`); + return null; + } + } + if (teamName) { + if (teamName.length > 100) { + connection.popup("Your team's name is too long."); + return null; + } + const filtered = Chat.namefilter(teamName, user); + if (filtered?.trim() !== teamName.trim()) { + connection.popup(`Your team's name has a filtered word.`); + return null; + } + } + const count = await this.count(user); + if (count >= MAX_TEAMS) { + connection.popup(`You have too many teams stored. If you wish to upload this team, delete some first.`); + return null; + } + rawTeam = Teams.pack(team); + // the && existing doesn't really matter because we've verified it above, this is just for TS + if (isUpdate && existing) { + const differenceExists = ( + existing.team !== rawTeam || + (teamName && teamName !== existing.title) || + format.id !== existing.format || + existing.private !== isPrivate + ); + if (!differenceExists) { + connection.popup("Your team was not saved as no changes were made."); + return null; + } + await this.query( + 'UPDATE teams SET team = $1, title = $2, private = $3, format = $4 WHERE teamid = $5', + [rawTeam, teamName, isPrivate, format.id, isUpdate] + ); + return isUpdate; + } else { + const exists = await this.query('SELECT * FROM teams WHERE ownerid = $1 AND team = $2', [user.id, rawTeam]); + if (exists.length) { + connection.popup("You've already uploaded that team."); + return null; + } + const teamId = crypto.randomBytes(10).toString('hex'); + const loaded = await this.query( + `INSERT INTO teams (teamid, ownerid, team, date, format, views, title, private) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING teamid`, + [teamId, user.id, rawTeam, new Date(), format.id, 0, teamName, isPrivate] + ); + return loaded?.[0].teamid; + } + } + updateViews(teamid: string) { + return this.query(`UPDATE teams SET views = views + 1 WHERE teamid = $1`, [teamid]); + } + list(userid: ID, count: number, publicOnly = false) { + let query = `SELECT * FROM teams WHERE ownerid = $1 `; + if (publicOnly) { + query += `AND private = false `; + } + query += `ORDER BY date DESC LIMIT $2`; + return this.query( + query, [userid, count] + ); + } + preview(teamData: StoredTeam, user?: User | null, isFull = false) { + let buf = Utils.html`${teamData.title || `Untitled ${teamData.teamid}`}`; + if (teamData.private) buf += ` (Private)`; + buf += `
`; + buf += `Uploaded by: ${teamData.ownerid}
`; + buf += `Uploaded on: ${Chat.toTimestamp(teamData.date, {human: true})}
`; + buf += `Format: ${Dex.formats.get(teamData.format).name}
`; + buf += `Views: ${teamData.views}`; + const team = Teams.unpack(teamData.team)!; + buf += `
`; + buf += team.map(set => ``).join(' '); + buf += `
${!isFull ? 'View full team' : 'Shareable link to team'}`; + buf += ` (or copy/paste <<view-team-${teamData.teamid}>> in chat to share!)`; + + if (user && (teamData.ownerid === user.id || user.can('rangeban'))) { + buf += `
`; + buf += `
Manage`; + buf += `
`; + buf += `
`; + buf += ``; + buf += `
`; + } + return buf; + } + renderTeam(teamData: StoredTeam, user?: User) { + let buf = this.preview(teamData, user, true); + buf += `
`; + const team = Teams.unpack(teamData.team)!; + buf += team.map(set => { + let teamBuf = Teams.exportSet(set).replace(/\n/g, '
'); + if (set.name && set.name !== set.species) { + teamBuf = teamBuf.replace(set.name, Utils.html`
${set.name}`); + } else { + teamBuf = teamBuf.replace(set.species, `
${set.species}`); + } + if (set.item) { + teamBuf = teamBuf.replace(set.item, `${set.item} `); + } + for (const move of set.moves) { + const type = Dex.moves.get(move).type; + teamBuf = teamBuf.replace(`- ${move}`, `- ${move}`); + } + return teamBuf; + }).join('
'); + return buf; + } + validateAccess(conn: Connection, popup = false) { + const user = conn.user; + const err = (message: string): never => { + if (popup) { + conn.popup(message); + throw new Chat.Interruption(); + } + throw new Chat.ErrorMessage(message); + }; + + if (!Config.usepostgres || !Config.usepostgresteams) { + err(`The teams database is currently disabled.`); + } + if (!Users.globalAuth.atLeast(user, Config.usepostgresteams)) { + err("You cannot currently use the teams database."); + } + if (user.locked || user.semilocked) err("You cannot use the teams database while locked."); + if (!user.autoconfirmed) err("You must be autoconfirmed to use the teams database."); + } + async count(user: string | User) { + const id = toID(user); + const result = await this.query<{count: number}>(`SELECT count(*) AS count FROM teams WHERE ownerid = $1`, [id]); + return result?.[0]?.count || 0; + } + async get(teamid: string): Promise { + const rows = await this.query( + `SELECT * FROM teams WHERE teamid = $1`, [teamid], + ); + if (!rows.length) return null; + return rows[0] as StoredTeam; + } + async delete(id: string) { + await this.query( + `DELETE FROM teams WHERE teamid = $1`, [id], + ); + } +}; + +export const destroy = () => TeamsHandler.destroy(); + +export const commands: Chat.ChatCommands = { + teams: { + upload() { + return this.parse('/j view-teams-upload'); + }, + update: 'save', + async save(target, room, user, connection, cmd) { + TeamsHandler.validateAccess(connection, true); + const targets = Utils.splitFirst(target, ',', 5); + const isEdit = cmd === 'update'; + const teamID = isEdit ? targets.shift() : undefined; + let [teamName, formatid, rawPrivacy, rawTeam] = targets; + if (isEdit && !teamID?.length) { + connection.popup("Invalid team ID provided."); + return null; + } + if (rawTeam.includes('\n')) { + rawTeam = Teams.pack(Teams.import(rawTeam)); + } + if (!rawTeam) { + connection.popup("Invalid team."); + return null; + } + formatid = toID(formatid); + teamName = toID(teamName) ? teamName : null!; + const id = await TeamsHandler.save( + connection, formatid, rawTeam, teamName, !!Number(rawPrivacy), isEdit ? teamID : undefined + ); + + const page = isEdit ? 'edit' : 'upload'; + if (id) { + connection.send(`>view-teams-${page}\n|deinit`); + this.parse(`/join view-teams-view-${id}-${id}`); + } else { + this.parse(`/join view-teams-${page}`); + return; + } + }, + ''(target) { + return this.parse('/teams user ' + toID(target) || this.user.id); + }, + latest() { + return this.parse(`/j view-teams-filtered-latest`); + }, + views: 'mostviews', + mostviews() { + return this.parse(`/j view-teams-filtered-views`); + }, + user: 'view', + for: 'view', + view(target) { + const [name, rawNum] = target.split(',').map(toID); + const num = parseInt(rawNum); + if (rawNum && isNaN(num)) { + return this.popupReply(`Invalid count.`); + } + let page = 'view'; + switch (this.cmd) { + case 'for': case 'user': + page = 'all'; + break; + } + return this.parse(`/j view-teams-${page}-${toID(name)}${num ? `-${num}` : ''}`); + }, + async delete(target, room, user, connection) { + TeamsHandler.validateAccess(connection, true); + const teamid = toID(target); + if (!teamid.length) return this.popupReply(`Invalid team ID.`); + const teamData = await TeamsHandler.get(teamid); + if (!teamData) return this.popupReply(`Team not found.`); + if (teamData.ownerid !== user.id && !user.can('rangeban')) { + return this.errorReply("You cannot delete teams you do not own."); + } + await TeamsHandler.delete(teamid); + this.popupReply(`Team ${teamid} deleted.`); + for (const page of connection.openPages || new Set()) { + if (page.startsWith('teams-')) this.refreshPage(page); + } + }, + async setprivacy(target, room, user, connection) { + TeamsHandler.validateAccess(connection, true); + const [teamId, rawPrivacy] = target.split(',').map(toID); + let privacy: boolean; + if (!teamId.length) { + return this.popupReply('Invalid team ID.'); + } + // these if checks may seem bit redundant but we want to ensure the user is certain about this + // if it might be invalid, we want them to know that + if (this.meansYes(rawPrivacy)) { + privacy = true; + } else if (this.meansNo(rawPrivacy)) { + privacy = false; + } else { + return this.popupReply(`Invalid privacy setting.`); + } + const team = await TeamsHandler.get(teamId); + if (!team) return this.popupReply(`Team not found.`); + if (team.ownerid !== user.id && !user.can('rangeban')) { + return this.popupReply(`You cannot change privacy for a team you don't own.`); + } + await TeamsHandler.query(`UPDATE teams SET private = $1 WHERE teamid = $2`, [privacy, teamId]); + for (const pageid of this.connection.openPages || new Set()) { + if (pageid.startsWith('teams-')) { + this.refreshPage(pageid); + } + } + return this.popupReply(privacy ? `Team set to private.` : `Team set to public.`); + }, + search(target, room, user) { + return this.parse(`/j view-teams-searchpersonal`); + }, + help() { + return this.parse('/help teams'); + }, + }, + teamshelp: [ + `/teams OR /teams for [user]- View the (public) teams of the given [user].`, + `/teams upload - Open the page to upload a team.`, + `/teams setprivacy [team id], [privacy] - Set the privacy of the team matching the [teamid].`, + `/teams delete [team id] - Delete the team matching the [teamid].`, + `/teams search - Opens the page to search your teams`, + `/teams mostviews - Views your teams sorted by most views.`, + `/teams view [team ID] - View the team matching the given [team ID]`, + ], +}; + +export const pages: Chat.PageTable = { + // support view-team-${teamid} + team(query, user, connection) { + return Chat.resolvePage(`view-teams-view-${query.join('-')}`, user, connection); + }, + teams: { + async all(query, user, connection) { + TeamsHandler.validateAccess(connection); + const targetUserid = toID(query.shift()) || user.id; + let count = Number(query.shift()) || 10; + if (count > MAX_TEAMS) count = MAX_TEAMS; + this.title = `[Teams] ${targetUserid}`; + const teams = await TeamsHandler.list(targetUserid, count, user.id !== targetUserid); + let buf = `

${targetUserid}'s last ${Chat.count(count, "teams")}

`; + buf += refresh(this); + buf += `
Search teams
`; + if (targetUserid === user.id) { + buf += `Upload new`; + } + buf += `
`; + for (const team of teams) { + buf += TeamsHandler.preview(team, user); + buf += `
`; + } + const total = await TeamsHandler.count(user.id); + if (total > count) { + buf += ``; + } + return buf; + }, + async filtered(query, user, connection) { + const type = query.shift() || ""; + TeamsHandler.validateAccess(connection); + let count = Number(query.shift()) || 50; + if (count > MAX_TEAMS) count = MAX_TEAMS; + let teams: StoredTeam[] = [], title = ''; + const buttons: {[k: string]: string} = { + views: ``, + latest: ``, + }; + switch (type) { + case 'views': + this.title = `[Most Viewed Teams]`; + teams = await TeamsHandler.query( + `SELECT * FROM teams WHERE private = false ORDER BY views DESC LIMIT $1`, [count] + ); + title = `Most viewed teams:`; + delete buttons.views; + break; + default: + this.title = `[Latest Teams]`; + teams = await TeamsHandler.query( + `SELECT * FROM teams WHERE private != true ORDER BY date DESC LIMIT $1`, [count] + ); + title = `Recently uploaded teams:`; + delete buttons.latest; + break; + } + let buf = `

${title}

${refresh(this)}`; + buf += Object.values(buttons).join('
'); + buf += `
`; + buf += teams.map(team => TeamsHandler.preview(team, user)).join('
'); + buf += `
`; + return buf; + }, + async view(query, user, connection) { + TeamsHandler.validateAccess(connection); + const teamid = toID(query.shift() || ""); + this.title = `[View Team]`; + if (!teamid.length) { + throw new Chat.ErrorMessage(`Invalid team ID.`); + } + const team = await TeamsHandler.get(teamid); + if (!team) { + this.title = `[Invalid Team]`; + return this.errorReply(`No team with the ID ${teamid} was found.`); + } + this.title = `[Team] ${team.teamid}`; + if (user.id !== team.ownerid) { + void TeamsHandler.updateViews(team.teamid); + } + return `
` + TeamsHandler.renderTeam(team, user) + "
"; + }, + upload(query, user, connection) { + TeamsHandler.validateAccess(connection); + this.title = `[Upload Team]`; + let buf = `

Upload a team

${refresh(this)}
`; + // let [teamName, formatid, rawPrivacy, rawTeam] = Utils.splitFirst(target, ',', 4); + buf += `
`; + + buf += `What's the name of the team?
`; + buf += `
`; + + buf += `What's the team's format?
`; + buf += `
`; + + buf += `Should the team be private? (yes/no)
`; + buf += `
`; + + buf += `Provide the team:
`; + buf += `
`; + + buf += ``; + buf += `
`; + return buf; + }, + async edit(query, user, connection) { + TeamsHandler.validateAccess(connection); + const teamID = toID(query.shift() || ""); + if (!teamID.length) { + return this.errorReply(`Invalid team ID.`); + } + this.title = `[Edit Team] ${teamID}`; + const data = await TeamsHandler.get(teamID); + if (!data) { + return this.errorReply(`Team ${teamID} not found.`); + } + let buf = `

Edit team ${teamID}

${refresh(this)}
`; + // let [teamName, formatid, rawPrivacy, rawTeam] = Utils.splitFirst(target, ',', 4); + buf += `
`; + + buf += `Team name
`; + buf += `
`; + + buf += `Team format
`; + buf += `
`; + + buf += `Team privacy
`; + const privacy = ['1', '0']; + if (!data.private) { + privacy.reverse(); // first option is the one shown by default so we gotta match it + } + buf += `
`; + + buf += `Team:
`; + const teamStr = Teams.export(Teams.import(data.team)!).replace(/\n/g, ' '); + buf += `
`; + + buf += ``; + buf += `
`; + return buf; + }, + async searchpublic(query, user, connection) { + TeamsHandler.validateAccess(connection, true); + this.title = '[Teams] Search'; + let buf = '
'; + buf += refresh(this); + buf += '

Search all teams

'; + const type = this.pageid.split('-')[2]; + const isPersonal = type === 'searchpersonal'; + query = query.join('-').split('--'); + if (!query.length || (isPersonal && query.length === 1)) { + buf += `
`; + buf += `
`; + buf += `Search metadata:
`; + buf += ``; + buf += `
`; + buf += `Team format:

`; + buf += `Search in team: (separate different searches with commas)
`; + buf += `Generation:
`; + buf += `Pokemon:
`; + buf += `Abilities:
`; + buf += `Moves:

`; + buf += ``; + return buf; + } + if (!query.map(toID).filter(Boolean).length) { + return this.errorReply(`Specify a search.`); + } + const [rawOwner, rawFormat, rawPokemon, rawMoves, rawAbilities, rawGen] = query; + const owner = toID(rawOwner); + if (owner.length > 18) { + return this.errorReply(`Invalid owner name. Names must be under 18 characters long.`); + } + const format = toID(rawFormat); + if (format && !Dex.formats.get(format).exists) { + return this.errorReply(`Format ${format} not found.`); + } + const gen = Number(rawGen); + if (rawGen && (isNaN(gen) || (gen < 1 || gen > Dex.gen))) { + return this.errorReply(`Invalid generation: '${rawGen}'`); + } + + const pokemon = rawPokemon?.split(',').map(toID).filter(Boolean); + const moves = rawMoves?.split(',').map(toID).filter(Boolean); + const abilities = rawAbilities?.split(',').map(toID).filter(Boolean); + + const search = { + pokemon, moves, format, owner, abilities, gen, + } as TeamSearch; + const results = await TeamsHandler.search(search, user, 50, isPersonal); + + // empty arrays will be falsy strings so this saves space + buf += `Search: ` + Object.entries(search) + .filter(([, v]) => !!(v.toString())) + .map(([k, v]) => `${k.charAt(0).toUpperCase() + k.slice(1)}: ${v}`) + .join(', '); + + buf += `
`; + if (!results.length) { + buf += `
No results found.
`; + return buf; + } + + buf += results.map(t => TeamsHandler.preview(t, user)).join('
'); + return buf; + }, + async searchpersonal(query, user, connection) { + this.pageid = 'view-teams-searchpersonal'; + + return ((pages.teams as Chat.PageTable).searchpublic as import('../chat').PageHandler).call( + this, `${user.id}${query.join('-')}`.split('-'), user, connection + ); + }, + async browse(query, user, connection) { + TeamsHandler.validateAccess(connection, true); + const sorter = toID(query.shift()) || 'latest'; + let count = Number(toID(query.shift())) || 50; + if (count > MAX_SEARCH) { + count = MAX_SEARCH; + } + let queryStr = 'SELECT * FROM teams WHERE private != false'; + let name = sorter; + switch (sorter) { + case 'views': + queryStr += ` ORDER BY views DESC `; + name = 'most viewed'; + break; + case 'latest': + queryStr += ` ORDER BY timestamp DESC`; + break; + default: + return this.errorReply(`Invalid sort term '${sorter}'. Must be either 'views' or 'latest'.`); + } + queryStr += ` LIMIT ${count}`; + let buf = `

Browse ${name} teams

`; + buf += refresh(this); + buf += `
Search`; + const opposite = sorter === 'views' ? 'latest' : 'views'; + buf += ``; + buf += `
`; + + const results = await TeamsHandler.query(queryStr, []); + if (!results.length) { + buf += `
None found.
`; + return buf; + } + for (const team of results) { + buf += TeamsHandler.preview(team, user); + buf += `
`; + } + if (count < MAX_SEARCH) { + buf += ``; + } + return buf; + }, + }, +}; + +process.nextTick(() => { + Chat.multiLinePattern.register('/teams save ', '/teams update '); +});