/** * Friends list plugin. * Allows for adding and removing friends, as well as seeing their activity. * Written by Mia. * @author mia-pi-git */ import { Utils } from '../../lib/utils'; import { MAX_REQUESTS, sendPM } from '../friends'; const STATUS_COLORS: { [k: string]: string } = { idle: '#ff7000', online: '#009900', busy: '#cc3838', }; const STATUS_TITLES: { [k: string]: string } = { online: 'Online', idle: 'Idle', busy: 'Busy', offline: 'Offline', }; // once every 15 minutes const LOGIN_NOTIFY_THROTTLE = 15 * 60 * 1000; export const Friends = new class { async notifyPending(user: User) { if (user.settings.blockFriendRequests) return; const friendRequests = await Chat.Friends.getRequests(user); const pendingCount = friendRequests.received.size; if (pendingCount < 1) return; if (pendingCount === 1) { const sender = [...friendRequests.received][0]; const senderName = Users.getExact(sender)?.name || sender; let buf = Utils.html`/uhtml sent, | `; buf += Utils.html`
`; buf += `(You can also stop this user from sending you friend requests with /ignore)`; sendPM(Utils.html`/raw ${senderName} sent you a friend request!`, user.id); sendPM(buf, user.id); sendPM( `/raw Note: If this request is accepted, your friend will be notified when you come online, ` + `and you will be notified when they do, unless you opt out of receiving them.`, user.id ); } else { sendPM(`/nonotify You have ${pendingCount} friend requests pending!`, user.id); sendPM(`/raw `, user.id); } } async notifyConnection(user: User) { const connected = await Chat.Friends.getLastLogin(user.id); if (connected && (Date.now() - connected) < LOGIN_NOTIFY_THROTTLE) { return; } const friends = await Chat.Friends.getFriends(user.id); const message = `/nonotify Your friend ${Utils.escapeHTML(user.name)} has just connected!`; for (const f of friends) { const curUser = Users.getExact(f.friend); if (curUser?.settings.allowFriendNotifications) { curUser.send(`|pm|~|${curUser.getIdentity()}|${message}`); } } } async visualizeList(userid: ID) { const friends = await Chat.Friends.getFriends(userid); const categorized: { [k: string]: string[] } = { online: [], idle: [], busy: [], offline: [], }; const loginTimes: { [k: string]: number } = {}; for (const { friend: friendID, last_login, allowing_login: hideLogin } of [...friends].sort()) { const friend = Users.getExact(friendID); if (friend?.connected) { categorized[friend.statusType].push(friend.id); } else { categorized.offline.push(friendID); // hidelogin - 1 to disable it being visible if (!hideLogin) { loginTimes[toID(friendID)] = last_login; } } } const sorted = Object.keys(categorized) .filter(item => categorized[item].length > 0) .map(item => `${STATUS_TITLES[item]} (${categorized[item].length})`); let buf = `

Your friends: `; if (sorted.length > 0) { buf += ` Total (${friends.length}) | ${sorted.join(' | ')}

`; } else { buf += `you have no friends added on Showdown lol


`; buf += `To add a friend, use /friend add [username].

`; return buf; } buf += `
Add friend:
`; buf += `
`; for (const key in categorized) { const friendArray = categorized[key].sort(); if (friendArray.length === 0) continue; buf += `

${STATUS_TITLES[key]} (${friendArray.length})

`; for (const friend of friendArray) { const friendID = toID(friend); buf += `
`; buf += this.displayFriend(friendID, loginTimes[friendID]); buf += `
`; } } return buf; } // much more info redacted async visualizePublicList(userid: ID) { const friends: string[] = (await Chat.Friends.getFriends(userid) as any[]).map(f => f.friend); let buf = `

${userid}'s friends:


