├── .babelrc ├── .github └── workflows │ └── cron-build.yml ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── config.ts ├── features │ ├── index.ts │ └── unfollowInactiveAccounts │ │ ├── criteria.ts │ │ ├── index.ts │ │ └── types.ts ├── index.ts └── settings.json └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-typescript"], 3 | "plugins": [ 4 | ["@babel/plugin-transform-runtime", 5 | { 6 | "regenerator": true 7 | } 8 | ] 9 | ] 10 | } -------------------------------------------------------------------------------- /.github/workflows/cron-build.yml: -------------------------------------------------------------------------------- 1 | name: Schedule bot monthly 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 1 * *' # Every first day of the month 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [16.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - name: Get dotenv secrets 22 | run: | 23 | touch .env 24 | echo WHITELIST_GIST_ID = ${{ secrets.WHITELIST_GIST_ID }} >> .env 25 | echo TWITTER_API_KEY = ${{ secrets.TWITTER_API_KEY }} >> .env 26 | echo TWITTER_API_KEY_SECRET = ${{ secrets.TWITTER_API_KEY_SECRET }} >> .env 27 | echo TWITTER_ACCESS_TOKEN = ${{ secrets.TWITTER_ACCESS_TOKEN }} >> .env 28 | echo TWITTER_ACCESS_TOKEN_SECRET = ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} >> .env 29 | - name: Clean install npm dependencies 30 | run: npm ci 31 | - name: Build project 32 | run: npm run build 33 | - name: Run script 34 | run: node dist 35 | - name: Keep workflow alive 36 | uses: gautamkrishnar/keepalive-workflow@master 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jack Domleo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Archived since January 2023 due to [the Twitter API no longer being a free sevrice](https://twitter.com/TwitterDev/status/1621026986784337922).** 2 | 3 | --- 4 | 5 | [![Schedule bot monthly](https://github.com/jackdomleo7/Spring_Clean_Twitter_Bot/actions/workflows/cron-build.yml/badge.svg?branch=main)](https://github.com/jackdomleo7/Spring_Clean_Twitter_Bot/actions/workflows/cron-build.yml) 6 | 7 | # Spring Clean Twitter Bot 8 | A Twitter bot to automatically clean up my Twitter account. 9 | 10 | ## Inspiration 11 | 12 | It has become evident that manually maintaining my Twitter account is very time-consuming and boring. For example, I don't like following inactive accounts (because what's the point), so I have decided to create a Twitter bot that will clean up certain things for me. I have made it future-proof so I can add more features and criteria as and when the ideas arise. 13 | 14 | ## How it works 15 | 16 | This is a script that runs on a GitHub Action workflow, scheduled to build and run on the first day of each month (it can also be run manually from your local machine). It will go through all the features and criteria listed to clean up your Twitter account. 17 | 18 | I have made this so that it can be a template or forked by anyone wanting to try it out. 19 | 20 | ### Technology 21 | 22 | - TypeScript 23 | - GitHub Actions & cron scheduling 24 | - Twitter API 25 | - GitHub Gists 26 | - Encrypted secrets (dotenv) 27 | 28 | ### Features & Criteria 29 | 30 | This project comes with a `settings.json` file that is useful for debugging and configuring the project. 31 | 32 | 'Features' are a piece of functionality, and 'criteria' are a checklist for something to meet a feature. 33 | 34 | #### Feature 1: Unfollow inactive accounts 35 | 36 | The script will automatically unfollow inactive users based on the criteria set below. I have made it so you can create a list of whitelisted users that will never be unfollowed (typically your friends & family or seasonal accounts like Hacktoberfest). Having a public list of whitelisted users may be problematic as some people may take offence if they see you are not whitelisting their account, so I have a private GitHub Gist with a JSON file of an array of whitelisted users, which the script will then retrieve. 37 | 38 | As a disclaimer, this is not a growth hacking technique, but that will help me clean up my Twitter profile. I don't feel I have to follow everyone who follows me and I only want to follow accounts that are active. 39 | 40 | Criteria: 41 | - Someone who hasn't tweeted in 3 months 42 | 43 | --- 44 | 45 | ## Development 46 | 47 | Run the script in development mode 48 | ```bash 49 | npm run dev 50 | ``` 51 | 52 | Build the project for production 53 | ``` 54 | npm run build 55 | ``` 56 | 57 | --- 58 | 59 | ## Create your own Spring Clean Twitter Bot 60 | 61 | You can easily start using this on your own Twitter account by clicking "Use this template" or forking this repository and following the steps below. 62 | 63 | ### Prerequisites 64 | 65 | - Node ≥ v16 66 | - npm ≥ v7 _(${Your handle} Spring Clean Twitter Bot", then go to settings and enable "Read and Write access". Then you will be presented with an API Key, an API Key Secret, a Bearer Token, an Access Token and an Access Token Secret. Copy and paste these to your `.env` file and add to your repository secrets (you don't need the bearer token). 81 | 5. Navigate to [https://gist.github.com](https://gist.github.com) and create a new gist with a file called `account-whitelist.json` and fill it with the following (you can add an array of strings of whitelisted user Twitter handles to the `handles` property - do not prefix with "@"), then click "Create secret gist". After creating the secret gist, get the gist ID from the url and add this to the `WHITELIST_GIST_ID` variable in your `.env` file and your repository's secret. 82 | ```json 83 | { 84 | "handles": [ 85 | "jackdomleo7" 86 | ] 87 | } 88 | ``` 89 | 6. Run the script 90 | 91 | When you push your code to GitHub, it will automatically create a GitHub Action because of the `.github/workflows/cron-build.yml` file. If you're uncomfortable with this and would rather run the script manually, either remove the file or disable the workflow. 92 | 93 | --- 94 | 95 | Created by [Jack Domleo](https://github.com/jackdomleo7/Spring_Clean_Twitter_Bot) 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spring_clean_twitter_bot", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "A Twitter bot to automatically clean up my Twitter account.", 6 | "scripts": { 7 | "dev": "tsnd --respawn -r @babel/register src/index.ts", 8 | "build": "rimraf dist && babel ./src -d ./dist --extensions .ts && cpy src/settings.json dist" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/jackdomleo7/Spring_Clean_Twitter_Bot.git" 13 | }, 14 | "author": { 15 | "name": "Jack Domleo", 16 | "url": "https://jackdomleo.dev" 17 | }, 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/jackdomleo7/Spring_Clean_Twitter_Bot/issues" 21 | }, 22 | "homepage": "https://github.com/jackdomleo7/Spring_Clean_Twitter_Bot#readme", 23 | "devDependencies": { 24 | "@babel/cli": "^7.16.8", 25 | "@babel/core": "^7.16.10", 26 | "@babel/node": "^7.16.8", 27 | "@babel/plugin-transform-runtime": "^7.16.10", 28 | "@babel/preset-env": "^7.16.10", 29 | "@babel/preset-typescript": "^7.16.7", 30 | "@babel/register": "^7.16.9", 31 | "@types/node": "^17.0.10", 32 | "cpy-cli": "^3.1.1", 33 | "rimraf": "^3.0.2", 34 | "ts-node-dev": "^1.1.8", 35 | "typescript": "^4.5.4" 36 | }, 37 | "dependencies": { 38 | "@babel/runtime": "^7.16.7", 39 | "date-fns": "^2.28.0", 40 | "dotenv": "^14.2.0", 41 | "log-symbols": "4.1.0", 42 | "octokit": "^1.7.1", 43 | "twitter-api-client": "^1.5.1" 44 | }, 45 | "engines": { 46 | "node": ">=16", 47 | "npm": ">=8" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | import { TwitterClient } from 'twitter-api-client' 3 | import { Octokit } from 'octokit' 4 | 5 | dotenv.config({ path: './.env' }) 6 | 7 | export const Twitter = new TwitterClient({ 8 | apiKey: process.env.TWITTER_API_KEY!, 9 | apiSecret: process.env.TWITTER_API_KEY_SECRET!, 10 | accessToken: process.env.TWITTER_ACCESS_TOKEN!, 11 | accessTokenSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET!, 12 | }) 13 | 14 | export const GitHub = new Octokit() 15 | -------------------------------------------------------------------------------- /src/features/index.ts: -------------------------------------------------------------------------------- 1 | import unfollowInactiveAccounts from './unfollowInactiveAccounts' 2 | 3 | export { 4 | unfollowInactiveAccounts 5 | } -------------------------------------------------------------------------------- /src/features/unfollowInactiveAccounts/criteria.ts: -------------------------------------------------------------------------------- 1 | import { sub, differenceInMonths } from 'date-fns' 2 | import { User } from 'twitter-api-client/dist/interfaces/types/FriendsListTypes' 3 | 4 | import { ICriteria } from './types' 5 | import settings from '../../settings.json' 6 | 7 | /***********/ 8 | /* HELPERS */ 9 | /***********/ 10 | 11 | export function meetsAllCriteria(criteria: ICriteria): boolean { 12 | for (let key in criteria) { 13 | if (typeof criteria[key] === 'string') return false // If a string is present, assume this criteria has not been met 14 | } 15 | return true 16 | } 17 | 18 | export function showUnmetCriteria(criteria: ICriteria): string { 19 | let unMetCriteria = [] 20 | 21 | for (let key in criteria) { 22 | if (typeof criteria[key] === 'string') unMetCriteria.push(criteria[key]) 23 | } 24 | 25 | return unMetCriteria.join(', ') 26 | } 27 | 28 | 29 | /***********/ 30 | /* CRITERIA */ 31 | /***********/ 32 | 33 | export function hasTweetedInXMonths(friend: User): boolean | string { 34 | const lastTweet = Date.parse(friend.status.created_at) 35 | const threeMonthsAgo = sub(new Date(), { months: settings.features.unfollowInactiveAccounts.criteria.tweetedInXMonths.no_of_months }) 36 | if (differenceInMonths(lastTweet, threeMonthsAgo) >= 0) { 37 | return true 38 | } 39 | else { 40 | return `they haven't tweeted in ${settings.features.unfollowInactiveAccounts.criteria.tweetedInXMonths.no_of_months} ${settings.features.unfollowInactiveAccounts.criteria.tweetedInXMonths.no_of_months === 1 ? 'month' : 'months'}` 41 | } 42 | } -------------------------------------------------------------------------------- /src/features/unfollowInactiveAccounts/index.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | import logSymbols from 'log-symbols' 3 | import { AccountSettings, FriendsList } from 'twitter-api-client' 4 | import { User } from 'twitter-api-client/dist/interfaces/types/FriendsListTypes' 5 | 6 | import { meetsAllCriteria, showUnmetCriteria, hasTweetedInXMonths } from './criteria' 7 | import { IWhiteList, ICriteria } from './types' 8 | 9 | import { Twitter, GitHub } from '../../config' 10 | import settings from '../../settings.json' 11 | dotenv.config({ path: './.env' }) 12 | 13 | async function getWhitelistedHandles(): Promise { 14 | try { 15 | const gist = await GitHub.rest.gists.get({ gist_id: process.env.WHITELIST_GIST_ID! }) 16 | const handles = (JSON.parse(gist.data.files!['account-whitelist.json']!.content!) as IWhiteList).handles 17 | settings.features.unfollowInactiveAccounts.show_logging.whitelisted_handles.found && console.log(logSymbols.info, `${handles.length} whitelisted handles found`) 18 | return handles 19 | } 20 | catch(err: unknown) { 21 | console.error(err) 22 | return [] 23 | } 24 | } 25 | 26 | async function getTwitterFriends(): Promise { 27 | // Get my account settings 28 | let myAccount: AccountSettings; 29 | try { 30 | myAccount = await Twitter.accountsAndUsers.accountSettings() 31 | } 32 | catch(err: unknown) { 33 | console.error(err) 34 | return [] 35 | } 36 | 37 | // Get all accounts I follow 38 | let friends: FriendsList; 39 | try { 40 | friends = await Twitter.accountsAndUsers.friendsList({ screen_name: myAccount.screen_name, count: 200 }) // Max page size = 200 41 | } 42 | catch(err: unknown) { 43 | console.error(err) 44 | return [] 45 | } 46 | let allFriends: User[] = friends.users 47 | while (friends.next_cursor !== 0) { 48 | try { 49 | friends = await Twitter.accountsAndUsers.friendsList({ screen_name: myAccount.screen_name, count: 200, cursor: friends.next_cursor }) 50 | allFriends = [...allFriends, ...friends.users] 51 | } 52 | catch(err: unknown) { 53 | console.log(logSymbols.error, `There was an error trying to retrieve the next page of friends, but was able to retrieve ${allFriends.length} of your friends, so will resume with these for now`) 54 | console.error(err) 55 | break 56 | } 57 | } 58 | 59 | // Summary and return all friends that were found 60 | settings.features.unfollowInactiveAccounts.show_logging.friends.found && console.log(logSymbols.info, `${allFriends.length} Twitter friends found`) 61 | return allFriends 62 | } 63 | 64 | async function unfollow(friends: User[], whitelistedHandles: string[]): Promise { 65 | let friendsUnfollowed = 0 66 | 67 | await Promise.all( 68 | friends.map(async (friend: User) => { 69 | if (!whitelistedHandles.includes(friend.screen_name)) { 70 | const criteria: ICriteria = { 71 | tweetedInXMonths: hasTweetedInXMonths(friend) 72 | } 73 | 74 | if (meetsAllCriteria(criteria)) { 75 | settings.features.unfollowInactiveAccounts.show_logging.friends.kept && console.log(`Keep following @${friend.screen_name} (${friend.name}) because they meet the criteria`) 76 | } 77 | else { 78 | try { 79 | if (settings.features.unfollowInactiveAccounts.actually_unfollow_accounts) { 80 | await Twitter.accountsAndUsers.friendshipsDestroy({ screen_name: friend.screen_name }) 81 | } 82 | else { 83 | await setTimeout(() => {}, 5000) // Mocking the asynchronous API call during debugging - wait 5 seconds 84 | } 85 | 86 | friendsUnfollowed++ 87 | settings.features.unfollowInactiveAccounts.show_logging.friends.unfollowed && console.log(`Unfollowing @${friend.screen_name} (${friend.name}) because they are not a whitelisted user, ${showUnmetCriteria(criteria)}`) 88 | } 89 | catch(err: unknown) { 90 | console.error(err) 91 | } 92 | } 93 | } else { 94 | settings.features.unfollowInactiveAccounts.show_logging.friends.whitelisted && console.log(`Skipping @${friend.screen_name} (${friend.name}) because they are a whitelisted user`) 95 | } 96 | }) 97 | ) 98 | 99 | settings.features.unfollowInactiveAccounts.show_logging.friends.summary && console.log(logSymbols.success, `${friendsUnfollowed} of ${friends.length} friends unfollowed (you are now following ${friends.length - friendsUnfollowed} accounts)`) 100 | } 101 | 102 | export default async function unfollowInactiveAccounts(): Promise { 103 | const whitelistedHandles = await getWhitelistedHandles() 104 | if (whitelistedHandles.length === 0) { 105 | // Skip this feature if a connection to the GitHub gist cannot be established - otherwise we will have no list of whitelisted users and may accidentally unfollow a whitelisted user 106 | console.log(logSymbols.error, 'Skipping unfollowInactiveAccounts feature because something went wrong whilst retrieving the whitelisted users') 107 | } 108 | else { 109 | const twitterFriends = await getTwitterFriends() 110 | if (twitterFriends.length === 0) { 111 | // Skip this feature if we cannot get a list of friends from Twitter - no point in proceeding with the feature 112 | console.log(logSymbols.error, 'Skipping unfollowInactiveAccounts feature because something went wrong whilst retrieving your Twitter friends') 113 | } 114 | else { 115 | await unfollow(twitterFriends, whitelistedHandles) 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /src/features/unfollowInactiveAccounts/types.ts: -------------------------------------------------------------------------------- 1 | export interface IWhiteList { 2 | handles: string[] 3 | } 4 | 5 | export interface ICriteria { // If typeof === 'string', assume criteria has failed 6 | [key: string]: any; 7 | tweetedInXMonths: boolean | string 8 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { unfollowInactiveAccounts } from './features' 2 | 3 | // Start of script 4 | (async function(){ 5 | unfollowInactiveAccounts() 6 | })() -------------------------------------------------------------------------------- /src/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "features" :{ 3 | "unfollowInactiveAccounts": { 4 | "criteria": { 5 | "tweetedInXMonths": { 6 | "no_of_months": 3 7 | } 8 | }, 9 | "actually_unfollow_accounts": true, 10 | "show_logging": { 11 | "whitelisted_handles": { 12 | "found": true 13 | }, 14 | "friends": { 15 | "found": true, 16 | "unfollowed": true, 17 | "kept": false, 18 | "whitelisted": false, 19 | "summary": true 20 | } 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": [ 6 | "ESNext" 7 | ], 8 | "outDir": "./dist", 9 | "rootDir": "./src", 10 | "noEmit": true, 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "resolveJsonModule": true, 15 | "types": [ 16 | "node", 17 | "@types/node" 18 | ] 19 | }, 20 | "include": ["src"] 21 | } --------------------------------------------------------------------------------