348 lines
7.8 KiB
Go
348 lines
7.8 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"
|
|
)
|
|
|
|
const (
|
|
CHARACTER_LIMIT = 500
|
|
MAX_BYTES = 40 * 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(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 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
|
|
}
|
|
|
|
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", 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)
|
|
}
|