swill/swill.go

358 lines
8.1 KiB
Go

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)
}