`; if (!friends.length) { buf += `None.`; return buf; } for (const friend of friends) { buf += `- ${friend}
`; } return buf; } displayFriend(userid: ID, login?: number) { const user = Users.getExact(userid); // we want this to be exact const name = Utils.escapeHTML(user ? user.name : userid); const statusType = user?.connected ? `\u25C9 ${STATUS_TITLES[user.statusType]}` : '\u25CC Offline'; let buf = user ? ` ${name} (${statusType})` : Utils.html`${name} (${statusType})`; buf += `
`; const curUser = Users.get(userid); // might be an alt if (user) { if (user.userMessage) buf += Utils.html`Status: ${user.userMessage}
`; } else if (curUser && curUser.id !== userid) { buf += `On an alternate account
`; } if (login && typeof login === 'number' && !user?.connected) { buf += `Last seen: `; buf += ` (${Chat.toDurationString(Date.now() - login, { precision: 1 })} ago)`; } else if (typeof login === 'string') { buf += `${login}`; } buf = `
${buf}
`; return toLink(buf); } checkCanUse(context: Chat.CommandContext | Chat.PageContext) { const user = context.user; if (!user.autoconfirmed) { throw new Chat.ErrorMessage(context.tr`You must be autoconfirmed to use the friends feature.`); } if (user.locked || user.namelocked || user.semilocked || user.permalocked) { throw new Chat.ErrorMessage(`You are locked, and so cannot use the friends feature.`); } if (!Config.usesqlitefriends || !Config.usesqlite) { throw new Chat.ErrorMessage(`The friends list feature is currently disabled.`); } if (!Users.globalAuth.atLeast(user, Config.usesqlitefriends)) { throw new Chat.ErrorMessage(`You are currently unable to use the friends feature.`); } } request(user: User, receiver: ID) { return Chat.Friends.request(user, receiver); } removeFriend(userid: ID, friendID: ID) { return Chat.Friends.removeFriend(userid, friendID); } approveRequest(receiverID: ID, senderID: ID) { return Chat.Friends.approveRequest(receiverID, senderID); } removeRequest(receiverID: ID, senderID: ID) { return Chat.Friends.removeRequest(receiverID, senderID); } updateSpectatorLists(user: User) { if (!user.friends) return; // probably should never happen for (const id of user.friends) { // should only work if theyre on that userid, since friends list is by userid const curUser = Users.getExact(id); if (curUser) { for (const conn of curUser.connections) { if (conn.openPages?.has('friends-spectate')) { void Chat.parse('/friends view spectate', null, curUser, conn); } } } } } }; /** UI functions chiefly for the chat page. */ function toLink(buf: string) { return buf.replace(/', received: '', all: '', help: '', settings: '', spectate: '', }; const titles: { [k: string]: string } = { all: 'All Friends', spectate: 'Spectate', sent: 'Sent', received: 'Received', help: 'Help', settings: 'Settings', }; for (const page in titles) { const title = titles[page]; const icon = icons[page]; if (page === type) { buf.push(`${icon} ${user.tr(title)}`); } else { buf.push(`${icon} ${user.tr(title)}`); } } const refresh = ( `` ); return `
${buf.join(' / ')}${refresh}

