Add inbox (#3458) Bruception

* Add basic inbox

* Changes

* Remove export

* Move condition

* Use for each
This commit is contained in:
Bruce Berrios 2022-08-30 09:19:26 -04:00 committed by GitHub
parent 59fa77fe18
commit 4b49900d57
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 361 additions and 5 deletions

View file

@ -489,4 +489,32 @@ describe("UserDal", () => {
expect(resetUser.bananas).toStrictEqual(0);
expect(resetUser.xp).toStrictEqual(0);
});
it("getInbox should return the user's inbox", async () => {
await UserDAL.addUser("test name", "test email", "TestID");
const emptyInbox = await UserDAL.getInbox("TestID");
expect(emptyInbox).toStrictEqual([]);
await UserDAL.addToInbox(
"TestID",
[
{
getTemplate: (user) => ({
subject: `Hello ${user.name}!`,
}),
} as any,
],
0
);
const inbox = await UserDAL.getInbox("TestID");
expect(inbox).toStrictEqual([
{
subject: "Hello test name!",
},
]);
});
});

View file

@ -21,6 +21,7 @@ const dailyLeaderboardsConfig = {
],
dailyLeaderboardCacheSize: 3,
topResultsToAnnounce: 3,
xpReward: 0,
};
describe("Daily Leaderboards", () => {

View file

@ -0,0 +1,22 @@
import { buildMonkeyMail } from "../../src/utils/monkey-mail";
describe("Monkey Mail", () => {
it("should properly create a mail object", () => {
const mailConfig = {
subject: "",
body: "",
timestamp: Date.now(),
getTemplate: (): any => ({}),
};
const mail = buildMonkeyMail(mailConfig) as any;
expect(mail.id).toBeDefined();
expect(mail.subject).toBe("");
expect(mail.body).toBe("");
expect(mail.timestamp).toBeDefined();
expect(mail.read).toBe(false);
expect(mail.rewards).toEqual([]);
expect(mail.getTemplate).toBeDefined();
});
});

View file

@ -117,6 +117,19 @@ export async function updateEmail(
return new MonkeyResponse("Email updated");
}
function getRelevantUserInfo(
user: MonkeyTypes.User
): Partial<MonkeyTypes.User> {
return _.omit(user, [
"bananas",
"lbPersonalBests",
"quoteMod",
"inbox",
"nameHistory",
"lastNameChange",
]);
}
export async function getUser(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
@ -142,7 +155,12 @@ export async function getUser(
const agentLog = buildAgentLog(req);
Logger.logToDb("user_data_requested", agentLog, uid);
return new MonkeyResponse("User data retrieved", userInfo);
const userData = {
...getRelevantUserInfo(userInfo),
inboxUnreadSize: _.filter(userInfo.inbox, { read: false }).length,
};
return new MonkeyResponse("User data retrieved", userData);
}
export async function linkDiscord(
@ -487,3 +505,24 @@ export async function updateProfile(
return new MonkeyResponse("Profile updated");
}
export async function getInbox(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const inbox = await UserDAL.getInbox(uid);
return new MonkeyResponse("Inbox retrieved", inbox);
}
export async function updateInbox(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { mailIdsToMarkRead, mailIdsToDelete } = req.body;
await UserDAL.updateInbox(uid, mailIdsToMarkRead, mailIdsToDelete);
return new MonkeyResponse("Inbox updated");
}

View file

@ -470,4 +470,35 @@ router.patch(
asyncHandler(UserController.updateProfile)
);
const mailIdSchema = joi.array().items(joi.string().guid()).min(1).default([]);
const requireInboxEnabled = validateConfiguration({
criteria: (configuration) => {
return configuration.users.inbox.enabled;
},
invalidMessage: "Your inbox is not available at this time.",
});
router.get(
"/inbox",
requireInboxEnabled,
authenticateRequest(),
RateLimit.userMailGet,
asyncHandler(UserController.getInbox)
);
router.patch(
"/inbox",
requireInboxEnabled,
authenticateRequest(),
RateLimit.userMailUpdate,
validateRequest({
body: {
mailIdsToDelete: mailIdSchema,
mailIdsToMarkRead: mailIdSchema,
},
}),
asyncHandler(UserController.updateInbox)
);
export default router;

View file

@ -47,6 +47,10 @@ export const BASE_CONFIGURATION: MonkeyTypes.Configuration = {
maxDailyBonus: 0,
minDailyBonus: 0,
},
inbox: {
enabled: false,
maxMail: 0,
},
},
rateLimiting: {
badAuthentication: {
@ -63,6 +67,7 @@ export const BASE_CONFIGURATION: MonkeyTypes.Configuration = {
// GOTCHA! MUST ATLEAST BE 1, LRUCache module will make process crash and die
dailyLeaderboardCacheSize: 1,
topResultsToAnnounce: 1, // This should never be 0. Setting to zero will announce all results.
xpReward: 0,
},
};
@ -255,6 +260,21 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = {
},
},
},
inbox: {
type: "object",
label: "Inbox",
fields: {
enabled: {
type: "boolean",
label: "Enabled",
},
maxMail: {
type: "number",
label: "Max Messages",
min: 0,
},
},
},
profiles: {
type: "object",
label: "User Profiles",
@ -347,6 +367,11 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = {
label: "Top Results To Announce",
min: 1,
},
xpReward: {
type: "number",
label: "XP Reward",
min: 0,
},
},
},
},

