From dae7c11433ef60e267e1684aa7e7b0e8838e0982 Mon Sep 17 00:00:00 2001 From: gutmet Date: Sun, 27 Sep 2020 11:46:52 +0200 Subject: [PATCH] new wipe subcommand --- Readme.md | 8 +- drivel.go | 241 ++++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 186 insertions(+), 63 deletions(-) diff --git a/Readme.md b/Readme.md index d659d68..8cc94e9 100644 --- a/Readme.md +++ b/Readme.md @@ -55,8 +55,14 @@ To lookup tweets with specific IDs: drivel lookup TWEET_ID1 [TWEET_ID2 TWEET_ID3 ...] ``` -To update your status with optional media upload: +To wipe your timeline and likes (keepDays defaults to 10, can only reach back as far as the result of the timeline): +``` +drivel wipe [--keepDays=N] +``` + + +To update your status with optional media upload: ``` drivel status STATUS [FILE1 FILE2 ...] diff --git a/drivel.go b/drivel.go index eca3a3c..ab6ff57 100644 --- a/drivel.go +++ b/drivel.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "flag" "fmt" "git.gutmet.org/goutil.git" "html" @@ -18,17 +19,21 @@ import ( ) const ( - MAX_BYTES = 50 * 1024 * 1024 - CHUNK_SIZE = 1024 * 1024 - CHARACTER_LIMIT = 280 - UPLOAD_ENDPOINT = "https://upload.twitter.com/1.1/media/upload.json" - STATUS_ENDPOINT = "https://api.twitter.com/1.1/statuses/update.json" - MENTIONS_ENDPOINT = "https://api.twitter.com/1.1/statuses/mentions_timeline.json?tweet_mode=extended&count=200" - HOME_ENDPOINT = "https://api.twitter.com/1.1/statuses/home_timeline.json?tweet_mode=extended&count=200" - TIMELINE_ENDPOINT = "https://api.twitter.com/1.1/statuses/user_timeline.json?tweet_mode=extended&count=200" - LOOKUP_ENDPOINT = "https://api.twitter.com/1.1/statuses/lookup.json?tweet_mode=extended" - RETWEET_ENDPOINT = "https://api.twitter.com/1.1/statuses/retweet/" - LIKE_ENDPOINT = "https://api.twitter.com/1.1/favorites/create.json" + MAX_BYTES = 50 * 1024 * 1024 + CHUNK_SIZE = 1024 * 1024 + CHARACTER_LIMIT = 280 + WIPE_KEEP_DAYS = 10 + UPLOAD_ENDPOINT = "https://upload.twitter.com/1.1/media/upload.json" + STATUS_ENDPOINT = "https://api.twitter.com/1.1/statuses/update.json" + MENTIONS_ENDPOINT = "https://api.twitter.com/1.1/statuses/mentions_timeline.json?tweet_mode=extended&count=200" + HOME_ENDPOINT = "https://api.twitter.com/1.1/statuses/home_timeline.json?tweet_mode=extended&count=200" + TIMELINE_ENDPOINT = "https://api.twitter.com/1.1/statuses/user_timeline.json?tweet_mode=extended&count=200" + LIKES_TIMELINE_ENDPOINT = "https://api.twitter.com/1.1/favorites/list.json?tweet_mode=extended&count=200" + LOOKUP_ENDPOINT = "https://api.twitter.com/1.1/statuses/lookup.json?tweet_mode=extended" + RETWEET_ENDPOINT = "https://api.twitter.com/1.1/statuses/retweet/" + LIKE_ENDPOINT = "https://api.twitter.com/1.1/favorites/create.json" + DESTROY_STATUS_ENDPOINT = "https://api.twitter.com/1.1/statuses/destroy/" + DESTROY_LIKE_ENDPOINT = "https://api.twitter.com/1.1/favorites/destroy.json" ) func optLogFatal(decorum string, err error) { @@ -155,15 +160,15 @@ func getMimetype(file string) string { } } -func get(client *http.Client, url string) []byte { - return _send(client, url, nil, false) +func get(url string) []byte { + return _send(url, nil, false) } -func send(client *http.Client, url string, vals url.Values) []byte { - return _send(client, url, vals, true) +func send(url string, vals url.Values) []byte { + return _send(url, vals, true) } -func _send(client *http.Client, url string, vals url.Values, usePost bool) []byte { +func _send(url string, vals url.Values, usePost bool) []byte { log := func(err error) { v, _ := json.Marshal(vals) optLogFatal("get/post "+url+" "+string(v), err) @@ -187,10 +192,10 @@ func _send(client *http.Client, url string, vals url.Values, usePost bool) []byt return body } -func initFileUpload(client *http.Client, file string, mediaData []byte) ObjectID { +func initFileUpload(file string, mediaData []byte) ObjectID { log := func(err error) { optLogFatal("initFileUpload "+file, err) } initRequest := InitRequest(getMimetype(file), len(mediaData)) - body := send(client, UPLOAD_ENDPOINT, initRequest) + body := send(UPLOAD_ENDPOINT, initRequest) var initResponse InitResponse err := json.Unmarshal(body, &initResponse) log(err) @@ -198,7 +203,7 @@ func initFileUpload(client *http.Client, file string, mediaData []byte) ObjectID return ObjectID(initResponse.Media_id_string) } -func appendFileChunks(client *http.Client, file string, media string, mediaId ObjectID) { +func appendFileChunks(file string, media string, mediaId ObjectID) { log := func(err error) { optLogFatal("appendFileChunks", err) } info := func(v ...interface{}) { if len(media) > CHUNK_SIZE { @@ -218,7 +223,7 @@ func appendFileChunks(client *http.Client, file string, media string, mediaId Ob var body []byte for try := 0; try < 3 && !appended; try++ { appRequest := AppendRequest(mediaId, media[start:end], i) - body = send(client, UPLOAD_ENDPOINT, appRequest) + body = send(UPLOAD_ENDPOINT, appRequest) if string(body) == "" { appended = true } @@ -229,9 +234,9 @@ func appendFileChunks(client *http.Client, file string, media string, mediaId Ob } } -func finalizeFileUpload(client *http.Client, file string, mediaId ObjectID) int64 { +func finalizeFileUpload(file string, mediaId ObjectID) int64 { log := func(err error) { optLogFatal("finalizeFileUpload", err) } - body := send(client, UPLOAD_ENDPOINT, FinalizeRequest(mediaId)) + body := send(UPLOAD_ENDPOINT, FinalizeRequest(mediaId)) var finalizeResponse FinalizeResponse err := json.Unmarshal(body, &finalizeResponse) log(err) @@ -251,12 +256,12 @@ func wait(seconds int64) { time.Sleep(time.Duration(seconds) * time.Second) } -func pollStatus(client *http.Client, mediaId ObjectID) { +func pollStatus(mediaId ObjectID) { log := func(err error) { optLogFatal("pollStatus "+string(mediaId), err) } succeeded := false var error TwitterError for try := 0; try < 6; try = try + 1 { - body := get(client, UPLOAD_ENDPOINT+PollStatusParameters(mediaId)) + body := get(UPLOAD_ENDPOINT + PollStatusParameters(mediaId)) var response PollStatusResponse err := json.Unmarshal(body, &response) log(err) @@ -282,26 +287,26 @@ func pollStatus(client *http.Client, mediaId ObjectID) { } } -func uploadFile(client *http.Client, file string) ObjectID { +func uploadFile(file string) ObjectID { log := func(err error) { optLogFatal("uploadFile "+file, err) } tmpMedia, err := ioutil.ReadFile(file) log(err) media := base64.RawURLEncoding.EncodeToString(tmpMedia) - mediaId := initFileUpload(client, file, tmpMedia) - appendFileChunks(client, file, media, mediaId) - seconds := finalizeFileUpload(client, file, mediaId) + mediaId := initFileUpload(file, tmpMedia) + appendFileChunks(file, media, mediaId) + seconds := finalizeFileUpload(file, mediaId) if seconds > 0 { wait(seconds) - pollStatus(client, mediaId) + pollStatus(mediaId) } return mediaId } -func uploadAll(client *http.Client, files []string) []ObjectID { +func uploadAll(files []string) []ObjectID { ids := []ObjectID{} for _, f := range files { if f != "" { - id := uploadFile(client, f) + id := uploadFile(f) ids = append(ids, id) } } @@ -391,10 +396,10 @@ func splitArguments(args []string) data { return d } -func tweet(client *http.Client, status string, mediaIDs []ObjectID, previousID ObjectID) ObjectID { +func tweet(status string, mediaIDs []ObjectID, previousID ObjectID) ObjectID { log := func(err error) { optLogFatal("tweet "+status, err) } request := UpdateStatusRequest(status, mediaIDs, previousID) - body := send(client, STATUS_ENDPOINT, request) + body := send(STATUS_ENDPOINT, request) var sr UpdateStatusResponse err := json.Unmarshal(body, &sr) log(err) @@ -424,22 +429,22 @@ func (d *data) getVideo(i int) string { return goutil.StrSliceAt(d.videos, i) } -func (d *data) uploadPics(client *http.Client, from, to int) []ObjectID { +func (d *data) uploadPics(from, to int) []ObjectID { pics := goutil.StrSlice(d.pics, from, to) - return uploadAll(client, pics) + return uploadAll(pics) } -func (d *data) uploadGif(client *http.Client, i int) []ObjectID { +func (d *data) uploadGif(i int) []ObjectID { gif := d.getGif(i) - return uploadAll(client, []string{gif}) + return uploadAll([]string{gif}) } -func (d *data) uploadVideo(client *http.Client, i int) []ObjectID { +func (d *data) uploadVideo(i int) []ObjectID { vid := d.getVideo(i) - return uploadAll(client, []string{vid}) + return uploadAll([]string{vid}) } -func (d *data) push(client *http.Client, previous ObjectID) { +func (d *data) push(previous ObjectID) { if d == nil { return } @@ -454,20 +459,20 @@ func (d *data) push(client *http.Client, previous ObjectID) { } from := i * 4 to := (i + 1) * 4 - mediaIDs = d.uploadPics(client, from, to) + mediaIDs = d.uploadPics(from, to) if len(mediaIDs) == 0 { - mediaIDs = d.uploadGif(client, g) + mediaIDs = d.uploadGif(g) g++ } if len(mediaIDs) == 0 { - mediaIDs = d.uploadVideo(client, v) + mediaIDs = d.uploadVideo(v) v++ } if len(mediaIDs) > 0 { empty = false } if !empty { - previous = tweet(client, status, mediaIDs, previous) + previous = tweet(status, mediaIDs, previous) i++ } } @@ -475,14 +480,13 @@ func (d *data) push(client *http.Client, previous ObjectID) { func updateStatus(args []string, previous ObjectID, embedTweet ObjectID) { d := splitArguments(args) - httpClient := getClient() if embedTweet != "" { - tweets := _lookup(httpClient, []string{string(embedTweet)}) + tweets := _lookup([]string{string(embedTweet)}) if len(tweets) == 1 { d.status[0] += " " + tweets[0].URL() } } - d.push(httpClient, previous) + d.push(previous) } func status(args []string) error { @@ -508,9 +512,21 @@ func quote(args []string) error { return nil } +type TwitterTime struct { + time.Time +} + +func (twt *TwitterTime) UnmarshalJSON(b []byte) error { + s := strings.Trim(string(b), "\"") + var err error + twt.Time, err = time.Parse(time.RubyDate, s) + return err +} + type Status struct { Full_text string Id_str string + Created_at TwitterTime In_reply_to_screen_name string In_reply_to_status_id_str string User StatusUser @@ -519,6 +535,10 @@ type Status struct { Extended_entities Entities } +func (t Status) equals(t2 Status) bool { + return t.Id_str == t2.Id_str +} + type Entities struct { Media []Media } @@ -573,9 +593,9 @@ type StatusUser struct { Screen_name string } -func _lookup(client *http.Client, ids []string) []Status { +func _lookup(ids []string) []Status { log := func(err error) { optLogFatal("lookup "+strings.Join(ids, ","), err) } - body := get(client, LOOKUP_ENDPOINT+LookupParameters(ids)) + body := get(LOOKUP_ENDPOINT + LookupParameters(ids)) var tweets []Status err := json.Unmarshal(body, &tweets) log(err) @@ -587,28 +607,30 @@ func lookup(args []string) error { fmt.Fprintln(os.Stderr, "USAGE: drivel lookup TWEET_ID1 [TWEET_ID2 TWEET_ID3 ...]") os.Exit(-1) } - tweets := _lookup(getClient(), args) + tweets := _lookup(args) PrintTweets(tweets) return nil } -func timeline(endpoint string) { +func timeline(endpoint string, quiet bool) []Status { log := func(err error) { optLogFatal("timeline", err) } - client := getClient() - body := get(client, endpoint) + body := get(endpoint) var tweets []Status err := json.Unmarshal(body, &tweets) log(err) - PrintTweets(tweets) + if !quiet { + PrintTweets(tweets) + } + return tweets } func mentions(args []string) error { - timeline(MENTIONS_ENDPOINT) + timeline(MENTIONS_ENDPOINT, false) return nil } func home(args []string) error { - timeline(HOME_ENDPOINT) + timeline(HOME_ENDPOINT, false) return nil } @@ -617,7 +639,7 @@ func UserTimelineParameters(screenName string) string { } func userTimeline(args []string) error { - timeline(TIMELINE_ENDPOINT + UserTimelineParameters(args[0])) + timeline(TIMELINE_ENDPOINT+UserTimelineParameters(args[0]), false) return nil } @@ -631,13 +653,12 @@ func retweet(args []string) error { fmt.Fprintln(os.Stderr, "USAGE: drivel retweet TWEET_ID") os.Exit(-1) } - client := getClient() id := args[0] - tweets := _lookup(client, []string{id}) + tweets := _lookup([]string{id}) if len(tweets) != 1 { log(errors.New("Could not find tweet " + id)) } - body := send(client, RETWEET_ENDPOINT+RetweetParameters(id), nil) + body := send(RETWEET_ENDPOINT+RetweetParameters(id), nil) var retweet Status err := json.Unmarshal(body, &retweet) log(err) @@ -658,8 +679,7 @@ func like(args []string) error { fmt.Fprintln(os.Stderr, "USAGE: drivel like TWEET_ID") os.Exit(-1) } - client := getClient() - body := send(client, LIKE_ENDPOINT, LikeRequest(args[0])) + body := send(LIKE_ENDPOINT, LikeRequest(args[0])) var tweet Status err := json.Unmarshal(body, &tweet) log(err) @@ -667,7 +687,103 @@ func like(args []string) error { return nil } +func equals(t1 []Status, t2 []Status) bool { + if len(t1) != len(t2) { + return false + } + for i := range t1 { + if !t1[i].equals(t2[i]) { + return false + } + } + return true +} + +func UnlikeRequest(id string) url.Values { + return map[string][]string{ + "id": {id}, + } +} + +func unlike(id string) { + log := func(err error) { optLogFatal("unlike", err) } + body := send(DESTROY_LIKE_ENDPOINT, UnlikeRequest(id)) + var tweet Status + err := json.Unmarshal(body, &tweet) + log(err) + fmt.Println("Unliked", tweet.Id_str) +} + +func DestroyParameters(id string) string { + return id + ".json" +} + +func destroyStatus(id string) { + log := func(err error) { optLogFatal("destroy", err) } + body := send(DESTROY_STATUS_ENDPOINT+DestroyParameters(id), nil) + var tweet Status + err := json.Unmarshal(body, &tweet) + log(err) + fmt.Println("Destroyed", tweet.Id_str) +} + +func wipeTimeline(likes bool, keepDays int) { + var endpoint string + if likes { + endpoint = LIKES_TIMELINE_ENDPOINT + } else { + endpoint = TIMELINE_ENDPOINT + } + n := 0 + now := time.Now() + tweets := timeline(endpoint, true) + for { + for _, tweet := range tweets { + daysSince := now.Sub(tweet.Created_at.Time).Hours() / 24 + if tweet.Created_at != (TwitterTime{}) && daysSince >= float64(keepDays) { + if likes { + unlike(tweet.Id_str) + } else { + destroyStatus(tweet.Id_str) + } + n++ + if n >= 200 { + fmt.Println("reached limit of 200") + return + } + } + } + newTweets := timeline(endpoint, true) + if !equals(newTweets, tweets) { + tweets = newTweets + } else { + return + } + } +} + +func wipe(flags wipeFlags) error { + wipeTimeline(true, flags.keepDays) + wipeTimeline(false, flags.keepDays) + return nil +} + +type wipeFlags struct { + keepDays int +} + +func wipeCommand() (goutil.CommandFlagsInit, goutil.CommandFunc) { + f := wipeFlags{} + flagsInit := func(s *flag.FlagSet) { + s.IntVar(&f.keepDays, "keepDays", WIPE_KEEP_DAYS, "don't wipe the last N days") + } + return flagsInit, func([]string) error { return wipe(f) } +} + +var client *http.Client + func main() { + client = getClient() commands := []goutil.Command{ goutil.NewCommand("status", status, "post a status with message and/or media"), goutil.NewCommand("home", home, "get your home timeline"), @@ -678,6 +794,7 @@ func main() { goutil.NewCommand("quote", quote, "quote retweet a tweet with a specific ID"), goutil.NewCommand("retweet", retweet, "retweet a tweet with a specific ID"), goutil.NewCommand("like", like, "like a tweet with a specific ID"), + goutil.NewCommandWithFlags("wipe", wipeCommand, "wipe your timeline and likes"), } err := goutil.Execute(commands) if err != nil {