`; } export const commands: Chat.ChatCommands = { unfriend(target) { return this.parse(`/friend remove ${target}`); }, friend: 'friends', friendslist: 'friends', friends: { ''(target) { if (toID(target)) { return this.parse(`/friend add ${target}`); } return this.parse(`/friends list`); }, viewlist(target, room, user) { Friends.checkCanUse(this); target = toID(target); if (!target) return this.errorReply(`Specify a user.`); if (target === user.id) return this.parse(`/friends list`); return this.parse(`/j view-friends-viewuser-${target}`); }, request: 'add', async add(target, room, user, connection) { Friends.checkCanUse(this); target = toID(target); if (target.length > 18) { return this.errorReply(this.tr`That name is too long - choose a valid name.`); } if (!target) return this.parse('/help friends'); await Friends.request(user, target as ID); this.refreshPage('friends-sent'); return this.sendReply(`You sent a friend request to '${target}'.`); }, unfriend: 'remove', async remove(target, room, user) { Friends.checkCanUse(this); target = toID(target); if (!target) return this.parse('/help friends'); await Friends.removeFriend(user.id, target as ID); this.sendReply(`Removed friend '${target}'.`); await Chat.Friends.updateUserCache(user); this.refreshPage('friends-all'); const targetUser = Users.get(target); if (targetUser) await Chat.Friends.updateUserCache(targetUser); }, view(target) { return this.parse(`/join view-friends-${target}`); }, list() { return this.parse(`/join view-friends-all`); }, async accept(target, room, user, connection) { Friends.checkCanUse(this); target = toID(target); if (user.settings.blockFriendRequests) { return this.errorReply(this.tr`You are currently blocking friend requests, and so cannot accept your own.`); } if (!target) return this.parse('/help friends'); await Friends.approveRequest(user.id, target as ID); const targetUser = Users.get(target); sendPM(`You accepted a friend request from "${target}".`, user.id); this.refreshPage('friends-received'); if (targetUser) { sendPM(`/text ${user.name} accepted your friend request!`, targetUser.id); sendPM(`/uhtmlchange sent-${targetUser.id},`, targetUser.id); sendPM(`/uhtmlchange undo-${targetUser.id},`, targetUser.id); } await Chat.Friends.updateUserCache(user); if (targetUser) await Chat.Friends.updateUserCache(targetUser); }, deny: 'reject', async reject(target, room, user, connection) { Friends.checkCanUse(this); target = toID(target); if (!target) return this.parse('/help friends'); const res = await Friends.removeRequest(user.id, target as ID); if (!res.changes) { return this.errorReply(`You do not have a friend request pending from '${target}'.`); } this.refreshPage('friends-received'); return sendPM(`You denied a friend request from '${target}'.`, user.id); }, toggle(target, room, user, connection) { Friends.checkCanUse(this); const setting = user.settings.blockFriendRequests; target = target.trim(); if (this.meansYes(target)) { if (!setting) return this.errorReply(this.tr`You already are allowing friend requests.`); user.settings.blockFriendRequests = false; this.sendReply(this.tr`You are now allowing friend requests.`); } else if (this.meansNo(target)) { if (setting) return this.errorReply(this.tr`You already are blocking incoming friend requests.`); user.settings.blockFriendRequests = true; this.sendReply(this.tr`You are now blocking incoming friend requests.`); } else { if (target) this.errorReply(this.tr`Unrecognized setting.`); this.sendReply( this.tr(setting ? `You are currently blocking friend requests.` : `You are not blocking friend requests.`) ); } this.refreshPage('friends-settings'); user.update(); }, async undorequest(target, room, user, connection) { Friends.checkCanUse(this); target = toID(target); await Friends.removeRequest(target as ID, user.id); this.refreshPage('friends-sent'); return sendPM(`You removed your friend request to '${target}'.`, user.id); }, hidenotifs: 'viewnotifications', hidenotifications: 'viewnotifications', viewnotifs: 'viewnotifications', viewnotifications(target, room, user, connection, cmd) { // Friends.checkCanUse(this); const setting = user.settings.allowFriendNotifications; target = target.trim(); if (!cmd.includes('hide') || target && this.meansYes(target)) { if (setting) return this.errorReply(this.tr(`You are already allowing friend notifications.`)); user.settings.allowFriendNotifications = true; this.sendReply(this.tr(`You will now receive friend notifications.`)); } else if (cmd.includes('hide') || target && this.meansNo(target)) { if (!setting) return this.errorReply(this.tr`You are already not receiving friend notifications.`); user.settings.allowFriendNotifications = false; this.sendReply(this.tr`You will not receive friend notifications.`); } else { if (target) this.errorReply(this.tr`Unrecognized setting.`); this.sendReply( this.tr(setting ? `You are currently allowing friend notifications.` : `Your friend notifications are disabled.`) ); } this.refreshPage('friends-settings'); user.update(); }, hidelogins: 'togglelogins', showlogins: 'togglelogins', async togglelogins(target, room, user, connection, cmd) { Friends.checkCanUse(this); const setting = user.settings.hideLogins; if (cmd.includes('hide')) { if (setting) return this.errorReply(this.tr`You are already hiding your logins from friends.`); user.settings.hideLogins = true; await Chat.Friends.hideLoginData(user.id); this.sendReply(`You are now hiding your login times from your friends.`); } else if (cmd.includes('show')) { if (!setting) return this.errorReply(this.tr`You are already allowing friends to see your login times.`); user.settings.hideLogins = false; await Chat.Friends.allowLoginData(user.id); this.sendReply(`You are now allowing your friends to see your login times.`); } else { return this.errorReply(`Invalid setting.`); } this.refreshPage('friends-settings'); user.update(); }, async listdisplay(target, room, user, connection) { Friends.checkCanUse(this); target = toID(target); const { public_list: setting } = await Chat.Friends.getSettings(user.id); if (this.meansYes(target)) { if (setting) { return this.errorReply(this.tr`You are already allowing other people to view your friends list.`); } await Chat.Friends.setHideList(user.id, true); this.refreshPage('friends-settings'); return this.sendReply(this.tr`You are now allowing other people to view your friends list.`); } else if (this.meansNo(target)) { if (!setting) { return this.errorReply(this.tr`You are already hiding your friends list.`); } await Chat.Friends.setHideList(user.id, false); this.refreshPage('friends-settings'); return this.sendReply(this.tr`You are now hiding your friends list.`); } this.sendReply(`You are currently ${setting ? 'displaying' : 'hiding'} your friends list.`); }, invalidatecache(target, room, user) { this.canUseConsole(); for (const curUser of Users.users.values()) { void Chat.Friends.updateUserCache(curUser); } Rooms.global.notifyRooms( ['staff', 'development'], `|c|${user.getIdentity()}|/log ${user.name} used /friends invalidatecache`, ); this.sendReply(`You invalidated each entry in the friends database cache.`); }, sharebattles(target, room, user) { Friends.checkCanUse(this); target = toID(target); if (this.meansYes(target)) { if (user.settings.displayBattlesToFriends) { return this.errorReply(this.tr`You are already sharing your battles with friends.`); } user.settings.displayBattlesToFriends = true; this.sendReply(`You are now allowing your friends to see your ongoing battles.`); } else if (this.meansNo(target)) { if (!user.settings.displayBattlesToFriends) { return this.errorReply(this.tr`You are already not sharing your battles with friends.`); } user.settings.displayBattlesToFriends = false; this.sendReply(`You are now hiding your ongoing battles from your friends.`); } else { if (!target) return this.parse('/help friends sharebattles'); return this.errorReply(`Invalid setting '${target}'. Provide 'on' or 'off'.`); } user.update(); this.refreshPage('friends-settings'); }, sharebattleshelp: [ `/friends sharebattles [on|off] - Allow or disallow your friends from seeing your ongoing battles.`, ], }, friendshelp() { this.runBroadcast(); if (this.broadcasting) { return this.sendReplyBox([ `/friend list - View current friends.`, `/friend add [name] OR /friend [name] - Send a friend request to [name], if you don't have them added.`, `/friend remove [username] OR /unfriend [username] - Unfriend the user.`, `
More commands...`, `/friend accept [username] - Accepts the friend request from [username], if it exists.`, `/friend reject [username] - Rejects the friend request from [username], if it exists.`, `/friend toggle [off/on] - Enable or disable receiving of friend requests.`, `/friend hidenotifications OR hidenotifs - Opts out of receiving friend notifications.`, `/friend viewnotifications OR viewnotifs - Opts into view friend notifications.`, `/friend listdisplay [on/off] - Opts [in/out] of letting others view your friends list.`, `/friend viewlist [user] - View the given [user]'s friend list, if they're allowing others to see.`, `/friends sharebattles [on|off] - Allow or disallow your friends from seeing your ongoing battles.
`, ].join('
')); } return this.parse('/join view-friends-help'); }, }; export const pages: Chat.PageTable = { async friends(args, user) { if (!user.named) return Rooms.RETRY_AFTER_LOGIN; Friends.checkCanUse(this); const type = args.shift(); let buf = '
'; switch (toID(type)) { case 'outgoing': case 'sent': this.title = `[Friends] Sent`; buf += headerButtons('sent', user); if (user.settings.blockFriendRequests) { buf += `

