initial
This commit is contained in:
commit
e00947596a
90
credentials.go
Normal file
90
credentials.go
Normal file
|
@ -0,0 +1,90 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/user"
|
||||
"path"
|
||||
|
||||
mastodon "git.gutmet.org/go-mastodon.git"
|
||||
goutil "git.gutmet.org/goutil.git/misc"
|
||||
)
|
||||
|
||||
const (
|
||||
AppDir = ".swill" //inside home dir
|
||||
fileServer = "Server"
|
||||
fileClientKey = "ClientKey"
|
||||
fileClientSecret = "ClientSecret"
|
||||
fileAccessToken = "AccessToken"
|
||||
)
|
||||
|
||||
type credentials struct {
|
||||
server string
|
||||
clientKey string
|
||||
clientSecret string
|
||||
accessToken string
|
||||
}
|
||||
|
||||
func (c *credentials) writeCredentials(appDir string) {
|
||||
write := func(file string, s string) {
|
||||
optLogFatal("writeToAppDir", ioutil.WriteFile(path.Join(appDir, file), []byte(s), 0640))
|
||||
}
|
||||
write(fileServer, c.server)
|
||||
write(fileClientKey, c.clientKey)
|
||||
write(fileClientSecret, c.clientSecret)
|
||||
write(fileAccessToken, c.accessToken)
|
||||
}
|
||||
|
||||
func readCredentials(appDir string) *credentials {
|
||||
read := func(f string) string {
|
||||
s, err := goutil.ReadFile(path.Join(appDir, f))
|
||||
optLogFatal("readFromAppDir", err)
|
||||
return s
|
||||
}
|
||||
s := read(fileServer)
|
||||
ck := read(fileClientKey)
|
||||
cs := read(fileClientSecret)
|
||||
at := read(fileAccessToken)
|
||||
return &credentials{s, ck, cs, at}
|
||||
}
|
||||
|
||||
func createAppDir(appDir string) {
|
||||
ask := func(s string) string {
|
||||
a, err := goutil.AskFor(s)
|
||||
optLogFatal("createAppDir", err)
|
||||
return a
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "Did not find "+appDir+", creating.")
|
||||
fmt.Fprintln(os.Stderr, "Go to Preferences > Development > New application to register a new app and generate access tokens with read & write permissions")
|
||||
err := os.MkdirAll(appDir, 0755)
|
||||
optLogFatal("createAppDir", err)
|
||||
s := ask("Server URL")
|
||||
ck := ask("Client key")
|
||||
cs := ask("Client secret")
|
||||
at := ask("Access token")
|
||||
c := credentials{s, ck, cs, at}
|
||||
c.writeCredentials(appDir)
|
||||
}
|
||||
|
||||
func appDir() string {
|
||||
log := func(err error) { optLogFatal("appDir", err) }
|
||||
currentUser, err := user.Current()
|
||||
log(err)
|
||||
homeDir := currentUser.HomeDir
|
||||
appDir := path.Join(homeDir, AppDir)
|
||||
if !goutil.PathExists(appDir) {
|
||||
createAppDir(appDir)
|
||||
}
|
||||
return appDir
|
||||
}
|
||||
|
||||
func initializeMastodon() {
|
||||
c := readCredentials(appDir())
|
||||
mastodon.Initialize(&mastodon.Config{
|
||||
Server: c.server,
|
||||
ClientID: c.clientKey,
|
||||
ClientSecret: c.clientSecret,
|
||||
AccessToken: c.accessToken,
|
||||
})
|
||||
}
|
8
go.mod
Normal file
8
go.mod
Normal file
|
@ -0,0 +1,8 @@
|
|||
module git.gutmet.org/swill.git
|
||||
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
git.gutmet.org/go-mastodon.git v0.0.0-20221120213223-7310a9e2f2c3
|
||||
git.gutmet.org/goutil.git v0.0.0-20201108182825-c19893df11f9
|
||||
)
|
6
go.sum
Normal file
6
go.sum
Normal file
|
@ -0,0 +1,6 @@
|
|||
git.gutmet.org/go-mastodon.git v0.0.0-20221120213223-7310a9e2f2c3 h1:H8U7k9RIp4ERB8/Z7e+Gaus0E7b6cn3aWE6bhmh4FNo=
|
||||
git.gutmet.org/go-mastodon.git v0.0.0-20221120213223-7310a9e2f2c3/go.mod h1:e/Z3dytr4MYC4rdOjbWEbWXS+zy1CWxjvplVGTE/eH8=
|
||||
git.gutmet.org/goutil.git v0.0.0-20201108182825-c19893df11f9 h1:XVD037Slgdl/CxcCWVtN6V+LzYl6QrTQ0upVIVpy6VE=
|
||||
git.gutmet.org/goutil.git v0.0.0-20201108182825-c19893df11f9/go.mod h1:iMgpxo9FxmbnUiQu5ugpjdtVZmh2rA9nySCr/GHkA64=
|
||||
git.gutmet.org/linkheader.git v0.0.0-20221120205136-a51e89fd8486 h1:7F1dwJvIgvHNvglosyIE7SA49BwG6b8DFkvD8NtHMD8=
|
||||
git.gutmet.org/linkheader.git v0.0.0-20221120205136-a51e89fd8486/go.mod h1:xArmd5A1lL5BEfB56+FUwqeG4XDfAvFGe35pqAgifCc=
|
320
swill.go
Normal file
320
swill.go
Normal file
|
@ -0,0 +1,320 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.gutmet.org/go-mastodon.git"
|
||||
goutil "git.gutmet.org/goutil.git/misc"
|
||||
)
|
||||
|
||||
const (
|
||||
CHARACTER_LIMIT = 500
|
||||
MAX_BYTES = 50 * 1024 * 1024
|
||||
)
|
||||
|
||||
var formatTemplate *template.Template
|
||||
|
||||
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 optLogFatal(decorum string, err error) {
|
||||
if err != nil && err.Error() != "" {
|
||||
panic("swill: " + 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)
|
||||
}
|
||||
}
|
||||
|
||||
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 uploadFile(file string) *mastodon.Attachment {
|
||||
att, err := mastodon.UploadMedia(file)
|
||||
optLogFatal("uploadFile", err)
|
||||
return att
|
||||
}
|
||||
|
||||
func uploadAll(files []string) []mastodon.ID {
|
||||
ids := []mastodon.ID{}
|
||||
for _, f := range files {
|
||||
if f != "" {
|
||||
id := uploadFile(f).ID
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (d *data) uploadPics(from, to int) []mastodon.ID {
|
||||
pics := goutil.StrSlice(d.pics, from, to)
|
||||
return uploadAll(pics)
|
||||
}
|
||||
|
||||
func (d *data) uploadGif(i int) []mastodon.ID {
|
||||
gif := d.getGif(i)
|
||||
return uploadAll([]string{gif})
|
||||
}
|
||||
|
||||
func (d *data) uploadVideo(i int) []mastodon.ID {
|
||||
vid := d.getVideo(i)
|
||||
return uploadAll([]string{vid})
|
||||
}
|
||||
|
||||
func setStatus(msg string, mediaIDs []mastodon.ID, previous *mastodon.Status) *mastodon.Status {
|
||||
toot := &mastodon.Toot{
|
||||
Status: msg,
|
||||
MediaIDs: mediaIDs,
|
||||
}
|
||||
if previous != nil {
|
||||
toot.InReplyToID = previous.ID
|
||||
}
|
||||
status, err := mastodon.PostStatus(toot)
|
||||
optLogFatal("setStatus", err)
|
||||
return status
|
||||
}
|
||||
|
||||
func (d *data) push(previous *mastodon.Status) []*mastodon.Status {
|
||||
empty := false
|
||||
i, g, v := 0, 0, 0
|
||||
statuses := []*mastodon.Status{}
|
||||
if d == nil {
|
||||
return statuses
|
||||
}
|
||||
for !empty {
|
||||
empty = true
|
||||
status := d.getStatus(i)
|
||||
mediaIDs := []mastodon.ID{}
|
||||
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 := setStatus(status, mediaIDs, previous)
|
||||
statuses = append(statuses, t)
|
||||
previous = t
|
||||
i++
|
||||
}
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
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 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: swill 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 updateStatus(args []string) []*mastodon.Status {
|
||||
d := splitArguments(args)
|
||||
return d.push(nil)
|
||||
}
|
||||
|
||||
func PrintStatuses(statuses []*mastodon.Status) {
|
||||
if formatTemplate != nil {
|
||||
optLogFatal("printStatuses", formatTemplate.Execute(os.Stdout, statuses))
|
||||
} else {
|
||||
for _, status := range statuses {
|
||||
fmt.Println(status.Account.Username, status.CreatedAt)
|
||||
fmt.Println(status.Content)
|
||||
fmt.Println("----------")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func status(args []string) error {
|
||||
checkUsage(args, 1, -1, "status STATUS [FILE1 FILE2 ...]")
|
||||
statuses := updateStatus(args)
|
||||
PrintStatuses(statuses)
|
||||
return nil
|
||||
}
|
||||
|
||||
func userTimeline(args []string) error {
|
||||
checkUsage(args, 1, 1, "timeline USER")
|
||||
user := args[0]
|
||||
account, err := mastodon.LookupAccount(user)
|
||||
optLogFatal("userTimeline - lookup account", err)
|
||||
statuses, err := account.GetStatuses(nil)
|
||||
optLogFatal("userTimeline - get statuses", err)
|
||||
PrintStatuses(statuses)
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintln(os.Stderr, r)
|
||||
os.Exit(-1)
|
||||
}
|
||||
}()
|
||||
initializeMastodon()
|
||||
commands := []goutil.Command{
|
||||
goutil.NewCommandWithFlags("status", wrapCommand(status), "post a status with message and/or media"),
|
||||
goutil.NewCommandWithFlags("timeline", wrapCommand(userTimeline), "get timeline of a specific user"),
|
||||
}
|
||||
_ = goutil.Execute(commands)
|
||||
}
|
Loading…
Reference in New Issue
Block a user