purge history since July 2018

This commit is contained in:
gutmet 2019-01-01 19:35:22 +01:00
commit 0ab5c3a911
5 changed files with 526 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.gitdist
drivel
go.sum

77
Readme.md Normal file
View 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
View 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
View 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
View 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
)