๐Ÿ 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.

Clean-up

-370
-140
cmd/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/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/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/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 - }