mirror of
https://github.com/smogon/pokemon-showdown.git
synced 2026-03-21 17:25:10 -05:00
Support adding friends (#7333)
This commit is contained in:
parent
40e36c8f38
commit
e5fbd64427
|
|
@ -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
|
||||
|
|
|
|||
5
databases/schemas/friends-startup.sql
Normal file
5
databases/schemas/friends-startup.sql
Normal file
|
|
@ -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;
|
||||
29
databases/schemas/friends.sql
Normal file
29
databases/schemas/friends.sql
Normal file
|
|
@ -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);
|
||||
44
lib/cache.ts
Normal file
44
lib/cache.ts
Normal file
|
|
@ -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<T> {
|
||||
readonly cache: {[k: string]: {data: T, lastCache: number}};
|
||||
expiryTime: number;
|
||||
dataFetcher: (key: string) => T | Promise<T>;
|
||||
constructor(fetcher: (key: string) => T | Promise<T>, invalidateTime = CACHE_EXPIRY_TIME) {
|
||||
this.cache = {};
|
||||
this.expiryTime = invalidateTime;
|
||||
this.dataFetcher = fetcher;
|
||||
}
|
||||
// todo make this only return T
|
||||
// <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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
|
|
|
|||
539
server/chat-plugins/friends.ts
Normal file
539
server/chat-plugins/friends.ts
Normal file
|
|
@ -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 <button class="button" name="send" value="/j view-friends-received">View</button></div>`, 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 = `<h3>Your friends: <small> `;
|
||||
if (sorted.length > 0) {
|
||||
buf += `Total (${friends.length}) | ${sorted.join(' | ')}`;
|
||||
} else {
|
||||
buf += `</h3><em>you have no friends added on Showdown lol</em><br /><br /><br />`;
|
||||
buf += `<strong>To add a friend, use </strong><code>/friend add [username]</code>.<br /><br />`;
|
||||
return buf;
|
||||
}
|
||||
buf += `</h3> `;
|
||||
|
||||
for (const key in categorized) {
|
||||
const friendArray = categorized[key].sort();
|
||||
if (friendArray.length === 0) continue;
|
||||
buf += `<h4>${STATUS_TITLES[key]} (${friendArray.length})</h4>`;
|
||||
for (const friend of friendArray) {
|
||||
const friendID = toID(friend);
|
||||
buf += `<div class="pad"><div>`;
|
||||
buf += this.displayFriend(friendID, loginTimes[friendID]);
|
||||
buf += `</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = `<h3>${userid}'s friends:</h3><hr />`;
|
||||
if (!friends.length) {
|
||||
buf += `None.`;
|
||||
return buf;
|
||||
}
|
||||
for (const friend of friends) {
|
||||
buf += `- <username>${friend}</username><br />`;
|
||||
}
|
||||
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 ?
|
||||
`<strong style="color:${STATUS_COLORS[user.statusType]}">\u25C9 ${STATUS_TITLES[user.statusType]}</strong>` :
|
||||
'\u25CC Offline';
|
||||
let buf = user ?
|
||||
`<span class="username"> <username>${name}</username></span><span><small> (${statusType})</small></span>` :
|
||||
Utils.html`<i>${name}</i> <small>(${statusType})</small>`;
|
||||
buf += `<br />`;
|
||||
|
||||
const curUser = Users.get(userid); // might be an alt
|
||||
if (user) {
|
||||
if (user.userMessage) buf += Utils.html`Status: <i>${user.userMessage}</i><br />`;
|
||||
} else if (curUser && curUser.id !== userid) {
|
||||
buf += `<small>On an alternate account</small><br />`;
|
||||
}
|
||||
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 = `<div class="infobox">${buf}</div>`;
|
||||
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(/<a roomid="/g, `<a target="replace" href="/`);
|
||||
}
|
||||
|
||||
function headerButtons(type: string, user: User) {
|
||||
const buf = [];
|
||||
const icons: {[k: string]: string} = {
|
||||
sent: '<i class="fa fa-paper-plane"></i>',
|
||||
received: '<i class="fa fa-get-pocket"></i>',
|
||||
all: '<i class="fa fa-users"></i>',
|
||||
help: '<i class="fa fa-question-circle"></i>',
|
||||
settings: '<i class="fa fa-cog"></i>',
|
||||
};
|
||||
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} <strong>${user.tr(title)}</strong>`);
|
||||
} else {
|
||||
buf.push(`${icon} <a roomid="view-friends-${page}">${user.tr(title)}</a>`);
|
||||
}
|
||||
}
|
||||
const refresh = (
|
||||
`<button class="button" name="send" value="/j view-friends${type?.trim() ? `-${type}` : ''}" style="float: right">` +
|
||||
` <i class="fa fa-refresh"></i> ${user.tr('Refresh')}</button>`
|
||||
);
|
||||
return `<div style="line-height:25px">${buf.join(' / ')}${refresh}</div>`;
|
||||
}
|
||||
|
||||
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 = '<div class="pad">';
|
||||
switch (toID(type)) {
|
||||
case 'outgoing': case 'sent':
|
||||
this.title = `[Friends] Sent`;
|
||||
buf += headerButtons('sent', user);
|
||||
buf += `<hr />`;
|
||||
if (user.settings.blockFriendRequests) {
|
||||
buf += `<h3>${this.tr(`You are currently blocking friend requests`)}.</h3>`;
|
||||
return buf;
|
||||
}
|
||||
const {sent} = await Chat.Friends.getRequests(user);
|
||||
if (sent.size < 1) {
|
||||
buf += `<strong>You have no outgoing friend requests pending.</strong><br />`;
|
||||
buf += `<br />To add a friend, use <code>/friend add [username]</code>.`;
|
||||
buf += `</div>`;
|
||||
return toLink(buf);
|
||||
}
|
||||
buf += `<h3>You have ${Chat.count(sent.size, 'friend requests')} pending${sent.size === MAX_REQUESTS ? ` (maximum reached)` : ''}.</h3>`;
|
||||
for (const request of sent) {
|
||||
buf += `<br /><div class="infobox">`;
|
||||
buf += `<strong>${request}</strong>`;
|
||||
buf += ` <button class="button" name="send" value="/friends undorequest ${request}">`;
|
||||
buf += `<i class="fa fa-undo"></i> ${this.tr('Undo')}</button>`;
|
||||
buf += `</div>`;
|
||||
}
|
||||
break;
|
||||
case 'received': case 'incoming':
|
||||
this.title = `[Friends] Received`;
|
||||
buf += headerButtons('received', user);
|
||||
buf += `<hr />`;
|
||||
const {received} = await Chat.Friends.getRequests(user);
|
||||
if (received.size < 1) {
|
||||
buf += `<strong>You have no pending friend requests.</strong>`;
|
||||
buf += `</div>`;
|
||||
return toLink(buf);
|
||||
}
|
||||
buf += `<h3>You have ${received.size} pending friend requests.</h3>`;
|
||||
for (const request of received) {
|
||||
buf += `<br /><div class="infobox">`;
|
||||
buf += `<strong>${request}</strong>`;
|
||||
buf += ` <button class="button" name="send" value="/friends accept ${request}">${this.tr('Accept')}</button> |`;
|
||||
buf += ` <button class="button" name="send" value="/friends reject ${request}">${this.tr('Deny')}</button>`;
|
||||
buf += `</div>`;
|
||||
}
|
||||
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 += `<hr /><h3>Help</h3>`;
|
||||
buf += `<strong>/friend OR /friends OR /friendslist:</strong><br /><ul><li>`;
|
||||
buf += [
|
||||
`<code>/friend list</code> - View current friends.`,
|
||||
`<code>/friend add [username]</code> - Send a friend request to [username], if you don't have them added.`,
|
||||
`<code>/friend remove [username]</code> OR <code>/unfriend [username]</code> - Unfriend the user.`,
|
||||
`<code>/friend accept [username]</code> - Accepts the friend request from [username], if it exists.`,
|
||||
`<code>/friend reject [username]</code> - Rejects the friend request from [username], if it exists.`,
|
||||
`<code>/friend toggle [off/on]</code> - Enable or disable receiving of friend requests.`,
|
||||
`<code>/friend hidenotifications</code> OR <code>hidenotifs</code> - Opts out of receiving friend notifications.`,
|
||||
`<code>/friend viewnotifications</code> OR <code>viewnotifs</code> - Opts into view friend notifications.`,
|
||||
`<code>/friend listdisplay [on/off]</code> - Opts [in/out] of letting others view your friends list.`,
|
||||
`<code>/friend viewlist [user]</code> - View the given [user]'s friend list, if they're allowing others to see.`,
|
||||
].join('</li><li>');
|
||||
buf += `</li></ul>`;
|
||||
break;
|
||||
case 'settings':
|
||||
this.title = `[Friends] Settings`;
|
||||
buf += headerButtons('settings', user);
|
||||
buf += `<hr /><h3>Friends Settings:</h3>`;
|
||||
const settings = user.settings;
|
||||
const {public_list} = await Chat.Friends.getSettings(user.id);
|
||||
buf += `<strong>Notify me when my friends come online:</strong><br />`;
|
||||
buf += `<button class="button${settings.allowFriendNotifications ? `` : ` disabled`}" name="send" `;
|
||||
buf += `value="/friends hidenotifs">Disable</button> `;
|
||||
buf += `<button class="button${settings.allowFriendNotifications ? ` disabled` : ``}" name="send" `;
|
||||
buf += `value="/friends viewnotifs">Enable</button> <br /><br />`;
|
||||
buf += `<strong>Receive friend requests:</strong><br />`;
|
||||
buf += `<button class="button${settings.blockFriendRequests ? ` disabled` : ''}" name="send" `;
|
||||
buf += `value="/friends toggle off">Disable</button> `;
|
||||
buf += `<button class="button${settings.blockFriendRequests ? `` : ` disabled`}" name="send" `;
|
||||
buf += `value="/friends toggle on">Enable</button> <br /><br />`;
|
||||
buf += `<strong>Allow others to see your list:</strong><br />`;
|
||||
buf += `<button class="button${public_list ? ` disabled` : ''}" name="send" `;
|
||||
buf += `value="/friends listdisplay yes">Allow</button> `;
|
||||
buf += `<button class="button${public_list ? `` : ` disabled`}" name="send" `;
|
||||
buf += `value="/friends listdisplay no">Hide</button> <br /><br />`;
|
||||
break;
|
||||
default:
|
||||
this.title = `[Friends] All Friends`;
|
||||
buf += headerButtons('all', user);
|
||||
buf += `<hr />`;
|
||||
buf += await Friends.visualizeList(user.id);
|
||||
}
|
||||
buf += `</div>`;
|
||||
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);
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
397
server/friends.ts
Normal file
397
server/friends.ts
Normal file
|
|
@ -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<Set<string>>;
|
||||
constructor(file: string = DEFAULT_FILE) {
|
||||
this.file = file === ':memory:' ? file : path.resolve(file);
|
||||
this.cache = new Cache<Set<string>>(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<AnyObject[]> {
|
||||
return this.all('get', [userid, MAX_FRIENDS]);
|
||||
}
|
||||
async getRequests(user: User) {
|
||||
const sent: Set<string> = new Set();
|
||||
const received: Set<string> = 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,<button class="button" name="send" value="/friends accept ${user.id}">Accept</button> | `;
|
||||
buf += Utils.html`<button class="button" name="send" value="/friends reject ${user.id}">Deny</button><br /> `;
|
||||
buf += `<small>(You can also stop this user from sending you friend requests with <code>/ignore</code>)</small>`;
|
||||
const disclaimer = (
|
||||
`/raw <small>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.</small>`
|
||||
);
|
||||
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,<button class="button" name="send" value="/friends undorequest ${Utils.escapeHTML(receiverID)}">` +
|
||||
`<i class="fa fa-undo"></i> Undo</button>`, 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<DatabaseRequest, DatabaseResult>(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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
17
test/server/chat-plugins/friends.js
Normal file
17
test/server/chat-plugins/friends.js
Normal file
|
|
@ -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:'));
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user