drivel/drivel.go

527 lines
13 KiB
Go

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)
}
}