412 lines
8.0 KiB
Go
412 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/mattn/go-mastodon"
|
|
"github.com/mattn/go-tty"
|
|
"github.com/urfave/cli"
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
func readFile(filename string) ([]byte, error) {
|
|
if filename == "-" {
|
|
return ioutil.ReadAll(os.Stdin)
|
|
}
|
|
return ioutil.ReadFile(filename)
|
|
}
|
|
|
|
func textContent(s string) string {
|
|
doc, err := html.Parse(strings.NewReader(s))
|
|
if err != nil {
|
|
return s
|
|
}
|
|
var buf bytes.Buffer
|
|
|
|
var extractText func(node *html.Node, w *bytes.Buffer)
|
|
extractText = func(node *html.Node, w *bytes.Buffer) {
|
|
if node.Type == html.TextNode {
|
|
data := strings.Trim(node.Data, "\r\n")
|
|
if data != "" {
|
|
w.WriteString(data)
|
|
}
|
|
}
|
|
for c := node.FirstChild; c != nil; c = c.NextSibling {
|
|
extractText(c, w)
|
|
}
|
|
if node.Type == html.ElementNode {
|
|
name := strings.ToLower(node.Data)
|
|
if name == "br" {
|
|
w.WriteString("\n")
|
|
}
|
|
}
|
|
}
|
|
extractText(doc, &buf)
|
|
return buf.String()
|
|
}
|
|
|
|
var (
|
|
readUsername = func() (string, error) {
|
|
b, _, err := bufio.NewReader(os.Stdin).ReadLine()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
readPassword func() (string, error)
|
|
)
|
|
|
|
func prompt() (string, string, error) {
|
|
fmt.Print("E-Mail: ")
|
|
email, err := readUsername()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
fmt.Print("Password: ")
|
|
var password string
|
|
if readPassword == nil {
|
|
var t *tty.TTY
|
|
t, err = tty.Open()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer t.Close()
|
|
password, err = t.ReadPassword()
|
|
} else {
|
|
password, err = readPassword()
|
|
}
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return email, password, nil
|
|
}
|
|
|
|
func configFile(c *cli.Context) (string, error) {
|
|
dir := os.Getenv("HOME")
|
|
if runtime.GOOS == "windows" {
|
|
dir = os.Getenv("APPDATA")
|
|
if dir == "" {
|
|
dir = filepath.Join(os.Getenv("USERPROFILE"), "Application Data", "mstdn")
|
|
}
|
|
dir = filepath.Join(dir, "mstdn")
|
|
} else {
|
|
dir = filepath.Join(dir, ".config", "mstdn")
|
|
}
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return "", err
|
|
}
|
|
var file string
|
|
profile := c.String("profile")
|
|
if profile != "" {
|
|
file = filepath.Join(dir, "settings-"+profile+".json")
|
|
} else {
|
|
file = filepath.Join(dir, "settings.json")
|
|
}
|
|
return file, nil
|
|
}
|
|
|
|
func getConfig(c *cli.Context) (string, *mastodon.Config, error) {
|
|
file, err := configFile(c)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
b, err := ioutil.ReadFile(file)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return "", nil, err
|
|
}
|
|
config := &mastodon.Config{
|
|
Server: "https://mstdn.jp",
|
|
ClientID: "1e463436008428a60ed14ff1f7bc0b4d923e14fc4a6827fa99560b0c0222612f",
|
|
ClientSecret: "72b63de5bc11111a5aa1a7b690672d78ad6a207ce32e16ea26115048ec5d234d",
|
|
}
|
|
if err == nil {
|
|
err = json.Unmarshal(b, &config)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("could not unmarshal %v: %v", file, err)
|
|
}
|
|
}
|
|
return file, config, nil
|
|
}
|
|
|
|
func authenticate(client *mastodon.Client, config *mastodon.Config, file string) error {
|
|
email, password, err := prompt()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = client.Authenticate(context.Background(), email, password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b, err := json.MarshalIndent(config, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to store file: %v", err)
|
|
}
|
|
err = ioutil.WriteFile(file, b, 0700)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to store file: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func argstr(c *cli.Context) string {
|
|
a := []string{}
|
|
for i := 0; i < c.NArg(); i++ {
|
|
a = append(a, c.Args().Get(i))
|
|
}
|
|
return strings.Join(a, " ")
|
|
}
|
|
|
|
func fatalIf(err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
fmt.Fprintf(os.Stderr, "%s: %v\n", os.Args[0], err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
func makeApp() *cli.App {
|
|
app := cli.NewApp()
|
|
app.Name = "mstdn"
|
|
app.Usage = "mastodon client"
|
|
app.Version = "0.0.1"
|
|
app.Flags = []cli.Flag{
|
|
cli.StringFlag{
|
|
Name: "profile",
|
|
Usage: "profile name",
|
|
Value: "",
|
|
},
|
|
}
|
|
app.Commands = []cli.Command{
|
|
{
|
|
Name: "toot",
|
|
Usage: "post toot",
|
|
Flags: []cli.Flag{
|
|
cli.StringFlag{
|
|
Name: "ff",
|
|
Usage: "post utf-8 string from a file(\"-\" means STDIN)",
|
|
Value: "",
|
|
},
|
|
cli.StringFlag{
|
|
Name: "i",
|
|
Usage: "in-reply-to",
|
|
Value: "",
|
|
},
|
|
},
|
|
Action: cmdToot,
|
|
},
|
|
{
|
|
Name: "stream",
|
|
Usage: "stream statuses",
|
|
Flags: []cli.Flag{
|
|
cli.StringFlag{
|
|
Name: "type",
|
|
Usage: "stream type (public,public/local,user:NAME,hashtag:TAG)",
|
|
},
|
|
cli.BoolFlag{
|
|
Name: "json",
|
|
Usage: "output JSON",
|
|
},
|
|
cli.BoolFlag{
|
|
Name: "simplejson",
|
|
Usage: "output simple JSON",
|
|
},
|
|
cli.StringFlag{
|
|
Name: "template",
|
|
Usage: "output with tamplate format",
|
|
},
|
|
},
|
|
Action: cmdStream,
|
|
},
|
|
{
|
|
Name: "timeline",
|
|
Usage: "show timeline",
|
|
Action: cmdTimeline,
|
|
},
|
|
{
|
|
Name: "timeline-home",
|
|
Usage: "show timeline home",
|
|
Action: cmdTimelineHome,
|
|
},
|
|
{
|
|
Name: "timeline-local",
|
|
Usage: "show timeline local",
|
|
Action: cmdTimelineLocal,
|
|
},
|
|
{
|
|
Name: "timeline-public",
|
|
Usage: "show timeline public",
|
|
Action: cmdTimelinePublic,
|
|
},
|
|
{
|
|
Name: "timeline-direct",
|
|
Usage: "show timeline direct",
|
|
Action: cmdTimelineDirect,
|
|
},
|
|
{
|
|
Name: "notification",
|
|
Usage: "show notification",
|
|
Action: cmdNotification,
|
|
},
|
|
{
|
|
Name: "instance",
|
|
Usage: "show instance information",
|
|
Action: cmdInstance,
|
|
},
|
|
{
|
|
Name: "instance_activity",
|
|
Usage: "show instance activity information",
|
|
Action: cmdInstanceActivity,
|
|
},
|
|
{
|
|
Name: "instance_peers",
|
|
Usage: "show instance peers information",
|
|
Action: cmdInstancePeers,
|
|
},
|
|
{
|
|
Name: "account",
|
|
Usage: "show account information",
|
|
Action: cmdAccount,
|
|
},
|
|
{
|
|
Name: "search",
|
|
Usage: "search content",
|
|
Action: cmdSearch,
|
|
},
|
|
{
|
|
Name: "follow",
|
|
Usage: "follow account",
|
|
Action: cmdFollow,
|
|
},
|
|
{
|
|
Name: "followers",
|
|
Usage: "show followers",
|
|
Action: cmdFollowers,
|
|
},
|
|
{
|
|
Name: "upload",
|
|
Usage: "upload file",
|
|
Action: cmdUpload,
|
|
},
|
|
{
|
|
Name: "delete",
|
|
Usage: "delete status",
|
|
Action: cmdDelete,
|
|
},
|
|
{
|
|
Name: "init",
|
|
Usage: "initialize profile",
|
|
Action: func(c *cli.Context) error { return nil },
|
|
},
|
|
{
|
|
Name: "mikami",
|
|
Usage: "search mikami",
|
|
Action: cmdMikami,
|
|
},
|
|
{
|
|
Name: "xsearch",
|
|
Usage: "cross search",
|
|
Action: cmdXSearch,
|
|
},
|
|
}
|
|
app.Setup()
|
|
return app
|
|
}
|
|
|
|
type screen struct {
|
|
host string
|
|
}
|
|
|
|
func newScreen(config *mastodon.Config) *screen {
|
|
var host string
|
|
u, err := url.Parse(config.Server)
|
|
if err == nil {
|
|
host = u.Host
|
|
}
|
|
return &screen{host}
|
|
}
|
|
|
|
func (s *screen) acct(a string) string {
|
|
if !strings.Contains(a, "@") {
|
|
a += "@" + s.host
|
|
}
|
|
return a
|
|
}
|
|
|
|
func (s *screen) displayError(w io.Writer, e error) {
|
|
color.Set(color.FgYellow)
|
|
fmt.Fprintln(w, e.Error())
|
|
color.Set(color.Reset)
|
|
}
|
|
|
|
func (s *screen) displayStatus(w io.Writer, t *mastodon.Status) {
|
|
if t == nil {
|
|
return
|
|
}
|
|
if t.Reblog != nil {
|
|
color.Set(color.FgHiRed)
|
|
fmt.Fprint(w, s.acct(t.Account.Acct))
|
|
color.Set(color.Reset)
|
|
fmt.Fprint(w, " reblogged ")
|
|
color.Set(color.FgHiBlue)
|
|
fmt.Fprintln(w, s.acct(t.Reblog.Account.Acct))
|
|
fmt.Fprintln(w, textContent(t.Reblog.Content))
|
|
color.Set(color.Reset)
|
|
} else {
|
|
color.Set(color.FgHiRed)
|
|
fmt.Fprintln(w, s.acct(t.Account.Acct))
|
|
color.Set(color.Reset)
|
|
fmt.Fprintln(w, textContent(t.Content))
|
|
}
|
|
}
|
|
|
|
func run() int {
|
|
app := makeApp()
|
|
|
|
app.Before = func(c *cli.Context) error {
|
|
if c.Args().Get(0) == "init" {
|
|
file, err := configFile(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
os.Remove(file)
|
|
}
|
|
|
|
file, config, err := getConfig(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
client := mastodon.NewClient(config)
|
|
client.UserAgent = "mstdn"
|
|
app.Metadata = map[string]interface{}{
|
|
"client": client,
|
|
"config": config,
|
|
"xsearch_url": "http://mastodonsearch.jp/cross/",
|
|
}
|
|
if config.AccessToken == "" {
|
|
return authenticate(client, config, file)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
fatalIf(app.Run(os.Args))
|
|
return 0
|
|
}
|
|
|
|
func main() {
|
|
os.Exit(run())
|
|
}
|