527 lines
13 KiB
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)
|
|
}
|
|
}
|