๐Ÿ Tiny CLI to post simultaneously to Mastodon and Bluesky
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix binary name

+371 -1
+1 -1
.gitignore
··· 1 1 .env 2 - not-my-ex 2 + ./not-my-ex
+140
cmd/not-my-ex/cmd.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "os" 8 + "strings" 9 + "time" 10 + 11 + "github.com/spf13/cobra" 12 + "golang.org/x/sync/errgroup" 13 + "tangled.org/cuducos.me/not-my-ex/auth" 14 + "tangled.org/cuducos.me/not-my-ex/post" 15 + ) 16 + 17 + var ( 18 + images []string 19 + altTexts []string 20 + lang string 21 + yesToAll bool 22 + skipBluesky bool 23 + skipMastodon bool 24 + editor = os.Getenv("EDITOR") 25 + ) 26 + 27 + var cli = &cobra.Command{ 28 + Use: "not-my-ex", 29 + Short: "Tiny CLI to post simultaneously to Mastodon and Bluesky", 30 + } 31 + 32 + var postCmd = &cobra.Command{ 33 + Use: "post", 34 + Short: "Post content.", 35 + Long: func() string { 36 + if editor == "" { 37 + return "Post content. The text to post can be passed as an optional argument or the path to a text file." 38 + } 39 + return fmt.Sprintf("Post content. The text to post can be passed as an optional argument or the path to a text file; alternatively, opens %s for typing the post content.", editor) 40 + }(), 41 + Args: cobra.MaximumNArgs(1), 42 + RunE: func(cmd *cobra.Command, args []string) error { 43 + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) 44 + defer cancel() 45 + 46 + cfg, err := NewConfig(ctx, skipBluesky, skipMastodon) 47 + if err != nil { 48 + return err 49 + } 50 + 51 + txt, err := postContent(args) 52 + if err != nil { 53 + return err 54 + } 55 + if txt == "" && len(images) == 0 { 56 + return errors.New("no content to post") 57 + } 58 + 59 + if strings.TrimSpace(lang) == "" && cfg.Language != nil && *cfg.Language != "" { 60 + lang = *cfg.Language 61 + } 62 + 63 + media, err := post.NewMedias(images, altTexts, cfg.Limit.Image, !yesToAll) 64 + if err != nil { 65 + return err 66 + } 67 + 68 + p, err := post.NewPost(txt, media, lang, cfg.Limit.Text, !yesToAll) 69 + if err != nil { 70 + return err 71 + } 72 + 73 + var g errgroup.Group 74 + for _, c := range cfg.Clients { 75 + g.Go(func() error { 76 + url, err := c.Post(ctx, p) 77 + if err != nil { 78 + return fmt.Errorf("failed to post to %s: %w", strings.ToLower(c.Name()), err) 79 + } 80 + 81 + fmt.Printf("%s %s => %s\n", c.Emoji(), c.Name(), url) 82 + return nil 83 + }) 84 + } 85 + if err := g.Wait(); err != nil { 86 + return err 87 + } 88 + return nil 89 + }, 90 + } 91 + 92 + var cleanCmd = &cobra.Command{ 93 + Use: "clean", 94 + Short: "Delete local authentication credentials and language preferences", 95 + RunE: func(cmd *cobra.Command, args []string) error { 96 + ok, err := auth.Clean() 97 + if err != nil { 98 + return err 99 + } 100 + if ok { 101 + fmt.Println("Credentials deleted.") 102 + } else { 103 + fmt.Println("Nothing to delete.") 104 + } 105 + return nil 106 + }, 107 + } 108 + 109 + var configCmd = &cobra.Command{ 110 + Use: "config", 111 + Short: "Prompt to save authentication credentials and language preferences", 112 + RunE: func(cmd *cobra.Command, args []string) error { 113 + pth, _, err := auth.Path() 114 + if err != nil { 115 + return err 116 + } 117 + if err := auth.Create(pth); err != nil { 118 + return err 119 + } 120 + fmt.Printf("Encrypted credentials saved at %s.\n", pth) 121 + return nil 122 + }, 123 + } 124 + 125 + func init() { 126 + postCmd.Flags().StringArrayVarP(&images, "images", "i", nil, "one to four images to post") 127 + postCmd.Flags().StringArrayVarP(&altTexts, "alt-texts", "a", nil, "one to four alt text for the images") 128 + postCmd.Flags().StringVarP(&lang, "lang", "l", "", "post language (2-letter ISO 639-1 code)") 129 + postCmd.Flags().BoolVarP(&yesToAll, "yes-to-all", "y", false, "Do not ask for alt text for images and/or post language confirmation") 130 + postCmd.Flags().BoolVar(&skipBluesky, "skip-bluesky", false, "Skip posting to Bluesky") 131 + postCmd.Flags().BoolVar(&skipMastodon, "skip-mastodon", false, "Skip posting to Mastodon") 132 + cli.AddCommand(cleanCmd, configCmd, postCmd) 133 + } 134 + 135 + func main() { 136 + if err := cli.Execute(); err != nil { 137 + fmt.Fprintln(os.Stderr, err) 138 + os.Exit(1) 139 + } 140 + }
+83
cmd/not-my-ex/config.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "slices" 6 + 7 + "golang.org/x/sync/errgroup" 8 + "tangled.org/cuducos.me/not-my-ex/auth" 9 + "tangled.org/cuducos.me/not-my-ex/client" 10 + ) 11 + 12 + type Config struct { 13 + Clients []client.Client 14 + Limit client.Limit 15 + Language *string 16 + } 17 + 18 + func NewConfig(ctx context.Context, skipBluesky, skipMastodon bool) (*Config, error) { 19 + auth, err := auth.Load(auth.Path) 20 + if err != nil { 21 + return nil, err 22 + } 23 + 24 + cfg := Config{Clients: make([]client.Client, 0, 2), Language: auth.Language} 25 + ch := make(chan client.Client) 26 + done := make(chan struct{}) 27 + go func() { 28 + for c := range ch { 29 + cfg.Clients = append(cfg.Clients, c) 30 + } 31 + done <- struct{}{} 32 + }() 33 + 34 + var g errgroup.Group 35 + if auth.Bluesky != nil && !skipBluesky { 36 + g.Go(func() error { 37 + c, err := client.NewBluesky(ctx, auth.Bluesky) 38 + if err != nil { 39 + return err 40 + } 41 + ch <- c 42 + return nil 43 + }) 44 + } 45 + 46 + if auth.Mastodon != nil && !skipMastodon { 47 + g.Go(func() error { 48 + c, err := client.NewMastodon(ctx, auth.Mastodon) 49 + if err != nil { 50 + return err 51 + } 52 + ch <- c 53 + return nil 54 + }) 55 + } 56 + 57 + if err := g.Wait(); err != nil { 58 + return nil, err 59 + } 60 + close(ch) 61 + <-done 62 + 63 + txts := make([]int, 0, 2) 64 + imgs := make([]int64, 0, 2) 65 + for _, c := range cfg.Clients { 66 + l := c.Limit() 67 + if l.Text > 0 { 68 + txts = append(txts, l.Text) 69 + } 70 + if l.Image > 0 { 71 + imgs = append(imgs, l.Image) 72 + } 73 + } 74 + 75 + if len(txts) > 0 { 76 + cfg.Limit.Text = slices.Min(txts) 77 + } 78 + if len(imgs) > 0 { 79 + cfg.Limit.Image = slices.Min(imgs) 80 + } 81 + 82 + return &cfg, nil 83 + }
+84
cmd/not-my-ex/post.go
··· 1 + package main 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "io" 7 + "log/slog" 8 + "os" 9 + "os/exec" 10 + "path/filepath" 11 + ) 12 + 13 + func postContentFromFile(pth string) (string, error) { 14 + f, err := os.Open(pth) 15 + if err != nil { 16 + return "", fmt.Errorf("could not open post content from %s: %w", pth, err) 17 + } 18 + defer func() { 19 + if err := f.Close(); err != nil { 20 + slog.Warn("could not properly close file", "path", pth, "error", err) 21 + } 22 + }() 23 + b, err := io.ReadAll(f) 24 + if err != nil { 25 + return "", fmt.Errorf("could not read post content from %s: %w", pth, err) 26 + } 27 + return string(b), nil 28 + } 29 + 30 + func postContentFromArgs(args []string) (string, error) { 31 + if len(args) != 1 { 32 + return "", nil 33 + } 34 + txt, err := postContentFromFile(args[0]) 35 + if err != nil { 36 + if errors.Is(err, os.ErrNotExist) { 37 + return args[0], nil 38 + } 39 + return "", err 40 + } 41 + return txt, nil 42 + } 43 + 44 + func postContentFromEditor() (string, error) { 45 + dir, err := os.MkdirTemp("", "not-my-ex-") 46 + if err != nil { 47 + return "", fmt.Errorf("could not create temporary file for post contents: %w", err) 48 + } 49 + defer func() { 50 + if err := os.RemoveAll(dir); err != nil { 51 + slog.Warn("could not remove temporary directory", "path", dir, "error", err) 52 + } 53 + }() 54 + pth := filepath.Join(dir, "post") 55 + cmd := exec.Command(editor, pth) 56 + cmd.Stderr = os.Stderr 57 + cmd.Stdin = os.Stdin 58 + cmd.Stdout = os.Stdout 59 + if err := cmd.Run(); err != nil { 60 + return "", fmt.Errorf("could not use %s to write post: %w", editor, err) 61 + } 62 + txt, err := postContentFromFile(pth) 63 + if err != nil && !os.IsNotExist(err) { 64 + return "", err 65 + } 66 + return txt, nil 67 + } 68 + 69 + func postContent(args []string) (txt string, err error) { 70 + txt, err = postContentFromArgs(args) 71 + if err != nil { 72 + return "", err 73 + } 74 + if txt != "" { 75 + return txt, nil 76 + } 77 + if editor != "" { 78 + txt, err = postContentFromEditor() 79 + if err != nil { 80 + return "", err 81 + } 82 + } 83 + return txt, nil 84 + }
+63
cmd/not-my-ex/post_test.go
··· 1 + package main 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + ) 8 + 9 + func TestPostContentFromArgsLiteralText(t *testing.T) { 10 + t.Parallel() 11 + got, err := postContentFromArgs([]string{"hello world"}) 12 + if err != nil { 13 + t.Fatalf("expected no error, got %s", err) 14 + } 15 + if got != "hello world" { 16 + t.Errorf("expected content to be %q, got %q", "hello world", got) 17 + } 18 + } 19 + 20 + func TestPostContentFromArgsFile(t *testing.T) { 21 + t.Parallel() 22 + f, err := os.CreateTemp(t.TempDir(), "post-*.txt") 23 + if err != nil { 24 + t.Fatalf("expected no error creating temp file, got %s", err) 25 + } 26 + if _, err := f.WriteString("from file"); err != nil { 27 + t.Fatalf("expected no error writing temp file, got %s", err) 28 + } 29 + if err := f.Close(); err != nil { 30 + t.Fatalf("error closing temp file: %v", err) 31 + } 32 + 33 + got, err := postContentFromArgs([]string{f.Name()}) 34 + if err != nil { 35 + t.Fatalf("expected no error, got %s", err) 36 + } 37 + if got != "from file" { 38 + t.Errorf("expected content to be %q, got %q", "from file", got) 39 + } 40 + } 41 + 42 + func TestPostContentFromArgsNoArgs(t *testing.T) { 43 + t.Parallel() 44 + got, err := postContentFromArgs([]string{}) 45 + if err != nil { 46 + t.Fatalf("expected no error, got %s", err) 47 + } 48 + if got != "" { 49 + t.Errorf("expected empty content, got %q", got) 50 + } 51 + } 52 + 53 + func TestPostContentFromFileNotExistTreatedAsText(t *testing.T) { 54 + t.Parallel() 55 + pth := filepath.Join(t.TempDir(), "does-not-exist.txt") 56 + got, err := postContentFromArgs([]string{pth}) 57 + if err != nil { 58 + t.Fatalf("expected no error, got %s", err) 59 + } 60 + if got != pth { 61 + t.Errorf("expected content to be the path %q, got %q", pth, got) 62 + } 63 + }