commit e00947596a14abef6d02bec2fbd48297906767b1 Author: gutmet Date: Sun Nov 20 22:40:43 2022 +0100 initial diff --git a/credentials.go b/credentials.go new file mode 100644 index 0000000..67553b5 --- /dev/null +++ b/credentials.go @@ -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, + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7fee988 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..70f7a20 --- /dev/null +++ b/go.sum @@ -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= diff --git a/swill.go b/swill.go new file mode 100644 index 0000000..a9e2500 --- /dev/null +++ b/swill.go @@ -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) +}