├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── testing.yml ├── .gitignore ├── .goreleaser.yml ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── flags └── flags.go ├── github └── github.go ├── go.mod ├── go.sum ├── img └── TrendingGithub.png ├── main.go ├── storage ├── memory.go ├── memory_test.go ├── redis.go ├── redis_test.go └── storage.go ├── trending ├── trending.go └── trending_test.go ├── tweets.go ├── tweets_test.go └── twitter ├── config.go ├── follow.go ├── tweet.go └── twitter.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: andygrunwald 2 | custom: "https://paypal.me/andygrunwald" -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "gomod" 7 | directory: "/" 8 | schedule: 9 | interval: "monthly" 10 | 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "monthly" 15 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | schedule: 9 | - cron: "5 1 * * *" # Run nightly 10 | workflow_dispatch: 11 | 12 | jobs: 13 | gofmt: 14 | name: go fmt (Go ${{ matrix.go }}) 15 | runs-on: ubuntu-22.04 16 | strategy: 17 | matrix: 18 | go: [ '1.20', '1.19' ] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-go@v5 23 | with: 24 | go-version: ${{ matrix.go }} 25 | 26 | - name: Run go fmt 27 | if: runner.os != 'Windows' 28 | run: diff -u <(echo -n) <(gofmt -d -s .) 29 | 30 | govet: 31 | name: go vet (Go ${{ matrix.go }}) 32 | runs-on: ubuntu-22.04 33 | strategy: 34 | matrix: 35 | go: [ '1.20', '1.19' ] 36 | 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: actions/setup-go@v5 40 | with: 41 | go-version: ${{ matrix.go }} 42 | 43 | - name: Run go vet 44 | run: make vet 45 | 46 | staticcheck: 47 | name: staticcheck (Go ${{ matrix.go }}) 48 | runs-on: ubuntu-22.04 49 | strategy: 50 | matrix: 51 | go: [ '1.20', '1.19' ] 52 | 53 | steps: 54 | - uses: actions/checkout@v4 55 | - uses: actions/setup-go@v5 56 | with: 57 | go-version: ${{ matrix.go }} 58 | 59 | - name: Run staticcheck 60 | uses: dominikh/staticcheck-action@v1.3.1 61 | with: 62 | version: "2023.1.3" 63 | install-go: false 64 | cache-key: ${{ matrix.go }} 65 | 66 | unittesting: 67 | name: unit testing (Go ${{ matrix.go }}) 68 | runs-on: ubuntu-22.04 69 | strategy: 70 | matrix: 71 | go: [ '1.20', '1.19' ] 72 | 73 | # See https://docs.github.com/en/actions/using-containerized-services/creating-redis-service-containers 74 | services: 75 | redis: 76 | # Docker Hub image 77 | image: redis 78 | # Set health checks to wait until redis has started 79 | options: >- 80 | --health-cmd "redis-cli ping" 81 | --health-interval 10s 82 | --health-timeout 5s 83 | --health-retries 5 84 | ports: 85 | # Maps port 6379 on service container to the host 86 | - 6379:6379 87 | 88 | steps: 89 | - uses: actions/checkout@v4 90 | - uses: actions/setup-go@v5 91 | with: 92 | go-version: ${{ matrix.go }} 93 | 94 | - name: Run Unit tests. 95 | run: make test 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | # Application 27 | TrendingGithub 28 | /config.json 29 | 30 | # Application release 31 | dist/ 32 | coverage.text 33 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - goos: 3 | - linux 4 | - darwin 5 | - windows 6 | goarch: 7 | - 386 8 | - amd64 9 | - arm 10 | - arm64 11 | 12 | checksum: 13 | name_template: '{{ .ProjectName }}_checksums.txt' 14 | 15 | changelog: 16 | sort: asc 17 | filters: 18 | exclude: 19 | - Merge pull request 20 | - Merge branch 21 | 22 | archive: 23 | name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 24 | replacements: 25 | darwin: Darwin 26 | linux: Linux 27 | windows: Windows 28 | 386: i386 29 | amd64: x86_64 30 | format_overrides: 31 | - goos: windows 32 | format: zip 33 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | sudo: false 4 | 5 | go: 6 | - "1.9.x" 7 | - "1.10.x" 8 | 9 | services: 10 | - redis-server 11 | 12 | script: 13 | - go test -v -race ./... 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2018 Andy Grunwald 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 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | .PHONY: help 4 | help: ## Outputs the help. 5 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 6 | 7 | .PHONY: test 8 | test: ## Runs all unit, integration and example tests. 9 | go test -race -v ./... 10 | 11 | .PHONY: vet 12 | vet: ## Runs go vet (to detect suspicious constructs). 13 | go vet ./... 14 | 15 | .PHONY: fmt 16 | fmt: ## Runs go fmt (to check for go coding guidelines). 17 | gofmt -d -s . 18 | 19 | .PHONY: staticcheck 20 | staticcheck: ## Runs static analysis to prevend bugs, foster code simplicity, performance and editor integration. 21 | go get -u honnef.co/go/tools/cmd/staticcheck 22 | staticcheck ./... 23 | 24 | .PHONY: all 25 | all: test vet fmt staticcheck ## Runs all source code quality targets (like test, vet, fmt, staticcheck) 26 | 27 | .PHONY: build 28 | build: ## Build app 29 | @go build -v -o ./build/TrendingGithub 30 | 31 | .PHONY: build 32 | debug: build ## Run app in debug mode 33 | ./build/TrendingGithub -debug -twitter-tweet-time 5s -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [@TrendingGithub](https://twitter.com/TrendingGithub) 2 | 3 | [![Build Status](https://travis-ci.org/andygrunwald/TrendingGithub.svg?branch=master)](https://travis-ci.org/andygrunwald/TrendingGithub) 4 | [![GoDoc](https://godoc.org/github.com/andygrunwald/TrendingGithub?status.svg)](https://godoc.org/github.com/andygrunwald/TrendingGithub) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/andygrunwald/TrendingGithub)](https://goreportcard.com/report/github.com/andygrunwald/TrendingGithub) 6 | 7 | A twitter bot (**[@TrendingGithub](https://twitter.com/TrendingGithub)**) to tweet [trending repositories](https://github.com/trending) and [developers](https://github.com/trending/developers) from GitHub. 8 | 9 | > Follow us at **[@TrendingGithub](https://twitter.com/TrendingGithub)**. 10 | 11 | [![@TrendingGithub twitter account](./img/TrendingGithub.png "@TrendingGithub twitter account")](https://twitter.com/TrendingGithub) 12 | 13 | **Important:** This is no official GitHub or Twitter product. 14 | 15 | ## Features 16 | 17 | * Tweets trending projects every 30 minutes 18 | * Refreshes the configuration of twitters URL shortener t.co every 24 hours 19 | * Blacklisting of repositories for 30 days (to avoid tweeting a project multiple times in a short timeframe) 20 | * Maximum use of 140 chars per tweet to fill up with information 21 | * Debug / development mode 22 | * Multiple storage backends (currently [Redis](http://redis.io/) and in memory) 23 | 24 | ## Installation 25 | 26 | 1. Download the [latest release](https://github.com/andygrunwald/TrendingGithub/releases/latest) 27 | 2. Extract the archive (zip / tar.gz) 28 | 3. Start the bot via `./TrendingGithub -debug` 29 | 30 | For linux this can look like: 31 | 32 | ```sh 33 | curl -L https://github.com/andygrunwald/TrendingGithub/releases/download/v0.4.0/TrendingGithub-v0.4.0-linux-amd64.tar.gz -o TrendingGithub-v0.4.0-linux-amd64.tar.gz 34 | tar xzvf TrendingGithub-v0.4.0-linux-amd64.tar.gz 35 | cd TrendingGithub-v0.4.0-linux-amd64 36 | ./TrendingGithub -debug 37 | ``` 38 | 39 | ## Usage 40 | 41 | ``` 42 | $ ./TrendingGithub -help 43 | Usage of ./TrendingGithub: 44 | -debug 45 | Outputs the tweet instead of tweet it (useful for development). Env var: TRENDINGGITHUB_DEBUG 46 | -expvar-port int 47 | Port which will be used for the expvar TCP server. Env var: TRENDINGGITHUB_EXPVAR_PORT (default 8123) 48 | -storage-auth string 49 | Storage Auth (e.g. myPassword or ). Env var: TRENDINGGITHUB_STORAGE_AUTH 50 | -storage-url string 51 | Storage URL (e.g. 1.2.3.4:6379 or :6379). Env var: TRENDINGGITHUB_STORAGE_URL (default ":6379") 52 | -twitter-access-token string 53 | Twitter-API: Access token. Env var: TRENDINGGITHUB_TWITTER_ACCESS_TOKEN 54 | -twitter-access-token-secret string 55 | Twitter-API: Access token secret. Env var: TRENDINGGITHUB_TWITTER_ACCESS_TOKEN_SECRET 56 | -twitter-conf-refresh-time duration 57 | Twitter: Time interval to refresh the configuration of twitter (e.g. char length for short url). Env var: TRENDINGGITHUB_TWITTER_CONF_REFRESH_TIME (default 24h0m0s) 58 | -twitter-consumer-key string 59 | Twitter-API: Consumer key. Env var: TRENDINGGITHUB_TWITTER_CONSUMER_KEY 60 | -twitter-consumer-secret string 61 | Twitter-API: Consumer secret. Env var: TRENDINGGITHUB_TWITTER_CONSUMER_SECRET 62 | -twitter-follow-new-person 63 | Twitter: Follows a friend of one of our followers. Env var: TRENDINGGITHUB_TWITTER_FOLLOW_NEW_PERSON 64 | -twitter-follow-new-person-time duration 65 | Growth hack: Time interval to search for a new person to follow. Env var: TRENDINGGITHUB_TWITTER_FOLLOW_NEW_PERSON_TIME (default 45m0s) 66 | -twitter-tweet-time duration 67 | Twitter: Time interval to search a new project and tweet it. Env var: TRENDINGGITHUB_TWITTER_TWEET_TIME (default 30m0s) 68 | -version 69 | Outputs the version number and exit. Env var: TRENDINGGITHUB_VERSION 70 | ``` 71 | 72 | **Every parameter can be set by environment variable as well.** 73 | 74 | **Twitter-API settings** (`twitter-access-token`, `twitter-access-token-secret`, `twitter-consumer-key` and `twitter-consumer-secret`) are necessary to use the Twitter API and to set up a tweet by your application. 75 | You can get those settings by [Twitter's application management](https://apps.twitter.com/). 76 | 77 | If you want to play around or develop this bot, use the `debug` setting. 78 | It avoids using the Twitter API for tweet purposes and outputs the tweet on stdout. 79 | 80 | The Redis url (`storage-url`)is the address of the Redis server in format *ip:port* (e.g. *192.168.0.12:6379*). 81 | If your server is running on localhost you can use *:6379* as a shortcut. 82 | `storage-auth` is the authentication string necessary for your Redis server if you use the [Authentication feature](http://redis.io/topics/security#authentication-feature). 83 | 84 | ## Storage backends 85 | 86 | Why is a storage backend needed at all? 87 | 88 | We are looking for popular projects in a regular interval. 89 | To avoid tweeting a project or developer multiple times after another we add those records to a blacklist for a specific time. 90 | 91 | At the moment there are two backends implemented: 92 | 93 | * Memory (used in development) 94 | * Redis (used in production) 95 | 96 | ## Growth hack 97 | 98 | We implemented a small growth hack to get a few followers. 99 | This hack was suggested by my colleague [@mre](https://github.com/mre). 100 | It works like described: 101 | 102 | * Get all followers from [@TrendingGithub](https://twitter.com/TrendingGithub) 103 | * Choose a random one and get the followers of the choosen person 104 | * Check if this person follows us already 105 | * If yes, repeat 106 | * If no, follow this person 107 | 108 | This feature can be activated via the `twitter-follow-new-person` flag. 109 | 110 | ## Motivation 111 | 112 | I love to discover new tools, new projects, new languages, new coding best practices, new exciting ideas and new people who share the same passion like me. 113 | [I use twitter a lot](https://twitter.com/andygrunwald) and have little time to check [trending repositories](https://github.com/trending) and [developers](https://github.com/trending/developers) on a daily basis. 114 | 115 | Why not combine both to save time and spread favorite projects and developers via tweets? 116 | 117 | ## License 118 | 119 | This project is released under the terms of the [MIT license](http://en.wikipedia.org/wiki/MIT_License). 120 | -------------------------------------------------------------------------------- /flags/flags.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | // Bool registers a flag and returns the pointer to the resulting boolean. 12 | // The default value is passed as fallback and env sets the env variable 13 | // that can override the default. 14 | func Bool(name, env string, fallback bool, help string) *bool { 15 | value := fallback 16 | if v := os.Getenv(env); v != "" { 17 | if b, err := strconv.ParseBool(v); err == nil { 18 | value = b 19 | } 20 | } 21 | 22 | return flag.Bool(name, value, help) 23 | } 24 | 25 | // String registers a flag and returns the pointer to the resulting string. 26 | // The default value is passed as fallback and env sets the env variable 27 | // that can override the default. 28 | func String(name, env, fallback, help string) *string { 29 | value := fallback 30 | if v := os.Getenv(env); v != "" { 31 | value = v 32 | } 33 | 34 | return flag.String(name, value, help) 35 | } 36 | 37 | // Int registers a flag and returns the pointer to the resulting int. 38 | // The default value is passed as fallback and env sets the env variable 39 | // that can override the default. 40 | func Int(name, env string, fallback int, help string) *int { 41 | value := fallback 42 | if v := os.Getenv(env); v != "" { 43 | if i, err := strconv.Atoi(v); err == nil { 44 | value = i 45 | } 46 | 47 | } 48 | 49 | return flag.Int(name, value, help) 50 | } 51 | 52 | // Duration registers a flag and returns the pointer to the resulting duration. 53 | // The default value is passed as fallback and env sets the env variable 54 | // that can override the default. 55 | func Duration(name, env string, fallback time.Duration, help string) *time.Duration { 56 | value := fallback 57 | if v := os.Getenv(env); v != "" { 58 | vv, err := time.ParseDuration(v) 59 | if err != nil { 60 | log.Fatalf("Error parsing duration from env variable %s: %s", env, v) 61 | } 62 | 63 | value = vv 64 | } 65 | 66 | return flag.Duration(name, value, help) 67 | } 68 | -------------------------------------------------------------------------------- /github/github.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/go-github/github" 7 | ) 8 | 9 | // Repository represents a single repository from github. 10 | // This struct is a stripped down version of github.Repository. 11 | // We only return the values we need here. 12 | type Repository struct { 13 | StargazersCount *int `json:"stargazers_count,omitempty"` 14 | } 15 | 16 | // GetRepositoryDetails will retrieve details about the repository owner/repo from github. 17 | func GetRepositoryDetails(owner, repo string) (*Repository, error) { 18 | client := github.NewClient(nil) 19 | repository, _, err := client.Repositories.Get(context.Background(), owner, repo) 20 | if repository == nil { 21 | return nil, err 22 | } 23 | 24 | r := &Repository{ 25 | StargazersCount: repository.StargazersCount, 26 | } 27 | return r, err 28 | } 29 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/andygrunwald/TrendingGithub 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/ChimeraCoder/anaconda v2.0.0+incompatible 7 | github.com/andygrunwald/go-trending v0.0.0-20220409064206-0c4061ad5100 8 | github.com/gomodule/redigo v1.9.2 9 | github.com/google/go-github v17.0.0+incompatible 10 | ) 11 | 12 | require ( 13 | github.com/ChimeraCoder/tokenbucket v0.0.0-20131201223612-c5a927568de7 // indirect 14 | github.com/PuerkitoBio/goquery v1.8.0 // indirect 15 | github.com/andybalholm/cascadia v1.3.1 // indirect 16 | github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330 // indirect 17 | github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc // indirect 18 | github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad // indirect 19 | github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 // indirect 20 | github.com/google/go-querystring v1.1.0 // indirect 21 | golang.org/x/net v0.38.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ChimeraCoder/anaconda v2.0.0+incompatible h1:F0eD7CHXieZ+VLboCD5UAqCeAzJZxcr90zSCcuJopJs= 2 | github.com/ChimeraCoder/anaconda v2.0.0+incompatible/go.mod h1:TCt3MijIq3Qqo9SBtuW/rrM4x7rDfWqYWHj8T7hLcLg= 3 | github.com/ChimeraCoder/tokenbucket v0.0.0-20131201223612-c5a927568de7 h1:r+EmXjfPosKO4wfiMLe1XQictsIlhErTufbWUsjOTZs= 4 | github.com/ChimeraCoder/tokenbucket v0.0.0-20131201223612-c5a927568de7/go.mod h1:b2EuEMLSG9q3bZ95ql1+8oVqzzrTNSiOQqSXWFBzxeI= 5 | github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= 6 | github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= 7 | github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= 8 | github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= 9 | github.com/andygrunwald/go-trending v0.0.0-20220409064206-0c4061ad5100 h1:YspYJnpMJPzCtq/32Avz8pqWfSfqvUoP4rLt4wDXipA= 10 | github.com/andygrunwald/go-trending v0.0.0-20220409064206-0c4061ad5100/go.mod h1:OxJO0zN92WWuxLuYSzhApoAd03li9NUm3U5Qnc53drY= 11 | github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330 h1:ekDALXAVvY/Ub1UtNta3inKQwZ/jMB/zpOtD8rAYh78= 12 | github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330/go.mod h1:nH+k0SvAt3HeiYyOlJpLLv1HG1p7KWP7qU9QPp2/pCo= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc h1:tP7tkU+vIsEOKiK+l/NSLN4uUtkyuxc6hgYpQeCWAeI= 15 | github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc/go.mod h1:ORH5Qp2bskd9NzSfKqAF7tKfONsEkCarTE5ESr/RVBw= 16 | github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad h1:Qk76DOWdOp+GlyDKBAG3Klr9cn7N+LcYc82AZ2S7+cA= 17 | github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad/go.mod h1:mPKfmRa823oBIgl2r20LeMSpTAteW5j7FLkc0vjmzyQ= 18 | github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 h1:GOfMz6cRgTJ9jWV0qAezv642OhPnKEG7gtUjJSdStHE= 19 | github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17/go.mod h1:HfkOCN6fkKKaPSAeNq/er3xObxTW4VLeY6UUK895gLQ= 20 | github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s= 21 | github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= 22 | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= 23 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 24 | github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= 25 | github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= 26 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 27 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 28 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 29 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 30 | golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 31 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 32 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 33 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 34 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 35 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 36 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 37 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 38 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 39 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 40 | -------------------------------------------------------------------------------- /img/TrendingGithub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andygrunwald/TrendingGithub/af51ab38d7efe1f84bfaa60987a5db2d9172c694/img/TrendingGithub.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "expvar" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net" 9 | "net/http" 10 | "time" 11 | 12 | "github.com/andygrunwald/TrendingGithub/flags" 13 | "github.com/andygrunwald/TrendingGithub/storage" 14 | "github.com/andygrunwald/TrendingGithub/twitter" 15 | ) 16 | 17 | const ( 18 | // Name of the application 19 | Name = "@TrendingGithub" 20 | ) 21 | 22 | var ( 23 | // Version of @TrendingGithub 24 | version = "dev" 25 | 26 | // Build commit of @TrendingGithub 27 | commit = "none" 28 | 29 | // Build date of @TrendingGithub 30 | date = "unknown" 31 | ) 32 | 33 | func main() { 34 | var ( 35 | // Twitter 36 | twitterConsumerKey = flags.String("twitter-consumer-key", "TRENDINGGITHUB_TWITTER_CONSUMER_KEY", "", "Twitter-API: Consumer key. Env var: TRENDINGGITHUB_TWITTER_CONSUMER_KEY") 37 | twitterConsumerSecret = flags.String("twitter-consumer-secret", "TRENDINGGITHUB_TWITTER_CONSUMER_SECRET", "", "Twitter-API: Consumer secret. Env var: TRENDINGGITHUB_TWITTER_CONSUMER_SECRET") 38 | twitterAccessToken = flags.String("twitter-access-token", "TRENDINGGITHUB_TWITTER_ACCESS_TOKEN", "", "Twitter-API: Access token. Env var: TRENDINGGITHUB_TWITTER_ACCESS_TOKEN") 39 | twitterAccessTokenSecret = flags.String("twitter-access-token-secret", "TRENDINGGITHUB_TWITTER_ACCESS_TOKEN_SECRET", "", "Twitter-API: Access token secret. Env var: TRENDINGGITHUB_TWITTER_ACCESS_TOKEN_SECRET") 40 | twitterFollowNewPerson = flags.Bool("twitter-follow-new-person", "TRENDINGGITHUB_TWITTER_FOLLOW_NEW_PERSON", false, "Twitter: Follows a friend of one of our followers. Env var: TRENDINGGITHUB_TWITTER_FOLLOW_NEW_PERSON") 41 | 42 | // Timings 43 | tweetTime = flags.Duration("twitter-tweet-time", "TRENDINGGITHUB_TWITTER_TWEET_TIME", 30*time.Minute, "Twitter: Time interval to search a new project and tweet it. Env var: TRENDINGGITHUB_TWITTER_TWEET_TIME") 44 | configurationRefreshTime = flags.Duration("twitter-conf-refresh-time", "TRENDINGGITHUB_TWITTER_CONF_REFRESH_TIME", 24*time.Hour, "Twitter: Time interval to refresh the configuration of twitter (e.g. char length for short url). Env var: TRENDINGGITHUB_TWITTER_CONF_REFRESH_TIME") 45 | followNewPersonTime = flags.Duration("twitter-follow-new-person-time", "TRENDINGGITHUB_TWITTER_FOLLOW_NEW_PERSON_TIME", 45*time.Minute, "Growth hack: Time interval to search for a new person to follow. Env var: TRENDINGGITHUB_TWITTER_FOLLOW_NEW_PERSON_TIME") 46 | 47 | // Redis storage 48 | storageURL = flags.String("storage-url", "TRENDINGGITHUB_STORAGE_URL", ":6379", "Storage URL (e.g. 1.2.3.4:6379 or :6379). Env var: TRENDINGGITHUB_STORAGE_URL") 49 | storageAuth = flags.String("storage-auth", "TRENDINGGITHUB_STORAGE_AUTH", "", "Storage Auth (e.g. myPassword or ). Env var: TRENDINGGITHUB_STORAGE_AUTH") 50 | 51 | expVarPort = flags.Int("expvar-port", "TRENDINGGITHUB_EXPVAR_PORT", 8123, "Port which will be used for the expvar TCP server. Env var: TRENDINGGITHUB_EXPVAR_PORT") 52 | showVersion = flags.Bool("version", "TRENDINGGITHUB_VERSION", false, "Outputs the version number and exit. Env var: TRENDINGGITHUB_VERSION") 53 | debugMode = flags.Bool("debug", "TRENDINGGITHUB_DEBUG", false, "Outputs the tweet instead of tweet it (useful for development). Env var: TRENDINGGITHUB_DEBUG") 54 | ) 55 | flag.Parse() 56 | 57 | // Output the version and exit 58 | if *showVersion { 59 | fmt.Printf("%s v%v, commit %v, built at %v", Name, version, commit, date) 60 | return 61 | } 62 | 63 | log.Printf("Hey, my name is %s (v%s). Lets get ready to tweet some trending content!\n", Name, version) 64 | defer log.Println("Nice session. A lot of knowledge was shared. Good work. See you next time!") 65 | 66 | twitterClient := initTwitterClient(*twitterConsumerKey, *twitterConsumerSecret, *twitterAccessToken, *twitterAccessTokenSecret, *debugMode, *configurationRefreshTime) 67 | 68 | // Activate the growth hack feature 69 | if *twitterFollowNewPerson { 70 | log.Println("Growth hack \"Follow a friend of a friend\": Enabled ✅ ") 71 | twitterClient.SetupFollowNewPeopleScheduling(*followNewPersonTime) 72 | } 73 | 74 | storageBackend := initStorageBackend(*storageURL, *storageAuth, *debugMode) 75 | initExpvarServer(*expVarPort) 76 | 77 | // Let the party begin 78 | StartTweeting(twitterClient, storageBackend, *tweetTime) 79 | } 80 | 81 | // initTwitterClient prepares and initializes the twitter client 82 | func initTwitterClient(consumerKey, consumerSecret, accessToken, accessTokenSecret string, debugMode bool, confRefreshTime time.Duration) *twitter.Client { 83 | var twitterClient *twitter.Client 84 | 85 | if debugMode { 86 | // When we are running in a debug mode, we are running with a debug configuration. 87 | // So we don`t need to load the configuration from twitter here. 88 | twitterClient = twitter.NewDebugClient() 89 | 90 | } else { 91 | twitterClient = twitter.NewClient(consumerKey, consumerSecret, accessToken, accessTokenSecret) 92 | err := twitterClient.LoadConfiguration() 93 | if err != nil { 94 | log.Fatalf("Twitter Configuration: Initialization ❌ (%s)", err) 95 | } 96 | log.Println("Twitter Configuration: Initialization ✅") 97 | twitterClient.SetupConfigurationRefresh(confRefreshTime) 98 | } 99 | 100 | return twitterClient 101 | } 102 | 103 | // initExpvarServer will start a small tcp server for the expvar package. 104 | // This server is only available via localhost on localhost:port/debug/vars 105 | func initExpvarServer(port int) { 106 | sock, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", port)) 107 | if err != nil { 108 | log.Fatalf("Expvar: Initialisation ❌ (%s)", err) 109 | } 110 | 111 | go func() { 112 | log.Printf("Expvar: Available at http://localhost:%d/debug/vars", port) 113 | http.Serve(sock, nil) 114 | }() 115 | 116 | log.Println("Expvar: Initialization ✅") 117 | } 118 | 119 | // initStorageBackend will start the storage backend 120 | func initStorageBackend(address, auth string, debug bool) storage.Pool { 121 | var storageBackend storage.Pool 122 | 123 | if debug { 124 | storageBackend = storage.NewDebugBackend() 125 | } else { 126 | storageBackend = storage.NewBackend(address, auth) 127 | } 128 | 129 | defer storageBackend.Close() 130 | log.Println("Storage backend: Initialization ✅") 131 | 132 | return storageBackend 133 | } 134 | -------------------------------------------------------------------------------- /storage/memory.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // MemoryStorageContainer is the backend of the "in memory" storage engine. 8 | // It supports a key (string) and a duration as time. 9 | // The time duration can act as a TTL. 10 | type MemoryStorageContainer map[string]time.Time 11 | 12 | // MemoryStorage represents the in memory storage engine. 13 | // This storage can be useful for debugging / development 14 | type MemoryStorage struct{} 15 | 16 | // MemoryPool is the pool of connections to your local memory ;) 17 | type MemoryPool struct { 18 | storage MemoryStorageContainer 19 | } 20 | 21 | // MemoryConnection represents a in memory connection 22 | type MemoryConnection struct { 23 | storage MemoryStorageContainer 24 | } 25 | 26 | // NewPool returns a pool to communicate with your in memory 27 | func (ms *MemoryStorage) NewPool(url, auth string) Pool { 28 | return MemoryPool{ 29 | storage: make(MemoryStorageContainer), 30 | } 31 | } 32 | 33 | // Close closes a in memory pool 34 | func (mp MemoryPool) Close() error { 35 | return nil 36 | } 37 | 38 | // Get returns you a connection to your in memory storage 39 | func (mp MemoryPool) Get() Connection { 40 | return &MemoryConnection{ 41 | storage: mp.storage, 42 | } 43 | } 44 | 45 | // Err will return an error once one occurred 46 | func (mc *MemoryConnection) Err() error { 47 | return nil 48 | } 49 | 50 | // Close shuts down a in memory connection 51 | func (mc *MemoryConnection) Close() error { 52 | return nil 53 | } 54 | 55 | // MarkRepositoryAsTweeted marks a single projects as "already tweeted". 56 | // This information will be stored as a hashmap with a TTL. 57 | // The timestamp of the tweet will be used as value. 58 | func (mc *MemoryConnection) MarkRepositoryAsTweeted(projectName, score string) (bool, error) { 59 | // Add grey listing to current time 60 | now := time.Now() 61 | future := now.Add(time.Second * BlackListTTL) 62 | 63 | mc.storage[projectName] = future 64 | 65 | return true, nil 66 | } 67 | 68 | // IsRepositoryAlreadyTweeted checks if a project was already tweeted. 69 | // If it is not available 70 | // 71 | // a) the project was not tweeted yet 72 | // b) the project ttl expired and is ready to tweet again 73 | func (mc *MemoryConnection) IsRepositoryAlreadyTweeted(projectName string) (bool, error) { 74 | if val, ok := mc.storage[projectName]; ok { 75 | if res := val.Before(time.Now()); res { 76 | delete(mc.storage, projectName) 77 | return false, nil 78 | } 79 | 80 | return true, nil 81 | } 82 | 83 | return false, nil 84 | } 85 | -------------------------------------------------------------------------------- /storage/memory_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | var ( 8 | memoryTestProjectName = "andygrunwald/TrendingGithub" 9 | ) 10 | 11 | func TestMemory_MarkRepositoryAsTweeted(t *testing.T) { 12 | storage := MemoryStorage{} 13 | pool := storage.NewPool("", "") 14 | defer pool.Close() 15 | conn := pool.Get() 16 | defer conn.Close() 17 | 18 | res, err := conn.MarkRepositoryAsTweeted(memoryTestProjectName, "1440946305") 19 | if err != nil { 20 | t.Fatalf("Error of marking repository: \"%s\"", err) 21 | } 22 | 23 | if res == false { 24 | t.Fatal("Marking repository failed, got false, expected true") 25 | } 26 | } 27 | 28 | func TestMemory_IsRepositoryAlreadyTweeted(t *testing.T) { 29 | storage := MemoryStorage{} 30 | pool := storage.NewPool("", "") 31 | defer pool.Close() 32 | conn := pool.Get() 33 | defer conn.Close() 34 | 35 | res, err := conn.IsRepositoryAlreadyTweeted(memoryTestProjectName) 36 | if err != nil { 37 | t.Fatalf("First already tweeted check throws an error: \"%s\"", err) 38 | } 39 | if res == true { 40 | t.Fatal("Repository was already tweeted, got true, expected false") 41 | } 42 | 43 | res, err = conn.MarkRepositoryAsTweeted(memoryTestProjectName, "1440946884") 44 | if err != nil { 45 | t.Fatalf("Error of marking repository: \"%s\"", err) 46 | } 47 | 48 | if res == false { 49 | t.Fatal("Marking repository failed, got false, expected true") 50 | } 51 | 52 | res, err = conn.IsRepositoryAlreadyTweeted(memoryTestProjectName) 53 | if err != nil { 54 | t.Fatalf("Second already tweeted check throws an error: \"%s\"", err) 55 | } 56 | if res == false { 57 | t.Fatal("Repository was not already tweeted, got false, expected true") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /storage/redis.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gomodule/redigo/redis" 7 | ) 8 | 9 | const ( 10 | // RedisOK is the standard response of a Redis server if everything went fine ("OK") 11 | RedisOK = "OK" 12 | ) 13 | 14 | // RedisStorage represents the storage engine based on the Redis project / server 15 | type RedisStorage struct{} 16 | 17 | // RedisPool is the connection pool to a redis instance 18 | type RedisPool struct { 19 | pool *redis.Pool 20 | } 21 | 22 | // RedisConnection represents a single connection to a redis instance. 23 | type RedisConnection struct { 24 | conn redis.Conn 25 | } 26 | 27 | // NewPool returns a new redis connection pool 28 | func (rs *RedisStorage) NewPool(url, auth string) Pool { 29 | rp := RedisPool{ 30 | pool: &redis.Pool{ 31 | MaxIdle: 3, 32 | IdleTimeout: 240 * time.Second, 33 | Dial: func() (redis.Conn, error) { 34 | c, err := redis.Dial("tcp", url) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | // If we don`t have an auth set, we don`t have to call redis 40 | if len(auth) == 0 { 41 | return c, err 42 | } 43 | 44 | if _, err := c.Do("AUTH", auth); err != nil { 45 | c.Close() 46 | return nil, err 47 | } 48 | return c, err 49 | }, 50 | TestOnBorrow: func(c redis.Conn, t time.Time) error { 51 | _, err := c.Do("PING") 52 | return err 53 | }, 54 | }, 55 | } 56 | 57 | return rp 58 | } 59 | 60 | // Close will close a connection pool 61 | func (rp RedisPool) Close() error { 62 | return rp.pool.Close() 63 | } 64 | 65 | // Get will return a new connection out the pool 66 | func (rp RedisPool) Get() Connection { 67 | rc := RedisConnection{ 68 | conn: rp.pool.Get(), 69 | } 70 | return &rc 71 | } 72 | 73 | // Err will return an error once one occurred 74 | func (rc *RedisConnection) Err() error { 75 | return rc.conn.Err() 76 | } 77 | 78 | // Close will close a single redis connection 79 | func (rc *RedisConnection) Close() error { 80 | return rc.conn.Close() 81 | } 82 | 83 | // MarkRepositoryAsTweeted marks a single projects as "already tweeted". 84 | // This information will be stored in Redis as a simple set with a TTL. 85 | // The timestamp of the tweet will be used as value. 86 | func (rc *RedisConnection) MarkRepositoryAsTweeted(projectName, score string) (bool, error) { 87 | result, err := redis.String(rc.conn.Do("SET", projectName, score, "EX", BlackListTTL, "NX")) 88 | if result == RedisOK && err == nil { 89 | return true, err 90 | } 91 | return false, err 92 | } 93 | 94 | // IsRepositoryAlreadyTweeted checks if a project was already tweeted. 95 | // If it is not available 96 | // 97 | // a) the project was not tweeted yet 98 | // b) the project ttl expired and is ready to tweet again 99 | func (rc *RedisConnection) IsRepositoryAlreadyTweeted(projectName string) (bool, error) { 100 | return redis.Bool(rc.conn.Do("EXISTS", projectName)) 101 | } 102 | -------------------------------------------------------------------------------- /storage/redis_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | var ( 8 | redisTestProjectName = "andygrunwald/TrendingGithub" 9 | ) 10 | 11 | func TestRedis_Get_FailedConnection(t *testing.T) { 12 | storage := RedisStorage{} 13 | 14 | pool := storage.NewPool("something:1234", "wrong-auth") 15 | defer pool.Close() 16 | 17 | conn := pool.Get() 18 | defer conn.Close() 19 | 20 | if conn.Err() == nil { 21 | t.Fatal("No error thrown, but expected one, because no redis server available.") 22 | } 23 | } 24 | 25 | // TestRedis_Get_SuccessConnection will only succeed if there is a 26 | // running redis server on localhost:6379 27 | // At travis this is the case. 28 | // @link http://docs.travis-ci.com/user/database-setup/#Redis 29 | func TestRedis_Get_SuccessConnection(t *testing.T) { 30 | storage := RedisStorage{} 31 | pool := storage.NewPool("localhost:6379", "") 32 | defer pool.Close() 33 | conn := pool.Get() 34 | err := conn.Close() 35 | 36 | if err != nil { 37 | t.Fatalf("An error occurred, but no one was expected: %s", err) 38 | } 39 | } 40 | 41 | func TestRedis_MarkRepositoryAsTweeted(t *testing.T) { 42 | storage := RedisStorage{} 43 | pool := storage.NewPool("localhost:6379", "") 44 | defer pool.Close() 45 | conn := pool.Get() 46 | defer conn.Close() 47 | 48 | res, err := conn.MarkRepositoryAsTweeted(redisTestProjectName, "1440946305") 49 | if err != nil { 50 | t.Fatalf("Error of marking repository: \"%s\"", err) 51 | } 52 | 53 | if res == false { 54 | t.Fatal("Marking repository failed, got false, expected true") 55 | } 56 | } 57 | 58 | func TestRedis_IsRepositoryAlreadyTweeted(t *testing.T) { 59 | testProject := redisTestProjectName + "Foo" 60 | 61 | storage := RedisStorage{} 62 | pool := storage.NewPool("localhost:6379", "") 63 | defer pool.Close() 64 | conn := pool.Get() 65 | defer conn.Close() 66 | 67 | res, err := conn.IsRepositoryAlreadyTweeted(testProject) 68 | if err != nil { 69 | t.Fatalf("First already tweeted check throws an error: \"%s\"", err) 70 | } 71 | if res == true { 72 | t.Fatal("Repository was already tweeted, got true, expected false") 73 | } 74 | 75 | res, err = conn.MarkRepositoryAsTweeted(testProject, "1440946884") 76 | if err != nil { 77 | t.Fatalf("Error of marking repository: \"%s\"", err) 78 | } 79 | 80 | if res == false { 81 | t.Fatal("Marking repository failed, got false, expected true") 82 | } 83 | 84 | res, err = conn.IsRepositoryAlreadyTweeted(testProject) 85 | if err != nil { 86 | t.Fatalf("Second already tweeted check throws an error: \"%s\"", err) 87 | } 88 | if res == false { 89 | t.Fatal("Repository was not already tweeted, got false, expected true") 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | const ( 8 | // BlackListTTL defined the TTL of a repository in seconds: 30 days (~30 days) 9 | BlackListTTL = 60 * 60 * 24 * 30 10 | ) 11 | 12 | // Storage represents a new storage type. 13 | // Examples as storages are Redis or in memory. 14 | type Storage interface { 15 | NewPool(url, auth string) Pool 16 | } 17 | 18 | // Pool is the implementation of a specific storage type. 19 | // It should handle a pool of connections to communicate with the storage type. 20 | type Pool interface { 21 | io.Closer 22 | Get() Connection 23 | } 24 | 25 | // Connection represents a single connection out of a pool from a storage type. 26 | type Connection interface { 27 | io.Closer 28 | 29 | // Err will return an error once one occurred 30 | Err() error 31 | 32 | // MarkRepositoryAsTweeted marks a single projects as "already tweeted". 33 | // This information will be stored in Redis as a simple set with a TTL. 34 | // The timestamp of the tweet will be used as value. 35 | MarkRepositoryAsTweeted(projectName, score string) (bool, error) 36 | 37 | // IsRepositoryAlreadyTweeted checks if a project was already tweeted. 38 | // If it is not available 39 | // a) the project was not tweeted yet 40 | // b) the project ttl expired and is ready to tweet again 41 | IsRepositoryAlreadyTweeted(projectName string) (bool, error) 42 | } 43 | 44 | // NewBackend returns a new connection pool based on the requested storage engine. 45 | func NewBackend(storageURL string, storageAuth string) Pool { 46 | storageBackend := RedisStorage{} 47 | pool := storageBackend.NewPool(storageURL, storageAuth) 48 | 49 | return pool 50 | } 51 | 52 | // NewDebugBackend returns a new connection pool for in memory. 53 | func NewDebugBackend() Pool { 54 | storageBackend := MemoryStorage{} 55 | pool := storageBackend.NewPool("", "") 56 | 57 | return pool 58 | } 59 | -------------------------------------------------------------------------------- /trending/trending.go: -------------------------------------------------------------------------------- 1 | package trending 2 | 3 | import ( 4 | "errors" 5 | "math/rand" 6 | 7 | "github.com/andygrunwald/go-trending" 8 | ) 9 | 10 | // TrendingAPI represents the interface to the github.com/trending website 11 | type TrendingAPI interface { 12 | GetTrendingLanguages() ([]trending.Language, error) 13 | GetProjects(time, language string) ([]trending.Project, error) 14 | } 15 | 16 | // Trend is the data structure to hold a github-trending client. 17 | // This will be used to retrieve trending projects 18 | type Trend struct { 19 | Client TrendingAPI 20 | } 21 | 22 | // NewClient will provide a new instance of Trend. 23 | func NewClient() *Trend { 24 | githubTrending := trending.NewTrending() 25 | 26 | t := &Trend{ 27 | Client: githubTrending, 28 | } 29 | 30 | return t 31 | } 32 | 33 | // GetTimeFrames returns all available timeframes of go-trending package. 34 | func (t *Trend) GetTimeFrames() []string { 35 | return []string{trending.TimeToday, trending.TimeWeek, trending.TimeMonth} 36 | } 37 | 38 | // GetTrendingLanguages returns all trending languages, but only the URLNames, because this is what we need. 39 | // Errors are not important here. 40 | func (t *Trend) GetTrendingLanguages() []string { 41 | languages, err := t.Client.GetTrendingLanguages() 42 | if err != nil { 43 | return []string{} 44 | } 45 | 46 | // I know. Slices with a predefined number of elements (0) is not a good idea. 47 | // But we are calling an external API and don`t know how many items will be there. 48 | // Further more we will filter some languages in the loop. 49 | // Does anyone got a better idea? Contact me! 50 | var trendingLanguages []string 51 | for _, language := range languages { 52 | if len(language.URLName) > 0 { 53 | trendingLanguages = append(trendingLanguages, language.URLName) 54 | } 55 | } 56 | 57 | return trendingLanguages 58 | } 59 | 60 | // GetRandomProjectGenerator returns a closure to retrieve a random project based on timeFrame. 61 | // timeFrame is a string based on the timeframes provided by go-trending or GetTimeFrames. 62 | // language is a (programing) language provided by go-trending. Can be empty as well. 63 | func (t *Trend) GetRandomProjectGenerator(timeFrame, language string) func() (trending.Project, error) { 64 | var projects []trending.Project 65 | var err error 66 | 67 | // Get projects based on timeframe 68 | // This makes the initial HTTP call to github. 69 | projects, err = t.Client.GetProjects(timeFrame, language) 70 | if err != nil { 71 | return func() (trending.Project, error) { 72 | return trending.Project{}, err 73 | } 74 | } 75 | 76 | // Once we got the projects we will provide a closure 77 | // to retrieve random projects of this project list. 78 | return func() (trending.Project, error) { 79 | 80 | // Check the number of projects left in the list 81 | // If there are no more projects anymore, we will return an error. 82 | numOfProjects := len(projects) 83 | if numOfProjects == 0 { 84 | return trending.Project{}, errors.New("no projects found") 85 | } 86 | 87 | // If there are projects left, chose a random one ... 88 | randomNumber := rand.Intn(numOfProjects) 89 | randomProject := projects[randomNumber] 90 | 91 | // ... and delete the chosen project from our list. 92 | projects = append(projects[:randomNumber], projects[randomNumber+1:]...) 93 | 94 | return randomProject, nil 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /trending/trending_test.go: -------------------------------------------------------------------------------- 1 | package trending 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTrending_GetTimeFrames_Length(t *testing.T) { 8 | trend := Trend{} 9 | timeFrames := trend.GetTimeFrames() 10 | 11 | if len(timeFrames) == 0 { 12 | t.Errorf("Expected more than %d timeframes", len(timeFrames)) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tweets.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "math/rand" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/andygrunwald/TrendingGithub/github" 11 | "github.com/andygrunwald/TrendingGithub/storage" 12 | trendingwrap "github.com/andygrunwald/TrendingGithub/trending" 13 | "github.com/andygrunwald/TrendingGithub/twitter" 14 | "github.com/andygrunwald/go-trending" 15 | ) 16 | 17 | // TweetLength represents the maximum number 18 | // of characters per tweet 19 | const TweetLength = 280 20 | 21 | // TweetSearch is the main structure of this Bot. 22 | // It contains alls logic and attribute to search, build and tweet a new project. 23 | type TweetSearch struct { 24 | Channel chan *Tweet 25 | Trending *trendingwrap.Trend 26 | Storage storage.Pool 27 | URLLength int 28 | } 29 | 30 | // Tweet is a structure to store the tweet and the project name based on the tweet. 31 | type Tweet struct { 32 | Tweet string 33 | ProjectName string 34 | } 35 | 36 | // GenerateNewTweet is responsible to search a new project / repository and build a new tweet based on this. 37 | // The generated tweet will be sent to tweetChan. 38 | func (ts *TweetSearch) GenerateNewTweet() { 39 | var projectToTweet trending.Project 40 | 41 | // Get timeframes and randomize them 42 | timeFrames := ts.Trending.GetTimeFrames() 43 | ShuffleStringSlice(timeFrames) 44 | 45 | // First get the timeframes without any languages 46 | projectToTweet = ts.TimeframeLoopToSearchAProject(timeFrames, "") 47 | 48 | // Check if we found a project. If yes tweet it. 49 | if !ts.IsProjectEmpty(projectToTweet) { 50 | ts.SendProject(projectToTweet) 51 | return 52 | } 53 | 54 | // If not, keep going and try to get some (trending) languages 55 | languages := ts.Trending.GetTrendingLanguages() 56 | ShuffleStringSlice(languages) 57 | ShuffleStringSlice(timeFrames) 58 | 59 | for _, language := range languages { 60 | projectToTweet = ts.TimeframeLoopToSearchAProject(timeFrames, language) 61 | 62 | // If we found a project, break this loop again. 63 | if !ts.IsProjectEmpty(projectToTweet) { 64 | ts.SendProject(projectToTweet) 65 | break 66 | } 67 | } 68 | } 69 | 70 | // TimeframeLoopToSearchAProject provides basically a loop over incoming timeFrames (+ language) 71 | // to try to find a new tweet. 72 | // You can say that this is nearly the <3 of this bot. 73 | func (ts *TweetSearch) TimeframeLoopToSearchAProject(timeFrames []string, language string) trending.Project { 74 | var projectToTweet trending.Project 75 | 76 | for _, timeFrame := range timeFrames { 77 | if len(language) > 0 { 78 | log.Printf("Getting trending projects for timeframe \"%s\" and language \"%s\"", timeFrame, language) 79 | } else { 80 | log.Printf("Getting trending projects for timeframe \"%s\"", timeFrame) 81 | } 82 | 83 | getProject := ts.Trending.GetRandomProjectGenerator(timeFrame, language) 84 | projectToTweet = ts.FindProjectWithRandomProjectGenerator(getProject) 85 | 86 | // Check if we found a project. 87 | // If yes we can leave the loop and keep on rockin 88 | if !ts.IsProjectEmpty(projectToTweet) { 89 | break 90 | } 91 | } 92 | 93 | return projectToTweet 94 | } 95 | 96 | // SendProject puts the project we want to tweet into the tweet queue 97 | // If the queue is ready to receive a new project, this will be tweeted 98 | func (ts *TweetSearch) SendProject(p trending.Project) { 99 | text := "" 100 | // Only build tweet if necessary 101 | if len(p.Name) > 0 { 102 | // This is a really hack here ... 103 | // We have to abstract this a little bit. 104 | // Eieieieiei 105 | repository, err := github.GetRepositoryDetails(p.Owner, p.RepositoryName) 106 | if err != nil { 107 | log.Printf("Error by retrieving repository details: %s", err) 108 | } 109 | 110 | text = ts.BuildTweet(p, repository) 111 | } 112 | 113 | tweet := &Tweet{ 114 | Tweet: text, 115 | ProjectName: p.Name, 116 | } 117 | ts.Channel <- tweet 118 | } 119 | 120 | // IsProjectEmpty checks if the incoming project is empty 121 | func (ts *TweetSearch) IsProjectEmpty(p trending.Project) bool { 122 | return len(p.Name) <= 0 123 | } 124 | 125 | // FindProjectWithRandomProjectGenerator retrieves a new project and checks if this was already tweeted. 126 | func (ts *TweetSearch) FindProjectWithRandomProjectGenerator(getProject func() (trending.Project, error)) trending.Project { 127 | var projectToTweet trending.Project 128 | var project trending.Project 129 | var projectErr error 130 | 131 | storageConn := ts.Storage.Get() 132 | defer storageConn.Close() 133 | 134 | for project, projectErr = getProject(); projectErr == nil; project, projectErr = getProject() { 135 | // Check if the project was already tweeted 136 | alreadyTweeted, err := storageConn.IsRepositoryAlreadyTweeted(project.Name) 137 | if err != nil { 138 | log.Println(err) 139 | continue 140 | } 141 | 142 | // If the project was already tweeted 143 | // we will skip this project and go to the next one 144 | if alreadyTweeted { 145 | continue 146 | } 147 | 148 | // This project wasn`t tweeted yet, so we will take over this job 149 | projectToTweet = project 150 | break 151 | } 152 | 153 | // Lets throw an error, when we dont get a project at all 154 | // This happened in the past and the bot tweeted nothing. 155 | // See https://github.com/andygrunwald/TrendingGithub/issues/12 156 | if projectErr != nil { 157 | log.Printf("Error by searching for a new project with random project generator: %s", projectErr) 158 | } 159 | 160 | return projectToTweet 161 | } 162 | 163 | // BuildTweet is responsible to build a TweetLength length string based on the project we found. 164 | func (ts *TweetSearch) BuildTweet(p trending.Project, repo *github.Repository) string { 165 | tweet := "" 166 | // Base length of a tweet 167 | tweetLen := TweetLength 168 | 169 | // Number of letters for the url (+ 1 character for a whitespace) 170 | // As URL shortener t.co from twitter is used 171 | // URLLength will be constantly refreshed 172 | tweetLen -= ts.URLLength + 1 173 | 174 | // Sometimes the owner name is the same as the repository name 175 | // Like FreeCodeCamp / FreeCodeCamp, docker/docker or flarum / flarum 176 | // In such cases we will drop the owner name and just use the repository name. 177 | usedName := p.Name 178 | if p.Owner == p.RepositoryName { 179 | usedName = p.RepositoryName 180 | } 181 | 182 | // Check if the length of the project name is bigger than the space in the tweet 183 | // Max length of a project name on github is 100 chars 184 | if nameLen := len(usedName); nameLen < tweetLen { 185 | tweetLen -= nameLen 186 | tweet += usedName 187 | } 188 | 189 | // We only post a description if we got more than 20 charactes available 190 | // We have to add 2 chars more, because of the prefix ": " 191 | if tweetLen > 22 && len(p.Description) > 0 { 192 | tweetLen -= 2 193 | tweet += ": " 194 | 195 | projectDescription := "" 196 | if len(p.Description) < tweetLen { 197 | projectDescription = p.Description 198 | } else { 199 | projectDescription = Crop(p.Description, (tweetLen - 4), "...", true) 200 | } 201 | 202 | tweetLen -= len(projectDescription) 203 | tweet += projectDescription 204 | } 205 | 206 | stars := strconv.Itoa(*repo.StargazersCount) 207 | if starsLen := len(stars) + 2; tweetLen >= starsLen { 208 | tweet += " ★" + stars 209 | tweetLen -= starsLen 210 | } 211 | 212 | // Lets add the URL, but we don`t need to subtract the chars 213 | // because we have done this before 214 | if p.URL != nil { 215 | tweet += " " 216 | tweet += p.URL.String() 217 | } 218 | 219 | // Lets check if we got space left to add the language as hashtag 220 | language := strings.Replace(p.Language, " ", "", -1) 221 | // len + 2, because of " #" in front of the hashtag 222 | hashTagLen := (len(language) + 2) 223 | if len(language) > 0 && tweetLen >= hashTagLen { 224 | tweet += " #" + language 225 | 226 | // When we want to do something more with the tweet we have to calculate the tweetLen further. 227 | // So if you want to add more features to the tweet, put this line below into production. 228 | // tweetLen -= hashTagLen 229 | } 230 | 231 | return tweet 232 | } 233 | 234 | // MarkTweetAsAlreadyTweeted adds a projectName to the global blacklist of already tweeted projects. 235 | // For this we use a Sorted Set where the score is the timestamp of the tweet. 236 | func (ts *TweetSearch) MarkTweetAsAlreadyTweeted(projectName string) (bool, error) { 237 | storageConn := ts.Storage.Get() 238 | defer storageConn.Close() 239 | 240 | // Generate score in format YYYYMMDDHHiiss 241 | now := time.Now() 242 | score := now.Format("20060102150405") 243 | 244 | res, err := storageConn.MarkRepositoryAsTweeted(projectName, score) 245 | if err != nil || !res { 246 | log.Printf("Adding project %s to tweeted list: ❌ s%s (%v)\n", projectName, err, res) 247 | } 248 | 249 | return res, err 250 | } 251 | 252 | // StartTweeting bundles the main logic of this bot. 253 | // It schedules the times when we are looking for a new project to tweet. 254 | // If we found a project, we will build the tweet and tweet it to our followers. 255 | // Because we love our followers ;) 256 | func StartTweeting(twitter *twitter.Client, storageBackend storage.Pool, tweetTime time.Duration) { 257 | 258 | // Setup tweet scheduling 259 | ts := &TweetSearch{ 260 | Channel: make(chan *Tweet), 261 | Trending: trendingwrap.NewClient(), 262 | Storage: storageBackend, 263 | URLLength: twitter.Configuration.ShortUrlLengthHttps, 264 | } 265 | SetupRegularTweetSearchProcess(ts, tweetTime) 266 | log.Println("Setup complete. Lets wait for the first trending project...") 267 | 268 | // Waiting for tweets ... 269 | for tweet := range ts.Channel { 270 | // Sometimes it happens that we won`t get a project. 271 | // In this situation we try to avoid empty tweets like ... 272 | // * https://twitter.com/TrendingGithub/status/628714326564696064 273 | // * https://twitter.com/TrendingGithub/status/628530032361795584 274 | // * https://twitter.com/TrendingGithub/status/628348405790711808 275 | // we will return here 276 | // We do this check here and not in tweets.go, because otherwise 277 | // a new tweet won`t be scheduled 278 | if len(tweet.ProjectName) <= 0 { 279 | log.Println("No project found. No tweet sent.") 280 | continue 281 | } 282 | 283 | // In debug mode the twitter variable is not available, so we won`t tweet the tweet. 284 | // We will just output them. 285 | // This is a good development feature ;) 286 | if twitter.API == nil { 287 | log.Printf("Tweet: %s (length: %d)", tweet.Tweet, len(tweet.Tweet)) 288 | 289 | } else { 290 | postedTweet, err := twitter.Tweet(tweet.Tweet) 291 | if err != nil { 292 | log.Printf("Tweet publishing: ❌ (%s)\n", err) 293 | } else { 294 | log.Printf("Tweet publishing: ✅ (https://twitter.com/TrendingGithub/status/%s)\n", postedTweet.IdStr) 295 | } 296 | } 297 | ts.MarkTweetAsAlreadyTweeted(tweet.ProjectName) 298 | } 299 | } 300 | 301 | // SetupRegularTweetSearchProcess is the time ticker to search a new project and 302 | // tweet it in a specific time interval. 303 | func SetupRegularTweetSearchProcess(tweetSearch *TweetSearch, d time.Duration) { 304 | go func() { 305 | for range time.Tick(d) { 306 | go tweetSearch.GenerateNewTweet() 307 | } 308 | }() 309 | log.Printf("Project search and tweet: Enabled ✅ (every %s)\n", d.String()) 310 | } 311 | 312 | // ShuffleStringSlice will randomize a string slice. 313 | // I know that is a really bad shuffle logic (i won`t call this an algorithm, why? because i wrote and understand it :D) 314 | // But this is YOUR chance to contribute to an open source project. 315 | // Replace this by a cool one! 316 | func ShuffleStringSlice(a []string) { 317 | for i := range a { 318 | j := rand.Intn(i + 1) 319 | a[i], a[j] = a[j], a[i] 320 | } 321 | } 322 | 323 | // Crop is a modified "sub string" function allowing to limit a string length to a certain number of chars (from either start or end of string) and having a pre/postfix applied if the string really was cropped. 324 | // content is the string to perform the operation on 325 | // chars is the max number of chars of the string. Negative value means cropping from end of string. 326 | // afterstring is the pre/postfix string to apply if cropping occurs. 327 | // crop2space is true, then crop will be applied at nearest space. False otherwise. 328 | // 329 | // This function is a port from the TYPO3 CMS (written in PHP) 330 | // @link https://github.com/TYPO3/TYPO3.CMS/blob/aae88a565bdbbb69032692f2d20da5f24d285cdc/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php#L4065 331 | func Crop(content string, chars int, afterstring string, crop2space bool) string { 332 | if chars == 0 { 333 | return content 334 | } 335 | 336 | if len(content) < chars || (chars < 0 && len(content) < (chars*-1)) { 337 | return content 338 | } 339 | 340 | var cropedContent string 341 | truncatePosition := -1 342 | 343 | if chars < 0 { 344 | cropedContent = content[len(content)+chars:] 345 | if crop2space { 346 | truncatePosition = strings.Index(cropedContent, " ") 347 | } 348 | if truncatePosition >= 0 { 349 | cropedContent = cropedContent[truncatePosition+1:] 350 | } 351 | cropedContent = afterstring + cropedContent 352 | 353 | } else { 354 | cropedContent = content[:chars-1] 355 | if crop2space { 356 | truncatePosition = strings.LastIndex(cropedContent, " ") 357 | } 358 | if truncatePosition >= 0 { 359 | cropedContent = cropedContent[0:truncatePosition] 360 | } 361 | cropedContent += afterstring 362 | } 363 | 364 | return cropedContent 365 | } 366 | -------------------------------------------------------------------------------- /tweets_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/andygrunwald/TrendingGithub/github" 8 | "github.com/andygrunwald/go-trending" 9 | ) 10 | 11 | func TestTweets_IsProjectEmpty(t *testing.T) { 12 | ts := TweetSearch{} 13 | mock := []struct { 14 | Project trending.Project 15 | Result bool 16 | }{ 17 | {trending.Project{Name: ""}, true}, 18 | {trending.Project{Name: "MyProject"}, false}, 19 | } 20 | 21 | for _, item := range mock { 22 | res := ts.IsProjectEmpty(item.Project) 23 | if res != item.Result { 24 | t.Errorf("Failed for project \"%s\", got %v, expected %v", item.Project.Name, res, item.Result) 25 | } 26 | } 27 | } 28 | 29 | func TestTweets_BuildTweet(t *testing.T) { 30 | owner := "andygrunwald" 31 | repositoryName := "TrendingGithub" 32 | projectName := owner + "/" + repositoryName 33 | projectURL, _ := url.Parse("https://github.com/andygrunwald/TrendingGithub") 34 | projectDescription := "A twitter bot (@TrendingGithub) to tweet trending repositories and developers from GitHub" 35 | 36 | ts := TweetSearch{ 37 | URLLength: 24, 38 | } 39 | 40 | stars := 123 41 | repository := &github.Repository{ 42 | StargazersCount: &(stars), 43 | } 44 | 45 | mock := []struct { 46 | Project trending.Project 47 | Result string 48 | }{ 49 | {trending.Project{ 50 | Name: "SuperDuperOwnerOrOrganisation/This-Is-A-Long-Project-Name-That-Will-Drop-The-Description-Of-The-Project", 51 | Owner: "SuperDuperOwnerOrOrganisation", 52 | RepositoryName: "This-Is-A-Long-Project-Name-That-Will-Drop-The-Description-Of-The-Project", 53 | Description: projectDescription + " and more and better and super duper text", 54 | Language: "Go", 55 | URL: projectURL, 56 | }, "SuperDuperOwnerOrOrganisation/This-Is-A-Long-Project-Name-That-Will-Drop-The-Description-Of-The-Project: A twitter bot (@TrendingGithub) to tweet trending repositories and developers from GitHub and more and better and super duper text ★123 https://github.com/andygrunwald/TrendingGithub #Go"}, 57 | {trending.Project{ 58 | Name: projectName + "-cool-super-project", 59 | Owner: owner, 60 | RepositoryName: repositoryName + "-cool-super-project", 61 | Description: projectDescription + " and more and better and super duper text", 62 | Language: "Go", 63 | URL: projectURL, 64 | }, "andygrunwald/TrendingGithub-cool-super-project: A twitter bot (@TrendingGithub) to tweet trending repositories and developers from GitHub and more and better and super duper text ★123 https://github.com/andygrunwald/TrendingGithub #Go"}, 65 | {trending.Project{ 66 | Name: projectName, 67 | Owner: owner, 68 | RepositoryName: repositoryName, 69 | Description: projectDescription, 70 | Language: "Go", 71 | URL: projectURL, 72 | }, "andygrunwald/TrendingGithub: A twitter bot (@TrendingGithub) to tweet trending repositories and developers from GitHub ★123 https://github.com/andygrunwald/TrendingGithub #Go"}, 73 | {trending.Project{ 74 | Name: projectName, 75 | Owner: owner, 76 | RepositoryName: repositoryName, 77 | Description: "Short description", 78 | Language: "Go Lang", 79 | URL: projectURL, 80 | }, "andygrunwald/TrendingGithub: Short description ★123 https://github.com/andygrunwald/TrendingGithub #GoLang"}, 81 | {trending.Project{ 82 | Name: projectName, 83 | Owner: owner, 84 | RepositoryName: repositoryName, 85 | Description: "Project without a URL", 86 | Language: "Go Lang", 87 | }, "andygrunwald/TrendingGithub: Project without a URL ★123 #GoLang"}, 88 | {trending.Project{ 89 | Name: repositoryName + "/" + repositoryName, 90 | Owner: repositoryName, 91 | RepositoryName: repositoryName, 92 | Description: projectDescription, 93 | Language: "Go", 94 | URL: projectURL, 95 | }, "TrendingGithub: A twitter bot (@TrendingGithub) to tweet trending repositories and developers from GitHub ★123 https://github.com/andygrunwald/TrendingGithub #Go"}, 96 | } 97 | 98 | for _, item := range mock { 99 | res := ts.BuildTweet(item.Project, repository) 100 | if res != item.Result { 101 | t.Errorf("Failed building a tweet for project \"%s\". Got \"%s\", expected \"%s\"", item.Project.Name, res, item.Result) 102 | } 103 | } 104 | } 105 | 106 | var testSlice = []string{"one", "two", "three", "four"} 107 | 108 | func TestUtility_ShuffleStringSlice_Length(t *testing.T) { 109 | shuffledSlice := make([]string, len(testSlice)) 110 | copy(shuffledSlice, testSlice) 111 | ShuffleStringSlice(shuffledSlice) 112 | 113 | if len(testSlice) != len(shuffledSlice) { 114 | t.Errorf("The length of slices are not equal. Got %d, expected %d", len(shuffledSlice), len(testSlice)) 115 | } 116 | } 117 | 118 | func TestUtility_ShuffleStringSlice_Items(t *testing.T) { 119 | shuffledSlice := make([]string, len(testSlice)) 120 | copy(shuffledSlice, testSlice) 121 | ShuffleStringSlice(shuffledSlice) 122 | 123 | for _, item := range testSlice { 124 | if IsStringInSlice(item, shuffledSlice) == false { 125 | t.Errorf("Item \"%s\" not found in shuffledSlice: %+v", item, shuffledSlice) 126 | } 127 | } 128 | } 129 | 130 | func TestUtility_Crop(t *testing.T) { 131 | testSentence := "This is a test sentence for the unit test." 132 | textMock := []struct { 133 | Content string 134 | Chars int 135 | AfterString string 136 | Crop2Space bool 137 | Result string 138 | }{ 139 | {testSentence, 0, "", false, testSentence}, 140 | {testSentence, 99, "", false, testSentence}, 141 | {testSentence, 13, "", false, "This is a te"}, 142 | {testSentence, 13, "...", false, "This is a te..."}, 143 | {testSentence, 13, "", true, "This is a"}, 144 | {testSentence, 13, "...", true, "This is a..."}, 145 | {testSentence, -99, "", false, testSentence}, 146 | {testSentence, -13, "", false, "he unit test."}, 147 | {testSentence, -13, "...", false, "...he unit test."}, 148 | {testSentence, -13, "", true, "unit test."}, 149 | {testSentence, -13, "...", true, "...unit test."}, 150 | } 151 | 152 | for _, mock := range textMock { 153 | res := Crop(mock.Content, mock.Chars, mock.AfterString, mock.Crop2Space) 154 | if res != mock.Result { 155 | t.Errorf("Crop result is \"%s\", but expected \"%s\".", res, mock.Result) 156 | } 157 | } 158 | } 159 | 160 | func IsStringInSlice(a string, list []string) bool { 161 | for _, b := range list { 162 | if b == a { 163 | return true 164 | } 165 | } 166 | return false 167 | } 168 | -------------------------------------------------------------------------------- /twitter/config.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "log" 5 | "net/url" 6 | "time" 7 | ) 8 | 9 | // LoadConfiguration requests the latest configuration 10 | // settings from twitter and stores those in memory. 11 | // Config settings like numbers of chars needed for the short url 12 | // and so on are part of this. Those settings 13 | // are importent to know how much text we can tweet later 14 | // See https://dev.twitter.com/rest/reference/get/help/configuration 15 | func (client *Client) LoadConfiguration() error { 16 | v := url.Values{} 17 | conf, err := client.API.GetConfiguration(v) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | client.Mutex.Lock() 23 | client.Configuration = &conf 24 | client.Mutex.Unlock() 25 | 26 | return nil 27 | } 28 | 29 | // SetupConfigurationRefresh sets up a scheduler and will refresh 30 | // the configuration from twitter every duration d. 31 | // The reason for this is that these values can change over time. 32 | // See https://dev.twitter.com/rest/reference/get/help/configuration 33 | func (client *Client) SetupConfigurationRefresh(d time.Duration) { 34 | go func() { 35 | for range time.Tick(d) { 36 | client.LoadConfiguration() 37 | } 38 | }() 39 | log.Printf("Twitter Configuration refresh: Enabled ✅ (every %s)\n", d.String()) 40 | } 41 | -------------------------------------------------------------------------------- /twitter/follow.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "log" 5 | "math/rand" 6 | "net/url" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/ChimeraCoder/anaconda" 11 | ) 12 | 13 | const ( 14 | // twitterStatusNone reflects the status "none" of GET friendships/lookup call 15 | // See https://dev.twitter.com/rest/reference/get/friendships/lookup 16 | twitterStatusNone = "none" 17 | ) 18 | 19 | // SetupFollowNewPeopleScheduling sets up a scheduler and will search 20 | // for a new person to follow every duration d. This is our growth hack. 21 | func (client *Client) SetupFollowNewPeopleScheduling(d time.Duration) { 22 | go func() { 23 | for range time.Tick(d) { 24 | client.FollowNewPerson() 25 | } 26 | }() 27 | log.Printf("Growth hack: Enabled ✅ (every %s)\n", d.String()) 28 | } 29 | 30 | // FollowNewPerson will follow a new person on twitter to raise the attraction for the bot. 31 | // We will follow a new person who follow on random follower of @TrendingGithub 32 | // Only persons who don`t have a relationship to the bot will be followed. 33 | func (client *Client) FollowNewPerson() error { 34 | // Get all own followers 35 | c, err := client.API.GetFollowersIds(nil) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | // We loop here, because we want to follow one person. 41 | // If we choose a random person it can be that we choose a person 42 | // that follows @TrendingGithub already. 43 | // We want to attract new persons ;) 44 | for { 45 | // Choose a random follower 46 | randomNumber := rand.Intn(len(c.Ids)) 47 | 48 | // Request the follower from the random follower 49 | v := url.Values{} 50 | v.Add("user_id", strconv.FormatInt(c.Ids[randomNumber], 10)) 51 | c, err = client.API.GetFollowersIds(v) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | // Choose a random follower (again) from the random follower chosen before 57 | randomNumber = rand.Intn(len(c.Ids)) 58 | 59 | // Get friendship details of @TrendingGithub and the chosen person 60 | v = url.Values{} 61 | v.Add("user_id", strconv.FormatInt(c.Ids[randomNumber], 10)) 62 | friendships, err := client.API.GetFriendshipsLookup(v) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | // Test if @TrendingGithub has a relationship to the new user. 68 | shouldIFollow := client.isThereARelationship(friendships) 69 | 70 | // If we got a relationship, we will repeat the process ... 71 | if !shouldIFollow { 72 | continue 73 | } 74 | 75 | // ... if not we will follow the new person 76 | // We drop the error and user here, because we got no logging yet ;) 77 | client.API.FollowUserId(c.Ids[randomNumber], nil) 78 | return nil 79 | } 80 | } 81 | 82 | // isThereARelationship will test if @TrendingGithub has a relationship to the new user. 83 | // Only if there is no relationship ("none"). 84 | // Default wise we assume that we got a relationship already 85 | // See https://dev.twitter.com/rest/reference/get/friendships/lookup 86 | func (client *Client) isThereARelationship(friendships []anaconda.Friendship) bool { 87 | shouldIFollow := false 88 | for _, friend := range friendships { 89 | for _, status := range friend.Connections { 90 | if status == twitterStatusNone { 91 | shouldIFollow = true 92 | break 93 | } 94 | } 95 | } 96 | 97 | return shouldIFollow 98 | } 99 | -------------------------------------------------------------------------------- /twitter/tweet.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/ChimeraCoder/anaconda" 7 | ) 8 | 9 | // Tweet will ... tweet the text :D ... Badum ts 10 | func (client *Client) Tweet(text string) (*anaconda.Tweet, error) { 11 | v := url.Values{} 12 | tweet, err := client.API.PostTweet(text, v) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | return &tweet, nil 18 | } 19 | -------------------------------------------------------------------------------- /twitter/twitter.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/url" 5 | "sync" 6 | 7 | "github.com/ChimeraCoder/anaconda" 8 | ) 9 | 10 | // API is the interface to decouple the twitter API 11 | type API interface { 12 | GetConfiguration(v url.Values) (conf anaconda.Configuration, err error) 13 | PostTweet(status string, v url.Values) (tweet anaconda.Tweet, err error) 14 | GetFollowersIds(v url.Values) (c anaconda.Cursor, err error) 15 | GetFriendshipsLookup(v url.Values) (friendships []anaconda.Friendship, err error) 16 | FollowUserId(userID int64, v url.Values) (user anaconda.User, err error) 17 | } 18 | 19 | // Client is the data structure to reflect the twitter client 20 | type Client struct { 21 | API API 22 | Configuration *anaconda.Configuration 23 | Mutex *sync.Mutex 24 | } 25 | 26 | // NewClient returns a new client to communicate with twitter. 27 | // To auth against twitter, credentials like consumer key and access tokens 28 | // are necessary. Those can be retrieved via https://apps.twitter.com/. 29 | func NewClient(consumerKey, consumerSecret, accessToken, accessTokenSecret string) *Client { 30 | api := anaconda.NewTwitterApiWithCredentials(accessToken, accessTokenSecret, consumerKey, consumerSecret) 31 | client := &Client{ 32 | API: api, 33 | Mutex: &sync.Mutex{}, 34 | } 35 | 36 | return client 37 | } 38 | 39 | // NewDebugClient returns a new debug client to interact with. 40 | // This client will not communicate via twitter. 41 | // This client should be used for debugging or developing purpose. 42 | // This client loads a debug configuration. 43 | func NewDebugClient() *Client { 44 | client := &Client{ 45 | // TODO Create a debugging client 46 | // API: api, 47 | Configuration: &anaconda.Configuration{ 48 | ShortUrlLength: 24, 49 | ShortUrlLengthHttps: 25, 50 | }, 51 | } 52 | return client 53 | } 54 | --------------------------------------------------------------------------------