package main import ( "encoding/json" "errors" "flag" "fmt" goutil "git.gutmet.org/goutil.git/misc" "io/ioutil" "net/http" "net/url" "os" "path/filepath" "strings" "text/template" "time" ) const ( CHARACTER_LIMIT = 280 WIPE_KEEP_DAYS = 10 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) { if err != nil && err.Error() != "" { fmt.Fprintln(os.Stderr, "drivel: "+decorum+": "+err.Error()) os.Exit(-1) } } func checkUsage(args []string, argcMin int, argcMax int, help string) { argc := len(args) if (argcMin > -1 && argc < argcMin) || (argcMax > -1 && argc > argcMax) { fmt.Fprintf(os.Stderr, "USAGE: %s %s\n", os.Args[0], help) os.Exit(-1) } } func get(url string) []byte { return _send(url, nil, false) } func send(url string, vals url.Values) []byte { return _send(url, vals, true) } 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) } var resp *http.Response var err error if usePost { resp, err = client.PostForm(url, vals) } else { resp, err = client.Get(url) } log(err) defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode > 299 { fmt.Fprintln(os.Stderr, "response:", resp, "\n") fmt.Fprintln(os.Stderr, "body:", string(body), "\n") log(errors.New("HTTP status " + fmt.Sprint(resp.StatusCode))) } log(err) return body } func lastSpace(slice []rune) int { for i := len(slice) - 1; i >= 0; i-- { if slice[i] == ' ' { return i } } return -1 } func splitStatus(status string) []string { characterLimit := CHARACTER_LIMIT // Twitter has an insane definition of what counts as a character // ( see https://developer.twitter.com/en/docs/counting-characters ) // - as a crude approximation, anything outside LATIN-1 halfs the limit for _, ch := range status { if ch > 0x10FF { characterLimit = CHARACTER_LIMIT / 2 break } } asRunes := []rune(status) split := []string{} for len(asRunes) != 0 { var limit int if len(asRunes) <= characterLimit { limit = len(asRunes) } else { limit = lastSpace(asRunes[0:characterLimit]) if limit == -1 { limit = characterLimit } else { limit = limit + 1 } } split = append(split, string(asRunes[0:limit])) asRunes = asRunes[limit:] } return split } func splitArguments(args []string) data { if len(args) < 1 { fmt.Fprintln(os.Stderr, "Usage: drivel status STATUS [FILE1 FILE2 ...]") os.Exit(-1) } d := data{} d.status = splitStatus(args[0]) for _, arg := range args[1:] { exitIfInvalid(arg) switch kind(arg) { case PIC: d.pics = append(d.pics, arg) case GIF: d.gifs = append(d.gifs, arg) case VIDEO: d.videos = append(d.videos, arg) default: optLogFatal("splitArguments", errors.New("Unsupported file: "+arg)) } } return d } func tweet(status string, mediaIDs []ObjectID, previousID ObjectID) Status { log := func(err error) { optLogFatal("tweet "+status, err) } request := UpdateStatusRequest(status, mediaIDs, previousID) body := send(STATUS_ENDPOINT, request) var tweet Status err := json.Unmarshal(body, &tweet) log(err) log(tweet) return tweet } type data struct { status []string pics []string gifs []string videos []string } func (d *data) getStatus(i int) string { return goutil.StrSliceAt(d.status, i) } func (d *data) getGif(i int) string { return goutil.StrSliceAt(d.gifs, i) } func (d *data) getVideo(i int) string { return goutil.StrSliceAt(d.videos, i) } func (d *data) uploadPics(from, to int) []ObjectID { pics := goutil.StrSlice(d.pics, from, to) return uploadAll(pics) } func (d *data) uploadGif(i int) []ObjectID { gif := d.getGif(i) return uploadAll([]string{gif}) } func (d *data) uploadVideo(i int) []ObjectID { vid := d.getVideo(i) return uploadAll([]string{vid}) } func (d *data) push(previous ObjectID) []Status { empty := false i, g, v := 0, 0, 0 tweets := []Status{} if d == nil { return tweets } for !empty { empty = true status := d.getStatus(i) mediaIDs := []ObjectID{} if status != "" { empty = false } from := i * 4 to := (i + 1) * 4 mediaIDs = d.uploadPics(from, to) if len(mediaIDs) == 0 { mediaIDs = d.uploadGif(g) g++ } if len(mediaIDs) == 0 { mediaIDs = d.uploadVideo(v) v++ } if len(mediaIDs) > 0 { empty = false } if !empty { t := tweet(status, mediaIDs, previous) tweets = append(tweets, t) previous = ObjectID(t.Id_str) i++ } } return tweets } func updateStatus(args []string, previous ObjectID, embedTweet ObjectID) []Status { d := splitArguments(args) if embedTweet != "" { tweets := _lookup([]string{string(embedTweet)}) if len(tweets) == 1 { d.status[0] += " " + tweets[0].URL() } } return d.push(previous) } func PrintTweets(tweets []Status, userFilter hashset) { filtered := []Status{} for _, tweet := range tweets { if !userFilter.contains(tweet.User.Screen_name) { filtered = append(filtered, tweet) } } if formatTemplate != nil { optLogFatal("printTweets", formatTemplate.Execute(os.Stdout, filtered)) } else { for _, tweet := range filtered { fmt.Println(tweet.String()) fmt.Println("---------") } } } func status(args []string) error { checkUsage(args, 1, -1, "status STATUS [FILE1 FILE2 ...]") tweets := updateStatus(args, "", "") PrintTweets(tweets, nil) return nil } func reply(args []string) error { checkUsage(args, 2, -1, "reply TWEET_ID MESSAGE [FILE1 FILE2 ...]") tweets := updateStatus(args[1:], ObjectID(args[0]), "") PrintTweets(tweets, nil) return nil } func quote(args []string) error { checkUsage(args, 2, -1, "quote TWEET_ID MESSAGE [FILE1 FILE2 ...]") tweets := updateStatus(args[1:], "", ObjectID(args[0])) PrintTweets(tweets, nil) return nil } func _lookup(ids []string) []Status { log := func(err error) { optLogFatal("lookup "+strings.Join(ids, ","), err) } body := get(LOOKUP_ENDPOINT + LookupParameters(ids)) var tweets []Status err := json.Unmarshal(body, &tweets) log(err) return tweets } func lookup(args []string) error { checkUsage(args, 1, -1, "lookup TWEET_ID1 [TWEET_ID2 TWEET_ID3 ...]") tweets := _lookup(args) PrintTweets(tweets, nil) return nil } func timeline(endpoint string) []Status { log := func(err error) { optLogFatal("timeline", err) } body := get(endpoint) var tweets []Status err := json.Unmarshal(body, &tweets) log(err) return tweets } func mentions(args []string) error { checkUsage(args, 0, 0, "mentions") tweets := timeline(MENTIONS_ENDPOINT) PrintTweets(tweets, mentionsFilter) return nil } func home(args []string) error { checkUsage(args, 0, 0, "home") tweets := timeline(HOME_ENDPOINT) PrintTweets(tweets, homeFilter) return nil } func userTimeline(flags userTimelineFlags, args []string) error { checkUsage(args, 1, 1, "timeline USER") tweets := timeline(TIMELINE_ENDPOINT + UserTimelineParameters(flags, args[0])) PrintTweets(tweets, nil) return nil } type userTimelineFlags struct { withReplies bool } func userTimelineCommand() (goutil.CommandFlagsInit, goutil.CommandFunc) { f := userTimelineFlags{} flagsInit := func(s *flag.FlagSet) { s.BoolVar(&f.withReplies, "with-replies", false, "include replies in timeline") } return flagsInit, func(args []string) error { return userTimeline(f, args) } } func retweet(args []string) error { checkUsage(args, 1, 1, "retweet TWEET_ID") log := func(err error) { optLogFatal("retweet", err) } id := args[0] tweets := _lookup([]string{id}) if len(tweets) != 1 { log(errors.New("Could not find tweet " + id)) } body := send(RETWEET_ENDPOINT+RetweetParameters(id), nil) var retweet Status err := json.Unmarshal(body, &retweet) log(err) PrintTweets([]Status{retweet}, nil) return nil } func like(args []string) error { checkUsage(args, 1, 1, "like TWEET_ID") log := func(err error) { optLogFatal("like", err) } body := send(LIKE_ENDPOINT, LikeRequest(args[0])) var tweet Status err := json.Unmarshal(body, &tweet) log(err) PrintTweets([]Status{tweet}, nil) return nil } 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 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) 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) if !equals(newTweets, tweets) { tweets = newTweets } else { return } } } func wipe(flags wipeFlags, args []string) error { checkUsage(args, 0, 0, "wipe") 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, "keep-days", WIPE_KEEP_DAYS, "don't wipe the last N days") } return flagsInit, func(args []string) error { return wipe(f, args) } } type hashset map[string]interface{} func makeHashset() hashset { return make(map[string]interface{}) } func (s hashset) add(member string) { if s != nil { s[member] = nil } } func (s hashset) contains(member string) bool { if s == nil { return false } if _, ok := s[member]; ok { return true } else { return false } } func setFilters(appDir string) { getHashset := func(s string, err error) hashset { set := makeHashset() if err == nil { for _, id := range strings.Split(s, "\n") { if trimmed := strings.TrimSpace(id); trimmed != "" { set.add(trimmed) } } } return set } homeFilter = getHashset(goutil.ReadFile(filepath.Join(appDir, "FilterHome"))) mentionsFilter = getHashset(goutil.ReadFile(filepath.Join(appDir, "FilterMentions"))) } type generalFlags struct { templateFile string } func wrapCommand(cmd goutil.CommandFunc) func() (goutil.CommandFlagsInit, goutil.CommandFunc) { return wrapCommandFl(func() (goutil.CommandFlagsInit, goutil.CommandFunc) { return nil, cmd }) } func wrapCommandFl(cmd func() (goutil.CommandFlagsInit, goutil.CommandFunc)) func() (goutil.CommandFlagsInit, goutil.CommandFunc) { f := generalFlags{} flagsInit := func(s *flag.FlagSet) { s.StringVar(&f.templateFile, "template", "", "use a template file to format tweets") } return func() (goutil.CommandFlagsInit, goutil.CommandFunc) { formerInit, commandFunc := cmd() return func(s *flag.FlagSet) { if formerInit != nil { formerInit(s) } flagsInit(s) }, func(args []string) error { if f.templateFile != "" { formatTemplate = template.Must(template.ParseFiles(f.templateFile)) } return commandFunc(args) } } } var client *http.Client var homeFilter hashset var mentionsFilter hashset var formatTemplate *template.Template func main() { client = getClient() setFilters(appDir()) commands := []goutil.Command{ goutil.NewCommandWithFlags("status", wrapCommand(status), "post a status with message and/or media"), goutil.NewCommandWithFlags("home", wrapCommand(home), "get your home timeline"), goutil.NewCommandWithFlags("mentions", wrapCommand(mentions), "get your mention timeline"), goutil.NewCommandWithFlags("timeline", wrapCommandFl(userTimelineCommand), "get timeline of a specific user"), goutil.NewCommandWithFlags("lookup", wrapCommand(lookup), "lookup tweets with specific IDs"), goutil.NewCommandWithFlags("reply", wrapCommand(reply), "reply to a tweet with a specific ID"), goutil.NewCommandWithFlags("quote", wrapCommand(quote), "quote retweet a tweet with a specific ID"), goutil.NewCommandWithFlags("retweet", wrapCommand(retweet), "retweet a tweet with a specific ID"), goutil.NewCommandWithFlags("like", wrapCommand(like), "like a tweet with a specific ID"), goutil.NewCommandWithFlags("wipe", wipeCommand, "wipe your timeline and likes"), } err := goutil.Execute(commands) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } }