diff --git a/CODEOWNERS b/CODEOWNERS index ded382fba7..fe722a39b2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -4,6 +4,7 @@ data/mods/ssb/ @HoeenCoder @KrisXV data/random-teams.ts @AnnikaCodes data/text/ @Marty-D databases/ @monsanto +server/chat-plugins/friends.ts @mia-pi-git server/chat-plugins/hosts.ts @AnnikaCodes server/chat-plugins/mafia.ts @HoeenCoder server/chat-plugins/net-filters.ts @mia-pi-git @@ -16,6 +17,7 @@ server/chat-plugins/rock-paper-scissors.ts @mia-pi-git server/chat-plugins/scavenger*.ts @xfix @sparkychildcharlie server/chat-plugins/the-studio.ts @KrisXV server/chat-plugins/trivia.ts @AnnikaCodes +server/friends.ts @mia-pi-git server/chat-plugins/username-prefixes.ts @AnnikaCodes server/modlog.ts @monsanto test/random-battles/* @AnnikaCodes diff --git a/databases/schemas/friends-startup.sql b/databases/schemas/friends-startup.sql new file mode 100644 index 0000000000..fb1be1a319 --- /dev/null +++ b/databases/schemas/friends-startup.sql @@ -0,0 +1,5 @@ +--- This is a separate file so migrations don't have to be done. + +CREATE TEMPORARY VIEW friends_simplified ( + userid, friend +) AS SELECT user1 as friend, user2 as userid FROM friends UNION ALL SELECT user2 as friend, user1 as userid FROM friends; diff --git a/databases/schemas/friends.sql b/databases/schemas/friends.sql new file mode 100644 index 0000000000..b0d6b2269c --- /dev/null +++ b/databases/schemas/friends.sql @@ -0,0 +1,29 @@ +CREATE TABLE friends ( + user1 TEXT NOT NULL, + user2 TEXT NOT NULL, + PRIMARY KEY (user1, user2) +) WITHOUT ROWID; + +CREATE TABLE friend_requests ( + sender TEXT NOT NULL, + receiver TEXT NOT NULL, + sent_at INTEGER NOT NULL, + PRIMARY KEY (sender, receiver) +) WITHOUT ROWID; + +CREATE TABLE friend_settings ( + userid TEXT NOT NULL, + send_login_data INTEGER NOT NULL, + last_login INTEGER NOT NULL, + public_list INTEGER NOT NULL, + PRIMARY KEY (userid) +) WITHOUT ROWID; + +CREATE TABLE database_settings ( + name TEXT NOT NULL, + val TEXT NOT NULL, + PRIMARY KEY (name, val) +) WITHOUT ROWID; + +-- set version if not exists +INSERT INTO database_settings (name, val) VALUES ('version', 0); diff --git a/lib/cache.ts b/lib/cache.ts new file mode 100644 index 0000000000..370328f62c --- /dev/null +++ b/lib/cache.ts @@ -0,0 +1,44 @@ +/** + * Basic implementation of a cache, mostly for use in caching database results. + * This is explicitly made to be synchronously accessed (to pair with results from asynchronous databases.) + * @author mia-pi-git + */ + +const CACHE_EXPIRY_TIME = 5 * 60 * 1000; + +export class Cache { + readonly cache: {[k: string]: {data: T, lastCache: number}}; + expiryTime: number; + dataFetcher: (key: string) => T | Promise; + constructor(fetcher: (key: string) => T | Promise, invalidateTime = CACHE_EXPIRY_TIME) { + this.cache = {}; + this.expiryTime = invalidateTime; + this.dataFetcher = fetcher; + } + // todo make this only return T + // roubling it doesn't, since that was the original intent, but for now this will do + get(key: string) { + const data = this.cache[key]; + if (!data || Date.now() - data.lastCache > this.expiryTime) { + void this.update(key); + } + if (!this.cache[key]) { + return; + } + // return default or last state + return this.cache[key].data; + } + async update(key: string) { + const data = await this.dataFetcher(key); + this.cache[key] = {lastCache: Date.now(), data}; + return this.cache[key]; + } + set(key: string, data: T) { + this.cache[key] = {data, lastCache: Date.now()}; + } + delete(key: string) { + const data = this.cache[key]?.data; + delete this.cache[key]; + return data as T | undefined; + } +} diff --git a/lib/index.ts b/lib/index.ts index 882a3670cc..0a137ae167 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -5,4 +5,5 @@ export * as Streams from './streams'; export {FS} from './fs'; export * as Utils from './utils'; export {crashlogger} from './crashlogger'; +export {Cache} from './cache'; export * as ProcessManager from './process-manager'; diff --git a/server/chat-commands/admin.ts b/server/chat-commands/admin.ts index 7e2072e00d..45a8636241 100644 --- a/server/chat-commands/admin.ts +++ b/server/chat-commands/admin.ts @@ -558,6 +558,7 @@ export const commands: Chat.ChatCommands = { void manager.destroy(); } } + void Chat.PM.destroy(); global.Chat = require('../chat').Chat; global.Tournaments = require('../tournaments').Tournaments; diff --git a/server/chat-commands/core.ts b/server/chat-commands/core.ts index 3451157a07..025c817a25 100644 --- a/server/chat-commands/core.ts +++ b/server/chat-commands/core.ts @@ -716,7 +716,10 @@ export const commands: Chat.ChatCommands = { } else if (target === 'autoconfirmed' || target === 'trusted' || target === 'unlocked') { user.settings.blockPMs = target; target = this.tr(target); - this.sendReply(this.tr`You are now blocking private messages, except from staff and ${target} users.`); + this.sendReply(this.tr `You are now blocking private messages, except from staff and ${target} users.`); + } else if (target === 'friends') { + user.settings.blockPMs = target; + this.sendReply(this.tr`You are now blocking private messages, except from staff and friends.`); } else { user.settings.blockPMs = true; this.sendReply(this.tr`You are now blocking private messages, except from staff.`); diff --git a/server/chat-plugins/friends.ts b/server/chat-plugins/friends.ts new file mode 100644 index 0000000000..f61efbac50 --- /dev/null +++ b/server/chat-plugins/friends.ts @@ -0,0 +1,539 @@ +/** + * 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', +}; + +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; + 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) < 2 * 60 * 1000) { + 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 {user1, user2} = f; + const friend = user1 !== user.id ? user1 : user2; + const curUser = Users.get(friend as string); + 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.get(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 += ` `; + + 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) { + // THIS IS A TERRIBLE HACK BUT IT WORKS OKAY + const time = Chat.toTimestamp(new Date(Number(login)), {human: true}); + buf += `Last login: ${time.split(' ').reverse().join(', on ')}`; + 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.locked || user.namelocked || user.semilocked || user.permalocked) { + throw new Chat.ErrorMessage(`You are locked, and so cannot use the friends feature.`); + } + if (!user.autoconfirmed) { + throw new Chat.ErrorMessage(context.tr`You must be autoconfirmed to 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); + } +}; + +/** UI functions chiefly for the chat page. */ + +function toLink(buf: string) { + return buf.replace(/', + received: '', + all: '', + help: '', + settings: '', + }; + const titles: {[k: string]: string} = { + all: 'All Friends', + 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) { + 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); + if (connection.openPages?.has('friends-sent')) { + this.parse(`/join view-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); + return this.sendReply(`Removed friend '${target}'.`); + }, + view(target) { + return this.parse(`/join view-friends-${target}`); + }, + list() { + return this.parse(`/join view-friends`); + }, + 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); + if (connection.openPages?.has('friends-received')) { + this.parse(`/j view-friends-received`); + } + if (targetUser) { + sendPM(`/text ${user.name} accepted your friend request!`, targetUser.id); + sendPM(`/uhtmlchange sent,`, targetUser.id); + sendPM(`/uhtmlchange undo,`, targetUser.id); + } + await Chat.Friends.cache.update(user.id); + await Chat.Friends.cache.update(target); + }, + deny: 'reject', + async reject(target, room, user, connection) { + Friends.checkCanUse(this); + target = toID(target); + if (!target) return this.parse('/help friends'); + await Friends.removeRequest(user.id, target as ID); + if (connection.openPages?.has('friends-received')) { + this.parse(`/j view-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.`) + ); + } + if (connection.openPages?.has('friends-settings')) { + this.parse(`/j view-friends-settings`); + } + user.update(); + }, + async undorequest(target, room, user, connection) { + Friends.checkCanUse(this); + target = toID(target); + if (user.settings.blockFriendRequests) { + return sendPM( + `/error ${this.tr`You are blocking friend requests, and so cannot undo requests, as you have none.`}`, user.id + ); + } + await Friends.removeRequest(target as ID, user.id); + if (connection.openPages?.has('friends-sent')) { + this.parse(`/j view-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.`) + ); + } + if (connection.openPages?.has('friends-settings')) { + this.parse(`/j view-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.`); + } + if (connection.openPages?.has('friends-settings')) { + this.parse(`/j view-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); + if (connection.openPages?.has('friends-settings')) { + this.parse(`/j view-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); + if (connection.openPages?.has('friends-settings')) { + this.parse(`/j view-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 k in Chat.Friends.cache) { + void Chat.Friends.cache.update(k); + } + 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.`); + }, + }, + friendshelp() { + 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); + buf += `
`; + if (user.settings.blockFriendRequests) { + buf += `

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

`; + return buf; + } + 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); + buf += `
`; + 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:
  • `; + buf += [ + `/friend list - View current friends.`, + `/friend add [username] - Send a friend request to [username], if you don't have them added.`, + `/friend remove [username] OR /unfriend [username] - Unfriend the user.`, + `/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.`, + ].join('
  • '); + buf += `
`; + break; + case 'settings': + this.title = `[Friends] Settings`; + buf += headerButtons('settings', user); + buf += `

Friends Settings:

`; + const settings = user.settings; + const {public_list} = 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 += `

`; + break; + default: + this.title = `[Friends] All Friends`; + buf += headerButtons('all', user); + buf += `
`; + buf += await Friends.visualizeList(user.id); + } + buf += ``; + return toLink(buf); + }, +}; + +export const loginfilter: Chat.LoginFilter = async user => { + if (!Config.usesqlitefriends || !Users.globalAuth.atLeast(user, Config.usesqlitefriends)) { + return; + } + // notify users of pending requests + await Friends.notifyPending(user); + + // (quietly) notify their friends (that have opted in) that they are online + await Friends.notifyConnection(user); + // write login time + await Chat.Friends.writeLogin(user.id); + + await Chat.Friends.cache.update(user.id); +}; diff --git a/server/chat.ts b/server/chat.ts index 4cc044520b..d57ececee2 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -24,6 +24,7 @@ To reload chat commands: */ import type {RoomPermission, GlobalPermission} from './user-groups'; +import {FriendsDatabase, PM} from './friends'; import type {Punishment} from './punishments'; import type {PartialModlogEntry} from './modlog'; @@ -1101,9 +1102,16 @@ export class CommandContext extends MessageContext { const groupName = Config.groups[Config.pmmodchat] && Config.groups[Config.pmmodchat].name || Config.pmmodchat; throw new Chat.ErrorMessage(this.tr`On this server, you must be of rank ${groupName} or higher to PM users.`); } - if (targetUser.settings.blockPMs && - (targetUser.settings.blockPMs === true || !Users.globalAuth.atLeast(user, targetUser.settings.blockPMs)) && - !user.can('lock') && targetUser.id !== user.id) { + const targetFriends = Chat.Friends.cache.get(targetUser.id); + const targetBlock = targetUser.settings.blockPMs; + // we check if they can lock the other before this so we're fine here + const authAtLeast = targetBlock === true ? + user.can('lock', targetUser) : + Users.globalAuth.atLeast(user, targetBlock as any); + + if (targetBlock && !user.can('lock', targetUser) && !( + targetBlock === 'friends' ? targetFriends?.has(user.id) : authAtLeast + )) { Chat.maybeNotifyBlocked('pm', targetUser, user); if (!targetUser.can('lock')) { throw new Chat.ErrorMessage(this.tr`This user is blocking private messages right now.`); @@ -1112,9 +1120,10 @@ export class CommandContext extends MessageContext { throw new Chat.ErrorMessage(this.tr`This ${Config.groups[targetUser.tempGroup].name} is too busy to answer private messages right now. Please contact a different staff member.`); } } + const userFriends = Chat.Friends.cache.get(user.id); if (user.settings.blockPMs && (user.settings.blockPMs === true || - !Users.globalAuth.atLeast(targetUser, user.settings.blockPMs)) && !targetUser.can('lock') && - targetUser.id !== user.id) { + (user.settings.blockPMs === 'friends' && !userFriends?.has(targetUser.id)) || + !Users.globalAuth.atLeast(targetUser, user.settings.blockPMs as AuthLevel)) && !targetUser.can('lock')) { throw new Chat.ErrorMessage(this.tr`You are blocking private messages right now.`); } } @@ -1203,8 +1212,11 @@ export class CommandContext extends MessageContext { if (!(this.room && (targetUser.id in this.room.users)) && !this.user.can('addhtml')) { throw new Chat.ErrorMessage("You do not have permission to use PM HTML to users who are not in this room."); } + const friends = Chat.Friends.cache.get(targetUser.id) || new Set(); if (targetUser.settings.blockPMs && - (targetUser.settings.blockPMs === true || !Users.globalAuth.atLeast(this.user, targetUser.settings.blockPMs)) && + (targetUser.settings.blockPMs === true || + (targetUser.settings.blockPMs === 'friends' && !friends.has(this.user.id)) || + !Users.globalAuth.atLeast(this.user, targetUser.settings.blockPMs as AuthLevel)) && !this.user.can('lock') ) { Chat.maybeNotifyBlocked('pm', targetUser, this.user); @@ -1441,6 +1453,8 @@ export const Chat = new class { * which tends to cause unexpected behavior. */ readonly MAX_TIMEOUT_DURATION = 2147483647; + readonly Friends = new FriendsDatabase(); + readonly PM = PM; readonly multiLinePattern = new PatternTester(); diff --git a/server/friends.ts b/server/friends.ts new file mode 100644 index 0000000000..fce7aac658 --- /dev/null +++ b/server/friends.ts @@ -0,0 +1,397 @@ +/** + * Friends chat-plugin database handler. + * @author mia-pi-git + */ +// @ts-ignore in case it isn't installed +import type * as Database from 'better-sqlite3'; +import {Utils, FS, ProcessManager, Repl, Cache} from '../lib'; +import {Config} from './config-loader'; +import * as path from 'path'; + +/** Max friends per user */ +export const MAX_FRIENDS = 100; +/** Max friend requests. */ +export const MAX_REQUESTS = 6; +export const DEFAULT_FILE = `${__dirname}/../databases/friends.db`; +const REQUEST_EXPIRY_TIME = 30 * 24 * 60 * 60 * 1000; +const PM_TIMEOUT = 30 * 60 * 1000; + +export interface DatabaseRequest { + statement: string; + type: 'all' | 'get' | 'run' | 'transaction'; + data: AnyObject | any[]; +} + +export interface DatabaseResult { + /** Specify this to return an error message to the user */ + error?: string; + result?: any; +} + +/** Like Chat.ErrorMessage, but made for the subprocess so we can throw errors to the user not using errorMessage + * because errorMessage crashes when imported (plus we have to spawn dex, etc, all unnecessary - this is easier) + */ +export class FailureMessage extends Error { + constructor(message: string) { + super(message); + this.name = 'FailureMessage'; + Error.captureStackTrace(this, FailureMessage); + } +} + +export function sendPM(message: string, to: string, from = '&') { + const senderID = toID(to); + const receiverID = toID(from); + const sendingUser = Users.get(senderID); + const receivingUser = Users.get(receiverID); + const fromIdentity = sendingUser ? sendingUser.getIdentity() : ` ${senderID}`; + const toIdentity = receivingUser ? receivingUser.getIdentity() : ` ${receiverID}`; + + if (from === '&') { + return sendingUser?.send(`|pm|&|${toIdentity}|${message}`); + } + if (sendingUser) { + sendingUser.send(`|pm|${fromIdentity}|${toIdentity}|${message}`); + } + if (receivingUser) { + receivingUser.send(`|pm|${fromIdentity}|${toIdentity}|${message}`); + } +} + +export class FriendsDatabase { + file: string; + cache: Cache>; + constructor(file: string = DEFAULT_FILE) { + this.file = file === ':memory:' ? file : path.resolve(file); + this.cache = new Cache>(async user => { + const data = await this.getFriends(user as ID); + return new Set(data.map(f => f.friend)); + }); + } + static setupDatabase(fileName?: string) { + const file = fileName || process.env.filename || DEFAULT_FILE; + const exists = FS(file).existsSync() || file === ':memory:'; + const database: Database.Database = new (require('better-sqlite3'))(file); + if (!exists) { + database.exec(FS('databases/schemas/friends.sql').readSync()); + } else { + let val; + try { + val = database.prepare(`SELECT val FROM database_settings WHERE name = 'version'`).get().val; + } catch (e) {} + const actualVersion = FS(`databases/migrations/`).readdirSync().length; + if (val === undefined) { + // hasn't been set up before, write new version. + database.exec(FS('databases/schemas/friends.sql').readSync()); + } + if (typeof val === 'number' && val !== actualVersion) { + throw new Error(`Friends DB is out of date, please migrate to latest version.`); + } + } + database.exec(FS(`databases/schemas/friends-startup.sql`).readSync()); + + for (const k in FUNCTIONS) { + database.function(k, FUNCTIONS[k]); + } + + for (const k in ACTIONS) { + try { + statements[k] = database.prepare(ACTIONS[k as keyof typeof ACTIONS]); + } catch (e) { + throw new Error(`Friends DB statement crashed: ${ACTIONS[k as keyof typeof ACTIONS]} (${e.message})`); + } + } + + for (const k in TRANSACTIONS) { + transactions[k] = database.transaction(TRANSACTIONS[k]); + } + + statements.expire.run(); + return database; + } + getFriends(userid: ID): Promise { + return this.all('get', [userid, MAX_FRIENDS]); + } + async getRequests(user: User) { + const sent: Set = new Set(); + const received: Set = new Set(); + if (user.settings.blockFriendRequests) { + // delete any pending requests that may have been sent to them while offline and return + await this.run('deleteRequest', [user.id]); + return {sent, received}; + } + const sentResults = await this.all('getSent', [user.id]); + for (const request of sentResults) { + sent.add(request.receiver); + } + const receivedResults = await this.all('getReceived', [user.id]); + for (const request of receivedResults) { + received.add(request.sender); + } + return {sent, received}; + } + all(statement: string, data: any[] | AnyObject) { + return this.query({type: 'all', data, statement}); + } + transaction(statement: string, data: any[] | AnyObject) { + return this.query({data, statement, type: 'transaction'}); + } + run(statement: string, data: any[] | AnyObject) { + return this.query({statement, data, type: 'run'}); + } + get(statement: string, data: any[] | AnyObject) { + return this.query({statement, data, type: 'get'}); + } + private async query(input: DatabaseRequest) { + const process = PM.acquire(); + if (!process) throw new Error(`Missing friends process`); + const result = await process.query(input); + if (result.error) { + throw new Chat.ErrorMessage(result.error); + } + return result.result; + } + async request(user: User, receiverID: ID) { + const receiver = Users.get(receiverID); + if (receiverID === user.id) { + throw new Chat.ErrorMessage(`You can't friend yourself.`); + } + if (receiver?.settings.blockFriendRequests) { + throw new Chat.ErrorMessage(`${receiver.name} is blocking friend requests.`); + } + let buf = Utils.html`/uhtml sent, | `; + buf += Utils.html`
`; + buf += `(You can also stop this user from sending you friend requests with /ignore)`; + const disclaimer = ( + `/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.` + ); + if (receiver?.settings.blockFriendRequests) { + throw new Chat.ErrorMessage(`This user is blocking friend requests.`); + } + if (receiver?.settings.blockPMs) { + throw new Chat.ErrorMessage(`This user is blocking PMs, and cannot be friended right now.`); + } + + const result = await this.transaction('send', [user.id, receiverID]); + if (receiver) { + sendPM(`/text ${Utils.escapeHTML(user.name)} sent you a friend request!`, receiver.id); + sendPM(buf, receiver.id); + sendPM(disclaimer, receiver.id); + } + sendPM(`/nonotify You sent a friend request to ${receiver?.connected ? receiver.name : receiverID}!`, user.id); + sendPM( + `/uhtml undo,`, user.id + ); + sendPM(disclaimer, user.id); + return result; + } + async removeRequest(receiverID: ID, senderID: ID) { + if (!senderID) throw new Chat.ErrorMessage(`Invalid sender username.`); + if (!receiverID) throw new Chat.ErrorMessage(`Invalid receiver username.`); + + return this.run('deleteRequest', [senderID, receiverID]); + } + async approveRequest(receiverID: ID, senderID: ID) { + return this.transaction('accept', [senderID, receiverID]); + } + async removeFriend(userid: ID, friendID: ID) { + if (!friendID || !userid) throw new Chat.ErrorMessage(`Invalid usernames supplied.`); + + const result = await this.run('delete', {user1: userid, user2: friendID}); + if (result.changes < 1) { + throw new Chat.ErrorMessage(`You do not have ${friendID} friended.`); + } + } + writeLogin(user: ID) { + return this.run('login', [user, Date.now(), Date.now()]); + } + hideLoginData(id: ID) { + return this.run('hideLogin', [id, Date.now()]); + } + allowLoginData(id: ID) { + return this.run('showLogin', [id]); + } + async getLastLogin(userid: ID) { + const result = await this.get('checkLastLogin', [userid]); + return parseInt(result?.['last_login']) || null; + } + async getSettings(userid: ID) { + return (await this.get('getSettings', [userid])) || {}; + } + setHideList(userid: ID, setting: boolean) { + const num = setting ? 1 : 0; + // name, send_login_data, last_login, public_list + return this.run('toggleList', [userid, num, num]); + } +} + +const statements: {[k: string]: Database.Statement} = {}; +const transactions: {[k: string]: Database.Transaction} = {}; + +const ACTIONS = { + add: ( + `REPLACE INTO friends (user1, user2) VALUES ($user1, $user2) ON CONFLICT (user1, user2) ` + + `DO UPDATE SET user1 = $user1, user2 = $user2` + ), + get: ( + `SELECT * FROM friends_simplified f LEFT JOIN friend_settings fs ON f.friend = fs.userid WHERE f.userid = ? LIMIT ?` + ), + delete: `DELETE FROM friends WHERE (user1 = $user1 AND user2 = $user2) OR (user1 = $user2 AND user2 = $user1)`, + getSent: `SELECT receiver, sender FROM friend_requests WHERE sender = ?`, + getReceived: `SELECT receiver, sender FROM friend_requests WHERE receiver = ?`, + insertRequest: `INSERT INTO friend_requests(sender, receiver, sent_at) VALUES (?, ?, ?)`, + deleteRequest: `DELETE FROM friend_requests WHERE sender = ? AND receiver = ?`, + findFriendship: `SELECT * FROM friends WHERE (user1 = $user1 AND user2 = $user2) OR (user2 = $user1 AND user1 = $user2)`, + findRequest: ( + `SELECT count(*) as num FROM friend_requests WHERE ` + + `(sender = $user1 AND receiver = $user2) OR (sender = $user2 AND receiver = $user1)` + ), + countRequests: `SELECT count(*) as num FROM friend_requests WHERE (sender = ? OR receiver = ?)`, + login: ( + `INSERT INTO friend_settings (userid, send_login_data, last_login, public_list) VALUES (?, 0, ?, 0) ` + + `ON CONFLICT (userid) DO UPDATE SET last_login = ?` + ), + checkLastLogin: `SELECT last_login FROM friend_settings WHERE userid = ?`, + deleteLogin: `UPDATE friend_settings SET last_login = 0 WHERE userid = ?`, + expire: ( + `DELETE FROM friend_requests WHERE EXISTS` + + `(SELECT sent_at FROM friend_requests WHERE should_expire(sent_at) = 1)` + ), + hideLogin: ( + `INSERT INTO friend_settings (userid, send_login_data, last_login, public_list) VALUES (?, 1, ?, ?) ` + + `ON CONFLICT (userid) DO UPDATE SET send_login_data = 1` + ), + showLogin: `DELETE FROM friend_settings WHERE userid = ? AND send_login_data = 1`, + countFriends: `SELECT count(*) as num FROM friends WHERE (user1 = ? OR user2 = ?)`, + getSettings: `SELECT * FROM friend_settings WHERE userid = ?`, + toggleList: ( + `INSERT INTO friend_settings (userid, send_login_data, last_login, public_list) VALUES (?, 0, 0, ?) ` + + `ON CONFLICT (userid) DO UPDATE SET public_list = ?` + ), +}; + +const FUNCTIONS: {[k: string]: (...input: any[]) => any} = { + 'should_expire': (sentTime: number) => { + if (Date.now() - sentTime > REQUEST_EXPIRY_TIME) return 1; + return 0; + }, +}; + +const TRANSACTIONS: {[k: string]: (input: any[]) => DatabaseResult} = { + send: requests => { + for (const request of requests) { + const [senderID, receiverID] = request; + const hasSentRequest = statements.findRequest.get({user1: senderID, user2: receiverID})['num']; + const friends = statements.countFriends.get(senderID, senderID)['num']; + const totalRequests = statements.countRequests.get(senderID, senderID)['num']; + if (friends >= MAX_FRIENDS) { + throw new FailureMessage(`You are at the maximum number of friends.`); + } + const existingFriendship = statements.findFriendship.all({user1: senderID, user2: receiverID}); + if (existingFriendship.length) { + throw new FailureMessage(`You are already friends with '${receiverID}'.`); + } + if (hasSentRequest) { + throw new FailureMessage(`You have already sent a friend request to '${receiverID}'.`); + } + if (totalRequests >= MAX_REQUESTS) { + throw new FailureMessage( + `You already have ${MAX_REQUESTS} outgoing friend requests. Use "/friends view sent" to see your outgoing requests.` + ); + } + statements.insertRequest.run(senderID, receiverID, Date.now()); + } + return {result: []}; + }, + add: requests => { + for (const request of requests) { + const [senderID, receiverID] = request; + statements.add.run({user1: senderID, user2: receiverID}); + } + return {result: []}; + }, + accept: requests => { + for (const request of requests) { + const [, receiverID] = request; + const results = TRANSACTIONS.removeRequest([request]); + if (!results) throw new Chat.ErrorMessage(`You have no request pending from ${receiverID}.`); + TRANSACTIONS.add([request]); + } + return {result: []}; + }, + removeRequest: requests => { + const result = []; + for (const request of requests) { + const [to, from] = request; + const {changes} = statements.deleteRequest.run(to, from); + if (changes) result.push(changes); + } + return {result}; + }, +}; + +export const PM = new ProcessManager.QueryProcessManager(module, query => { + const {type, statement, data} = query; + const start = Date.now(); + const result: DatabaseResult = {}; + try { + switch (type) { + case 'run': + result.result = statements[statement].run(data); + break; + case 'get': + result.result = statements[statement].get(data); + break; + case 'transaction': + result.result = transactions[statement]([data]); + break; + case 'all': + result.result = statements[statement].all(data); + break; + } + } catch (e) { + if (!e.name.endsWith('FailureMessage')) { + result.error = "Sorry! The database process crashed. We've been notified and will fix this."; + Monitor.crashlog(e, "A friends database process", query); + } else { + result.error = e.message; + } + return result; + } + const delta = Date.now() - start; + if (delta > 1000) { + Monitor.slow(`[Slow friends list query] ${JSON.stringify(query)}`); + } + return result; +}, PM_TIMEOUT, message => { + if (message.startsWith('SLOW\n')) { + Monitor.slow(message.slice(5)); + } +}); + +if (!PM.isParentProcess) { + global.Config = (require as any)('./config-loader').Config; + if (Config.usesqlite) { + FriendsDatabase.setupDatabase(); + } + global.Monitor = { + crashlog(error: Error, source = 'A friends database process', details: AnyObject | null = null) { + const repr = JSON.stringify([error.name, error.message, source, details]); + process.send!(`THROW\n@!!@${repr}\n${error.stack}`); + }, + slow(message: string) { + process.send!(`CALLBACK\nSLOW\n${message}`); + }, + }; + process.on('uncaughtException', err => { + if (Config.crashguard) { + Monitor.crashlog(err, 'A friends child process'); + } + }); + // eslint-disable-next-line no-eval + Repl.start(`friends-${process.pid}`, cmd => eval(cmd)); +} else { + PM.spawn(Config.friendsprocesses || 1); +} diff --git a/server/room-battle.ts b/server/room-battle.ts index 2f4a6879ba..bb79edb3dd 100644 --- a/server/room-battle.ts +++ b/server/room-battle.ts @@ -1357,7 +1357,6 @@ export const PM = new ProcessManager.StreamProcessManager(module, () => new Room if (!PM.isParentProcess) { // This is a child process! global.Config = require('./config-loader').Config; - global.Chat = require('./chat').Chat; global.Dex = require('../sim/dex').Dex; global.Monitor = { crashlog(error: Error, source = 'A simulator process', details: AnyObject | null = null) { diff --git a/server/users.ts b/server/users.ts index 4b6f71eee0..cfe27c41cc 100644 --- a/server/users.ts +++ b/server/users.ts @@ -301,11 +301,14 @@ type ChatQueueEntry = [string, RoomID, Connection]; export interface UserSettings { blockChallenges: boolean | AuthLevel; - blockPMs: boolean | AuthLevel; + blockPMs: boolean | AuthLevel | 'friends'; ignoreTickets: boolean; hideBattlesFromTrainerCard: boolean; blockInvites: AuthLevel | boolean; doNotDisturb: boolean; + blockFriendRequests: boolean; + allowFriendNotifications: boolean; + hideLogins: boolean; } // User @@ -435,6 +438,9 @@ export class User extends Chat.MessageContext { hideBattlesFromTrainerCard: false, blockInvites: false, doNotDisturb: false, + blockFriendRequests: false, + allowFriendNotifications: true, + hideLogins: false, }; this.battleSettings = { team: '', diff --git a/test/server/chat-plugins/friends.js b/test/server/chat-plugins/friends.js new file mode 100644 index 0000000000..016a2476ab --- /dev/null +++ b/test/server/chat-plugins/friends.js @@ -0,0 +1,17 @@ +/** +* Tests for the friends list chat plugin. By Mia +* @author mia-pi-git +*/ +'use strict'; + +const {FriendsDatabase} = require('../../../server/friends.ts'); +const {Config} = require('../../../server/config-loader.ts'); +const assert = require('../../assert'); + +const test = (Config.usesqlite ? it : it.skip); + +describe("Friends lists", () => { + test("Should properly setup database", () => { + assert.doesNotThrow(() => FriendsDatabase.setupDatabase(':memory:')); + }); +});