/** * The Studio room chat-plugin. * Supports scrobbling and searching for music from last.fm. * Also supports storing and suggesting recommendations. * Written by dhelmise, loosely based on the concept from bumbadadabum. * @author dhelmise */ import { FS, Net, Utils } from '../../lib'; import { YouTube, type VideoData } from './youtube'; const LASTFM_DB = 'config/chat-plugins/lastfm.json'; const RECOMMENDATIONS = 'config/chat-plugins/the-studio.json'; const API_ROOT = 'http://ws.audioscrobbler.com/2.0/'; const DEFAULT_IMAGES = [ 'https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png', 'https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png', 'https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png', 'https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png', ]; interface Recommendation { artist: string; title: string; url: string; videoInfo: VideoData | null; description: string; tags: string[]; userData: { name: string, avatar?: string, }; likes: number; liked?: { ips: string[], userids: string[], }; } interface Recommendations { suggested: Recommendation[]; saved: Recommendation[]; // Storing here so I don't need to rewrite the lastfm json youtubeSearchDisabled?: boolean; } const lastfm: { [userid: string]: string } = JSON.parse(FS(LASTFM_DB).readIfExistsSync() || "{}"); const recommendations: Recommendations = JSON.parse(FS(RECOMMENDATIONS).readIfExistsSync() || "{}"); if (!recommendations.saved) recommendations.saved = []; if (!recommendations.suggested) recommendations.suggested = []; saveRecommendations(); function updateRecTags() { for (const rec of recommendations.saved) { if (!rec.tags.map(toID).includes(toID(rec.artist))) rec.tags.push(rec.artist); if (!rec.tags.map(toID).includes(toID(rec.userData.name))) rec.tags.push(rec.userData.name); } for (const rec of recommendations.suggested) { if (!rec.tags.map(toID).includes(toID(rec.artist))) rec.tags.push(rec.artist); if (!rec.tags.map(toID).includes(toID(rec.userData.name))) rec.tags.push(rec.userData.name); } saveRecommendations(); } updateRecTags(); function saveLastFM() { FS(LASTFM_DB).writeUpdate(() => JSON.stringify(lastfm)); } function saveRecommendations() { FS(RECOMMENDATIONS).writeUpdate(() => JSON.stringify(recommendations)); } export class LastFMInterface { async getScrobbleData(username: string, displayName?: string) { this.checkHasKey(); const accountName = this.getAccountName(username); let raw; try { raw = await Net(API_ROOT).get({ query: { method: 'user.getRecentTracks', user: accountName, limit: 1, api_key: Config.lastfmkey, format: 'json', }, }); } catch { throw new Chat.ErrorMessage(`No scrobble data found.`); } const res = JSON.parse(raw); if (res.error) { throw new Chat.ErrorMessage(`${res.message}.`); } if (!res?.recenttracks?.track?.length) throw new Chat.ErrorMessage(`last.fm account not found.`); const track = res.recenttracks.track[0]; let buf = ``; if (track.image?.length) { const imageIndex = track.image.length >= 3 ? 2 : track.image.length - 1; if (track.image[imageIndex]['#text']) { buf += ``; } buf += `
${Utils.escapeHTML(displayName || accountName)}`; if (track['@attr']?.nowplaying) { buf += ` is currently listening to:`; } else { buf += ` was last seen listening to:`; } buf += `
`; const trackName = `${track.artist?.['#text'] ? `${track.artist['#text']} - ` : ''}${track.name}`; let videoIDs: string[] | undefined; try { videoIDs = await YouTube.searchVideo(trackName, 1); } catch (e: any) { if (!recommendations.youtubeSearchDisabled) { throw new Chat.ErrorMessage(`Error while fetching video data: ${e.message}`); } } if (!videoIDs?.length && !recommendations.youtubeSearchDisabled) { throw new Chat.ErrorMessage(`Something went wrong with the YouTube API.`); } if (recommendations.youtubeSearchDisabled) { buf += Utils.escapeHTML(trackName); } else { buf += `${Utils.escapeHTML(trackName)}`; } buf += `
${this.getScrobbleBadge()}`; } return buf; } addAccountName(userid: ID, accountName: string) { this.checkHasKey(); accountName = accountName.trim(); if (lastfm[userid]) { const oldName = lastfm[userid]; lastfm[userid] = accountName; saveLastFM(); return `last.fm account name changed from '${oldName}' to '${accountName}'.`; } lastfm[userid] = accountName; saveLastFM(); return `Registered last.fm account '${accountName}'.`; } validateAccountName(accountName: string) { accountName = accountName.trim(); const sanitizedName = accountName.replace(/[^-_a-zA-Z0-9]+/g, ''); if (!(!accountName.includes(' ') && accountName === sanitizedName && /^[a-zA-Z]/.test(sanitizedName) && sanitizedName.length > 1 && sanitizedName.length < 16)) { throw new Chat.ErrorMessage(`The provided account name (${sanitizedName}) is invalid. Valid last.fm usernames are between 2-15 characters, start with a letter, and only contain letters, numbers, hyphens, and underscores.`); } return true; } getAccountName(username: string) { if (lastfm[toID(username)]) return lastfm[toID(username)]; return username.trim().replace(/ /g, '_').replace(/[^-_a-zA-Z0-9]/g, ''); } async tryGetTrackData(track: string, artist?: string) { this.checkHasKey(); const query: { [k: string]: any } = { method: 'track.search', limit: 1, api_key: Config.lastfmkey, track, format: 'json', }; if (artist) query.artist = artist; let raw; try { raw = await Net(API_ROOT).get({ query }); } catch { throw new Chat.ErrorMessage(`No track data found.`); } const req = JSON.parse(raw); let buf = ``; if (req.results?.trackmatches?.track?.length) { buf += `
`; const obj = req.results.trackmatches.track[0]; const trackName = obj.name || "Untitled"; const artistName = obj.artist || "Unknown Artist"; const searchName = `${artistName} - ${trackName}`; if (obj.image?.length) { const img = obj.image; const imageIndex = img.length >= 3 ? 2 : img.length - 1; if (img[imageIndex]['#text'] && !DEFAULT_IMAGES.includes(img[imageIndex]['#text'])) { buf += ``; } } buf += ``; const artistUrl = obj.url.split('_/')[0]; buf += `${artistName} - ${trackName}
`; let videoIDs: string[] | undefined; try { videoIDs = await YouTube.searchVideo(searchName, 1); } catch (e: any) { if (!recommendations.youtubeSearchDisabled) { throw new Chat.ErrorMessage(`Error while fetching video data: ${e.message}`); } } if (!videoIDs?.length || recommendations.youtubeSearchDisabled) { buf += searchName; } else { buf += `YouTube link`; } buf += `
${this.getScrobbleBadge()}`; } if (req.error) { throw new Chat.ErrorMessage(`${req.message}.`); } if (!buf) { throw new Chat.ErrorMessage(`No results for '${artist ? `${artist} - ` : ``}${track}' found. Check spelling?`); } return buf; } checkHasKey() { if (!Config.lastfmkey) { throw new Chat.ErrorMessage(`This server does not support last.fm commands. If you're the owner, you can enable them by setting up Config.lastfmkey.`); } } getScrobbleBadge() { return `
[powered by AudioScrobbler]
`; } } class RecommendationsInterface { getRandomRecommendation() { const recs = recommendations.saved; return recs[Math.floor(Math.random() * recs.length)]; } async add( artist: string, title: string, url: string, description: string, username: string, tags: string[], avatar?: string ) { artist = artist.trim(); title = title.trim(); if (this.get(artist, title)) { throw new Chat.ErrorMessage(`The song titled '${title}' by ${artist} is already recommended.`); } if (!/^https?:\/\//.test(url)) url = `https://${url}`; if (!YouTube.linkRegex.test(url)) { throw new Chat.ErrorMessage(`Please provide a valid YouTube link.`); } url = url.split('~')[0]; const videoInfo = await YouTube.getVideoData(url); this.checkTags(tags); // JUST in case if (!recommendations.saved) recommendations.saved = []; const rec: Recommendation = { artist, title, videoInfo, url, description, tags, userData: { name: username }, likes: 0, }; if (!rec.tags.map(toID).includes(toID(username))) rec.tags.push(username); if (!rec.tags.map(toID).includes(toID(artist))) rec.tags.push(artist); if (avatar) rec.userData.avatar = avatar; recommendations.saved.push(rec); saveRecommendations(); } delete(artist: string, title: string) { artist = artist.trim(); title = title.trim(); if (!recommendations.saved?.length) { throw new Chat.ErrorMessage(`The song titled '${title}' by ${artist} isn't recommended.`); } const recIndex = this.getIndex(artist, title); if (recIndex < 0) { throw new Chat.ErrorMessage(`The song titled '${title}' by ${artist} isn't recommended.`); } recommendations.saved.splice(recIndex, 1); saveRecommendations(); } async suggest( artist: string, title: string, url: string, description: string, username: string, tags: string[], avatar?: string ) { artist = artist.trim(); title = title.trim(); if (this.get(artist, title)) { throw new Chat.ErrorMessage(`The song titled '${title}' by ${artist} is already recommended.`); } if (this.get(artist, title, null, true)) { throw new Chat.ErrorMessage(`The song titled '${title}' by ${artist} is already suggested.`); } if (!/^https?:\/\//.test(url)) url = `https://${url}`; if (!YouTube.linkRegex.test(url)) { throw new Chat.ErrorMessage(`Please provide a valid YouTube link.`); } url = url.split('~')[0]; const videoInfo = await YouTube.getVideoData(url); this.checkTags(tags); const rec: Recommendation = { artist, title, videoInfo, url, description, tags, userData: { name: username }, likes: 0, }; if (!rec.tags.map(toID).includes(toID(username))) rec.tags.push(username); if (!rec.tags.map(toID).includes(toID(artist))) rec.tags.push(artist); if (avatar) rec.userData.avatar = avatar; recommendations.suggested.push(rec); saveRecommendations(); } approveSuggestion(submitter: string, artist: string, title: string) { artist = artist.trim(); title = title.trim(); const rec = this.get(artist, title, submitter, true); if (!rec) { throw new Chat.ErrorMessage(`There is no song titled '${title}' by ${artist} suggested from ${submitter.trim()}.`); } if (!recommendations.saved) recommendations.saved = []; recommendations.saved.push(rec); recommendations.suggested.splice(recommendations.suggested.indexOf(rec), 1); saveRecommendations(); } denySuggestion(submitter: string, artist: string, title: string) { artist = artist.trim(); title = title.trim(); const index = this.getIndex(artist, title, submitter, true); if (index < 0) { throw new Chat.ErrorMessage(`There is no song titled '${title}' by ${artist} suggested from ${submitter.trim()}.`); } recommendations.suggested.splice(index, 1); saveRecommendations(); } async render(rec: Recommendation, suggested = false) { let buf = ``; buf += `
`; buf += ``; if (!rec.videoInfo) { // eslint-disable-next-line require-atomic-updates rec.videoInfo = await YouTube.getVideoData(YouTube.getId(rec.url)); saveRecommendations(); } if (rec.videoInfo) { buf += ``; } buf += Utils.html``; if (rec.userData.avatar) { buf += ``; } else { buf += ``; } buf += `

`; buf += `${!suggested ? `${Chat.count(rec.likes, "points")} | ` : ``}${rec.videoInfo.views} views
${rec.artist} - ${rec.title}`; const tags = rec.tags.map(x => Utils.escapeHTML(x)) .filter(x => toID(x) !== toID(rec.userData.name) && toID(x) !== toID(rec.artist)); if (tags.length) { buf += `
Tags: ${tags.join(', ')}`; } if (rec.description) { buf += `
Description: ${Utils.escapeHTML(rec.description)}`; } if (!rec.videoInfo && !suggested) { buf += `
Score: ${Chat.count(rec.likes, "points")}`; } if (!rec.userData.avatar) { buf += `
Recommended by: ${rec.userData.name}`; } buf += `
`; if (suggested) { buf += Utils.html` | `; buf += Utils.html``; } else { buf += Utils.html``; } buf += `
`; const isCustom = rec.userData.avatar.startsWith('#'); buf += ``; buf += `
Recommended by:`; buf += `
${rec.userData.name}
Recommended by: ${rec.userData.name}
`; buf += `
`; return buf; } likeRecommendation(artist: string, title: string, liker: User) { const rec = this.get(artist, title); if (!rec) { throw new Chat.ErrorMessage(`The song titled '${title}' by ${artist} isn't recommended.`); } if (!rec.liked) { rec.liked = { ips: [], userids: [] }; } if ((!Config.noipchecks && rec.liked.ips.includes(liker.latestIp)) || rec.liked.userids.includes(liker.id)) { throw new Chat.ErrorMessage(`You've already liked this recommendation.`); } rec.likes++; rec.liked.ips.push(liker.latestIp); rec.liked.userids.push(liker.id); saveRecommendations(); } get(artist: string, title: string, submitter: string | null = null, fromSuggestions = false) { let recs = recommendations.saved; if (fromSuggestions) recs = recommendations.suggested; return recs.find(x => ( toID(x.artist) === toID(artist) && toID(x.title) === toID(title) && (!submitter || toID(x.userData.name) === toID(submitter)) )); } getIndex(artist: string, title: string, submitter: string | null = null, fromSuggestions = false) { let recs = recommendations.saved; if (fromSuggestions) recs = recommendations.suggested; return recs.findIndex(x => ( toID(x.artist) === toID(artist) && toID(x.title) === toID(title) && (!submitter || toID(x.userData.name) === toID(submitter)) )); } checkTags(tags: string[]) { const cleansedTags = new Set(); for (const tag of tags) { if (!toID(tag)) throw new Chat.ErrorMessage(`Empty tag detected.`); if (cleansedTags.has(toID(tag))) { throw new Chat.ErrorMessage(`Duplicate tag: ${tag.trim()}`); } cleansedTags.add(toID(tag)); } } } export const LastFM = new LastFMInterface(); export const Recs = new RecommendationsInterface(); export const commands: Chat.ChatCommands = { lastfmyoutubesearch(target, room, user) { this.checkCan('gdeclare'); target = toID(target); if (!target || !['enable', 'disable'].includes(target)) { return this.parse('/help lastfmyoutubesearch'); } if (target === 'enable') { if (!recommendations.youtubeSearchDisabled) { throw new Chat.ErrorMessage(`The YouTube API is already enabled for Last.fm commands.`); } delete recommendations.youtubeSearchDisabled; saveRecommendations(); this.sendReply(`YouTube API enabled for Last.fm commands.`); this.globalModlog(`LASTFM YOUTUBE API`, null, 'enabled'); } else { if (recommendations.youtubeSearchDisabled) { throw new Chat.ErrorMessage(`The YouTube API is already disabled for Last.fm commands.`); } recommendations.youtubeSearchDisabled = true; saveRecommendations(); this.sendReply(`YouTube API disabled for Last.fm commands.`); this.globalModlog(`LASTFM YOUTUBE API`, null, 'disabled'); } }, lastfmyoutubesearchhelp: [ '/lastfmyoutubesearch [enable|disable] - Enables/disables the YouTube API for Last.fm commands. Requires: ~', ], registerlastfm(target, room, user) { if (!target) return this.parse(`/help registerlastfm`); this.checkChat(target); target = this.filter(target) || ''; if (!target) { throw new Chat.ErrorMessage(`The provided account name has phrases that PS doesn't allow.`); } LastFM.validateAccountName(target); this.sendReply(LastFM.addAccountName(user.id, target.trim())); }, registerlastfmhelp: [ `/registerlastfm [username] - Adds the provided [username] to the last.fm database for scrobbling.`, `Usernames can only be 2-15 characters long, must start with a letter, and can only contain letters, numbers, hyphens, and underscores.`, ], async lastfm(target, room, user) { this.checkChat(); if (!user.autoconfirmed) return this.errorReply(`You cannot use this command while not autoconfirmed.`); this.runBroadcast(true); const targetUsername = this.splitUser(target).targetUsername || (user.named ? user.name : ''); const username = LastFM.getAccountName(targetUsername); this.sendReplyBox(await LastFM.getScrobbleData(username, targetUsername)); }, lastfmhelp: [ `/lastfm [username] - Displays the last scrobbled song for the person using the command or for [username] if provided.`, `To link up your last.fm account, check out "/help registerlastfm".`, ], async track(target, room, user) { if (!target) return this.parse('/help track'); this.checkChat(); if (!user.autoconfirmed) return this.errorReply(`You cannot use this command while not autoconfirmed.`); const [track, artist] = this.splitOne(target); if (!track) return this.parse('/help track'); this.runBroadcast(true); this.sendReplyBox(await LastFM.tryGetTrackData(track, artist || undefined)); }, trackhelp: [ `/track [song name], [artist] - Displays the most relevant search result to the song name (and artist if specified) provided.`, ], addrec: 'addrecommendation', async addrecommendation(target, room, user) { room = this.requireRoom('thestudio' as RoomID); this.checkCan('show', null, room); const [artist, title, url, description, ...tags] = target.split('|').map(x => x.trim()); if (!(artist && title && url && description && tags?.length)) { return this.parse(`/help addrecommendation`); } const cleansedTags = tags.map(x => x.trim()); await Recs.add(artist, title, url, description, user.name, cleansedTags, String(user.avatar)); this.privateModAction(`${user.name} added a recommendation for '${title}' by ${artist}.`); this.modlog(`RECOMMENDATION`, null, `add: '${toID(title)}' by ${toID(artist)}`); }, addrecommendationhelp: [ `/addrecommendation artist | song title | url | description | tag1 | tag2 | ... - Adds a song recommendation. Requires: + % @ * # ~`, ], delrec: 'removerecommendation', removerecommendation(target, room, user) { room = this.requireRoom('thestudio' as RoomID); const [artist, title] = target.split(`|`).map(x => x.trim()); if (!(artist && title)) return this.parse(`/help removerecommendation`); const rec = Recs.get(artist, title); if (!rec) throw new Chat.ErrorMessage(`Recommendation not found.`); if (toID(rec.userData.name) !== user.id) { this.checkCan('mute', null, room); } Recs.delete(artist, title); this.privateModAction(`${user.name} removed a recommendation for '${title}' by ${artist}.`); this.modlog(`RECOMMENDATION`, null, `remove: '${toID(title)}' by ${toID(artist)}`); }, removerecommendationhelp: [ `/removerecommendation artist | song title - Removes a song recommendation. Requires: % @ * # ~`, `If you added a recommendation, you can remove it on your own without being one of the required ranks.`, ], suggestrec: 'suggestrecommendation', async suggestrecommendation(target, room, user) { room = this.requireRoom('thestudio' as RoomID); if (!target) { return this.parse('/help suggestrecommendation'); } this.checkChat(target); if (!user.autoconfirmed) return this.errorReply(`You cannot use this command while not autoconfirmed.`); const [artist, title, url, description, ...tags] = target.split('|').map(x => x.trim()); if (!(artist && title && url && description && tags?.length)) { return this.parse(`/help suggestrecommendation`); } const cleansedTags = tags.map(x => x.trim()); await Recs.suggest(artist, title, url, description, user.name, cleansedTags, String(user.avatar)); this.sendReply(`Your suggestion for '${title}' by ${artist} has been submitted.`); const html = await Recs.render({ artist, title, url, description, userData: { name: user.name, avatar: String(user.avatar) }, tags: cleansedTags, likes: 0, videoInfo: null, }, true); room.sendRankedUsers(`|html|${html}`, '%'); }, suggestrecommendationhelp: [ `/suggestrecommendation artist | song title | url | description | tag1 | tag2 | ... - Suggest a song recommendation.`, ], approvesuggestion(target, room, user) { room = this.requireRoom('thestudio' as RoomID); this.checkCan('mute', null, room); const [submitter, artist, title] = target.split('|').map(x => x.trim()); if (!(submitter && artist && title)) return this.parse(`/help approvesuggestion`); Recs.approveSuggestion(submitter, artist, title); this.privateModAction(`${user.name} approved a suggested recommendation from ${submitter} for '${title}' by ${artist}.`); this.modlog(`RECOMMENDATION`, null, `approve: '${toID(title)}' by ${toID(artist)} from ${submitter}`); }, approvesuggestionhelp: [ `/approvesuggestion submitter | artist | strong title - Approve a submitted song recommendation. Requires: % @ * # ~`, ], denysuggestion(target, room, user) { room = this.requireRoom('thestudio' as RoomID); this.checkCan('mute', null, room); const [submitter, artist, title] = target.split('|').map(x => x.trim()); if (!(submitter && artist && title)) return this.parse(`/help approvesuggestion`); Recs.denySuggestion(submitter, artist, title); this.privateModAction(`${user.name} denied a suggested recommendation from ${submitter} for '${title}' by ${artist}.`); this.modlog(`RECOMMENDATION`, null, `deny: '${toID(title)}' by ${toID(artist)} from ${submitter}`); }, denysuggestionhelp: [ `/denysuggestion submitter | artist | strong title - Deny a submitted song recommendation. Requires: % @ * # ~`, ], rec: 'recommendation', searchrec: 'recommendation', viewrec: 'recommendation', searchrecommendation: 'recommendation', viewrecommendation: 'recommendation', randrec: 'recommendation', randomrecommendation: 'recommendation', async recommendation(target, room, user) { if (!recommendations.saved.length) { throw new Chat.ErrorMessage(`There are no recommendations saved.`); } room = this.requireRoom('thestudio' as RoomID); this.runBroadcast(); if (!target) { return this.sendReply(`|html|${await Recs.render(Recs.getRandomRecommendation())}`); } const matches: Recommendation[] = []; target = target.slice(0, 300); const args = target.split(','); for (const rec of recommendations.saved) { if (!args.every(x => rec.tags.map(toID).includes(toID(x)))) continue; matches.push(rec); } if (!matches.length) { throw new Chat.ErrorMessage(`No matches found.`); } const sample = Utils.shuffle(matches)[0]; this.sendReply(`|html|${await Recs.render(sample)}`); }, recommendationhelp: [ `/recommendation [key1, key2, key3, ...] - Displays a random recommendation that matches all keys, if one exists.`, `If no arguments are provided, a random recommendation is shown.`, `/addrecommendation artist | song title | url | description | tag1 | tag2 | ... - Adds a song recommendation. Requires: + % @ * # ~`, `/removerecommendation artist | song title - Removes a song recommendation. Requires: % @ * # ~`, `If you added a recommendation, you can remove it on your own without being one of the required ranks.`, `/suggestrecommendation artist | song title | url | description | tag1 | tag2 | ... - Suggest a song recommendation.`, ], likerec: 'likerecommendation', likerecommendation(target, room, user, connection) { room = this.requireRoom('thestudio' as RoomID); const [artist, title] = target.split('|').map(x => x.trim()); if (!(artist && title)) return this.parse(`/help likerecommendation`); Recs.likeRecommendation(artist, title, user); this.sendReply(`You liked '${title}' by ${artist}.`); }, likerecommendationhelp: [ `/likerecommendation artist | title - Upvotes a recommendation for the provided artist and title.`, ], viewrecs: 'viewrecommendations', viewrecommendations(target, room, user) { room = this.requireRoom('thestudio' as RoomID); this.parse(`/j view-recommendations-${room.roomid}`); }, viewrecommendationshelp: [ `/viewrecommendations OR /viewrecs - View all recommended songs.`, ], viewsuggestions: 'viewsuggestedrecommendations', viewsuggestedrecs: 'viewsuggestedrecommendations', viewsuggestedrecommendations(target, room, user) { room = this.requireRoom('thestudio' as RoomID); this.parse(`/j view-suggestedrecommendations-${room.roomid}`); }, viewsuggestedrecommendationshelp: [ `/viewsuggestedrecommendations OR /viewsuggestions - View all suggested recommended songs. Requires: % @ * # ~`, ], }; export const pages: Chat.PageTable = { async recommendations(query, user, connection) { const room = this.requireRoom(); this.checkCan('mute', null, room); if (!user.inRooms.has(room.roomid)) throw new Chat.ErrorMessage(`You must be in ${room.title} to view this page.`); this.title = 'Recommendations'; let buf = `
`; buf += ``; const recs = recommendations.saved; if (!recs?.length) { return `${buf}

There are currently no recommendations.

`; } buf += `

Recommendations (${recs.length}):

`; for (const rec of recs) { buf += `
`; buf += await Recs.render(rec); if (user.can('mute', null, room) || toID(rec.userData.name) === user.id) { buf += `
`; } buf += `
`; } return buf; }, async suggestedrecommendations(query, user, connection) { const room = this.requireRoom(); this.checkCan('mute', null, room); if (!user.inRooms.has(room.roomid)) throw new Chat.ErrorMessage(`You must be in ${room.title} to view this page.`); this.title = 'Suggested Recommendations'; let buf = `
`; buf += ``; const recs = recommendations.suggested; if (!recs?.length) { return `${buf}

There are currently no suggested recommendations.

`; } buf += `

Suggested Recommendations (${recs.length}):

`; for (const rec of recs) { buf += `
`; buf += await Recs.render(rec, true); buf += `
`; } return buf; }, };