${this.tr(`You are currently blocking friend requests`)}.

`; } const { sent } = await Chat.Friends.getRequests(user); if (sent.size < 1) { buf += `You have no outgoing friend requests pending.
`; buf += `
To add a friend, use /friend add [username].`; buf += `
`; return toLink(buf); } buf += `

You have ${Chat.count(sent.size, 'friend requests')} pending${sent.size === MAX_REQUESTS ? ` (maximum reached)` : ''}.

`; for (const request of sent) { buf += `
`; buf += `${request}`; buf += ` `; buf += `
`; } break; case 'received': case 'incoming': this.title = `[Friends] Received`; buf += headerButtons('received', user); const { received } = await Chat.Friends.getRequests(user); if (received.size < 1) { buf += `You have no pending friend requests.`; buf += ``; return toLink(buf); } buf += `

You have ${received.size} pending friend requests.

`; for (const request of received) { buf += `
`; buf += `${request}`; buf += ` |`; buf += ` `; buf += `
`; } break; case 'viewuser': const target = toID(args.shift()); if (!target) return this.errorReply(`Specify a user.`); if (target === user.id) { return this.errorReply(`Use /friends list to view your own list.`); } const { public_list: isAllowing } = await Chat.Friends.getSettings(target); if (!isAllowing) return this.errorReply(`${target}'s friends list is not public or they do not have one.`); this.title = `[Friends List] ${target}`; buf += await Friends.visualizePublicList(target); break; case 'help': this.title = `[Friends] Help`; buf += headerButtons('help', user); buf += `

