diff --git a/cloud-functions/src/notification-engine/townhall/_utils/getMentionedUsernames.ts b/cloud-functions/src/notification-engine/townhall/_utils/getMentionedUsernames.ts new file mode 100644 index 0000000..b1d8f4d --- /dev/null +++ b/cloud-functions/src/notification-engine/townhall/_utils/getMentionedUsernames.ts @@ -0,0 +1,5 @@ +export default function getMentionedUsernames(content: string): string[] { + // matches for spaces around it and/or html tags, except for the anchor tag + const mentionedUsernamesPattern = /(?<=(?:^|\s+|<((?!a\b)\w+)>| )@)\w+(?=(?:\s+| |<\/((?!a\b)\w+)>))/g; + return String(content).match(mentionedUsernamesPattern) || []; +} diff --git a/cloud-functions/src/notification-engine/townhall/_utils/sendMentionNotifications.ts b/cloud-functions/src/notification-engine/townhall/_utils/sendMentionNotifications.ts new file mode 100644 index 0000000..d6bce08 --- /dev/null +++ b/cloud-functions/src/notification-engine/townhall/_utils/sendMentionNotifications.ts @@ -0,0 +1,78 @@ +import { NotificationService } from '../../NotificationService'; +import getTemplateRender from '../../global-utils/getTemplateRender'; +import { NOTIFICATION_SOURCE } from '../../notification_engine_constants'; +import getHouseNotificationPrefsFromTHNotificationPrefs from './getHouseNotificationPrefsFromTHNotificationPrefs'; +import getMentionedUsernames from './getMentionedUsernames'; +import { ETHContentType, ITHUser } from './types'; + +interface Args { + firestore_db: FirebaseFirestore.Firestore + authorUsername: string | null; + htmlContent: string; + house_id: string; + type: ETHContentType; + url: string; +} + +const TRIGGER_NAME = 'newMention'; +const SOURCE = NOTIFICATION_SOURCE.TOWNHALL; + +export default async function sendMentionNotifications(args : Args) { + if (!args) throw Error(`Missing arguments for trigger: ${TRIGGER_NAME}`); + const { firestore_db, authorUsername, htmlContent, house_id, type, url } = args; + console.log(`Running trigger: ${TRIGGER_NAME}, with args: ${JSON.stringify({ authorUsername, htmlContent, house_id, type, url })}`); + + const mentionedUsernames = getMentionedUsernames(htmlContent).filter((username) => username !== authorUsername); + console.log(`Mentioned usernames: ${JSON.stringify(mentionedUsernames)}`); + if (!mentionedUsernames.length) return; + + for (const mentionedUsername of mentionedUsernames) { + // get user preferences + const mentionedUserDocSnapshot = await firestore_db.collection('users').where('username', '==', mentionedUsername).limit(1).get(); + if (mentionedUserDocSnapshot.empty) continue; + + const mentionedUserData = mentionedUserDocSnapshot.docs[0].data() as ITHUser; + if (!mentionedUserData || !mentionedUserData.notification_preferences) continue; + + const mentionedUserNotificationPreferences = getHouseNotificationPrefsFromTHNotificationPrefs(mentionedUserData.notification_preferences, house_id); + if (!mentionedUserNotificationPreferences) continue; + + if (!mentionedUserNotificationPreferences.triggerPreferences?.[TRIGGER_NAME].enabled || !(mentionedUserNotificationPreferences.triggerPreferences?.[TRIGGER_NAME]?.mention_types || []).includes(type)) continue; + + let domain = ''; + try { + const urlObject = new URL(url); + domain = urlObject.origin; // This gets the protocol + hostname + port (if any) + } catch (error) { + console.error('Invalid URL: ', error); + } + + const { htmlMessage, markdownMessage, textMessage, subject } = await getTemplateRender( + SOURCE, + TRIGGER_NAME, + { + ...args, + authorUsername, + url, + content: htmlContent, + domain: domain, + username: mentionedUsername, + mentionType: type + }); + + const notificationServiceInstance = new NotificationService( + SOURCE, + TRIGGER_NAME, + htmlMessage, + markdownMessage, + textMessage, + subject, + { + link: url + } + ); + + console.log(`Sending notification for trigger: ${TRIGGER_NAME}, mention type: ${type} by user ${mentionedUserData.id}`); + await notificationServiceInstance.notifyAllChannels(mentionedUserNotificationPreferences); + } +} diff --git a/cloud-functions/src/notification-engine/townhall/_utils/types.ts b/cloud-functions/src/notification-engine/townhall/_utils/types.ts index 3e31dc4..21f2dce 100644 --- a/cloud-functions/src/notification-engine/townhall/_utils/types.ts +++ b/cloud-functions/src/notification-engine/townhall/_utils/types.ts @@ -69,6 +69,8 @@ export interface ITHPost { // PROJECT FOR CUSTOM DEPLOYMENT project_id: string; + + subscribers: string[]; } export interface ITHUserNotificationPreferences { @@ -104,6 +106,12 @@ export interface ITHNotification { url?: string; } +export enum ETHContentType { + COMMENT = 'comment', + REPLY = 'reply', + POST = 'post' +} + export enum ETHBountySource { TWITTER = 'twitter', LENS = 'lens', diff --git a/cloud-functions/src/notification-engine/townhall/postCommentAdded/index.ts b/cloud-functions/src/notification-engine/townhall/postCommentAdded/index.ts index 0dfe728..55f55c3 100644 --- a/cloud-functions/src/notification-engine/townhall/postCommentAdded/index.ts +++ b/cloud-functions/src/notification-engine/townhall/postCommentAdded/index.ts @@ -3,8 +3,11 @@ import getSourceFirebaseAdmin from '../../global-utils/getSourceFirebaseAdmin'; import { NOTIFICATION_SOURCE } from '../../notification_engine_constants'; import getTemplateRender from '../../global-utils/getTemplateRender'; import { thCommentRef, thPostRef, thUserRef } from '../_utils/thFirestoreRefs'; -import { ITHComment, ITHUser } from '../_utils/types'; +import { ETHContentType, ITHComment, ITHPost, ITHUser, ITHUserNotificationPreferences } from '../_utils/types'; import getHouseNotificationPrefsFromTHNotificationPrefs from '../_utils/getHouseNotificationPrefsFromTHNotificationPrefs'; +import { generatePostUrl } from '../_utils/generateUrl'; +import Showdown from 'showdown'; +import sendMentionNotifications from '../_utils/sendMentionNotifications'; const TRIGGER_NAME = 'postCommentAdded'; const SOURCE = NOTIFICATION_SOURCE.TOWNHALL; @@ -31,12 +34,18 @@ export default async function postCommentAdded(args: Args) { // get post const postId = commentData.post_id; - const postData = (await thPostRef(firestore_db, postId).get()).data(); + const postData = (await thPostRef(firestore_db, postId).get()).data() as ITHPost; if (!postData) { throw Error(`Post with id ${postId} not found`); } + const subscribers = [...(postData?.subscribers || []), postData.user_id].filter((user_id) => !!user_id) as string[]; // add post author to subscribers + if (!subscribers || !subscribers?.length) { + console.log(`No subscribers for a ${postData.post_type} type, post ${postId} in house ${postData?.house_id}.`); + return; + } + // get comment author const commentAuthorData = (await thUserRef(firestore_db, commentData.user_id).get()).data() as ITHUser; @@ -44,37 +53,76 @@ export default async function postCommentAdded(args: Args) { throw Error(`Comment author with id ${commentData.user_id} not found`); } - // fetch all users who have newPostCreated trigger enabled for this network - const subscribersSnapshot = await firestore_db - .collection('users') - .where(`notification_preferences.triggerPreferences.${postData.house_id}.${TRIGGER_NAME}.enabled`, '==', true) - .get(); + const commentUrl = `${generatePostUrl(postData)}#${comment_id}`; + + const converter = new Showdown.Converter(); + const commentHTML = converter.makeHtml(commentData.content); - console.log(`Found ${subscribersSnapshot.size} subscribers for TRIGGER_NAME ${TRIGGER_NAME}`); + for (const userId of subscribers) { + if (userId === commentAuthorData.id) continue; - for (const subscriberDoc of subscribersSnapshot.docs) { - const subscriberData = subscriberDoc.data() as ITHUser; - if (!subscriberData.notification_preferences) continue; + const userDoc = await thUserRef(firestore_db, userId).get(); + if (!userDoc.exists) continue; + const userData = userDoc.data() as ITHUser; + if (!userData) continue; - console.log(`Subscribed user for ${TRIGGER_NAME} with id: ${subscriberData.id}`); + const userTHNotificationPreferences: ITHUserNotificationPreferences | null = userData.notification_preferences || null; + if (!userTHNotificationPreferences && userId !== postData.user_id) continue; // only skip if user is not the post author - const subscriberNotificationPreferences = getHouseNotificationPrefsFromTHNotificationPrefs( - subscriberData.notification_preferences, + let userNotificationPreferences = getHouseNotificationPrefsFromTHNotificationPrefs( + userTHNotificationPreferences, postData.house_id ); - if (!subscriberNotificationPreferences) continue; - - const link = `https://www.townhallgov.com/${postData.house_id}/post/${postId}`; + // send notification to post author even if he hasn't set any notification preferences (or for this trigger) + if (userId === postData.user_id) { + // only skip if user has explicitly disabled 'commentsOnMyPosts' sub-trigger + if (userNotificationPreferences.triggerPreferences?.[TRIGGER_NAME]?.enabled === false || + !(userNotificationPreferences.triggerPreferences?.[TRIGGER_NAME]?.sub_triggers || ['commentsOnMyPosts']).includes('commentsOnMyPosts') + ) continue; + + // pseudo notification prefs with 'commentsOnMyPosts' sub-trigger enabled (to make default behaviour as enabled) + userNotificationPreferences = { + ...userNotificationPreferences, + triggerPreferences: { + ...userNotificationPreferences.triggerPreferences, + [TRIGGER_NAME]: { + ...userNotificationPreferences.triggerPreferences?.[TRIGGER_NAME], + enabled: true, + name: TRIGGER_NAME, + sub_triggers: ['commentsOnMyPosts'] + } + } + }; + } else { + // only skip if user has explicitly disabled 'commentsOnSubscribedPosts' sub-trigger + if (userNotificationPreferences.triggerPreferences?.[TRIGGER_NAME]?.enabled === false || + !(userNotificationPreferences.triggerPreferences?.[TRIGGER_NAME]?.sub_triggers || ['commentsOnSubscribedPosts']).includes('commentsOnSubscribedPosts') + ) continue; + + // pseudo notification prefs with 'commentsOnSubscribedPosts' sub-trigger enabled (to make default behaviour as enabled) + userNotificationPreferences = { + ...userNotificationPreferences, + triggerPreferences: { + ...userNotificationPreferences.triggerPreferences, + [TRIGGER_NAME]: { + ...userNotificationPreferences.triggerPreferences?.[TRIGGER_NAME], + enabled: true, + name: TRIGGER_NAME, + sub_triggers: ['commentsOnSubscribedPosts'] + } + } + }; + } const { htmlMessage, markdownMessage, textMessage, subject } = await getTemplateRender(SOURCE, TRIGGER_NAME, { ...args, - username: subscriberData.name || !subscriberData.is_username_autogenerated ? subscriberData.username : 'user', + username: userData.name || !userData.is_username_autogenerated ? userData.username : 'user', comment_author: commentAuthorData.name || !commentAuthorData.is_username_autogenerated ? commentAuthorData.username : 'user', proposal_title: postData.title, - link, + link: commentUrl, post_type: (`${postData.post_type}`).replaceAll('_', ' '), - comment: commentData.content + comment: commentHTML }); const notificationServiceInstance = new NotificationService( @@ -85,15 +133,24 @@ export default async function postCommentAdded(args: Args) { textMessage, subject, { - link + link: commentUrl } ); console.log( - `Sending notification to user_id ${subscriberDoc.id} for trigger ${TRIGGER_NAME} on house ${postData.house_id} for postId ${postData.id}` + `Sending notification to user_id ${userData.id} for trigger ${TRIGGER_NAME} on house ${postData.house_id} for postId ${postData.id}` ); - await notificationServiceInstance.notifyAllChannels(subscriberNotificationPreferences); + await notificationServiceInstance.notifyAllChannels(userNotificationPreferences); } + await sendMentionNotifications({ + firestore_db, + authorUsername: commentAuthorData.username, + htmlContent: commentHTML, + house_id: postData.house_id || commentData.house_id, + type: ETHContentType.COMMENT, + url: commentUrl + }); + return; }