-
-
Notifications
You must be signed in to change notification settings - Fork 14
/
tweets.go
365 lines (308 loc) · 11.7 KB
/
tweets.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
package main
import (
"log"
"math/rand"
"strconv"
"strings"
"time"
"github.com/andygrunwald/TrendingGithub/github"
"github.com/andygrunwald/TrendingGithub/storage"
trendingwrap "github.com/andygrunwald/TrendingGithub/trending"
"github.com/andygrunwald/TrendingGithub/twitter"
"github.com/andygrunwald/go-trending"
)
// TweetLength represents the maximum number
// of characters per tweet
const TweetLength = 280
// TweetSearch is the main structure of this Bot.
// It contains alls logic and attribute to search, build and tweet a new project.
type TweetSearch struct {
Channel chan *Tweet
Trending *trendingwrap.Trend
Storage storage.Pool
URLLength int
}
// Tweet is a structure to store the tweet and the project name based on the tweet.
type Tweet struct {
Tweet string
ProjectName string
}
// GenerateNewTweet is responsible to search a new project / repository and build a new tweet based on this.
// The generated tweet will be sent to tweetChan.
func (ts *TweetSearch) GenerateNewTweet() {
var projectToTweet trending.Project
// Get timeframes and randomize them
timeFrames := ts.Trending.GetTimeFrames()
ShuffleStringSlice(timeFrames)
// First get the timeframes without any languages
projectToTweet = ts.TimeframeLoopToSearchAProject(timeFrames, "")
// Check if we found a project. If yes tweet it.
if !ts.IsProjectEmpty(projectToTweet) {
ts.SendProject(projectToTweet)
return
}
// If not, keep going and try to get some (trending) languages
languages := ts.Trending.GetTrendingLanguages()
ShuffleStringSlice(languages)
ShuffleStringSlice(timeFrames)
for _, language := range languages {
projectToTweet = ts.TimeframeLoopToSearchAProject(timeFrames, language)
// If we found a project, break this loop again.
if !ts.IsProjectEmpty(projectToTweet) {
ts.SendProject(projectToTweet)
break
}
}
}
// TimeframeLoopToSearchAProject provides basically a loop over incoming timeFrames (+ language)
// to try to find a new tweet.
// You can say that this is nearly the <3 of this bot.
func (ts *TweetSearch) TimeframeLoopToSearchAProject(timeFrames []string, language string) trending.Project {
var projectToTweet trending.Project
for _, timeFrame := range timeFrames {
if len(language) > 0 {
log.Printf("Getting trending projects for timeframe \"%s\" and language \"%s\"", timeFrame, language)
} else {
log.Printf("Getting trending projects for timeframe \"%s\"", timeFrame)
}
getProject := ts.Trending.GetRandomProjectGenerator(timeFrame, language)
projectToTweet = ts.FindProjectWithRandomProjectGenerator(getProject)
// Check if we found a project.
// If yes we can leave the loop and keep on rockin
if !ts.IsProjectEmpty(projectToTweet) {
break
}
}
return projectToTweet
}
// SendProject puts the project we want to tweet into the tweet queue
// If the queue is ready to receive a new project, this will be tweeted
func (ts *TweetSearch) SendProject(p trending.Project) {
text := ""
// Only build tweet if necessary
if len(p.Name) > 0 {
// This is a really hack here ...
// We have to abstract this a little bit.
// Eieieieiei
repository, err := github.GetRepositoryDetails(p.Owner, p.RepositoryName)
if err != nil {
log.Printf("Error by retrieving repository details: %s", err)
}
text = ts.BuildTweet(p, repository)
}
tweet := &Tweet{
Tweet: text,
ProjectName: p.Name,
}
ts.Channel <- tweet
}
// IsProjectEmpty checks if the incoming project is empty
func (ts *TweetSearch) IsProjectEmpty(p trending.Project) bool {
return len(p.Name) <= 0
}
// FindProjectWithRandomProjectGenerator retrieves a new project and checks if this was already tweeted.
func (ts *TweetSearch) FindProjectWithRandomProjectGenerator(getProject func() (trending.Project, error)) trending.Project {
var projectToTweet trending.Project
var project trending.Project
var projectErr error
storageConn := ts.Storage.Get()
defer storageConn.Close()
for project, projectErr = getProject(); projectErr == nil; project, projectErr = getProject() {
// Check if the project was already tweeted
alreadyTweeted, err := storageConn.IsRepositoryAlreadyTweeted(project.Name)
if err != nil {
log.Println(err)
continue
}
// If the project was already tweeted
// we will skip this project and go to the next one
if alreadyTweeted {
continue
}
// This project wasn`t tweeted yet, so we will take over this job
projectToTweet = project
break
}
// Lets throw an error, when we dont get a project at all
// This happened in the past and the bot tweeted nothing.
// See https://github.com/andygrunwald/TrendingGithub/issues/12
if projectErr != nil {
log.Printf("Error by searching for a new project with random project generator: %s", projectErr)
}
return projectToTweet
}
// BuildTweet is responsible to build a TweetLength length string based on the project we found.
func (ts *TweetSearch) BuildTweet(p trending.Project, repo *github.Repository) string {
tweet := ""
// Base length of a tweet
tweetLen := TweetLength
// Number of letters for the url (+ 1 character for a whitespace)
// As URL shortener t.co from twitter is used
// URLLength will be constantly refreshed
tweetLen -= ts.URLLength + 1
// Sometimes the owner name is the same as the repository name
// Like FreeCodeCamp / FreeCodeCamp, docker/docker or flarum / flarum
// In such cases we will drop the owner name and just use the repository name.
usedName := p.Name
if p.Owner == p.RepositoryName {
usedName = p.RepositoryName
}
// Check if the length of the project name is bigger than the space in the tweet
// Max length of a project name on github is 100 chars
if nameLen := len(usedName); nameLen < tweetLen {
tweetLen -= nameLen
tweet += usedName
}
// We only post a description if we got more than 20 charactes available
// We have to add 2 chars more, because of the prefix ": "
if tweetLen > 22 && len(p.Description) > 0 {
tweetLen -= 2
tweet += ": "
projectDescription := ""
if len(p.Description) < tweetLen {
projectDescription = p.Description
} else {
projectDescription = Crop(p.Description, (tweetLen - 4), "...", true)
}
tweetLen -= len(projectDescription)
tweet += projectDescription
}
stars := strconv.Itoa(*repo.StargazersCount)
if starsLen := len(stars) + 2; tweetLen >= starsLen {
tweet += " ★" + stars
tweetLen -= starsLen
}
// Lets add the URL, but we don`t need to subtract the chars
// because we have done this before
if p.URL != nil {
tweet += " "
tweet += p.URL.String()
}
// Lets check if we got space left to add the language as hashtag
language := strings.Replace(p.Language, " ", "", -1)
// len + 2, because of " #" in front of the hashtag
hashTagLen := (len(language) + 2)
if len(language) > 0 && tweetLen >= hashTagLen {
tweet += " #" + language
// When we want to do something more with the tweet we have to calculate the tweetLen further.
// So if you want to add more features to the tweet, put this line below into production.
// tweetLen -= hashTagLen
}
return tweet
}
// MarkTweetAsAlreadyTweeted adds a projectName to the global blacklist of already tweeted projects.
// For this we use a Sorted Set where the score is the timestamp of the tweet.
func (ts *TweetSearch) MarkTweetAsAlreadyTweeted(projectName string) (bool, error) {
storageConn := ts.Storage.Get()
defer storageConn.Close()
// Generate score in format YYYYMMDDHHiiss
now := time.Now()
score := now.Format("20060102150405")
res, err := storageConn.MarkRepositoryAsTweeted(projectName, score)
if err != nil || !res {
log.Printf("Adding project %s to tweeted list: ❌ s%s (%v)\n", projectName, err, res)
}
return res, err
}
// StartTweeting bundles the main logic of this bot.
// It schedules the times when we are looking for a new project to tweet.
// If we found a project, we will build the tweet and tweet it to our followers.
// Because we love our followers ;)
func StartTweeting(twitter *twitter.Client, storageBackend storage.Pool, tweetTime time.Duration) {
// Setup tweet scheduling
ts := &TweetSearch{
Channel: make(chan *Tweet),
Trending: trendingwrap.NewClient(),
Storage: storageBackend,
URLLength: twitter.Configuration.ShortUrlLengthHttps,
}
SetupRegularTweetSearchProcess(ts, tweetTime)
log.Println("Setup complete. Lets wait for the first trending project...")
// Waiting for tweets ...
for tweet := range ts.Channel {
// Sometimes it happens that we won`t get a project.
// In this situation we try to avoid empty tweets like ...
// * https://twitter.com/TrendingGithub/status/628714326564696064
// * https://twitter.com/TrendingGithub/status/628530032361795584
// * https://twitter.com/TrendingGithub/status/628348405790711808
// we will return here
// We do this check here and not in tweets.go, because otherwise
// a new tweet won`t be scheduled
if len(tweet.ProjectName) <= 0 {
log.Println("No project found. No tweet sent.")
continue
}
// In debug mode the twitter variable is not available, so we won`t tweet the tweet.
// We will just output them.
// This is a good development feature ;)
if twitter.API == nil {
log.Printf("Tweet: %s (length: %d)", tweet.Tweet, len(tweet.Tweet))
} else {
postedTweet, err := twitter.Tweet(tweet.Tweet)
if err != nil {
log.Printf("Tweet publishing: ❌ (%s)\n", err)
} else {
log.Printf("Tweet publishing: ✅ (https://twitter.com/TrendingGithub/status/%s)\n", postedTweet.IdStr)
}
}
ts.MarkTweetAsAlreadyTweeted(tweet.ProjectName)
}
}
// SetupRegularTweetSearchProcess is the time ticker to search a new project and
// tweet it in a specific time interval.
func SetupRegularTweetSearchProcess(tweetSearch *TweetSearch, d time.Duration) {
go func() {
for range time.Tick(d) {
go tweetSearch.GenerateNewTweet()
}
}()
log.Printf("Project search and tweet: Enabled ✅ (every %s)\n", d.String())
}
// ShuffleStringSlice will randomize a string slice.
// I know that is a really bad shuffle logic (i won`t call this an algorithm, why? because i wrote and understand it :D)
// But this is YOUR chance to contribute to an open source project.
// Replace this by a cool one!
func ShuffleStringSlice(a []string) {
for i := range a {
j := rand.Intn(i + 1)
a[i], a[j] = a[j], a[i]
}
}
// 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.
// content is the string to perform the operation on
// chars is the max number of chars of the string. Negative value means cropping from end of string.
// afterstring is the pre/postfix string to apply if cropping occurs.
// crop2space is true, then crop will be applied at nearest space. False otherwise.
//
// This function is a port from the TYPO3 CMS (written in PHP)
// @link https://github.com/TYPO3/TYPO3.CMS/blob/aae88a565bdbbb69032692f2d20da5f24d285cdc/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php#L4065
func Crop(content string, chars int, afterstring string, crop2space bool) string {
if chars == 0 {
return content
}
if len(content) < chars || (chars < 0 && len(content) < (chars*-1)) {
return content
}
var cropedContent string
truncatePosition := -1
if chars < 0 {
cropedContent = content[len(content)+chars:]
if crop2space {
truncatePosition = strings.Index(cropedContent, " ")
}
if truncatePosition >= 0 {
cropedContent = cropedContent[truncatePosition+1:]
}
cropedContent = afterstring + cropedContent
} else {
cropedContent = content[:chars-1]
if crop2space {
truncatePosition = strings.LastIndex(cropedContent, " ")
}
if truncatePosition >= 0 {
cropedContent = cropedContent[0:truncatePosition]
}
cropedContent += afterstring
}
return cropedContent
}