Help

`; buf += `/friend OR /friends OR /friendslist:
`; break; case 'settings': this.title = `[Friends] Settings`; buf += headerButtons('settings', user); buf += `

Friends Settings:

`; const settings = user.settings; const { public_list, send_login_data } = await Chat.Friends.getSettings(user.id); buf += `Notify me when my friends come online:
`; buf += ` `; buf += `

`; buf += `Receive friend requests:
`; buf += ` `; buf += `

`; buf += `Allow others to see your list:
`; buf += ` `; buf += `

`; buf += `Allow others to see my login times
`; buf += ` `; buf += `

`; buf += `Allow friends to see my hidden battles on the spectator list:
`; buf += ` `; buf += `

`; buf += `Block PMs except from friends (and staff):
`; buf += ` `; buf += `

`; buf += `Block challenges except from friends (and staff):
`; buf += ` `; buf += `

`; break; case 'spectate': this.title = `[Friends] Spectating`; buf += headerButtons('spectate', user); buf += `

Spectate your friends:

`; const toggleMessage = user.settings.displayBattlesToFriends ? ' disallow your friends from seeing your hidden battles' : ' allow your friends to see your hidden battles'; buf += `Use the settings page to ${toggleMessage} on this page.
`; buf += `
`; if (!user.friends?.size) { buf += `

You have no friends to spectate.

`; break; } const friends = []; for (const friendID of user.friends) { const friend = Users.getExact(friendID); if (!friend) continue; friends.push(friend); } if (!friends.length) { buf += `None of your friends are currently around to spectate.`; break; } const battles: [User, string][] = []; for (const friend of friends) { const curBattles: [User, string][] = [...friend.inRooms] .filter(id => { const battle = Rooms.get(id)?.battle; return ( battle?.playerTable[friend.id] && (!battle.roomid.endsWith('pw') || friend.settings.displayBattlesToFriends) ); }) .map(id => [friend, id]); if (!curBattles.length) continue; battles.push(...curBattles); } Utils.sortBy(battles, ([, id]) => -Number(id.split('-')[2])); if (!battles.length) { buf += `None of your friends are currently in a battle.`; } else { buf += battles.map(([friend, battle]) => { // we've already ensured the battle exists in the filter above // (and .battle only exists if it's a GameRoom, so this cast is safe) const room = Rooms.get(battle) as GameRoom & { battle: Rooms.RoomBattle }; const format = Dex.formats.get(room.battle.format).name; const rated = room.battle.rated ? `(Rated: ${room.battle.rated})` : ''; const title = room.title.includes(friend.name) ? room.title.replace(friend.name, `${friend.name}`) : (room.title + ` (with ${friend.name})`); return `[${format}]${rated}
${title}
`; }).join('
'); } break; default: this.title = `[Friends] All Friends`; buf += headerButtons('all', user); buf += await Friends.visualizeList(user.id); } buf += ``; return toLink(buf); }, }; export const handlers: Chat.Handlers = { onBattleStart(user) { return Friends.updateSpectatorLists(user); }, onBattleLeave(user, room) { return Friends.updateSpectatorLists(user); }, onBattleEnd(battle, winner, players) { for (const id of players) { const user = Users.get(id); if (!user) continue; Friends.updateSpectatorLists(user); } }, onDisconnect(user) { void Chat.Friends.writeLogin(user.id); }, }; export const loginfilter: Chat.LoginFilter = user => { if (!Config.usesqlitefriends || !Users.globalAuth.atLeast(user, Config.usesqlitefriends)) { return; } // notify users of pending requests void Friends.notifyPending(user); // (quietly) notify their friends (that have opted in) that they are online void Friends.notifyConnection(user); // write login time void Chat.Friends.writeLogin(user.id); void Chat.Friends.updateUserCache(user); };