Skip to content

Commit

Permalink
Send tweets to new API directly instead of retweeting (#7)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Jeto143 authored Apr 29, 2024
1 parent 89cc194 commit 189a80c
Show file tree
Hide file tree
Showing 9 changed files with 127 additions and 86 deletions.
2 changes: 1 addition & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
**/.github
**/.gitignore
.idea
5m5v-config.yaml*
.env*
compose.yaml
Dockerfile
LICENSE
Expand Down
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
TWITTER_EMAIL="[email protected]"
TWITTER_USERNAME="user1"
TWITTER_PASSWORD="password123#@!"
TWEETS_API_ENDPOINT="https://5minutes5vegans.org/api/tweets"
TWEETS_API_KEY="insertrandomstring123"
5 changes: 2 additions & 3 deletions .github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
144 changes: 85 additions & 59 deletions 5m5v-bot.js
Original file line number Diff line number Diff line change
@@ -1,97 +1,123 @@
#!/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}`);
}
}
}
} catch (error) {
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 })),
};
}
5 changes: 0 additions & 5 deletions 5m5v-config.yaml.example

This file was deleted.

28 changes: 12 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
19 changes: 19 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,15 @@ [email protected]:
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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 189a80c

Please sign in to comment.