drivel/drivel.go

619 lines
16 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"
"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"
FOLLOWING_ENDPOINT = "https://api.twitter.com/1.1/friends/list.json?count=200"
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 _following() (following []string) {
log := func(err error) { optLogFatal("following", err) }
defer func() {
if r := recover(); r != nil {
fmt.Fprintln(os.Stderr, r)
}
}()
var cursor int64 = -1
var err error
for cursor != 0 && err == nil {
body := get(FOLLOWING_ENDPOINT + FollowingParameters(cursor))
var response FollowingResponse
err = json.Unmarshal(body, &response)
log(err)
for _, user := range response.Users {
following = append(following, user.Screen_name)
}
cursor = response.Next_cursor
}
return
}
func following(args []string) error {
checkUsage(args, 0, 0, "following")
users := _following()
for _, user := range users {
fmt.Println(user)
}
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.New(filepath.Base(f.templateFile)).Funcs(template.FuncMap{"replaceAll": strings.ReplaceAll}).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.NewCommand("following", following, "try to get the list of users you are following"),
goutil.NewCommandWithFlags("wipe", wipeCommand, "wipe your timeline and likes"),
}
_ = goutil.Execute(commands)
}