├── .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 | [](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 | }
--------------------------------------------------------------------------------