This commit is contained in:
gutmet 2022-11-20 22:40:43 +01:00
commit e00947596a
4 changed files with 424 additions and 0 deletions

90
credentials.go Normal file
View 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
View 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
View 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
View 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)
}