From 9c6ead60e2cbe3e7a963f5ae487b39f70b6f7ab7 Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Sun, 11 Apr 2021 18:42:06 +0100 Subject: [PATCH] Process tweets and sent to Discord --- development.env.sample | 2 + production.env.sample | 2 + src/index.js | 112 ++++++++++++++++++++++++++++++++++++----- wrangler.toml | 6 +-- 4 files changed, 106 insertions(+), 16 deletions(-) diff --git a/development.env.sample b/development.env.sample index 4e4f9da..c225f1e 100644 --- a/development.env.sample +++ b/development.env.sample @@ -3,3 +3,5 @@ SENTRY_ORG= SENTRY_PROJECT= SENTRY_DSN= TWITTER_BEARER_AUTH= +TWITTER_USER_ID= +DISCORD_WEBHOOK= diff --git a/production.env.sample b/production.env.sample index 4e4f9da..c225f1e 100644 --- a/production.env.sample +++ b/production.env.sample @@ -3,3 +3,5 @@ SENTRY_ORG= SENTRY_PROJECT= SENTRY_DSN= TWITTER_BEARER_AUTH= +TWITTER_USER_ID= +DISCORD_WEBHOOK= diff --git a/src/index.js b/src/index.js index 6681048..f11fa77 100644 --- a/src/index.js +++ b/src/index.js @@ -10,21 +10,102 @@ const textResponse = content => new Response(content, { }, }); -// Util to send a JSON response -const jsonResponse = obj => new Response(JSON.stringify(obj), { - headers: { - 'Content-Type': 'application/json', - }, +// Util to suppress for Discord +const suppressLinks = text => text.replace(/(https?:\/\/\S+)/g, '<$1>') + +// Util to escape Discord markdown in text +const escapeMarkdown = text => suppressLinks(text) + .replace(/\\([*_`~\\])/g, '$1') // unescape already escaped chars + .replace(/([*_`~\\])/g, '\\$1'); // escape all MD chars + +// Util to add quote markdown to text +const quoteText = text => `> ${text.split('\n').join('\n> ')}`; + +// Fetch latest Tweet data from Twitter +const fetchLatestTweets = since => { + // Define the query params for the data we need + const req = new URL(`https://api.twitter.com/2/users/${process.env.TWITTER_USER_ID}/tweets`); + req.searchParams.set('max_results', '100'); + req.searchParams.set('tweet.fields', ['referenced_tweets', 'text', 'created_at', 'id', 'author_id'].join(',')); + req.searchParams.set('user.fields', ['username', 'profile_image_url'].join(',')); + req.searchParams.set('expansions', ['author_id', 'referenced_tweets.id', 'referenced_tweets.id.author_id'].join(',')); + if (since) req.searchParams.set('since_id', since); + + // Make the request with the OAuth 2 token + return fetch(req.toString(), { headers: { Authorization: `Bearer ${process.env.TWITTER_BEARER_AUTH}` } }) + .then(req => req.json()); +}; + +// Post tweet information to Discord +const postDiscordTweet = (type, content, links, username, avatar) => fetch(process.env.DISCORD_WEBHOOK, { + method: 'POST', + headers: { 'Content-type': 'application/json' }, + body: JSON.stringify({ + content: `**${type}**\n\n${quoteText(escapeMarkdown(content))}\n\n${suppressLinks(links)}`, + username, + avatar_url: avatar, + }), }); -const fetchRecentTweets = lastId => fetch( - `https://api.twitter.com/2/users/:id/tweets?limit=100${lastId ? `&since=${lastId}` : ''}`, - { headers: { Authorization: `Bearer ${process.env.TWITTER_BEARER_AUTH}` } }, -).then(req => req.json()); +// Process a raw Tweet from Discord and send it to Discord +const processTweet = (tweet, includes) => { + // Resolve the author of the tweet + const author = includes.users.find(inclUser => inclUser.id === tweet.author_id); + + // Resolve any referenced tweet + const refTweet = tweet.referenced_tweets && tweet.referenced_tweets.length + ? includes.tweets.find(inclTweet => inclTweet.id === tweet.referenced_tweets[0].id) + : null; + const refType = refTweet ? tweet.referenced_tweets[0].type : null; + const refAuthor = refTweet ? includes.users.find(inclUser => inclUser.id === refTweet.author_id) : null; + + // Determine the Discord title + const title = refType === 'retweeted' + ? '๐Ÿ” Retweeted' + : refType === 'quoted' + ? '๐Ÿ“ Quoted' + : refType === 'replied_to' + ? 'โคด๏ธ Replied' + : '๐Ÿ’ฌ Tweeted'; + // Determine what content to use + const content = refType === 'retweeted' ? refTweet.text : tweet.text; + + // Determine what links to reference + const links = refType === 'retweeted' + ? `https://twitter.com/${refAuthor.username}/status/${refTweet.id}` + : refType === 'quoted' + ? `https://twitter.com/${author.username}/status/${tweet.id}\nQuoting https://twitter.com/${refAuthor.username}/status/${refTweet.id}` + : refType === 'replied_to' + ? `https://twitter.com/${author.username}/status/${tweet.id}\nReplying to https://twitter.com/${refAuthor.username}/status/${refTweet.id}` + : `https://twitter.com/${author.username}/status/${tweet.id}`; + + // Post to Discord + return postDiscordTweet(title, content, links, author.username, author.profile_image_url); +}; + +// Mirror latest tweets from Twitter to Discord const mirrorLatestTweets = async () => { - const data = await fetchRecentTweets(); - console.log(data); + // Get the last tweet we processed + const last = await TWEETS_TO_DISCORD_LAST_TWEET.get('latest_id'); + + // Get new tweets since last processed (oldest first) + const data = await fetchLatestTweets(last); + const tweets = data.data ? data.data.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)) : []; + + // If we haven't run before, store the most recent tweet and abort + if (!last && tweets.length) { + await TWEETS_TO_DISCORD_LAST_TWEET.put('latest_id', tweets[tweets.length - 1].id); + return; + } + + // Process each tweet + for (const tweet of tweets) { + console.log(await processTweet(tweet, data.includes).then(res => res.text())); + + // Store this as the most recent tweet we've processed + await TWEETS_TO_DISCORD_LAST_TWEET.put('latest_id', tweet.id); + } }; // Process all requests to the worker @@ -37,8 +118,13 @@ const handleRequest = async ({ request, wait, sentry }) => { // Execute triggers route if (url.pathname === '/execute') { // Trigger each workflow in the background after - wait(mirrorLatestTweets().catch(err => sentry.captureException(err))); - return jsonResponse({}); + wait(mirrorLatestTweets().catch(err => { + // Log & re-throw any errors + console.error(err); + sentry.captureException(err); + throw err; + })); + return textResponse('Executed'); } // Not found diff --git a/wrangler.toml b/wrangler.toml index 08f0c34..a763c47 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -4,13 +4,13 @@ account_id = "fb1f542488f2441acf88ca15f3a8390d" workers_dev = true webpack_config = "webpack.config.js" kv_namespaces = [ - { binding = "TWEETS_TO_DISCORD_LAST_TWEET", id = "4fc9f04e2f29425c878cf74200359002", preview_id = "4fc9f04e2f29425c878cf74200359002" } + { binding = "TWEETS_TO_DISCORD_LAST_TWEET", id = "f22f1aee1f3642bcaa81eaf306528577", preview_id = "f22f1aee1f3642bcaa81eaf306528577" } ] [triggers] -crons = ["* * * * *"] +crons = ["*/2 * * * *"] [env.production] kv_namespaces = [ - { binding = "TWEETS_TO_DISCORD_LAST_TWEET", id = "e8a41596dbff45ab84f7833da8834bbb" } + { binding = "TWEETS_TO_DISCORD_LAST_TWEET", id = "87d5c2be0d2342f680bec6a0b20a372d" } ]