View file

@ -4,9 +4,10 @@ import { updateUserEmail } from "../utils/auth";
import { checkAndUpdatePb } from "../utils/pb";
import * as db from "../init/db";
import MonkeyError from "../utils/error";
import { Collection, ObjectId, WithId, Long } from "mongodb";
import { Collection, ObjectId, WithId, Long, UpdateFilter } from "mongodb";
import Logger from "../utils/logger";
import { flattenObjectDeep } from "../utils/misc";
import { MonkeyMailWithTemplate } from "../utils/monkey-mail";
const SECONDS_PER_HOUR = 3600;
@ -732,3 +733,110 @@ export async function updateProfile(
}
);
}
export async function getInbox(
uid: string
): Promise<MonkeyTypes.User["inbox"]> {
const user = await getUser(uid, "get inventory");
return user.inbox ?? [];
}
export async function addToInbox(
uid: string,
mail: MonkeyMailWithTemplate[],
maxInboxSize: number
): Promise<void> {
const user = await getUser(uid, "add to inbox");
const inbox = user.inbox ?? [];
for (let i = 0; i < inbox.length + mail.length - maxInboxSize; i++) {
inbox.pop();
}
const evaluatedMail: MonkeyTypes.MonkeyMail[] = mail.map((mail) => {
return _.omit(
{
...mail,
...(mail.getTemplate && mail.getTemplate(user)),
},
"getTemplate"
);
});
inbox.unshift(...evaluatedMail);
const newInbox = inbox.sort((a, b) => b.timestamp - a.timestamp);
await getUsersCollection().updateOne(
{ uid },
{
$set: {
inbox: newInbox,
},
}
);
}
function buildRewardUpdates(
rewards: MonkeyTypes.AllRewards[]
): UpdateFilter<WithId<MonkeyTypes.User>> {
let totalXp = 0;
const newBadges: MonkeyTypes.Badge[] = [];
rewards.forEach((reward) => {
if (reward.type === "xp") {
totalXp += reward.item;
} else if (reward.type === "badge") {
newBadges.push(reward.item);
}
});
return {
$inc: {
xp: totalXp,
},
$push: {
"inventory.badges": { $each: newBadges },
},
};
}
export async function updateInbox(
uid: string,
mailToRead: string[],
mailToDelete: string[]
): Promise<void> {
const user = await getUser(uid, "update inbox");
const inbox = user.inbox ?? [];
const mailToReadSet = new Set(mailToRead);
const mailToDeleteSet = new Set(mailToDelete);
const allRewards: MonkeyTypes.AllRewards[] = [];
const newInbox = inbox
.filter((mail) => {
const { id, rewards } = mail;
if (mailToReadSet.has(id) && !mail.read) {
mail.read = true;
if (rewards.length > 0) {
allRewards.push(...rewards);
}
}
return !mailToDeleteSet.has(id);
})
.sort((a, b) => b.timestamp - a.timestamp);
const baseUpdate = {
$set: {
inbox: newInbox,
},
};
const rewardUpdates = buildRewardUpdates(allRewards);
const mergedUpdates = _.merge(baseUpdate, rewardUpdates);
await getUsersCollection().updateOne({ uid }, mergedUpdates);
}

View file

