/** * 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; const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'.split(''); export interface StoredTeam { teamid: string; team: string; ownerid: ID; format: ID; title: string | null; date: Date; /** password */ private: string | null; 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 IS NULL'); const result = await this.query( `SELECT * FROM teams${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY date DESC 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( context: Chat.CommandContext, formatName: string, rawTeam: string, teamName: string | null = null, isPrivate?: string | null, isUpdate?: number ) { const connection = context.connection; 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.effectType !== 'Format' || 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; } if (context.user.id !== existing.ownerid) { connection.popup("This is not your team."); return null; } } const team = Teams.import(rawTeam, true); if (!team) { connection.popup('Invalid team:\n\n' + rawTeam); return null; } if (team.length > 24) { connection.popup("Your team has too many Pokemon."); } let unownWord = ''; // 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; } const speciesid = toID(set.species); if (speciesid.length <= 6 && speciesid.startsWith('unown')) { unownWord += speciesid.charAt(5) || 'a'; } 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; } } // i have no idea how people are getting this, but we got enough reports that // i guess it's worth handling if (toID(set.ability) === 'none') { set.ability = 'No Ability'; } 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 (unownWord) { const filtered = Chat.nicknamefilter(unownWord, user); if (!filtered || filtered !== unownWord) { connection.popup( `Your team was rejected for the following reason:\n\n` + `- Your Unowns spell out a banned word: ${unownWord.toUpperCase()}` ); return null; } } if (teamName) { if (teamName.length > 100) { connection.popup("Your team's name is too long."); return null; } const filtered = context.filter(teamName); if (!filtered || 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); if (!rawTeam.trim()) { // extra sanity check connection.popup("Invalid team provided."); return null; } // 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 loaded = await this.query( `INSERT INTO teams (ownerid, team, date, format, views, title, private) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING teamid`, [user.id, rawTeam, new Date(), format.id, 0, teamName, isPrivate] ); return loaded?.[0].teamid; } } generatePassword(len = 20) { let pw = ''; for (let i = 0; i < len; i++) pw += ALPHABET[crypto.randomInt(0, ALPHABET.length - 1)]; return pw; } 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 IS NULL `; } 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 === -1 ? 0 : teamData.views}`; const team = Teams.import(teamData.team); if (!team) { Monitor.crashlog(new Error(`Malformed team drawn from database`), 'A teams chat page', teamData); throw new Chat.ErrorMessage("Oops! Something went wrong. Try again later."); } let link = `view-team-${teamData.teamid}`; if (teamData.private) { link += `-${teamData.private}`; } buf += `
`; buf += team.map(set => ``).join(' '); buf += `
${isFull ? 'View full team' : 'Shareable link to team'}`; buf += ` (or copy/paste <<${link}>> in chat to share!)`; if (user && (teamData.ownerid === user.id || user.can('rangeban'))) { buf += `
`; buf += `
Manage (edit/delete/etc)`; 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); if (!team) { Monitor.crashlog(new Error("Invalid team retrieved from database"), "A teams database request", teamData); throw new Chat.ErrorMessage("An error occurred with retrieving the team. Please try again later."); } 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) { const tester = new RegExp(`${Utils.escapeRegex(set.item)}\\b`); teamBuf = teamBuf.replace(tester, `${set.item} `); } return teamBuf; }).join('
'); return buf; } validateAccess(conn: Connection, popup = false) { const user = conn.user; // if there's no user, they've disconnected, so it's safe to just interrupt here if (!user) throw new Chat.Interruption(); 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: number | string): Promise { teamid = Number(teamid); if (isNaN(teamid)) { throw new Chat.ErrorMessage(`Invalid team ID.`); } 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 | number) { id = Number(id); if (isNaN(id)) { throw new Chat.ErrorMessage("Invalid team ID"); } 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 rawTeamID = isEdit ? targets.shift() : undefined; let [teamName, formatid, rawPrivacy, rawTeam] = targets; const teamID = Number(rawTeamID); if (isEdit && (!rawTeamID?.length || isNaN(teamID))) { connection.popup("Invalid team ID provided."); return null; } if (rawTeam.includes('\n')) { rawTeam = Teams.pack(Teams.import(rawTeam, true)); } if (!rawTeam) { connection.popup("Invalid team."); return null; } formatid = toID(formatid); teamName = toID(teamName) ? teamName : null!; const privacy = toID(rawPrivacy) === '1' ? TeamsHandler.generatePassword() : null; const id = await TeamsHandler.save( this, formatid, rawTeam, teamName, privacy, isEdit ? teamID : undefined ); const page = isEdit ? 'edit' : 'upload'; if (id) { connection.send(`|queryresponse|teamupload|` + JSON.stringify({ teamid: id, teamName })); connection.send(`>view-teams-${page}\n|deinit`); this.parse(`/join view-teams-view-${id}-${id}`); } else { this.parse(`/join view-teams-${page}`); } }, ''(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 = Number(toID(target)); if (isNaN(teamid)) 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: string | null; 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 = TeamsHandler.generatePassword(); } else if (this.meansNo(rawPrivacy)) { privacy = null; } 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. Password: ${privacy}` : `Team set to public.`); }, search(target, room, user) { return this.parse(`/j view-teams-searchpersonal`); }, browse(target, room, user) { return this.parse(`/j view-teams-browse${target ? `-${target}` : ''}`); }, 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 public teams, sorted by most views.`, `/teams view [team ID] - View the team matching the given [team ID]`, `/teams browse - Opens a list of public teams uploaded by other users.`, ], }; export const pages: Chat.PageTable = { // support view-team-${teamid} team(query, user, connection) { return ((pages.teams as Chat.PageTable).view as Chat.PageHandler).call(this, query, user, connection); }, teams: { async all(query, user, connection) { if (!user.named) return Rooms.RETRY_AFTER_LOGIN; 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 your teams `; buf += `Browse public 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) { if (!user.named) return Rooms.RETRY_AFTER_LOGIN; 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 IS NULL 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 IS NULL 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) { if (!user.named) return Rooms.RETRY_AFTER_LOGIN; TeamsHandler.validateAccess(connection); const rawTeamid = toID(query.shift() || ""); const password = toID(query.shift()); this.title = `[View Team]`; const teamid = Number(rawTeamid); if (isNaN(teamid)) { 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.`); } if (team?.private && user.id !== team.ownerid && password !== team.private) { this.title = `[Private Team]`; return this.errorReply(`That team is private.`); } this.title = `[Team] ${team.teamid}`; if (user.id !== team.ownerid && team.views >= 0) { void TeamsHandler.updateViews(team.teamid); } return `
` + TeamsHandler.renderTeam(team, user) + "
"; }, upload(query, user, connection) { if (!user.named) return Rooms.RETRY_AFTER_LOGIN; 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 += `[Gen ${Dex.gen} OU]
`; buf += `Should the team be private? (yes/no)
`; buf += `
`; buf += `Provide the team:
`; buf += `
`; buf += ``; buf += `
`; return buf; }, async edit(query, user, connection) { if (!user.named) return Rooms.RETRY_AFTER_LOGIN; 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 += `${Dex.formats.get(data.format).name}
`; 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) { if (!user.named) return Rooms.RETRY_AFTER_LOGIN; 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.map(toID).filter(Boolean).length || (isPersonal && query.length === 1)) { buf += `
`; buf += `
`; buf += `Search metadata:
`; buf += ``; buf += `
`; buf += `Team format: [Gen ${Dex.gen}] OU

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

`; buf += ``; return buf; } 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: gen || undefined, } 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) { if (!user.named) return Rooms.RETRY_AFTER_LOGIN; 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) { if (!user.named) return Rooms.RETRY_AFTER_LOGIN; 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 IS NULL'; let name = sorter; switch (sorter) { case 'views': queryStr += ` ORDER BY views DESC `; name = 'most viewed'; break; case 'latest': queryStr += ` ORDER BY date 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 '); });