From 35956bb7b1aaafca3ccd622a334ce7ddfdece414 Mon Sep 17 00:00:00 2001 From: sloppyjosh Date: Tue, 5 Mar 2024 01:25:05 -0500 Subject: [PATCH] notification logic --- src/db/services/database.ts | 29 ++++++++++- src/index.ts | 24 ++++++--- src/reddit/model/Subreddit.ts | 44 +++++++++++++++++ src/reddit/model/User.ts | 71 +++++++++++++++++++++++++++ src/reddit/services/reddit.ts | 16 +++--- src/reddit/session/SessionManager.ts | 2 +- src/utils/ShouldNotify.ts | 28 +++++++++++ src/workflows/WorkflowOrchestrator.ts | 69 +++++++++++--------------- 8 files changed, 228 insertions(+), 55 deletions(-) create mode 100644 src/reddit/model/Subreddit.ts create mode 100644 src/reddit/model/User.ts create mode 100644 src/utils/ShouldNotify.ts diff --git a/src/db/services/database.ts b/src/db/services/database.ts index e6bb992..b144790 100644 --- a/src/db/services/database.ts +++ b/src/db/services/database.ts @@ -48,7 +48,7 @@ export class DatabaseService { * * @param {Object} mention - The user mention object to insert, containing rdrama_comment_id, username, and optionally message. */ - public async insertUserMention(mention: { rdrama_comment_id: string; username: string; message?: string }): Promise { + public async insertUserMention(mention: { rdrama_comment_id: number; username: string; message?: string }): Promise { const sql = `INSERT INTO user_mentions (rdrama_comment_id, username, message) VALUES (?, ?, ?)`; await this.db.run(sql, [mention.rdrama_comment_id, mention.username, mention.message]); } @@ -164,4 +164,31 @@ export class DatabaseService { return tokenRow || null; } + /** + * Checks if the cooldown period has passed since the last notification was sent to any user. + * + * @returns {Promise} True if the cooldown has passed, allowing new notifications to be sent. + */ + public async canSendNotification(): Promise { + const cooldownHours = parseInt(process.env.NOTIFICATION_COOLDOWN_HOURS || '4', 10); + const sql = ` + SELECT MAX(sent_time) as last_notification_time + FROM user_mentions + `; + const result = await this.db.get(sql); + + if (!result || !result.last_notification_time) { + // No notifications have been sent yet, or unable to retrieve the last sent time. + return true; + } + + const lastNotificationTime = new Date(result.last_notification_time).getTime(); + const currentTime = Date.now(); + const timeElapsed = currentTime - lastNotificationTime; + const cooldownPeriod = cooldownHours * 60 * 60 * 1000; // Convert hours to milliseconds + + return timeElapsed >= cooldownPeriod; + } + + } diff --git a/src/index.ts b/src/index.ts index 40ec440..d7ef158 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,20 @@ import { CommentPoster } from './rdrama/services/CommentPoster'; // Import other necessary services or configurations async function startApplication() { + console.log('Database Start') + const databaseInitializer = DatabaseInitializer.getInstance(); + const db = await databaseInitializer.getDbInstance() + if (!db) { + throw new Error('Failed to initialize the database.'); + } + const databaseService = new DatabaseService(db) + const canSend = await databaseService.canSendNotification(); + const coolDownHours = process.env.NOTIFICATION_COOLDOWN_HOURS + if (!canSend) { + console.log(`Last Message Sent less than ${coolDownHours ? coolDownHours : 4} hours ago. Set NOTIFICATION_COOLDOWN_HOURS to change this`) + } + + console.log('RDrama Session Start') // Initialize SessionManager or other global configurations const rDramaSessionManager = rDramaSession.getInstance(); if (!process.env.RDRAMA_API_KEY) { @@ -21,16 +35,11 @@ async function startApplication() { } rDramaSessionManager.setAuthorizationToken(process.env.RDRAMA_API_KEY); - const databaseInitializer = DatabaseInitializer.getInstance(); - const db = await databaseInitializer.getDbInstance() - if (!db) { - throw new Error('Failed to initialize the database.'); - } - const databaseService = new DatabaseService(db) - const databaseMaintenance = new DatabaseMaintenanceService(databaseService) console.log('Database Maintenance Start') + const databaseMaintenance = new DatabaseMaintenanceService(databaseService) await databaseMaintenance.runMaintenanceTasks() + console.log('Reddit Session Start') await redditSession.getInstance(databaseService) // Initialize services with any required dependencies @@ -45,6 +54,7 @@ async function startApplication() { commentParser, commentPoster, messageService, + databaseService, ); await workflowOrchestrator.executeWorkflow(); } diff --git a/src/reddit/model/Subreddit.ts b/src/reddit/model/Subreddit.ts new file mode 100644 index 0000000..108a991 --- /dev/null +++ b/src/reddit/model/Subreddit.ts @@ -0,0 +1,44 @@ +export type Subreddit = { + default_set?: boolean; + user_is_contributor?: boolean; + banner_img?: string; + allowed_media_in_comments?: any[]; + user_is_banned?: boolean; + free_form_reports?: boolean; + community_icon?: string | null; + show_media?: boolean; + icon_color?: string; + user_is_muted?: boolean | null; + display_name?: string; + header_img?: string | null; + title?: string; + coins?: number; + previous_names?: any[]; + over_18?: boolean; + icon_size?: number[] | null; + primary_color?: string; + icon_img?: string; + description?: string; + submit_link_label?: string; + header_size?: number[] | null; + restrict_posting?: boolean; + restrict_commenting?: boolean; + subscribers?: number; + submit_text_label?: string; + is_default_icon?: boolean; + link_flair_position?: string; + display_name_prefixed?: string; + key_color?: string; + name?: string; + is_default_banner?: boolean; + url?: string; + quarantine?: boolean; + banner_size?: number[] | null; + user_is_moderator?: boolean; + accept_followers?: boolean; + public_description?: string; + link_flair_enabled?: boolean; + disable_contributor_requests?: boolean; + subreddit_type?: string; + user_is_subscriber?: boolean; +}; \ No newline at end of file diff --git a/src/reddit/model/User.ts b/src/reddit/model/User.ts new file mode 100644 index 0000000..1657dd7 --- /dev/null +++ b/src/reddit/model/User.ts @@ -0,0 +1,71 @@ +import { Subreddit } from "./Subreddit"; + +export type RedditUser = { + kind: string; + data: { + is_employee?: boolean; + has_visited_new_profile?: boolean; + is_friend: boolean; + pref_no_profanity?: boolean; + has_external_account?: boolean; + pref_geopopular?: string; + pref_show_trending?: boolean; + subreddit: Subreddit + pref_show_presence?: boolean; + snoovatar_img?: string; + snoovatar_size?: number[] | null; + gold_expiration?: null; + has_gold_subscription?: boolean; + is_sponsor?: boolean; + num_friends?: number; + features?: any; + can_edit_name?: boolean; + is_blocked?: boolean; + verified?: boolean; + new_modmail_exists?: null; + pref_autoplay?: boolean; + coins?: number; + has_paypal_subscription?: boolean; + has_subscribed_to_premium?: boolean; + id: string; + can_create_subreddit?: boolean; + over_18?: boolean; + is_gold?: boolean; + is_mod?: boolean; + awarder_karma?: number; + suspension_expiration_utc?: null; + has_stripe_subscription?: boolean; + is_suspended?: boolean; + pref_video_autoplay?: boolean; + in_chat?: boolean; + has_android_subscription?: boolean; + in_redesign_beta?: boolean; + icon_img: string; + has_mod_mail?: boolean; + pref_nightmode?: boolean; + awardee_karma?: number; + hide_from_robots?: boolean; + password_set?: boolean; + modhash?: null; + link_karma: number; + force_password_reset?: boolean; + total_karma: number; + inbox_count?: number; + pref_top_karma_subreddits?: boolean; + has_mail?: boolean; + pref_show_snoovatar?: boolean; + name: string; + pref_clickgadget?: number; + created: number; + has_verified_email: boolean; + gold_creddits?: number; + created_utc: number; + has_ios_subscription?: boolean; + pref_show_twitter?: boolean; + in_beta?: boolean; + comment_karma: number; + accept_followers: boolean; + has_subscribed: boolean; + accept_pms?: boolean; + } +}; diff --git a/src/reddit/services/reddit.ts b/src/reddit/services/reddit.ts index fddc199..641cf8f 100644 --- a/src/reddit/services/reddit.ts +++ b/src/reddit/services/reddit.ts @@ -1,5 +1,6 @@ import axios, { AxiosError } from 'axios'; import SessionManager from '../session/SessionManager'; +import { RedditUser } from '../model/user'; export class RedditService { private sessionManager: SessionManager; @@ -8,7 +9,7 @@ export class RedditService { this.sessionManager = sessionManager; } - async getUserInfo(username: string): Promise { + async getUserInfo(username: string): Promise { try { const response = await this.sessionManager.axiosInstance.get(`/user/${username}/about`); return response.data; @@ -20,12 +21,13 @@ export class RedditService { async sendMessage(username: string, subject: string, message: string): Promise { try { - await this.sessionManager.axiosInstance.post('/api/compose', { - api_type: 'json', - to: username, - subject: subject, - text: message, - }); + console.log(`await this.sessionManager.axiosInstance.post('/api/compose', {\n\tapi_type: 'json',\n\tto: ${username},\n\tsubject: ${subject},\n\ttext: ${message},\n});`) + //await this.sessionManager.axiosInstance.post('/api/compose', { + // api_type: 'json', + // to: username, + // subject: subject, + // text: message, + //}); console.log(`Message sent to ${username}`); } catch (error) { console.error('Error sending message:', error); diff --git a/src/reddit/session/SessionManager.ts b/src/reddit/session/SessionManager.ts index 55dfb93..a75f03f 100644 --- a/src/reddit/session/SessionManager.ts +++ b/src/reddit/session/SessionManager.ts @@ -17,7 +17,7 @@ class RedditSessionManager { axiosThrottle.use(axios, { requestsPerSecond: 1 }); // Throttle setup this.axiosInstance = axios.create({ - baseURL: 'https://oauth.reddit.com/api/v1/', // Base URL for OAuth2 Reddit API + baseURL: 'https://oauth.reddit.com/', // Base URL for OAuth2 Reddit API headers: { 'User-Agent': 'CrossTalk PM/0.1 by Whitneywisconson' } diff --git a/src/utils/ShouldNotify.ts b/src/utils/ShouldNotify.ts new file mode 100644 index 0000000..d47f2c2 --- /dev/null +++ b/src/utils/ShouldNotify.ts @@ -0,0 +1,28 @@ +import dotenv from 'dotenv'; +import { RedditService } from '../reddit/services/reddit'; +import { DatabaseService } from '../db/services/Database'; + +// Load environment variables from .env file +dotenv.config(); + +export async function shouldNotifyUser(username: string, redditService: RedditService, databaseService: DatabaseService): Promise { + const userInfo = await redditService.getUserInfo(username); + if (!userInfo) return false; + + const { is_mod, is_employee, accept_pms, total_karma } = userInfo.data; + + const excludeMods = process.env.EXCLUDE_MODS !== 'false'; // Defaults to true unless explicitly set to 'false' + const excludeEmployees = process.env.EXCLUDE_EMPLOYEES !== 'false'; // Defaults to true unless explicitly set to 'false' + const notifyAcceptPms = accept_pms !== false; // Notify if accept_pms is true or undefined + const karmaThreshold = parseInt(process.env.KARMA_THRESHOLD || '100000', 10); + const hasBeenNotifiedBefore = await databaseService.userMentionExists(username); + + const meetsCriteria = + (!excludeMods || !is_mod) && // Notify unless we're excluding mods and the user is a mod + (!excludeEmployees || !is_employee) && // Notify unless we're excluding employees and the user is an employee + notifyAcceptPms && + total_karma < karmaThreshold && + !hasBeenNotifiedBefore; + + return meetsCriteria; +} diff --git a/src/workflows/WorkflowOrchestrator.ts b/src/workflows/WorkflowOrchestrator.ts index aee0d04..852bb53 100644 --- a/src/workflows/WorkflowOrchestrator.ts +++ b/src/workflows/WorkflowOrchestrator.ts @@ -2,6 +2,10 @@ import { CommentProcessor } from "../rdrama/services/CommentProcessor"; import { CommentParser } from "../rdrama/services/CommentParser"; import { CommentPoster } from "../rdrama/services/CommentPoster"; import { MessageService } from "../utils/MessageService"; +import { DatabaseService } from "../db/services/Database"; +import { RedditService } from "../reddit/services/reddit"; +import RedditSessionManager from "../reddit/session/SessionManager"; +import { shouldNotifyUser } from "../utils/ShouldNotify"; class WorkflowOrchestrator { constructor( @@ -9,7 +13,7 @@ class WorkflowOrchestrator { private commentParser: CommentParser, private commentPoster: CommentPoster, private messageService: MessageService, - //private redditNotifier: RedditNotifier // Handles notifications to Reddit users + private databaseService: DatabaseService, ) { } /** @@ -30,52 +34,39 @@ class WorkflowOrchestrator { const redditUsers = this.commentParser.extractUsernames(comment) if (redditUsers.length === 0) continue console.log('found:', redditUsers) - const placeholders = { + const placeholdersRdrama = { author_name: comment.author_name, }; - const commentResponse = this.messageService.getRandomRdramaMessage(placeholders) - const postedComment = await this.commentPoster.postComment(`c_${comment.id}`, `##### TEST MESSAGE NO REDDITOR PINGED (YET...)\n${commentResponse}`) - //const postedComment = await this.commentPoster.postComment(`c_${comment.id}`, ${commentResponse}`) //TODO make this live - console.log(`Sent Comment to`, JSON.stringify(postedComment, null, 4)) - //FOR now, only reply to one user and we would check against DB with a random interval, we should only message between 2-3 people daily? + for (const redditUser of redditUsers) { + const userMentionExists = await this.databaseService.userMentionExists(redditUser) + if (userMentionExists) continue + const commentResponseRdrama = this.messageService.getRandomRdramaMessage(placeholdersRdrama) + const postedComment = await this.commentPoster.postComment(`c_${comment.id}`, `##### TEST MESSAGE NO REDDITOR PINGED (YET...)\n${commentResponseRdrama}`) + //const postedComment = await this.commentPoster.postComment(`c_${comment.id}`, ${commentResponse}`) //TODO uncomment after golive + console.log(`Sent Comment to`, JSON.stringify(postedComment, null, 4)) + const redditSession = await RedditSessionManager.getInstance(this.databaseService) + const redditService = new RedditService(redditSession) + const resultshouldNotifyUser = await shouldNotifyUser(redditUser, redditService, this.databaseService) + if (!resultshouldNotifyUser) continue + const placeholdersReddit = { + author_name: comment.author_name, + }; + const redditMessage = this.messageService.getRandomRedditMessage(placeholdersReddit) + await this.databaseService.insertUserMention({ + rdrama_comment_id: comment.id, + username: redditUser, + message: redditMessage, + }) + await redditService.sendMessage(redditUser, 'Crosstalk PM Notification', redditMessage) + return; + + } } - - //// Query user information based on usernames - //const userInfo = await this.databaseService.queryUsersInfo(uniqueUsernames); - //console.log(`Queried information for ${userInfo.length} users`); - // - //// Filter users who should be notified - //const usersToNotify = userInfo.filter(user => this.shouldNotifyUser(user)); - //console.log(`Identified ${usersToNotify.length} users to notify`); - // - //// Notify users - //for (const user of usersToNotify) { - // await this.redditNotifier.notifyUser(user); - // console.log(`Notified user: ${user.username}`); - //} - console.log('Workflow executed successfully.'); } catch (error) { console.error('An error occurred during workflow execution:', error); } } - - /** - * Determines whether a user should be notified based on certain criteria. - * - * @param user - The user information object. - * @returns A boolean indicating whether the user should be notified. - */ - //private shouldNotifyUser(user: UserInfo): boolean { - // // Placeholder for the actual logic to determine if a user should be notified. - // // This could involve checking the last notification time against the current time, - // // user preferences, or other criteria defined in the business logic. - // - // // Example logic (to be replaced with actual implementation): - // const lastNotifiedTime = new Date(user.lastNotified); // Assuming 'lastNotified' is a Date or string. - // const notificationThreshold = 24 * 60 * 60 * 1000; // 24 hours in milliseconds. - // return (Date.now() - lastNotifiedTime.getTime()) > notificationThreshold; - //} } export default WorkflowOrchestrator; \ No newline at end of file