@ -3,6 +3,8 @@ import { getCurrentDayTimestamp } from "../utils/misc";
import { getCachedConfiguration } from "../init/configuration";
import { DailyLeaderboard } from "../utils/daily-leaderboards";
import { announceDailyLeaderboardTopResults } from "../tasks/george";
import { addToInbox } from "../dal/user";
import { buildMonkeyMail } from "../utils/monkey-mail";
const CRON_SCHEDULE = "1 0 * * *"; // At 00:01.
const ONE_DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
@ -24,7 +26,8 @@ async function announceDailyLeaderboard(
language: string,
mode: string,
mode2: string,
dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"]
dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"],
inboxConfig: MonkeyTypes.Configuration["users"]["inbox"]
): Promise<void> {
const yesterday = getCurrentDayTimestamp() - ONE_DAY_IN_MILLISECONDS;
const dailyLeaderboard = new DailyLeaderboard(
@ -43,6 +46,29 @@ async function announceDailyLeaderboard(
return;
}
if (inboxConfig.enabled) {
const { xpReward } = dailyLeaderboardsConfig;
const inboxPromises = topResults.map(async (entry) => {
const mail = buildMonkeyMail({
rewards: [
{
type: "xp",
item: xpReward,
},
],
getTemplate: (user) => ({
subject: `${xpReward} XP for top placement in the daily leaderboard!`,
body: `Congratulations ${user.name} on placing top ${entry.rank} in the ${language} ${mode} ${mode2} daily leaderboard! Claim your ${xpReward} xp!`,
}),
});
return await addToInbox(entry.uid, [mail], inboxConfig.maxMail);
});
await Promise.allSettled(inboxPromises);
}
const leaderboardId = `${mode} ${mode2} ${language}`;
await announceDailyLeaderboardTopResults(
leaderboardId,
@ -52,14 +78,24 @@ async function announceDailyLeaderboard(
}
async function announceDailyLeaderboards(): Promise<void> {
const { dailyLeaderboards, maintenance } = await getCachedConfiguration();
const {
dailyLeaderboards,
users: { inbox },
maintenance,
} = await getCachedConfiguration();
if (!dailyLeaderboards.enabled || maintenance) {
return;
}
await Promise.allSettled(
leaderboardsToAnnounce.map(({ language, mode, mode2 }) => {
return announceDailyLeaderboard(language, mode, mode2, dailyLeaderboards);
return announceDailyLeaderboard(
language,
mode,
mode2,
dailyLeaderboards,
inbox
);
})
);
}

View file

@ -439,6 +439,20 @@ export const userProfileUpdate = rateLimit({
handler: customHandler,
});
export const userMailGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userMailUpdate = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// ApeKeys Routing
export const apeKeysGet = rateLimit({
windowMs: ONE_HOUR_MS,

View file

@ -46,6 +46,10 @@ declare namespace MonkeyTypes {
maxDailyBonus: number;
minDailyBonus: number;
};
inbox: {
enabled: boolean;
maxMail: number;
};
};
apeKeys: {
endpointsEnabled: boolean;
@ -68,6 +72,7 @@ declare namespace MonkeyTypes {
validModeRules: ValidModeRule[];
dailyLeaderboardCacheSize: number;
topResultsToAnnounce: number;
xpReward: number;
};
}
@ -98,6 +103,32 @@ declare namespace MonkeyTypes {
};
}
interface Reward<T> {
type: string;
item: T;
}
interface XpReward extends Reward<number> {
type: "xp";
item: number;
}
interface BadgeReward extends Reward<Badge> {
type: "badge";
item: Badge;
}
type AllRewards = XpReward | BadgeReward;
interface MonkeyMail {
id: string;
subject: string;
body: string;
timestamp: number;
read: boolean;
rewards: AllRewards[];
}
interface User {
autoBanTimestamps?: number[];
addedAt: number;
@ -128,6 +159,7 @@ declare namespace MonkeyTypes {
profileDetails?: UserProfileDetails;
inventory?: UserInventory;
xp?: number;
inbox?: MonkeyMail[];
}
interface UserInventory {

View file

@ -0,0 +1,20 @@
import { v4 } from "uuid";
type MonkeyMailOptions = Partial<Omit<MonkeyMailWithTemplate, "id" | "read">>;
export interface MonkeyMailWithTemplate extends MonkeyTypes.MonkeyMail {
getTemplate?: (user: MonkeyTypes.User) => MonkeyMailOptions;
}
export function buildMonkeyMail(
options: MonkeyMailOptions
): MonkeyMailWithTemplate {
return {
id: v4(),
subject: options.subject || "",
body: options.body || "",
timestamp: options.timestamp || Date.now(),
read: false,
rewards: options.rewards || [],
getTemplate: options.getTemplate,
};
}