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