package main import ( "encoding/json" "errors" "flag" "fmt" goutil "git.gutmet.org/goutil.git/misc" "io/ioutil" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "text/template" "time" ) const ( CHARACTER_LIMIT = 280 ALLOWLIST_MENTIONS = "AllowlistMentions" ALLOWLIST_HOME = "AllowlistHome" WIPE_KEEP_DAYS = 10 STATUS_ENDPOINT = "https://api.twitter.com/1.1/statuses/update.json" MAX_TIMELINE_REQUESTS = 15 DEFAULT_COUNT = 200 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() != "" { panic("drivel: " + decorum + ": " + err.Error()) } } 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 post(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 { log(errors.New(fmt.Sprintf("HTTP status %d\n\nresponse: %v\n\nbody: %s", resp.StatusCode, resp, string(body)))) } 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 := post(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) { if formatTemplate != nil { optLogFatal("printTweets", formatTemplate.Execute(os.Stdout, tweets)) } else { for _, tweet := range tweets { fmt.Println(tweet.String()) fmt.Println("---------") } } } func status(args []string) error { checkUsage(args, 1, -1, "status STATUS [FILE1 FILE2 ...]") tweets := updateStatus(args, "", "") PrintTweets(tweets) 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) 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) 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) return nil } func timeline(endpoint string, maxID string) []Status { log := func(err error) { optLogFatal("timeline", err) } if maxID != "" { endpoint += "&max_id=" + maxID } body := get(endpoint) var tweets []Status err := json.Unmarshal(body, &tweets) log(err) return tweets } func timelineLoop(endpoint string, flags timelineFlags, allowlist hashset) (tweets []Status) { defer func() { if r := recover(); r != nil { fmt.Fprintln(os.Stderr, "INFO:", r) } }() requestCount := 0 maxID := flags.maxID for len(tweets) < flags.count && requestCount < flags.maxRequests { tmp := timeline(endpoint, maxID) if len(tmp) == 0 { break } var lowestSoFar int64 lowestSoFar, _ = strconv.ParseInt(tmp[len(tmp)-1].Id_str, 10, 64) maxID = strconv.FormatInt(lowestSoFar-1, 10) for _, tweet := range tmp { if allowlist == nil || allowlist.contains(tweet.User.Screen_name) { tweets = append(tweets, tweet) } } if len(tweets) > flags.count { tweets = tweets[:flags.count] } requestCount++ } return } type timelineFlags struct { count int maxRequests int maxID string } func timelineFlagsVars(s *flag.FlagSet, f *timelineFlags) { s.IntVar(&f.count, "count", DEFAULT_COUNT, "try to get up to N tweets") s.IntVar(&f.maxRequests, "max-requests", MAX_TIMELINE_REQUESTS, "try to achieve count with a maximum of N requests") s.StringVar(&f.maxID, "max-id", "", "only get tweets with an ID lower or equal to max-id") } func mentions(flags timelineFlags, args []string) error { checkUsage(args, 0, 0, "mentions") allowlist := getHashset(ALLOWLIST_MENTIONS) tweets := timelineLoop(MENTIONS_ENDPOINT, flags, allowlist) PrintTweets(tweets) return nil } func mentionsCommand() (goutil.CommandFlagsInit, goutil.CommandFunc) { f := timelineFlags{} flagsInit := func(s *flag.FlagSet) { timelineFlagsVars(s, &f) } return flagsInit, func(args []string) error { return mentions(f, args) } } func home(flags timelineFlags, args []string) error { checkUsage(args, 0, 0, "home") allowlist := getHashset(ALLOWLIST_HOME) tweets := timelineLoop(HOME_ENDPOINT, flags, allowlist) PrintTweets(tweets) return nil } func homeCommand() (goutil.CommandFlagsInit, goutil.CommandFunc) { f := timelineFlags{} flagsInit := func(s *flag.FlagSet) { timelineFlagsVars(s, &f) } return flagsInit, func(args []string) error { return home(f, args) } } func userTimeline(flags userTimelineFlags, args []string) error { checkUsage(args, 1, 1, "timeline USER") tweets := timelineLoop(TIMELINE_ENDPOINT+UserTimelineParameters(flags, args[0]), flags.timelineFlags, nil) PrintTweets(tweets) return nil } type userTimelineFlags struct { timelineFlags withReplies bool } func userTimelineCommand() (goutil.CommandFlagsInit, goutil.CommandFunc) { f := userTimelineFlags{} flagsInit := func(s *flag.FlagSet) { timelineFlagsVars(s, &f.timelineFlags) 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 := post(RETWEET_ENDPOINT+RetweetParameters(id), nil) var retweet Status err := json.Unmarshal(body, &retweet) log(err) PrintTweets([]Status{retweet}) return nil } func like(args []string) error { checkUsage(args, 1, 1, "like TWEET_ID") log := func(err error) { optLogFatal("like", err) } body := post(LIKE_ENDPOINT, LikeRequest(args[0])) var tweet Status err := json.Unmarshal(body, &tweet) log(err) PrintTweets([]Status{tweet}) return nil } func unlike(id string) { log := func(err error) { optLogFatal("unlike", err) } body := post(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 := post(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() maxID := "" tweets := timeline(endpoint, maxID) 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 } } maxID = tweet.Id_str } newTweets := timeline(endpoint, maxID) 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 getHashset(name string) (set hashset) { fullpath := filepath.Join(appDir(), name) s, err := goutil.ReadFile(fullpath) if err == nil { set = makeHashset() for _, id := range strings.Split(s, "\n") { if trimmed := strings.TrimSpace(id); trimmed != "" { set.add(trimmed) } } } return } 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 formatTemplate *template.Template func main() { defer func() { if r := recover(); r != nil { fmt.Fprintln(os.Stderr, r) os.Exit(-1) } }() client = getClient() commands := []goutil.Command{ goutil.NewCommandWithFlags("status", wrapCommand(status), "post a status with message and/or media"), goutil.NewCommandWithFlags("home", wrapCommandFl(homeCommand), "get your home timeline"), goutil.NewCommandWithFlags("mentions", wrapCommandFl(mentionsCommand), "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"), } _ = goutil.Execute(commands) }