purge history since July 2018
This commit is contained in:
commit
0ab5c3a911
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
.gitdist
|
||||
drivel
|
||||
go.sum
|
||||
|
77
Readme.md
Normal file
77
Readme.md
Normal file
|
@ -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
|
||||
```
|
90
credentials.go
Normal file
90
credentials.go
Normal file
|
@ -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)
|
||||
}
|
346
drivel.go
Normal file
346
drivel.go
Normal file
|
@ -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)
|
||||
}
|
9
go.mod
Normal file
9
go.mod
Normal file
|
@ -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
|
||||
)
|
Loading…
Reference in New Issue
Block a user