shithub: hell

Download patch

ref: e3419ac8376d16abdd502810bc18b696ec2340ed
author: penny <penny@pennys-iMac.lan>
date: Tue Jul 29 09:16:17 EDT 2025

Initial commit

--- /dev/null
+++ b/commands.go
@@ -1,0 +1,47 @@
+package main
+
+import (
+	"strings"
+)
+
+var commands = []string{"examine", "reply", "like", "thread", "open", "preview", "dm", "rt", "parent", "children", "thread"}
+
+func processInput(input string) (command string, arguments string) {
+
+	if input == "" {
+		command = ""
+		arguments = ""
+		return
+	}
+
+	if input[0] != '/' {
+		command = ""
+		arguments = input
+		return
+	}
+
+	input = input[1:]
+
+	command = ""
+	arguments = ""
+	commandInput := ""
+
+	firstSpaceIdx := strings.Index(input, " ")
+
+	if firstSpaceIdx == -1 {
+		commandInput = input
+		arguments = ""
+	} else {
+		commandInput = input[:firstSpaceIdx]
+		arguments = strings.TrimSpace(input[firstSpaceIdx+1:])
+	}
+
+	for _, cmd := range commands {
+		if strings.HasPrefix(cmd, commandInput) {
+			if command == "" || len(cmd) < len(command) {
+				command = cmd
+			}
+		}
+	}
+	return
+}
--- /dev/null
+++ b/config.go
@@ -1,0 +1,54 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"os"
+)
+
+const configFileName = "config.json"
+
+type account struct {
+	MASTODON_CLIENT_ID     string `json:"MASTODON_CLIENT_ID"`
+	MASTODON_CLIENT_SECRET string `json:"MASTODON_CLIENT_SECRET"`
+	MASTODON_ACCESS_TOKEN  string `json:"MASTODON_ACCESS_TOKEN"`
+	URL                    string `json:"URL"`
+}
+
+func loadConfig() (*account, error) {
+	config := &account{}
+
+	// Check if the config file exists
+	if _, err := os.Stat(configFileName); os.IsNotExist(err) {
+		// no config, create account
+		client := ConfigureClient()
+		config.URL = "https://eldritch.cafe"
+		config.MASTODON_CLIENT_ID = client.Config.ClientID
+		config.MASTODON_CLIENT_SECRET = client.Config.ClientSecret
+		config.MASTODON_ACCESS_TOKEN = client.Config.AccessToken
+
+		data, err := json.MarshalIndent(config, "", "  ")
+		if err != nil {
+			return nil, fmt.Errorf("error marshalling config: %w", err)
+		}
+
+		err = ioutil.WriteFile(configFileName, data, 0644)
+		if err != nil {
+			return nil, fmt.Errorf("error writing config file: %w", err)
+		}
+		return config, nil
+	}
+
+	// File exists, load it
+	data, err := ioutil.ReadFile(configFileName)
+	if err != nil {
+		return nil, fmt.Errorf("error reading config file: %w", err)
+	}
+
+	err = json.Unmarshal(data, config)
+	if err != nil {
+		return nil, fmt.Errorf("error unmarshalling config: %w", err)
+	}
+	return config, nil
+}
--- /dev/null
+++ b/main.go
@@ -1,0 +1,159 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"github.com/chzyer/readline"
+	"github.com/mattn/go-mastodon"
+	"io"
+	"os"
+	"os/exec"
+	"strings"
+)
+
+func main() {
+	account, err := loadConfig()
+	if err != nil {
+		fmt.Printf("Error: %v\n", err)
+		return
+	}
+
+	client := initClient(account)
+
+	rl, _ := readline.New("> ")
+
+	//Horrible io pipe hack
+	//Replaces system stdout with the readline one
+	r, w, _ := os.Pipe()
+	os.Stdout = w
+
+	go func() {
+		io.Copy(rl.Stdout(), r)
+	}()
+
+	homeMap := make(map[string]*mastodon.Status)
+	debugMap := make(map[string]interface{})
+	postref := "a"
+
+	go StreamHomeTimeline(client, rl, homeMap)
+
+	for {
+		line, err := rl.Readline()
+
+		if err != nil {
+			fmt.Println("Error:", err)
+			return
+		}
+		command, arguments := processInput(line)
+
+		//if we didn't get a slash command then the user is just posting
+		if command == "" && arguments != "" {
+			postStatus(fmt.Sprintf(line), account, *client, "public")
+			continue
+		}
+		if arguments == "" {
+			fmt.Printf("%v requires an argument\n", command)
+			continue
+		}
+		index, content, _ := strings.Cut(arguments, " ")
+		postItem, postOK := homeMap[index]
+		debugItem, debugOK := debugMap[index]
+
+		//Commands that don't take an index
+		switch command {
+		case "dm":
+			postStatus(arguments, account, *client, "direct")
+			continue
+		}
+
+		if !postOK && !debugOK {
+			fmt.Printf("\"%v\" not a valid index\n", index)
+			continue
+		}
+
+		//Commands that accept debug indexes
+		switch command {
+		case "examine":
+			if postOK {
+				PrintObjectProperties(postItem, debugMap)
+				continue
+			}
+			PrintObjectProperties(debugItem, debugMap)
+			continue
+		}
+
+		//if a user passes a debug index to a status command
+		if !postOK {
+			fmt.Printf("\"%v\" not a valid post index\n", index)
+			continue
+		}
+		//Commands that only accept status indexes
+		switch command {
+		case "like":
+			_, err := client.Favourite(context.Background(), postItem.ID)
+			if err != nil {
+				printMastodonErr(err)
+			} else {
+				fmt.Printf("Favorited: %v\n", formatFavorite(postItem, arguments))
+			}
+		case "open":
+			url := fmt.Sprintf("%v/statuses/%v", client.Config.Server, postItem.ID)
+			cmd := exec.Command("open", url, "-a", "Eldritch Café")
+			cmd.Run()
+		case "preview":
+			url := fmt.Sprintf("%v/statuses/%v", client.Config.Server, postItem.ID)
+			cmd := exec.Command("open", url)
+			cmd.Run()
+		case "reply":
+			
+			postReply("@"+getUserString(postItem)+" "+content, account, *client, postItem.Visibility, postItem.ID)
+		case "parent":
+			if postItem.InReplyToID == nil {
+				fmt.Printf("%v doesn't have a parent\n", index)
+				continue
+			}
+			parentStatus, _ := client.GetStatus(context.Background(), mastodon.ID(postItem.InReplyToID.(string)))
+			saveWorkRef(homeMap, parentStatus, postref)
+			printPost("?"+postref, parentStatus)
+			postref = IncrementString(postref)
+		case "children":
+			context, err := client.GetStatusContext(context.Background(), postItem.ID)
+			if (err != nil) {
+				fmt.Println(err)
+				continue
+			}
+			if (len(context.Descendants) == 0) {
+				fmt.Printf("\"%v\" has no children")
+			}
+			for post := range context.Descendants {
+				saveWorkRef(homeMap, context.Descendants[post], postref)
+				printPost("?"+postref, context.Descendants[post])
+				postref = IncrementString(postref)
+			}
+		case "thread":
+			context, err := client.GetStatusContext(context.Background(), postItem.ID)
+			if (err != nil) {
+				fmt.Println(err)
+				continue
+			}
+		
+			for post := range context.Ancestors {
+				saveWorkRef(homeMap, context.Ancestors[post], postref)
+				printPost("?"+postref, context.Ancestors[post])
+				postref = IncrementString(postref)
+			}
+			
+			printPost(index, postItem)
+			
+			for post := range context.Descendants {
+				saveWorkRef(homeMap, context.Descendants[post], postref)
+				printPost("?"+postref, context.Descendants[post])
+				postref = IncrementString(postref)
+			}
+
+			
+		default:
+			fmt.Printf("Unimplemented command \"%v\"?\"\n", command)
+		}
+	}
+}
--- /dev/null
+++ b/mastodon.go
@@ -1,0 +1,262 @@
+package main
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"github.com/chzyer/readline"
+	"github.com/k3a/html2text"
+	"github.com/mattn/go-mastodon"
+	"golang.org/x/net/html"
+	"log"
+	"net/url"
+	"strings"
+)
+
+func ConfigureClient() *mastodon.Client {
+	appConfig := &mastodon.AppConfig{
+		Server:       "https://eldritch.cafe",
+		ClientName:   "hellclient",
+		Scopes:       "read write follow",
+		Website:      "https://github.com/mattn/go-mastodon",
+		RedirectURIs: "urn:ietf:wg:oauth:2.0:oob",
+	}
+
+	app, err := mastodon.RegisterApp(context.Background(), appConfig)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	// Have the user manually get the token and send it back to us
+	u, err := url.Parse(app.AuthURI)
+	if err != nil {
+		log.Fatal(err)
+	}
+	fmt.Printf("Open your browser to \n%s\n and copy/paste the given authroization code\n", u)
+	var userAuthorizationCode string
+	fmt.Print("Paste the code here:")
+	fmt.Scanln(&userAuthorizationCode)
+
+	config := &mastodon.Config{
+		Server:       "https://eldritch.cafe",
+		ClientID:     app.ClientID,
+		ClientSecret: app.ClientSecret,
+	}
+
+	// Create the client
+	c := mastodon.NewClient(config)
+
+	// Exchange the User authentication code with an access token, that can be used to interact with the api on behalf of the user
+	err = c.GetUserAccessToken(context.Background(), userAuthorizationCode, app.RedirectURI)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	return c
+}
+
+func initClient(account *account) *mastodon.Client {
+
+	clientID := account.MASTODON_CLIENT_ID
+	clientSecret := account.MASTODON_CLIENT_ID
+	accessToken := account.MASTODON_ACCESS_TOKEN
+	url := account.URL
+
+	config := &mastodon.Config{
+		Server:       url,
+		ClientID:     clientID,
+		ClientSecret: clientSecret,
+		AccessToken:  accessToken,
+	}
+
+	c := mastodon.NewClient(config)
+	return c
+}
+
+func renderStatus(content string) string {
+	doc, err := html.Parse(strings.NewReader(content))
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	for node := range doc.Descendants() {
+		if node.Data == "a" {
+			for attr := range node.Attr {
+				if node.Attr[attr].Key == "class" && strings.Contains(node.Attr[attr].Val, "mention") {
+					node.Data = "span"
+				}
+			}
+		}
+	}
+
+	//Rip off the HTML body the parser made for us
+	for node := range doc.Descendants() {
+		if node.Data == "body" {
+			node.Type = html.DocumentNode
+			doc = node
+		}
+	}
+
+	var rendered bytes.Buffer
+	err = html.Render(&rendered, doc)
+
+	if err != nil {
+		log.Fatal(err)
+	}
+	return rendered.String()
+}
+
+func postReply(posttext string, account *account, client mastodon.Client, visibility string, replyto mastodon.ID) {
+	toot := mastodon.Toot{
+		Status:      posttext,
+		Visibility:  visibility,
+		InReplyToID: replyto,
+	}
+	postStatusDetailed(posttext, account, client, visibility, toot)
+}
+
+func postStatus(posttext string, account *account, client mastodon.Client, visibility string) {
+	// Post a toot
+	toot := mastodon.Toot{
+		Status:     posttext,
+		Visibility: visibility,
+	}
+	postStatusDetailed(posttext, account, client, visibility, toot)
+}
+
+func postStatusDetailed(posttext string, account *account, client mastodon.Client, visibility string, toot mastodon.Toot) {
+	_, err := client.PostStatus(context.Background(), &toot)
+
+	if err != nil {
+		printMastodonErr(err)
+		return
+	}
+}
+
+func getUserString(post *mastodon.Status) string {
+	return post.Account.Acct
+}
+
+// Spaces before prefixes....
+func formatReblog(post *mastodon.Status, index string) string {
+	reblogString := fmt.Sprintf(" <%v> Reblogged", post.Account.Username)
+	return formatStatusDetailed(post.Reblog, index, reblogString)
+}
+
+func formatFavorite(post *mastodon.Status, index string) string {
+	return fmt.Sprintf("\rFavorited: %v <%v> %v", index, post.Account.Username, html2text.HTML2Text(post.Content))
+}
+
+func formatStatus(post *mastodon.Status, index string) string {
+	return formatStatusDetailed(post, index, " ")
+}
+
+func formatStatusDetailed(post *mastodon.Status, index string, prefix string) string {
+	renderedPost := renderStatus(post.Content)
+	return fmt.Sprintf("%v%v <%v> %v", index, prefix, post.Account.Username, html2text.HTML2Text(renderedPost))
+}
+
+func formatEdit(post *mastodon.Status, index string) string {
+	return fmt.Sprintf("\r%v <%v> EDITED: %v", index, post.Account.Username, html2text.HTML2Text(post.Content))
+}
+
+func printMastodonErr(err error) {
+	fmt.Printf("\r%w\n", err)
+}
+
+func printPost(postref string, post *mastodon.Status) *mastodon.Status {
+	return printPostDetailed(postref, post, "")
+}
+
+func printPostDetailed(postref string, post *mastodon.Status, prefix string) *mastodon.Status {
+	post, plaintext := RenderPostPlaintext(post, postref, prefix)
+	fmt.Println(plaintext)
+	return post
+}
+
+func RenderPostPlaintext(post *mastodon.Status, postref string, prefix string) (selectedPost *mastodon.Status, plaintext string) {
+	poststring := ""
+	postfix := ""
+	var media []mastodon.Attachment
+	if post.Reblog != nil {
+		poststring = formatReblog(post, postref)
+		selectedPost = post.Reblog
+		media = post.Reblog.MediaAttachments
+	} else {
+		poststring = formatStatusDetailed(post, postref, prefix)
+		selectedPost = post
+		media = post.MediaAttachments
+	}
+
+	for _, _ = range media {
+		postfix = postfix + "🖼️"
+	}
+	plaintext = fmt.Sprintf("%v %v", poststring, postfix)
+	return
+}
+
+func StreamHomeTimeline(client *mastodon.Client, rl *readline.Instance, postMap map[string]*mastodon.Status) {
+
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	eventCh, err := client.StreamingUser(ctx)
+	if err != nil {
+		log.Fatalf("Error starting user stream: %v", err)
+	}
+
+	initReferenceSystem()
+
+	postref := "a"
+	plaintext := ""
+	idmap := make(map[mastodon.ID]*mastodon.Status)
+
+	// Enter a loop to continuously listen for events from the event channel.
+	for {
+		select {
+		case event, ok := <-eventCh: // Read from the event channel, checking 'ok' for closure
+			if !ok {
+				// The channel was closed, which indicates the stream has ended.
+				fmt.Println("Stream closed.")
+				return // Exit the function
+			}
+
+			switch post := event.(type) {
+			case *mastodon.UpdateEvent:
+				post.Status = printPost(postref, post.Status)
+				saveRef(postMap, post.Status, postref)
+				idmap[post.Status.ID] = post.Status
+				postref = IncrementString(postref)
+
+			case *mastodon.UpdateEditEvent:
+				saveRef(postMap, post.Status, postref)
+				fmt.Println(formatEdit(post.Status, postref))
+				postref = IncrementString(postref)
+				idmap[post.Status.ID] = post.Status
+
+			case *mastodon.DeleteEvent:
+				deleted, ok := idmap[post.ID]
+				//didn't have this in the cache
+				if(!ok) {
+					fmt.Printf("Deleted: ID %v", post.ID)
+					continue
+				}
+				printPostDetailed("", deleted, "Deleted:")
+				continue
+
+			case *mastodon.NotificationEvent:
+				if post.Notification.Status == nil {
+					fmt.Printf("Notification [%v] from <%v>\n", post.Notification.Type, post.Notification.Account.Acct)
+					continue
+				}
+				_, plaintext = RenderPostPlaintext(post.Notification.Status, postref, "")
+				fmt.Printf("Notification [%v] from <%v>: %v\n", post.Notification.Type, post.Notification.Account.Acct, plaintext)
+				saveRef(postMap, post.Notification.Status, postref)
+				postref = IncrementString(postref)
+			default:
+				// Catch any other unexpected event types.
+				fmt.Printf("Unhandled event: %T\n", post)
+			}
+		}
+	}
+}
--- /dev/null
+++ b/references.go
@@ -1,0 +1,90 @@
+package main
+
+import (
+	"github.com/mattn/go-mastodon"
+	"strings"
+)
+
+var charSequence = []rune{'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'}
+
+// charToIndex maps each character in charSequence to its index for quick lookup.
+var charToIndex = make(map[rune]int)
+
+func initReferenceSystem() {
+	// Initialize the charToIndex map for efficient lookups.
+	for i, char := range charSequence {
+		charToIndex[char] = i
+	}
+}
+
+func saveDebugRef(debugMap map[string]interface{}, obj interface{}, index string) {
+	debugindex := "!" + index
+	(debugMap)[debugindex] = obj
+}
+
+func saveWorkRef(statusMap map[string]*mastodon.Status, post *mastodon.Status, index string) {
+	saveCustomStatusRef(statusMap, post, index, "?")
+}
+
+func saveCustomStatusRef(statusMap map[string]*mastodon.Status, post *mastodon.Status, index string, prefix string) {
+	index = prefix + index
+	(statusMap)[index] = post
+}
+
+func saveRef(statusMap map[string]*mastodon.Status, post *mastodon.Status, index string) {
+	(statusMap)[index] = post
+}
+
+func IncrementSequence(r rune) (rune, bool) {
+	idx, exists := charToIndex[r]
+	if !exists {
+		// If the character is not in our defined sequence, return it as is, no carry.
+		return r, false
+	}
+
+	// Handle specific rules first
+	if r == 'z' {
+		return '0', false // z to 0
+	}
+
+	if r == '9' {
+		return 'a', true
+	}
+
+	if idx < len(charSequence)-1 {
+		return charSequence[idx+1], false
+	}
+
+	return ' ', false
+}
+
+func IncrementString(s string) string {
+	runes := []rune(s)
+	n := len(runes)
+	carry := false
+
+	for i := n - 1; i >= 0; i-- {
+		currentRune := runes[i]
+		nextRune, currentCarry := IncrementSequence(currentRune)
+
+		runes[i] = nextRune
+		carry = currentCarry
+
+		if !carry {
+			// No carry, so we're done with the increment
+			return string(runes)
+		}
+		// If there's a carry, continue to the next character to the left
+	}
+
+	// If we've iterated through all characters and still have a carry,
+	// it means we need to expand the string (e.g., "9" -> "aa", "z9" -> "0a", "99" -> "aa")
+	if carry {
+		var sb strings.Builder
+		sb.WriteRune(charSequence[0]) // Prepend the first character of the sequence ('a')
+		sb.WriteString(string(runes))
+		return sb.String()
+	}
+
+	return string(runes)
+}
--- /dev/null
+++ b/reflect.go
@@ -1,0 +1,34 @@
+package main
+
+import (
+	"fmt"
+	"reflect"
+)
+
+func PrintObjectProperties(obj interface{}, debugMap map[string]interface{}) {
+	val := reflect.ValueOf(obj)
+
+	if val.Kind() == reflect.Ptr {
+		val = val.Elem()
+	}
+
+	if val.Kind() != reflect.Struct {
+		fmt.Printf("%v: %v\n", val.Kind(), obj)
+		return
+	}
+
+	typ := val.Type()
+
+	index := "a"
+	// Iterate over the fields of the struct.
+	for i := 0; i < val.NumField(); i++ {
+		fieldValue := val.Field(i)
+		fieldType := typ.Field(i)
+
+		if fieldType.IsExported() {
+			saveDebugRef(debugMap, fieldValue.Interface(), index)
+			fmt.Printf("!%s  %s: %v\n", index, fieldType.Name, fieldValue.Interface())
+			index = IncrementString(index)
+		}
+	}
+}
--