From 189a80c40797c278d4298af40f0dedd26cd5c9af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Touati?= Date: Mon, 29 Apr 2024 17:06:02 +0200 Subject: [PATCH] Send tweets to new API directly instead of retweeting (#7) * Update dependencies * Update bot to send tweets instead of retweeting * CS * CS * Reorganize file * Use `Array#some` for .env check * Only re-login if necessary --- .dockerignore | 2 +- .env.example | 5 ++ .github/workflows/deploy.yaml | 5 +- .gitignore | 2 +- 5m5v-bot.js | 144 ++++++++++++++++++++-------------- 5m5v-config.yaml.example | 5 -- README.md | 28 +++---- package.json | 3 +- yarn.lock | 19 +++++ 9 files changed, 127 insertions(+), 86 deletions(-) create mode 100644 .env.example delete mode 100644 5m5v-config.yaml.example diff --git a/.dockerignore b/.dockerignore index e8aedc4..130c566 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,7 +10,7 @@ **/.github **/.gitignore .idea -5m5v-config.yaml* +.env* compose.yaml Dockerfile LICENSE diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..312e565 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +TWITTER_EMAIL="user1@domain.com" +TWITTER_USERNAME="user1" +TWITTER_PASSWORD="password123#@!" +TWEETS_API_ENDPOINT="https://5minutes5vegans.org/api/tweets" +TWEETS_API_KEY="insertrandomstring123" \ No newline at end of file diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 6f25d33..7a7ca3e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -12,7 +12,6 @@ env: SERVICE_NAME: 5m5v-bot DEPLOY_HOST: 206.189.96.198 DEPLOY_USER: deploy - CONFIG_FILE: 5m5v-config.yaml jobs: deploy: @@ -54,12 +53,12 @@ jobs: ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_HOST} -C " mkdir -p \${HOME}/${SERVICE_NAME} && - echo \"${{ secrets.BOT_CONFIG }}\" > \${HOME}/${SERVICE_NAME}/${CONFIG_FILE} && + echo \"${{ secrets.BOT_CONFIG }}\" > \${HOME}/${SERVICE_NAME}/.env && echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin && docker pull ${{ steps.meta.outputs.tags }} && docker rm -f ${SERVICE_NAME} && docker run -d \ - -v \${HOME}/${SERVICE_NAME}/${CONFIG_FILE}:/usr/src/app/${CONFIG_FILE} \ + -v \${HOME}/${SERVICE_NAME}/.env:/usr/src/app/.env \ --restart always \ --name ${SERVICE_NAME} \ ${{ steps.meta.outputs.tags }} diff --git a/.gitignore b/.gitignore index 5adeca2..6d8b153 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,4 @@ build/Release # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git node_modules -5m5v-config.yaml +.env diff --git a/5m5v-bot.js b/5m5v-bot.js index dc2bd91..6418aa5 100644 --- a/5m5v-bot.js +++ b/5m5v-bot.js @@ -1,88 +1,71 @@ #!/usr/bin/env node -const fs = require('fs'); -const yaml = require('js-yaml'); -const { Rettiwt } = require('rettiwt-api'); -const TweetFilter = require('./lib/filter'); -const util = require('./lib/util'); - -let config = {}; -try { - config = yaml.safeLoad(fs.readFileSync('5m5v-config.yaml', 'utf8')); -} catch (error) { - throw `Unable to load config file: ${error.message}`; +require('dotenv').config(); + +if ([ + 'TWEETS_API_ENDPOINT', + 'TWEETS_API_KEY', + 'TWITTER_EMAIL', + 'TWITTER_USERNAME', + 'TWITTER_PASSWORD', +].some(key => !process.env[key])) { + console.error('One or more required environment variables are missing. Exiting.'); + process.exit(1); } -if ((config?.users || []).length === 0) { - throw 'No users defined in config file'; -} +const { Rettiwt } = require('rettiwt-api'); +const axios = require('axios'); -if (!config.users.every(user => ['language', 'email', 'username', 'password'].every(key => key in (user ?? [])))) { - throw 'At least one user is missing required fields in config file'; -} +const TweetFilter = require('./lib/filter'); +const util = require('./lib/util'); -const languages = config.users.map(user => user.language); const pollingIntervalMs = 40 * 1000; -const loginDelayMs = 5 * 60 * 1000; -const retweetDelayMs = config.delaytime || 2 * 60 * 1000; +const retryLoginDelayMs = 5 * 60 * 1000; const isDryRun = process.argv[2] === '--dry-run'; -const tweetFilter = new TweetFilter(config.exclude, languages); -async function getApiKey(user, { retry = true } = {}) { - if (user.apiKey) { - return user.apiKey; - } +const languageKeys = { + english: 'en', + french: 'fr', + spanish: 'es', + german: 'de', +}; - while (true) { - try { - console.log(`Logging in as ${user.username}...`); +const tweetFilter = new TweetFilter([], Object.keys(languageKeys)); - await new Promise(resolve => setTimeout(resolve, loginDelayMs)); +const streamFilter = { + includeWords: [util.trackedTerms.map(term => `"${term}"`).join(' OR ')], +}; - user.apiKey = await new Rettiwt().auth.login(user.email, user.username, user.password); - - console.log('Logged in!'); - - return user.apiKey; - } catch (e) { - console.error(`Unable to log in: ${e.message}`); - - if (!retry) { - throw e; - } - } - } -} +let twitterApiKey = null; (async () => { + twitterApiKey = await loginToTwitter(); + while (true) { - const rettiwt = new Rettiwt({ apiKey: await getApiKey(config.users[0], { retry: true }) }); + const rettiwt = new Rettiwt({ apiKey: twitterApiKey }); console.log(isDryRun ? 'Looking for new tweets (dry run)...' : 'Looking for new tweets...'); try { - for await (const tweet of rettiwt.tweet.stream({ includeWords: [util.trackedTerms.map(term => `"${term}"`).join(' OR ')] }, pollingIntervalMs)) { + for await (const tweet of rettiwt.tweet.stream(streamFilter, pollingIntervalMs)) { const matchingLanguages = tweetFilter.matches(tweet) || []; for (const language of matchingLanguages) { - await new Promise(resolve => setTimeout(resolve, retweetDelayMs)); - - const user = config.users.find(user => user.language === language); - try { if (!isDryRun) { - const rettiwt = new Rettiwt({ apiKey: await getApiKey(user, { retry: false }) }); - - await rettiwt.tweet.retweet(tweet.id); + await axios.post(process.env.TWEETS_API_ENDPOINT, { + lang: languageKeys[language], + tweets: [buildTweetPayload(tweet)], + }, { + headers: { + 'X-API-KEY': process.env.TWEETS_API_KEY, + }, + }); } - console.log(`Retweeted tweet ${tweet.id} in ${language}:\n${tweet.fullText}`); + console.log(`Sent tweet ${tweet.id} in ${language}:\n${tweet.fullText}`); } catch (error) { - console.error(`Unable to retweet ${tweet.id} in ${language}: ${error.message}`); - - if (error.constructor.name === 'RettiwtError' && error.code === 32) { - user.apiKey = null; - } + console.error(`Unable to send tweet ${tweet.id} in ${language}: ${error.message}`); } } } @@ -90,8 +73,51 @@ async function getApiKey(user, { retry = true } = {}) { console.error(`Error while streaming tweets: ${error.message}`); if (error.constructor.name === 'RettiwtError' && error.code === 32) { - config.users[0].apiKey = null; + twitterApiKey = await loginToTwitter(); } } } })(); + +async function loginToTwitter() { + while (true) { + try { + console.log('Logging in...'); + + const apiKey = await new Rettiwt().auth.login( + process.env.TWITTER_EMAIL, + process.env.TWITTER_USERNAME, + process.env.TWITTER_PASSWORD, + ); + + if (!apiKey) { + throw new Error('No API key returned'); + } + + console.log('Logged in!'); + + return apiKey; + } catch (e) { + console.error(`Unable to log in: ${e.message}`); + + await new Promise(resolve => setTimeout(resolve, retryLoginDelayMs)); + } + } +} + +function buildTweetPayload(tweet) { + return { + id: tweet.id, + date: tweet.createdAt, + text: tweet.fullText, + from_user_name: tweet.tweetBy.fullName, + from_full_name: tweet.tweetBy.fullName, + from_profile_image: tweet.tweetBy.profileImage, + view_count: ~~tweet.viewCount, + like_count: ~~tweet.likeCount, + reply_count: ~~tweet.replyCount, + retweet_count: ~~tweet.retweetCount, + quote_count: ~~tweet.quoteCount, + media: (tweet.media ?? []).map(media => ({ ...media })), + }; +} diff --git a/5m5v-config.yaml.example b/5m5v-config.yaml.example deleted file mode 100644 index 0cfd2b3..0000000 --- a/5m5v-config.yaml.example +++ /dev/null @@ -1,5 +0,0 @@ -users: - - language: 'english' - username: 'user1' - email: 'user1@domain.com' - password: 'password123#@!' diff --git a/README.md b/README.md index d20953a..ea8908b 100644 --- a/README.md +++ b/README.md @@ -24,22 +24,18 @@ This bot tracks usage of the term "vegan" - and its translated variants dependin ## Configuration -The configuration is loaded from `5m5v-config.yaml` in the working directory, which is likely either the directory of your cloned repository or the directory of your project where you installed the node package. The configuration format is YAML, with its options listed below. - -#### `users` -A list containing all users to retweet with. The first user listed will be used to search tweets with. -#### `users.language` -The language that this user will be retweeting matches from. -#### `users.username` -The Twitter username of that user (without the @). -#### `users.email` -The email address of the Twitter account. -#### `users.password` -The password of the Twitter account. -#### `exclude` *(optional)* -A list of keywords that if found in a Tweet will exclude that Tweet from being retweeted. -#### `delaytime` *(optional)* -The time to delay retweets with once a matching Tweet has been found. Defaults to two minutes. +The configuration is loaded from the `.env` file in the root of the project. The following keys are required: + +#### `TWEETS_API_ENDPOINT` +The endpoint of the 5M5V API that will receive the tweets. +#### `TWEETS_API_KEY` +The API key of the 5M5V API that will receive the tweets. +#### `TWITTER_EMAIL` +The email address of the Twitter account that the bot will use to stream tweets. +#### `TWITTER_USERNAME` +The username of the Twitter account that the bot will use to stream tweets. +#### `TWITTER_PASSWORD` +The password of the Twitter account that the bot will use to stream tweets. ## Running the tests diff --git a/package.json b/package.json index b28b958..c32439c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "Twitter Bot That Retweets People Who Are Considering Veganism", "main": "5m5v-bot.js", "dependencies": { - "js-yaml": "^3.13.1", + "axios": "^1.6.8", + "dotenv": "^16.4.5", "rettiwt-api": "2.7.1" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index d616600..093a7fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -236,6 +236,15 @@ axios@1.6.3: form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.6.8: + version "1.6.8" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.8.tgz#66d294951f5d988a00e87a0ffb955316a619ea66" + integrity sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" @@ -470,6 +479,11 @@ domain-browser@^1.2.0: resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== +dotenv@^16.4.5: + version "16.4.5" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" + integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== + ecc-jsbn@~0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" @@ -571,6 +585,11 @@ follow-redirects@^1.15.0: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf" integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw== +follow-redirects@^1.15.6: + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== + foreground-child@^1.3.3, foreground-child@^1.5.6: version "1.5.6" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-1.5.6.tgz#4fd71ad2dfde96789b980a5c0a295937cb2f5ce9"