diff --git a/package-lock.json b/package-lock.json index 4157594cb..959d6dc7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14530,6 +14530,11 @@ "safe-buffer": "^5.0.1" } }, + "twitter-api-v2": { + "version": "1.17.2", + "resolved": "https://registry.npmjs.org/twitter-api-v2/-/twitter-api-v2-1.17.2.tgz", + "integrity": "sha512-V8QvCkuQ+ydIakwYbVC4cuQxGlvjdRZI0L7TT5v9zGsf+SwX40rv3IFRHB8Z1cXJLcRFT0FI3xG3/f4zwS6cRA==" + }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 2336fca25..6f7040daf 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "sanitize-html": "^2.13.0", "sharp": "^0.28.3", "supertest": "^6.3.4", + "twitter-api-v2": "^1.17.2", "uuid": "^3.4.0", "ws": "^8.17.1" }, diff --git a/src/app.js b/src/app.js index 817017108..94313eeca 100644 --- a/src/app.js +++ b/src/app.js @@ -4,6 +4,7 @@ const Sentry = require('@sentry/node'); const app = express(); const logger = require('./startup/logger'); const globalErrorHandler = require('./utilities/errorHandling/globalErrorHandler'); +// const socialMediaRoutes = require('./routes/socialMediaRoutes'); logger.init(); @@ -15,6 +16,10 @@ require('./startup/bodyParser')(app); require('./startup/middleware')(app); require('./startup/routes')(app); +app.set('view engine', 'ejs'); + +// app.use('/social-media', socialMediaRoutes); + // The error handler must be before any other error middleware and after all controllers app.use(Sentry.Handlers.errorHandler()); diff --git a/src/controllers/socialMediaController.js b/src/controllers/socialMediaController.js new file mode 100644 index 000000000..cc7c3519d --- /dev/null +++ b/src/controllers/socialMediaController.js @@ -0,0 +1,314 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +const fetch = require('node-fetch'); +// eslint-disable-next-line import/no-extraneous-dependencies +const cheerio = require('cheerio'); +// eslint-disable-next-line import/no-extraneous-dependencies +const { TwitterApi } = require('twitter-api-v2'); +const ScheduledPost = require('../models/scheduledPostSchema'); + +function extractTextAndImgUrl(htmlString) { + const $ = cheerio.load(htmlString); + + const textContent = $('body').text().replace(/\+/g, '').trim(); + const urlSrcs = []; + const base64Srcs = []; + + $('img').each((i, img) => { + const src = $(img).attr('src'); + if (src) { + if (src.startsWith('data:image')) { + base64Srcs.push(src); + } else { + urlSrcs.push(src); + } + } + }); + + return { textContent, urlSrcs, base64Srcs }; +} + +async function downloadImage(url) { + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + return { + buffer: Buffer.from(arrayBuffer), + mimeType: response.headers.get('content-type'), + }; +} + +async function postToTwitter(content, image) { + console.log('create Scheduled Tweet call'); + const TwitterClient = new TwitterApi({ + appKey: process.env.REACT_APP_TWITTER_APP_KEY, + appSecret: process.env.REACT_APP_TWITTER_APP_SECRET, + accessToken: process.env.REACT_APP_TWITTER_ACCESS_TOKEN, + accessSecret: process.env.REACT_APP_TWITTER_ACCESS_SECRET, + }); + + const rwClient = TwitterClient.readWrite; + const textContent = content; + const base64Srcs = image; + + try { + let mediaIds = []; + // src type is base64 + if (base64Srcs && base64Srcs.length > 0) { + console.log('Uploading base64 media...'); + const base64MediaIds = await Promise.all( + base64Srcs.map(async (imgSrc) => { + try { + const base64Data = imgSrc.replace(/^data:image\/\w+;base64,/, ''); + const buffer = Buffer.from(base64Data, 'base64'); + const mimeType = imgSrc.split(';')[0].split(':')[1] || 'image/png'; + return await rwClient.v1.uploadMedia(buffer, { mimeType }); + } catch (error) { + console.error('Error uploading base64 image', error); + return null; + } + }), + ); + mediaIds = mediaIds.concat(base64MediaIds.filter((id) => id !== null)); + console.log('Base64 media uploaded, IDs:', mediaIds); + } + + const tweetOptions = { text: textContent }; + + if (mediaIds && mediaIds.length > 0) { + tweetOptions.media = { media_ids: mediaIds }; + } + + const tweet = await rwClient.v2.tweet(tweetOptions); + console.log('Tweet posted successfully:', tweet); + } catch (error) { + console.error('[Backend] Network or other error: ', error); + } +} + +async function getTwitterAccessToken(req, res) { + console.log('gTAT'); + console.log('Twitter Client ID:', process.env.REACT_APP_TWITTER_CLIENT_ID); + console.log('Twitter Client Secret:', process.env.REACT_APP_TWITTER_CLIENT_SECRET); + + const twitterOAuth = new TwitterApi({ + clientId: process.env.REACT_APP_TWITTER_CLIENT_ID, + clientSecret: process.env.REACT_APP_TWITTER_CLIENT_SECRET, + }); + + const { code, state, codeVerifier } = req.body; + + if (!code || !state || !codeVerifier) { + return res.status(400).json({ error: 'Missing required parameters' }); + } + + try { + twitterOAuth + .loginWithOAuth2({ code, codeVerifier, redirectUri: 'http://localhost:4500/announcements' }) + .then(async ({ client: loggedClient, accessToken, expiresIn, scope }) => { + try { + const { data } = await loggedClient.v2.me(); + console.log('User data:', data); + console.log('scope:', scope); + res.json({ + access_token: accessToken, + expires_in: expiresIn, + }); + } catch (error) { + console.error('API Error:', error); + } + }); + } catch (error) { + console.error('Error exchanging code for token:', error); + res.status(500).json({ error: 'Failed to obtain access token' }); + } +} + +async function scheduleTweet(req, res) { + console.log('scheduleTweet call'); + console.log('Request body:', req.body); + const { textContent, urlSrcs, base64Srcs } = extractTextAndImgUrl(req.body.EmailContent); + const scheduledDate = req.body.ScheduleDate; + const scheduledTime = req.body.ScheduleTime; + console.log('scheduledDate', scheduledDate); + console.log('scheduledTime', scheduledTime); + + if (!scheduledDate || !scheduledTime) { + return res + .status(400) + .json({ error: 'Missing required parameters: scheduledDate or scheduledTime' }); + } + + const platform = 'twitter'; + const newScheduledTweet = new ScheduledPost({ + textContent, + urlSrcs, + base64Srcs, + scheduledDate, + scheduledTime, + platform, + status: 'scheduled', + }); + + newScheduledTweet + .save() + .then((scheduledTweet) => { + console.log('scheduledTweet saved:', scheduledTweet); + res.status(200).json({ success: true, scheduledTweet }); + }) + .catch((error) => { + console.error('[Backend] Database error: ', error); + res.status(500).json({ success: false, error: 'Internal server error' }); + }); +} + +async function createTweet(req, res) { + console.log('createTweet call'); + const TwitterClient = new TwitterApi({ + appKey: process.env.REACT_APP_TWITTER_APP_KEY, + appSecret: process.env.REACT_APP_TWITTER_APP_SECRET, + accessToken: process.env.REACT_APP_TWITTER_ACCESS_TOKEN, + accessSecret: process.env.REACT_APP_TWITTER_ACCESS_SECRET, + }); + + const rwClient = TwitterClient.readWrite; + const { textContent, urlSrcs, base64Srcs } = extractTextAndImgUrl(req.body.EmailContent); + + try { + let mediaIds = []; + // src type is url + if (urlSrcs && urlSrcs.length > 0) { + console.log('Uploading URL media...'); + const urlMediaIds = await Promise.all( + urlSrcs.map(async (imageUrl) => { + try { + const { buffer, mimeType } = await downloadImage(imageUrl); + return await rwClient.v1.uploadMedia(buffer, { mimeType }); + } catch (error) { + console.error(`Error uploading URL image: ${imageUrl}`, error); + return null; + } + }), + ); + mediaIds = mediaIds.concat(urlMediaIds.filter((id) => id !== null)); + console.log('URL media uploaded, IDs:', mediaIds); + } + + // src type is base64 + if (base64Srcs && base64Srcs.length > 0) { + console.log('Uploading base64 media...'); + const base64MediaIds = await Promise.all( + base64Srcs.map(async (imgSrc) => { + try { + const base64Data = imgSrc.replace(/^data:image\/\w+;base64,/, ''); + const buffer = Buffer.from(base64Data, 'base64'); + const mimeType = imgSrc.split(';')[0].split(':')[1] || 'image/png'; + return await rwClient.v1.uploadMedia(buffer, { mimeType }); + } catch (error) { + console.error('Error uploading base64 image', error); + return null; + } + }), + ); + mediaIds = mediaIds.concat(base64MediaIds.filter((id) => id !== null)); + console.log('Base64 media uploaded, IDs:', mediaIds); + } + + const tweetOptions = { text: textContent }; + + if (mediaIds && mediaIds.length > 0) { + tweetOptions.media = { media_ids: mediaIds }; + } + + const tweet = await rwClient.v2.tweet(tweetOptions); + + const newTweet = new ScheduledPost({ + textContent, + urlSrcs, + base64Srcs, + scheduledDate: new Date().toLocaleDateString(), + scheduledTime: new Date().toLocaleTimeString(), + platform: 'twitter', + status: 'posted', + }); + + await newTweet.save(); + + res.status(200).json({ success: true, tweet }); + } catch (error) { + console.error('[Backend] Network or other error: ', error); + res.status(500).json({ success: false, error: 'Internal server error' }); + } +} + +async function getPosts(req, res) { + console.log('getPosts call'); + try { + const posts = await ScheduledPost.find({}).select( + 'textContent urlSrcs scheduledDate scheduledTime platform createdAt base64Srcs', + ); + console.log(posts); + res.status(200).json({ success: true, posts }); + } catch (error) { + console.error('[Backend] Database error: ', error); + res.status(500).json({ success: false, error: 'Internal server error' }); + } +} + +async function deletePosts(req, res) { + console.log('Request URL:', req.originalUrl); + console.log('Request Method:', req.method); + console.log('deletePosts call'); + const { _id } = req.body; + + if (!_id) { + return res.status(400).json({ error: 'Missing required parameter: postId' }); + } + + try { + const deletedPost = await ScheduledPost.findOneAndDelete({ _id }); + + if (!deletedPost) { + return res.status(404).json({ error: 'Post not found or already deleted' }); + } + + res.status(200).json({ success: true, message: 'Post deleted successfully' }); + } catch (error) { + console.error('[Backend] Database error: ', error); + res.status(500).json({ success: false, error: 'Internal server error' }); + } +} + +async function updatePosts(req, res) { + console.log('updatePosts call'); + const { _id, textContent, urlSrcs, scheduledDate, scheduledTime, platform } = req.body; + + if (!_id) { + return res.status(400).json({ error: 'Missing required parameter: _id' }); + } + + try { + const updatedPost = await ScheduledPost.findOneAndUpdate( + { _id }, + { textContent, urlSrcs, scheduledDate, scheduledTime, platform }, + { new: true }, + ); + + if (!updatedPost) { + return res.status(404).json({ error: 'Post not found' }); + } + + res.status(200).json({ success: true, updatedPost }); + } catch (error) { + console.error('[Backend] Database error: ', error); + res.status(500).json({ success: false, error: 'Internal server error' }); + } +} + +module.exports = { + getTwitterAccessToken, + createTweet, + scheduleTweet, + postToTwitter, + getPosts, + deletePosts, + updatePosts, +}; diff --git a/src/cronjobs/socialPostScheduler.js b/src/cronjobs/socialPostScheduler.js new file mode 100644 index 000000000..f613a14ad --- /dev/null +++ b/src/cronjobs/socialPostScheduler.js @@ -0,0 +1,92 @@ +const { CronJob } = require('cron'); +const moment = require('moment-timezone'); +const ScheduledPost = require('../models/scheduledPostSchema'); +const socialMediaController = require('../controllers/socialMediaController'); + +const socialPostScheduler = () => { + const checkScheduledPosts = new CronJob( + '*/1 * * * *', // Run every minute for testing + // '0 0 * * *', // Run once daily at midnight + async () => { + console.log('cron job started'); + const now = moment().tz('America/Chicago').format('YYYY-MM-DD HH:mm'); // right now i kept timezone as america/chicago,will change later. + console.log('Current date and time:', now); + + const posts = await ScheduledPost.find(); + + posts.forEach(async (post) => { + const scheduledDateTime = moment + .tz(`${post.scheduledDate} ${post.scheduledTime}`, 'YYYY-MM-DD HH:mm', 'America/Chicago') + .format('YYYY-MM-DD HH:mm'); + + if (scheduledDateTime === now) { + console.log(`Running scheduled task for ${post.platform} at`, new Date()); + + switch (post.platform) { + case 'twitter': + await socialMediaController.postToTwitter(post.textContent, post.base64Srcs); + break; + default: + console.error('Unknown platform:', post.platform); + } + + // Remove the post after execution + await ScheduledPost.findByIdAndDelete(post._id); + } + }); + }, + null, + false, + 'America/Chicago', + ); + + checkScheduledPosts.start(); +}; + +module.exports = socialPostScheduler; + +// const { CronJob } = require('cron'); +// const moment = require('moment-timezone'); +// // const mongoose = require('mongoose'); +// const ScheduledPost = require('../models/scheduledPostSchema'); +// const socialMediaController = require('../controllers/socialMediaController'); +// const socialPostScheduler = () => { +// const checkScheduledPosts = new CronJob( +// // '0 0 * * *', // Run once daily at midnight +// // '0 0 * * *', // Run once daily at midnight +// '*/1 * * * *', // Run every minute for testing +// async () => { +// console.log('cron job started'); +// const now = moment().tz('America/Chicago'); +// console.log('Current date and time:', now.format()); +// const posts = await ScheduledPost.find({ +// scheduledTime: { +// $gte: now.startOf('minute').toDate(), +// $lt: now.add(1, 'minute').toDate(), +// }, +// }); + +// posts.forEach(async (post) => { +// console.log(`Running scheduled task for ${post.platform} at`, new Date()); + +// switch (post.platform) { +// case 'twitter': +// await socialMediaController.postToTwitter(post.textContent, post.base64Srcs, post.scheduledTime); +// break; +// default: +// console.error('Unknown platform:', post.platform); +// } + +// // Remove the post after execution while testing +// await ScheduledPost.findByIdAndDelete(post._id); +// }); +// }, +// null, +// false, +// 'America/Los_Angeles', +// ); + +// checkScheduledPosts.start(); +// }; + +// module.exports = socialPostScheduler; diff --git a/src/models/scheduledPostSchema.js b/src/models/scheduledPostSchema.js new file mode 100644 index 000000000..07ca6cc22 --- /dev/null +++ b/src/models/scheduledPostSchema.js @@ -0,0 +1,59 @@ +const mongoose = require('mongoose'); +const { v4: uuidv4 } = require('uuid'); + +const scheduledPostSchema = new mongoose.Schema({ + postId: { + type: String, + default: uuidv4, // Generate a UUID for each post + unique: true, + }, + textContent: { + type: String, + required: false, + }, + urlSrcs: { + type: [String], + required: false, + }, + base64Srcs: { + type: [String], + required: false, + }, + scheduledDate: { + type: String, // mm/dd/yyyy + required: true, + }, + scheduledTime: { + type: String, // hr:min AM/PM + required: true, + }, + platform: { + type: String, + enum: ['twitter'], + required: true, + }, + status: { + type: String, + enum: ['scheduled', 'posted'], + default: 'scheduled', + }, + createdBy: { + type: String, + required: false, + }, + updatedBy: { + type: String, + required: false, + }, + updatedAt: { + type: Date, + default: Date.now, + required: false, + }, + createdAt: { + type: Date, + default: Date.now, + }, +}); + +module.exports = mongoose.model('ScheduledPost', scheduledPostSchema); diff --git a/src/routes/socialMediaRouter.js b/src/routes/socialMediaRouter.js new file mode 100644 index 000000000..f320680e4 --- /dev/null +++ b/src/routes/socialMediaRouter.js @@ -0,0 +1,28 @@ +const express = require('express'); +const { + getTwitterAccessToken, + createTweet, + scheduleTweet, + getPosts, + deletePosts, + updatePosts, +} = require('../controllers/socialMediaController'); + +const routes = function () { + const socialMediaRouter = express.Router(); + + socialMediaRouter.route('/getTwitterAccessToken').post(getTwitterAccessToken); + socialMediaRouter.route('/createTweet').post(createTweet); + socialMediaRouter.route('/scheduleTweet').post(scheduleTweet); + socialMediaRouter.route('/getPosts').get(getPosts); + socialMediaRouter.route('/deletePosts').delete(deletePosts); + + socialMediaRouter.route('/posts').post(scheduleTweet); + socialMediaRouter.route('/posts').delete(deletePosts); + socialMediaRouter.route('/posts').get(getPosts); + socialMediaRouter.route('/posts').put(updatePosts); + + return socialMediaRouter; +}; + +module.exports = routes; diff --git a/src/server.js b/src/server.js index 95b78dc64..fa77ca662 100644 --- a/src/server.js +++ b/src/server.js @@ -4,6 +4,7 @@ const { app, logger } = require('./app'); const websockets = require('./websockets').default; require('./startup/db')(); require('./cronjobs/userProfileJobs')(); +require('./cronjobs/socialPostScheduler')(); const port = process.env.PORT || 4500; diff --git a/src/startup/routes.js b/src/startup/routes.js index 99b339849..27fa8b8b5 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -81,7 +81,8 @@ const profileInitialSetupRouter = require('../routes/profileInitialSetupRouter') mapLocations, ); const permissionChangeLogRouter = require('../routes/permissionChangeLogsRouter')( - permissionChangeLog, userPermissionChangeLog, + permissionChangeLog, + userPermissionChangeLog, ); const isEmailExistsRouter = require('../routes/isEmailExistsRouter')(); const jobNotificationListRouter = require('../routes/jobNotificationListRouter'); @@ -138,6 +139,8 @@ const registrationRouter = require('../routes/registrationRouter')(registration) const collaborationRouter=require('../routes/collaborationRouter'); +const socialMediaRouter = require('../routes/socialMediaRouter')(); + module.exports = function (app) { app.use('/api', forgotPwdRouter); app.use('/api', loginRouter); @@ -194,4 +197,5 @@ module.exports = function (app) { app.use('/api/bm', bmExternalTeam); app.use('api', bmIssueRouter); app.use('/api', registrationRouter); + app.use('/api', socialMediaRouter); };