commit 0ab5c3a911f28645a9562b2da71761f441f36fdd Author: gutmet Date: Tue Jan 1 19:35:22 2019 +0100 purge history since July 2018 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c1431a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.gitdist +drivel +go.sum + diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..26614d6 --- /dev/null +++ b/Readme.md @@ -0,0 +1,77 @@ +drivel +======== + +drivel is a Twitter command line interface for status updates and media upload. + +You can find releases on [releases.gutmet.org](https://releases.gutmet.org) or +build it yourself. + +build +----- + +``` +go get -u git.gutmet.org/drivel.git +``` + +This will also fetch the dependencies [goutil](/goutil) and [dghubble's oauth1 library](https://github.com/dghubble/oauth1) ([backup](/oauth1)). Compile +with + +``` +go build drivel.go credentials.go +``` + + +usage +----- + +``` +drivel STATUS [FILE1, FILE2, ...] +``` + +with any number of files, as long as they are .jpg, .png, .gif or .mp4 and smaller than 5 MB each. On first use, drivel will ask you to go to [https://apps.twitter.com/app/new](https://apps.twitter.com/app/new), register a new app and create an access token. Those values will be stored in HOME/.drivel/ for later use. + +drivel will automatically split large status messages and multiple files into separate tweets belonging to the same thread. + +example: + +``` +$ ./drivel +Usage: drivel STATUS [FILE1, FILE2, ...] +$ ./drivel "First Message" +Did not find /home/alexander/.drivel, creating. +Go to https://apps.twitter.com/app/new to register a new app +and create an access token + +Consumer Key: someconsumerkey +Consumer Secret: somesecret +Access Token: sometoken +Access Token Secret: sometokensecret +==> Updated status to 'First Message' with id 1013198854823514112 +$ +$ ./drivel "Maxitest Lorem ipsum dolor sit amet, consetetur sadipscing elitr, +sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, +sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet +clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod +tempor invidunt ut labor" *.jpg *.mp4 *.gif +==> Uploaded 7-Sins-in-the-Digital-World.jpg with id 1013200017602043904 +==> Uploaded DifferenceTechEnthusiasts.jpg with id 1013200023608287234 +==> Uploaded disappointednotsurprised.jpg with id 1013200028339507200 +==> Uploaded fpalm30c3.jpg with id 1013200033053896704 +==> Updated status to 'Maxitest Lorem ipsum dolor sit amet, consetetur sadipscing +elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam +erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. +Stet clita kasd gubergren, no sea takimata sanctus est' with id 1013200053387874305 +==> Uploaded howtoeven.jpg with id 1013200057108135936 +==> Updated status to 'Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, +consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labor' +with id 1013200061533171712 +==> Uploaded disgusted-clint-eastwood.gif with id 1013200063978500097 +==> Updated status to '' with id 1013200074254450688 +==> Uploaded headwall.gif with id 1013200076871696384 +==> Updated status to '' with id 1013200088049602562 +==> Uploaded KittingUp.mp4 with id 1013200090595545089 +==> Updated status to '' with id 1013200101811122178 +==> Uploaded SteveHughes_Metal.mp4 with id 1013200104357064704 +==> Updated status to '' with id 1013200119813033985 +``` \ No newline at end of file diff --git a/credentials.go b/credentials.go new file mode 100644 index 0000000..55e5b43 --- /dev/null +++ b/credentials.go @@ -0,0 +1,90 @@ +package main + +import ( + "fmt" + "git.gutmet.org/goutil.git" + "github.com/dghubble/oauth1" + "io/ioutil" + "net/http" + "os" + "os/user" + "path" +) + +const ( + AppDir = ".drivel" //inside home dir + fileConsumerKey = "ConsumerKey" + fileConsumerSecret = "ConsumerSecret" + fileAccessToken = "AccessToken" + fileAccessTokenSecret = "AccessTokenSecret" + registerAppURL = "https://apps.twitter.com/app/new" +) + +type credentials struct { + consumerKey string + consumerSecret string + accessToken string + accessTokenSecret string +} + +func (c *credentials) writeToAppDir(appDir string) { + write := func(file string, s string) { + optLogFatal("writeToAppDir", ioutil.WriteFile(path.Join(appDir, file), []byte(s), 0640)) + } + write(fileConsumerKey, c.consumerKey) + write(fileConsumerSecret, c.consumerSecret) + write(fileAccessToken, c.accessToken) + write(fileAccessTokenSecret, c.accessTokenSecret) +} + +func readFromAppDir(appDir string) *credentials { + read := func(f string) string { + s, err := goutil.ReadFile(path.Join(appDir, f)) + optLogFatal("readFromAppDir", err) + return s + } + ck := read(fileConsumerKey) + cs := read(fileConsumerSecret) + at := read(fileAccessToken) + ats := read(fileAccessTokenSecret) + return &credentials{ck, cs, at, ats} +} + +func createAppDir(appDir string) { + ask := func(s string) string { + a, err := goutil.AskFor(s) + optLogFatal("createAppDir", err) + return a + } + fmt.Println("Did not find " + appDir + ", creating.") + fmt.Println("Go to " + registerAppURL + " to register a new app") + fmt.Println("and create an access token\n") + err := os.MkdirAll(appDir, 0755) + optLogFatal("createAppDir", err) + ck := ask("Consumer Key") + cs := ask("Consumer Secret") + at := ask("Access Token") + ats := ask("Access Token Secret") + c := credentials{ck, cs, at, ats} + c.writeToAppDir(appDir) +} + +func loadCredentials() *credentials { + log := func(err error) { optLogFatal("loadCredentials", err) } + currentUser, err := user.Current() + log(err) + homeDir := currentUser.HomeDir + appDir := path.Join(homeDir, AppDir) + if !goutil.PathExists(appDir) { + createAppDir(appDir) + } + c := readFromAppDir(appDir) + return c +} + +func getClient() *http.Client { + c := loadCredentials() + config := oauth1.NewConfig(c.consumerKey, c.consumerSecret) + token := oauth1.NewToken(c.accessToken, c.accessTokenSecret) + return config.Client(oauth1.NoContext, token) +} diff --git a/drivel.go b/drivel.go new file mode 100644 index 0000000..91c8d16 --- /dev/null +++ b/drivel.go @@ -0,0 +1,346 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "git.gutmet.org/goutil.git" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" +) + +const ( + MAX_BYTES = 5 * 1024 * 1024 + CHARACTER_LIMIT = 280 + UPLOAD_ENDPOINT = "https://upload.twitter.com/1.1/media/upload.json" + STATUS_ENDPOINT = "https://api.twitter.com/1.1/statuses/update.json" +) + +func optLogFatal(decorum string, err error) { + if err != nil { + fmt.Fprintln(os.Stderr, "drivel: "+decorum+": "+err.Error()) + os.Exit(-1) + } +} + +type ObjectID string + +type TwitterError struct { + Code int64 + Message string + Label string +} + +type Response struct { + Errors []TwitterError +} + +func (r Response) Error() string { + if len(r.Errors) == 0 { + return "" + } else { + s, _ := json.Marshal(r) + return "Response error " + string(s) + } +} + +func InitRequest(mediaType string, totalBytes int) url.Values { + return map[string][]string{ + "command": {"INIT"}, + "media_type": {mediaType}, + "total_bytes": {strconv.Itoa(totalBytes)}, + } +} + +type InitResponse struct { + Response + Media_id_string string +} + +func AppendRequest(mediaID string, mediaData string, segmentIndex int) url.Values { + return map[string][]string{ + "command": {"APPEND"}, + "media_id": {mediaID}, + "media_data": {mediaData}, + "segment_index": {strconv.Itoa(segmentIndex)}, + } +} + +func FinalizeRequest(mediaID string) url.Values { + return map[string][]string{ + "command": {"FINALIZE"}, + "media_id": {mediaID}, + } +} + +type FinalizeResponse struct { + Error string + Media_id_string string +} + +func UpdateStatusRequest(status string, mediaIDs []ObjectID, previousStatusID ObjectID) url.Values { + r := map[string][]string{"status": {status}} + if len(mediaIDs) > 0 { + ids := []string{} + for _, id := range mediaIDs { + ids = append(ids, string(id)) + } + r["media_ids"] = []string{strings.Join(ids, ",")} + } + if len(previousStatusID) > 0 { + r["in_reply_to_status_id"] = []string{string(previousStatusID)} + r["auto_populate_reply_metadata"] = []string{"true"} + } + return r +} + +type UpdateStatusResponse struct { + Response + Id_str string +} + +var mimetype = map[string]string{ + ".mp4": "video/mp4", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", +} + +func getMimetype(file string) string { + ext := filepath.Ext(file) + if v, ok := mimetype[ext]; ok { + return v + } else { + return "application/octet-stream" + } +} + +func send(client *http.Client, url string, vals url.Values) []byte { + log := func(err error) { + v, _ := json.Marshal(vals) + optLogFatal("send "+url+" "+string(v), err) + } + resp, err := client.PostForm(url, vals) + log(err) + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + log(err) + return body +} + +func uploadFile(client *http.Client, file string) ObjectID { + log := func(err error) { optLogFatal("uploadFile "+file, err) } + media, err := ioutil.ReadFile(file) + log(err) + initRequest := InitRequest(getMimetype(file), len(media)) + body := send(client, UPLOAD_ENDPOINT, initRequest) + var initResponse InitResponse + err = json.Unmarshal(body, &initResponse) + log(err) + if len(initResponse.Errors) == 0 { + mediaId := initResponse.Media_id_string + appRequest := AppendRequest(mediaId, base64.RawURLEncoding.EncodeToString(media), 0) + body := send(client, UPLOAD_ENDPOINT, appRequest) + if string(body) == "" { + body := send(client, UPLOAD_ENDPOINT, FinalizeRequest(mediaId)) + var finalizeResponse FinalizeResponse + json.Unmarshal(body, &finalizeResponse) + if id := ObjectID(finalizeResponse.Media_id_string); id != "" { + fmt.Println("==> Uploaded " + file + " with id " + string(id)) + return id + } + } + } + log(errors.New("Could not upload file " + file)) + return "" +} + +func uploadAll(client *http.Client, files []string) []ObjectID { + ids := []ObjectID{} + for _, f := range files { + if f != "" { + id := uploadFile(client, f) + ids = append(ids, id) + } + } + return ids +} + +type mediaKind int + +const ( + UNKNOWN mediaKind = iota + PIC + GIF + VIDEO +) + +func kind(path string) mediaKind { + ext := filepath.Ext(path) + switch ext { + case ".jpg": + fallthrough + case ".jpeg": + fallthrough + case ".png": + return PIC + case ".gif": + return GIF + case ".mp4": + return VIDEO + } + return UNKNOWN +} + +func splitStatus(status string) []string { + split := []string{} + words := strings.Split(status, " ") + s := "" + for _, word := range words { + if s == "" && len(word) <= CHARACTER_LIMIT { + s = word + } else if len(s)+1+len(word) <= CHARACTER_LIMIT { + s = s + " " + word + } else { + split = append(split, s) + bound := goutil.IntMin(len(word), CHARACTER_LIMIT) + s = string([]rune(word)[:bound]) + } + } + if s != "" { + split = append(split, s) + } + return split +} + +func exitIfInvalid(path string) { + log := func(err error) { optLogFatal("exitIfInvalid", err) } + // check existence AND readability + f, err := os.Open(path) + log(err) + defer f.Close() + tmp, err := ioutil.ReadAll(f) + log(err) + if len(tmp) > MAX_BYTES { + log(errors.New("File too big: " + path + " is bigger than maximum of " + strconv.Itoa(MAX_BYTES) + " Bytes")) + } +} + +func splitArguments() data { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "Usage: drivel STATUS [FILE1, FILE2, ...]") + os.Exit(-1) + } + d := data{} + d.status = splitStatus(os.Args[1]) + for _, arg := range os.Args[2:] { + 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(client *http.Client, status string, mediaIDs []ObjectID, previousID ObjectID) ObjectID { + log := func(err error) { optLogFatal("tweet "+status, err) } + request := UpdateStatusRequest(status, mediaIDs, previousID) + body := send(client, STATUS_ENDPOINT, request) + var sr UpdateStatusResponse + err := json.Unmarshal(body, &sr) + log(err) + if len(sr.Errors) > 0 { + log(sr) + } + fmt.Println("==> Updated status to '" + status + "' with id " + sr.Id_str) + return ObjectID(sr.Id_str) +} + +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(client *http.Client, from, to int) []ObjectID { + pics := goutil.StrSlice(d.pics, from, to) + return uploadAll(client, pics) +} + +func (d *data) uploadGif(client *http.Client, i int) []ObjectID { + gif := d.getGif(i) + return uploadAll(client, []string{gif}) +} + +func (d *data) uploadVideo(client *http.Client, i int) []ObjectID { + vid := d.getVideo(i) + return uploadAll(client, []string{vid}) +} + +func (d *data) push(client *http.Client) { + if d == nil { + return + } + var previous ObjectID = "" + empty := false + i, g, v := 0, 0, 0 + for !empty { + empty = true + status := d.getStatus(i) + mediaIDs := []ObjectID{} + if status != "" { + empty = false + } + from := i * 4 + to := (i + 1) * 4 + mediaIDs = d.uploadPics(client, from, to) + if len(mediaIDs) == 0 { + mediaIDs = d.uploadGif(client, g) + g++ + } + if len(mediaIDs) == 0 { + mediaIDs = d.uploadVideo(client, v) + v++ + } + if len(mediaIDs) > 0 { + empty = false + } + if !empty { + previous = tweet(client, status, mediaIDs, previous) + i++ + } + } +} + +func main() { + d := splitArguments() + httpClient := getClient() + d.push(httpClient) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..40257a9 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module git.gutmet.org/drivel.git + +require ( + git.gutmet.org/goutil.git v0.0.0-20181104220708-a4cd634b6eca + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dghubble/oauth1 v0.5.0 + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.2.2 // indirect +)