mirror of
				https://github.com/monkeytypegame/monkeytype.git
				synced 2025-10-31 03:08:29 +08:00 
			
		
		
		
	Merge branch 'master' of https://github.com/Miodec/monkeytype
This commit is contained in:
		
						commit
						aec22620b6
					
				
					 11 changed files with 361 additions and 5 deletions
				
			
		|  | @ -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!", | ||||
|       }, | ||||
|     ]); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -21,6 +21,7 @@ const dailyLeaderboardsConfig = { | |||
|   ], | ||||
|   dailyLeaderboardCacheSize: 3, | ||||
|   topResultsToAnnounce: 3, | ||||
|   xpReward: 0, | ||||
| }; | ||||
| 
 | ||||
| describe("Daily Leaderboards", () => { | ||||
|  |  | |||
							
								
								
									
										22
									
								
								backend/__tests__/utils/monkey-mail.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								backend/__tests__/utils/monkey-mail.spec.ts
									
										
									
									
									
										Normal 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(); | ||||
|   }); | ||||
| }); | ||||
|  | @ -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"); | ||||
| } | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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, | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
|  |  | |||
|  | @ -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); | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
|       ); | ||||
|     }) | ||||
|   ); | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
							
								
								
									
										32
									
								
								backend/src/types/types.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										32
									
								
								backend/src/types/types.d.ts
									
										
									
									
										vendored
									
									
								
							|  | @ -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 { | ||||
|  |  | |||
							
								
								
									
										20
									
								
								backend/src/utils/monkey-mail.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								backend/src/utils/monkey-mail.ts
									
										
									
									
									
										Normal 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, | ||||
|   }; | ||||
| } | ||||
		Loading…
	
	Add table
		
		Reference in a new issue