package main import ( "errors" "flag" "fmt" "io/ioutil" "os" "path/filepath" "strconv" "strings" "text/template" "git.gutmet.org/go-mastodon.git" goutil "git.gutmet.org/goutil.git/misc" strip "github.com/grokify/html-strip-tags-go" ) const ( CHARACTER_LIMIT = 500 MAX_BYTES = 40 * 1024 * 1024 ) var formatTemplate *template.Template var stripHTML bool 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(f statusFlags, msg string, mediaIDs []mastodon.ID, previous *mastodon.Status) *mastodon.Status { toot := &mastodon.Toot{ Status: msg, MediaIDs: mediaIDs, } if previous != nil { toot.InReplyToID = previous.ID } if f.visibility != "" { toot.Visibility = f.visibility } status, err := mastodon.PostStatus(toot) optLogFatal("setStatus", err) return status } func (d *data) push(f statusFlags, 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(f, 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(f statusFlags, args []string) []*mastodon.Status { d := splitArguments(args) return d.push(f, nil) } func PrintStatuses(statuses []*mastodon.Status) { if stripHTML { for _, status := range statuses { status.Content = strip.StripTags(status.Content) } } 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("----------") } } } type statusFlags struct { visibility string } func status(f statusFlags, args []string) error { checkUsage(args, 1, -1, "status STATUS [FILE1 FILE2 ...]") statuses := updateStatus(f, args) PrintStatuses(statuses) return nil } func statusCommand() (goutil.CommandFlagsInit, goutil.CommandFunc) { f := statusFlags{} flagsInit := func(s *flag.FlagSet) { s.StringVar(&f.visibility, "visibility", "", "public, unlisted, private or direct") } return flagsInit, func(args []string) error { return status(f, args) } } type timelineFlags struct { ignoreBoosts bool } func userTimeline(f timelineFlags, 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(f.ignoreBoosts, nil) optLogFatal("userTimeline - get statuses", err) PrintStatuses(statuses) return nil } func userTimelineCommand() (goutil.CommandFlagsInit, goutil.CommandFunc) { f := timelineFlags{} flagsInit := func(s *flag.FlagSet) { s.BoolVar(&f.ignoreBoosts, "ignore-boosts", true, "") } return flagsInit, func(args []string) error { return userTimeline(f, args) } } type generalFlags struct { templateFile string stripHTML bool } 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") s.BoolVar(&f.stripHTML, "strip-html", true, "strip HTML tags from statuses") } 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)) } stripHTML = f.stripHTML 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", wrapCommandFl(statusCommand), "post a status with message and/or media"), goutil.NewCommandWithFlags("timeline", wrapCommandFl(userTimelineCommand), "get latest timeline of a specific user"), } _ = goutil.Execute(commands) }