2022-08-19 17:43:57 +00:00
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract'
2022-10-05 16:41:57 +00:00
import {
getAllPrivateUsers ,
getGroup ,
getPrivateUser ,
getUser ,
getValues ,
isProd ,
log ,
} from './utils'
2022-08-19 17:43:57 +00:00
import { createRNG , shuffle } from '../../common/util/random'
2022-10-04 22:47:06 +00:00
import { DAY_MS , HOUR_MS } from '../../common/util/time'
2022-08-30 16:02:51 +00:00
import { filterDefined } from '../../common/util/array'
2022-10-04 22:47:06 +00:00
import { Follow } from '../../common/follow'
2022-10-05 16:41:57 +00:00
import { countBy , uniq , uniqBy } from 'lodash'
2022-10-04 22:47:06 +00:00
import { sendInterestingMarketsEmail } from './emails'
2022-08-19 17:43:57 +00:00
export const weeklyMarketsEmails = functions
2022-09-20 13:45:14 +00:00
. runWith ( { secrets : [ 'MAILGUN_KEY' ] , memory : '4GB' } )
2022-10-04 22:47:06 +00:00
// every minute on Monday for 2 hours starting at 12pm PT (UTC -07:00)
. pubsub . schedule ( '* 19-20 * * 1' )
2022-08-22 22:36:39 +00:00
. timeZone ( 'Etc/UTC' )
2022-08-19 17:43:57 +00:00
. onRun ( async ( ) = > {
await sendTrendingMarketsEmailsToAllUsers ( )
} )
const firestore = admin . firestore ( )
export async function getTrendingContracts() {
return await getValues < Contract > (
firestore
. collection ( 'contracts' )
. where ( 'isResolved' , '==' , false )
. where ( 'visibility' , '==' , 'public' )
2022-08-19 20:37:16 +00:00
// can't use multiple inequality (/orderBy) operators on different fields,
// so have to filter for closed contracts separately
2022-08-19 17:43:57 +00:00
. orderBy ( 'popularityScore' , 'desc' )
2022-08-19 20:37:16 +00:00
// might as well go big and do a quick filter for closed ones later
. limit ( 500 )
2022-08-19 17:43:57 +00:00
)
}
2022-10-04 22:47:06 +00:00
export async function sendTrendingMarketsEmailsToAllUsers() {
2022-08-19 21:01:53 +00:00
const numContractsToSend = 6
2022-10-05 16:41:57 +00:00
const privateUsers = isProd ( )
? await getAllPrivateUsers ( )
: filterDefined ( [
await getPrivateUser ( '6hHpzvRG0pMq8PNJs7RZj2qlZGn2' ) , // dev Ian
] )
2022-10-05 19:07:23 +00:00
const privateUsersToSendEmailsTo = privateUsers
// Get all users that haven't unsubscribed from weekly emails
. filter (
( user ) = >
user . notificationPreferences . trending_markets . includes ( 'email' ) &&
! user . weeklyTrendingEmailSent
)
. slice ( 0 , 90 ) // Send the emails out in batches
2022-10-05 16:41:57 +00:00
// For testing different users on prod: (only send ian an email though)
2022-10-05 19:07:23 +00:00
// const privateUsersToSendEmailsTo = filterDefined([
2022-10-05 16:41:57 +00:00
// await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), // prod Ian
2022-10-05 19:07:23 +00:00
// // isProd()
// await getPrivateUser('FptiiMZZ6dQivihLI8MYFQ6ypSw1'), // prod Mik
// // : await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
2022-10-05 16:41:57 +00:00
// ])
2022-10-04 22:47:06 +00:00
2022-08-22 20:59:11 +00:00
log (
'Sending weekly trending emails to' ,
privateUsersToSendEmailsTo . length ,
'users'
)
2022-08-19 20:45:04 +00:00
const trendingContracts = ( await getTrendingContracts ( ) )
. filter (
( contract ) = >
! (
contract . question . toLowerCase ( ) . includes ( 'trump' ) &&
contract . question . toLowerCase ( ) . includes ( 'president' )
2022-08-22 22:36:39 +00:00
) &&
( contract ? . closeTime ? ? 0 ) > Date . now ( ) + DAY_MS &&
! contract . groupSlugs ? . includes ( 'manifold-features' ) &&
! contract . groupSlugs ? . includes ( 'manifold-6748e065087e' )
2022-08-19 20:45:04 +00:00
)
2022-10-05 16:41:57 +00:00
. slice ( 0 , 50 )
const uniqueTrendingContracts = removeSimilarQuestions (
trendingContracts ,
trendingContracts ,
true
) . slice ( 0 , 20 )
2022-10-04 22:47:06 +00:00
await Promise . all (
privateUsersToSendEmailsTo . map ( async ( privateUser ) = > {
if ( ! privateUser . email ) {
log ( ` No email for ${ privateUser . username } ` )
return
}
2022-10-05 16:41:57 +00:00
const unbetOnFollowedMarkets = await getUserUnBetOnFollowsMarkets (
privateUser . id
)
const unBetOnGroupMarkets = await getUserUnBetOnGroupsMarkets (
privateUser . id ,
unbetOnFollowedMarkets
)
const similarBettorsMarkets = await getSimilarBettorsMarkets (
privateUser . id ,
unBetOnGroupMarkets
)
2022-10-04 22:47:06 +00:00
const marketsAvailableToSend = uniqBy (
[
2022-10-05 16:41:57 +00:00
. . . chooseRandomSubset ( unbetOnFollowedMarkets , 2 ) ,
// // Most people will belong to groups but may not follow other users,
// so choose more from the other subsets if the followed markets is sparse
. . . chooseRandomSubset (
unBetOnGroupMarkets ,
2022-10-05 19:07:23 +00:00
unbetOnFollowedMarkets . length < 2 ? 3 : 2
2022-10-05 16:41:57 +00:00
) ,
. . . chooseRandomSubset (
similarBettorsMarkets ,
2022-10-05 19:07:23 +00:00
unbetOnFollowedMarkets . length < 2 ? 3 : 2
2022-10-05 16:41:57 +00:00
) ,
2022-10-04 22:47:06 +00:00
] ,
( contract ) = > contract . id
)
2022-10-05 16:41:57 +00:00
// // at least send them trending contracts if nothing else
2022-10-05 19:07:23 +00:00
if ( marketsAvailableToSend . length < numContractsToSend ) {
const trendingMarketsToSend =
numContractsToSend - marketsAvailableToSend . length
log (
` not enough personalized markets, sending ${ trendingMarketsToSend } trending `
)
2022-10-04 22:47:06 +00:00
marketsAvailableToSend . push (
2022-10-05 16:41:57 +00:00
. . . removeSimilarQuestions (
uniqueTrendingContracts ,
marketsAvailableToSend ,
false
)
2022-10-04 22:47:06 +00:00
. filter (
2022-10-05 16:41:57 +00:00
( contract ) = > ! contract . uniqueBettorIds ? . includes ( privateUser . id )
2022-10-04 22:47:06 +00:00
)
2022-10-05 19:07:23 +00:00
. slice ( 0 , trendingMarketsToSend )
2022-10-04 22:47:06 +00:00
)
2022-10-05 19:07:23 +00:00
}
2022-10-04 22:47:06 +00:00
if ( marketsAvailableToSend . length < numContractsToSend ) {
log (
'not enough new, unbet-on contracts to send to user' ,
privateUser . id
)
await firestore . collection ( 'private-users' ) . doc ( privateUser . id ) . update ( {
weeklyTrendingEmailSent : true ,
} )
return
}
// choose random subset of contracts to send to user
const contractsToSend = chooseRandomSubset (
marketsAvailableToSend ,
numContractsToSend
)
const user = await getUser ( privateUser . id )
if ( ! user ) return
2022-10-05 16:41:57 +00:00
log (
2022-10-04 22:47:06 +00:00
'sending contracts:' ,
2022-10-05 16:41:57 +00:00
contractsToSend . map ( ( c ) = > c . question + ' ' + c . popularityScore )
2022-10-04 22:47:06 +00:00
)
// if they don't have enough markets, find user bets and get the other bettor ids who most overlap on those markets, then do the same thing as above for them
2022-10-05 16:41:57 +00:00
await sendInterestingMarketsEmail ( user , privateUser , contractsToSend )
2022-10-04 22:47:06 +00:00
await firestore . collection ( 'private-users' ) . doc ( user . id ) . update ( {
2022-09-26 22:05:50 +00:00
weeklyTrendingEmailSent : true ,
} )
2022-10-04 22:47:06 +00:00
} )
)
}
2022-10-05 19:07:23 +00:00
const MINIMUM_POPULARITY_SCORE = 10
2022-10-04 22:47:06 +00:00
2022-10-05 16:41:57 +00:00
const getUserUnBetOnFollowsMarkets = async ( userId : string ) = > {
2022-10-04 22:47:06 +00:00
const follows = await getValues < Follow > (
firestore . collection ( 'users' ) . doc ( userId ) . collection ( 'follows' )
)
const unBetOnContractsFromFollows = await Promise . all (
follows . map ( async ( follow ) = > {
const unresolvedContracts = await getValues < Contract > (
firestore
. collection ( 'contracts' )
. where ( 'isResolved' , '==' , false )
. where ( 'visibility' , '==' , 'public' )
. where ( 'creatorId' , '==' , follow . userId )
// can't use multiple inequality (/orderBy) operators on different fields,
// so have to filter for closed contracts separately
. orderBy ( 'popularityScore' , 'desc' )
. limit ( 50 )
)
// filter out contracts that have close times less than 6 hours from now
const openContracts = unresolvedContracts . filter (
( contract ) = > ( contract ? . closeTime ? ? 0 ) > Date . now ( ) + 6 * HOUR_MS
)
return openContracts . filter (
2022-10-05 16:41:57 +00:00
( contract ) = > ! contract . uniqueBettorIds ? . includes ( userId )
2022-10-04 22:47:06 +00:00
)
} )
)
2022-10-05 19:07:23 +00:00
const sortedMarkets = uniqBy (
unBetOnContractsFromFollows . flat ( ) ,
( contract ) = > contract . id
)
2022-10-04 22:47:06 +00:00
. filter (
( contract ) = >
contract . popularityScore !== undefined &&
contract . popularityScore > MINIMUM_POPULARITY_SCORE
2022-08-19 17:43:57 +00:00
)
2022-10-04 22:47:06 +00:00
. sort ( ( a , b ) = > ( b . popularityScore ? ? 0 ) - ( a . popularityScore ? ? 0 ) )
2022-10-05 16:41:57 +00:00
const uniqueSortedMarkets = removeSimilarQuestions (
sortedMarkets ,
sortedMarkets ,
true
2022-10-04 22:47:06 +00:00
)
2022-10-05 16:41:57 +00:00
const topSortedMarkets = uniqueSortedMarkets . slice ( 0 , 10 )
2022-10-05 19:09:40 +00:00
// log(
// 'top 10 sorted markets by followed users',
// topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
// )
2022-10-05 16:41:57 +00:00
return topSortedMarkets
2022-10-04 22:47:06 +00:00
}
2022-10-05 16:41:57 +00:00
const getUserUnBetOnGroupsMarkets = async (
userId : string ,
differentThanTheseContracts : Contract [ ]
) = > {
2022-10-04 22:47:06 +00:00
const snap = await firestore
. collectionGroup ( 'groupMembers' )
. where ( 'userId' , '==' , userId )
. get ( )
2022-08-19 17:43:57 +00:00
2022-10-04 22:47:06 +00:00
const groupIds = filterDefined (
snap . docs . map ( ( doc ) = > doc . ref . parent . parent ? . id )
)
const groups = filterDefined (
await Promise . all ( groupIds . map ( async ( groupId ) = > await getGroup ( groupId ) ) )
)
2022-10-05 19:07:23 +00:00
if ( groups . length === 0 ) return [ ]
2022-10-04 22:47:06 +00:00
const unBetOnContractsFromGroups = await Promise . all (
groups . map ( async ( group ) = > {
const unresolvedContracts = await getValues < Contract > (
firestore
. collection ( 'contracts' )
. where ( 'isResolved' , '==' , false )
. where ( 'visibility' , '==' , 'public' )
. where ( 'groupSlugs' , 'array-contains' , group . slug )
// can't use multiple inequality (/orderBy) operators on different fields,
// so have to filter for closed contracts separately
. orderBy ( 'popularityScore' , 'desc' )
. limit ( 50 )
)
// filter out contracts that have close times less than 6 hours from now
const openContracts = unresolvedContracts . filter (
( contract ) = > ( contract ? . closeTime ? ? 0 ) > Date . now ( ) + 6 * HOUR_MS
)
2022-08-19 17:43:57 +00:00
2022-10-04 22:47:06 +00:00
return openContracts . filter (
( contract ) = > ! contract . uniqueBettorIds ? . includes ( userId )
)
2022-08-22 22:36:39 +00:00
} )
2022-10-04 22:47:06 +00:00
)
2022-10-05 16:41:57 +00:00
2022-10-05 19:07:23 +00:00
const sortedMarkets = uniqBy (
unBetOnContractsFromGroups . flat ( ) ,
( contract ) = > contract . id
)
2022-10-04 22:47:06 +00:00
. filter (
( contract ) = >
contract . popularityScore !== undefined &&
contract . popularityScore > MINIMUM_POPULARITY_SCORE
)
. sort ( ( a , b ) = > ( b . popularityScore ? ? 0 ) - ( a . popularityScore ? ? 0 ) )
2022-10-05 16:41:57 +00:00
const uniqueSortedMarkets = removeSimilarQuestions (
sortedMarkets ,
sortedMarkets ,
true
)
const topSortedMarkets = removeSimilarQuestions (
uniqueSortedMarkets ,
differentThanTheseContracts ,
false
) . slice ( 0 , 10 )
2022-10-05 19:09:40 +00:00
// log(
// 'top 10 sorted group markets',
// topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
// )
2022-10-05 16:41:57 +00:00
return topSortedMarkets
2022-10-04 22:47:06 +00:00
}
// Gets markets followed by similar bettors and bet on by similar bettors
2022-10-05 16:41:57 +00:00
const getSimilarBettorsMarkets = async (
userId : string ,
differentThanTheseContracts : Contract [ ]
) = > {
2022-10-04 22:47:06 +00:00
// get contracts with unique bettor ids with this user
const contractsUserHasBetOn = await getValues < Contract > (
firestore
. collection ( 'contracts' )
. where ( 'uniqueBettorIds' , 'array-contains' , userId )
)
2022-10-05 19:07:23 +00:00
if ( contractsUserHasBetOn . length === 0 ) return [ ]
2022-10-04 22:47:06 +00:00
// count the number of times each unique bettor id appears on those contracts
const bettorIdsToCounts = countBy (
contractsUserHasBetOn . map ( ( contract ) = > contract . uniqueBettorIds ) . flat ( ) ,
( bettorId ) = > bettorId
)
// sort by number of times they appear with at least 2 appearances
const sortedBettorIds = Object . entries ( bettorIdsToCounts )
. sort ( ( a , b ) = > b [ 1 ] - a [ 1 ] )
. filter ( ( bettorId ) = > bettorId [ 1 ] > 2 )
. map ( ( entry ) = > entry [ 0 ] )
. filter ( ( bettorId ) = > bettorId !== userId )
// get the top 10 most similar bettors (excluding this user)
const similarBettorIds = sortedBettorIds . slice ( 0 , 10 )
2022-10-05 19:07:23 +00:00
if ( similarBettorIds . length === 0 ) return [ ]
2022-10-04 22:47:06 +00:00
// get contracts with unique bettor ids with this user
2022-10-05 19:07:23 +00:00
const contractsSimilarBettorsHaveBetOn = uniqBy (
(
await getValues < Contract > (
firestore
. collection ( 'contracts' )
. where (
'uniqueBettorIds' ,
'array-contains-any' ,
similarBettorIds . slice ( 0 , 10 )
)
. orderBy ( 'popularityScore' , 'desc' )
. limit ( 200 )
)
) . filter (
( contract ) = >
! contract . uniqueBettorIds ? . includes ( userId ) &&
( contract . popularityScore ? ? 0 ) > MINIMUM_POPULARITY_SCORE
) ,
( contract ) = > contract . id
)
2022-10-04 23:12:07 +00:00
// sort the contracts by how many times similar bettor ids are in their unique bettor ids array
2022-10-05 16:41:57 +00:00
const sortedContractsInSimilarBettorsBets = contractsSimilarBettorsHaveBetOn
. map ( ( contract ) = > {
const appearances = contract . uniqueBettorIds ? . filter ( ( bettorId ) = >
similarBettorIds . includes ( bettorId )
) . length
return [ contract , appearances ] as [ Contract , number ]
} )
. sort ( ( a , b ) = > b [ 1 ] - a [ 1 ] )
. map ( ( entry ) = > entry [ 0 ] )
const uniqueSortedContractsInSimilarBettorsBets = removeSimilarQuestions (
sortedContractsInSimilarBettorsBets ,
sortedContractsInSimilarBettorsBets ,
true
2022-10-04 23:12:07 +00:00
)
2022-10-05 16:41:57 +00:00
const topMostSimilarContracts = removeSimilarQuestions (
uniqueSortedContractsInSimilarBettorsBets ,
differentThanTheseContracts ,
false
) . slice ( 0 , 10 )
2022-10-04 23:12:07 +00:00
2022-10-05 19:09:40 +00:00
// log(
// 'top 10 sorted contracts other similar bettors have bet on',
// topMostSimilarContracts.map((c) => c.question)
// )
2022-10-04 22:47:06 +00:00
2022-10-04 23:12:07 +00:00
return topMostSimilarContracts
2022-08-19 17:43:57 +00:00
}
2022-10-05 16:41:57 +00:00
// search contract array by question and remove contracts with 3 matching words in the question
const removeSimilarQuestions = (
contractsToFilter : Contract [ ] ,
byContracts : Contract [ ] ,
allowExactSameContracts : boolean
) = > {
// log(
// 'contracts to filter by',
// byContracts.map((c) => c.question + ' ' + c.popularityScore)
// )
let contractsToRemove : Contract [ ] = [ ]
byContracts . length > 0 &&
byContracts . forEach ( ( contract ) = > {
2022-10-05 19:07:23 +00:00
const contractQuestion = stripNonAlphaChars (
contract . question . toLowerCase ( )
)
2022-10-05 16:41:57 +00:00
const contractQuestionWords = uniq ( contractQuestion . split ( ' ' ) ) . filter (
2022-10-05 19:07:23 +00:00
( w ) = > ! IGNORE_WORDS . includes ( w )
2022-10-05 16:41:57 +00:00
)
contractsToRemove = contractsToRemove . concat (
contractsToFilter . filter (
2022-10-05 19:07:23 +00:00
// Remove contracts with more than 2 matching (uncommon) words and a lower popularity score
2022-10-05 16:41:57 +00:00
( c2 ) = > {
const significantOverlap =
2022-10-05 19:07:23 +00:00
// TODO: we should probably use a library for comparing strings/sentiments
uniq (
stripNonAlphaChars ( c2 . question . toLowerCase ( ) ) . split ( ' ' )
) . filter ( ( word ) = > contractQuestionWords . includes ( word ) ) . length >
2
2022-10-05 16:41:57 +00:00
const lessPopular =
( c2 . popularityScore ? ? 0 ) < ( contract . popularityScore ? ? 0 )
return (
( significantOverlap && lessPopular ) ||
( allowExactSameContracts ? false : c2 . id === contract . id )
)
}
)
)
} )
// log(
// 'contracts to filter out',
// contractsToRemove.map((c) => c.question)
// )
const returnContracts = contractsToFilter . filter (
( cf ) = > ! contractsToRemove . map ( ( c ) = > c . id ) . includes ( cf . id )
)
return returnContracts
}
2022-08-23 05:27:07 +00:00
const fiveMinutes = 5 * 60 * 1000
const seed = Math . round ( Date . now ( ) / fiveMinutes ) . toString ( )
const rng = createRNG ( seed )
2022-08-19 17:43:57 +00:00
function chooseRandomSubset ( contracts : Contract [ ] , count : number ) {
2022-08-23 05:27:07 +00:00
shuffle ( contracts , rng )
2022-08-19 17:43:57 +00:00
return contracts . slice ( 0 , count )
}
2022-10-05 16:41:57 +00:00
function stripNonAlphaChars ( str : string ) {
return str . replace ( /[^\w\s']|_/g , '' ) . replace ( /\s+/g , ' ' )
}
const IGNORE_WORDS = [
'the' ,
'a' ,
'an' ,
'and' ,
'or' ,
'of' ,
'to' ,
'in' ,
'on' ,
'will' ,
'be' ,
'is' ,
'are' ,
'for' ,
'by' ,
'at' ,
'from' ,
'what' ,
'when' ,
'which' ,
'that' ,
'it' ,
'as' ,
'if' ,
'then' ,
'than' ,
'but' ,
'have' ,
'has' ,
'had' ,
]