main.go

  1package main
  2
  3import (
  4	"bytes"
  5	"encoding/json"
  6	"fmt"
  7	"io"
  8	"log"
  9	"net/http"
 10	"net/url"
 11	"os"
 12	"strings"
 13	"text/template"
 14
 15	"github.com/mmcdole/gofeed"
 16	flag "github.com/spf13/pflag"
 17	"golang.org/x/net/html"
 18)
 19
 20var (
 21	flagFeed         *string = flag.StringP("feed", "f", "", "RSS/Atom feed URL (required)")
 22	flagTemplate     *string = flag.StringP("template", "t", "", "Template file to use, (required)")
 23	flagVisibility   *string = flag.StringP("visibility", "v", "", "Visibility of the post, public, unlisted, or private (required)")
 24	flagLanguage     *string = flag.StringP("language", "l", "en", "Language of the post, ISO 639-1 code (optional)")
 25	flagSpoiler      *string = flag.StringP("spoiler", "s", "", "Spoiler text for the post (optional)")
 26	flagSensitive    *bool   = flag.BoolP("sensitive", "S", false, "Mark the post as sensitive (optional)")
 27	flagAuthenticate *bool   = flag.BoolP("authenticate", "a", false, "Authenticate with the server")
 28	flagInstance     *string = flag.StringP("instance", "i", "", "Instance to post to (required)")
 29	flagTagsSkip     *string = flag.StringP("tagsskip", "T", "", "Particular tags to skip (optional)")
 30)
 31
 32type post struct {
 33	Title       string
 34	Description string `json:"status"`
 35	Tags        string
 36	Image       string `json:"media_attachments"`
 37	Link        string
 38	Visibility  string `json:"visibility"`
 39	Sensitive   bool   `json:"sensitive"`
 40	Spoiler     string `json:"spoiler_text"`
 41}
 42
 43func main() {
 44	flag.Parse()
 45	validateFlags()
 46
 47	fp := gofeed.NewParser()
 48	feed, _ := fp.ParseURL(*flagFeed)
 49
 50	skippedTags := strings.Split(*flagTagsSkip, ",")
 51	skippedTagsMap := make(map[string]int)
 52	for i := range skippedTags {
 53		skippedTagsMap[skippedTags[i]] = 0
 54	}
 55
 56	i := 0
 57	if feed.Items[i].Categories != nil {
 58		for i = range feed.Items {
 59			if feed.Items[i].Categories == nil {
 60				feed.Items[i].Categories = []string{feed.Title}
 61				break
 62			}
 63			c := false
 64			tags := strings.Split(feed.Items[i].Categories[0], "/")
 65			for _, tag := range tags {
 66				if _, ok := skippedTagsMap[tag]; ok {
 67					c = true
 68					break
 69				}
 70			}
 71			if c {
 72				continue
 73			}
 74			break
 75		}
 76	} else {
 77		feed.Items[i].Categories = []string{feed.Title}
 78	}
 79
 80	post := post{}
 81
 82	tags := strings.Split(feed.Items[i].Categories[0], "/")
 83	for i, tag := range tags {
 84		tag = "#" + strings.ReplaceAll(tag, " ", "")
 85		tag = strings.ReplaceAll(tag, ".", "")
 86		tag = strings.ReplaceAll(tag, "-", "")
 87		if i == 0 {
 88			post.Tags = tag
 89			continue
 90		}
 91		post.Tags += " " + tag
 92	}
 93
 94	post.Title = feed.Items[i].Title
 95	post.Link = feed.Items[i].Link
 96
 97	if feed.Items[i].Image != nil {
 98		post.Image = feed.Items[i].Image.URL
 99	}
100
101	description, _ := htmlToPlaintext(feed.Items[i].Description)
102	description = strings.ReplaceAll(description, "\n\n", " ")
103	description = strings.ReplaceAll(description, "\n", " ")
104	splitDesc := strings.Split(description, " ")
105	if len(splitDesc) > 50 {
106		description = strings.Join(splitDesc[:50], " ")
107	}
108	post.Description = description + "..."
109
110	tFile, err := os.ReadFile(*flagTemplate)
111	if err != nil {
112		fmt.Println(err)
113	}
114	tmpl, err := template.New("template").Parse(string(tFile))
115	if err != nil {
116		fmt.Println(err)
117	}
118	buffer := new(strings.Builder)
119	err = tmpl.Execute(buffer, post)
120	if err != nil {
121		fmt.Println(err)
122	}
123
124	post.Description = buffer.String()
125	post.Sensitive = *flagSensitive
126	post.Spoiler = *flagSpoiler
127	post.Visibility = *flagVisibility
128	postBytes, err := json.Marshal(post)
129	if err != nil {
130		fmt.Println(err)
131		os.Exit(1)
132	}
133
134	client := &http.Client{}
135	req, err := http.NewRequest("POST", "https://"+*flagInstance+"/api/v1/statuses", bytes.NewBuffer(postBytes))
136	if err != nil {
137		log.Fatal(err)
138	}
139	req.Header.Set("Authorization", "Bearer "+os.Getenv("RSS2FEDI_TOKEN"))
140	req.Header.Set("Content-Type", "application/json")
141	resp, err := client.Do(req)
142	if err != nil {
143		log.Fatal(err)
144	}
145	defer resp.Body.Close()
146	respData, err := io.ReadAll(resp.Body)
147	if err != nil {
148		log.Fatal(err)
149	}
150	respMap := make(map[string]string)
151	err = json.Unmarshal(respData, &respMap)
152	fmt.Println(respMap["url"])
153}
154
155func extractText(node *html.Node) string {
156	if node.Type == html.TextNode {
157		return node.Data
158	}
159
160	var result string
161	for child := node.FirstChild; child != nil; child = child.NextSibling {
162		result += extractText(child)
163	}
164
165	return result
166}
167
168func htmlToPlaintext(htmlString string) (string, error) {
169	doc, err := html.Parse(strings.NewReader(htmlString))
170	if err != nil {
171		return "", err
172	}
173
174	return extractText(doc), nil
175}
176
177func authenticate() {
178	v := url.Values{}
179	v.Add("client_name", "RSS2Fedi")
180	v.Add("redirect_uris", "urn:ietf:wg:oauth:2.0:oob")
181	v.Add("scopes", "write:statuses")
182	v.Add("website", "https://git.sr.ht/~amolith/rss2fedi")
183	resp, err := http.PostForm("https://"+*flagInstance+"/api/v1/apps", v)
184	if err != nil {
185		fmt.Println(err)
186		os.Exit(1)
187	}
188	defer resp.Body.Close()
189
190	defer resp.Body.Close()
191	data, err := io.ReadAll(resp.Body)
192	if err != nil {
193		fmt.Println(err)
194		os.Exit(1)
195	}
196
197	var result map[string]string
198	err = json.Unmarshal(data, &result)
199
200	fmt.Println("Visit the following URL to authenticate:")
201	fmt.Println("https://" + *flagInstance + "/oauth/authorize?client_id=" + result["client_id"] + "&response_type=code&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=write:statuses\n")
202	fmt.Print("Paste the code you receive here then press enter: ")
203	var code string
204	fmt.Scanln(&code)
205	fmt.Println()
206
207	v = url.Values{}
208	v.Add("grant_type", "authorization_code")
209	v.Add("code", code)
210	v.Add("client_id", result["client_id"])
211	v.Add("client_secret", result["client_secret"])
212	v.Add("redirect_uri", "urn:ietf:wg:oauth:2.0:oob")
213	v.Add("scope", "write:statuses")
214	resp, err = http.PostForm("https://"+*flagInstance+"/oauth/token", v)
215
216	if err != nil {
217		fmt.Println(err)
218		os.Exit(1)
219	}
220	defer resp.Body.Close()
221
222	data, err = io.ReadAll(resp.Body)
223	if err != nil {
224		fmt.Println(err)
225		os.Exit(1)
226	}
227
228	var response map[string]string
229	err = json.Unmarshal(data, &response)
230
231	fmt.Println("Run the following command to set your token for this session:")
232	fmt.Println("	export RSS2FEDI_TOKEN=" + response["access_token"] + "")
233	fmt.Println("If using systemd, you can add the following to your service file in the [Service] section:")
234	fmt.Println("	Environment='RSS2FEDI_TOKEN=" + response["access_token"] + "'\n")
235	fmt.Println("You can now run RSS2Fedi without the authenticate flag! :)")
236
237	os.Exit(0)
238}
239
240func validateFlags() {
241	if *flagAuthenticate {
242		authenticate()
243	}
244	// Ensure the token is set
245	if os.Getenv("RSS2FEDI_TOKEN") == "" {
246		fmt.Println("No token set! Please run with the -a/--authenticate flag to obtain one.")
247		os.Exit(1)
248	}
249	if *flagFeed == "" {
250		flag.Usage()
251		panic("No feed URL specified")
252	}
253	if *flagTemplate == "" {
254		flag.Usage()
255		panic("No template file specified")
256	}
257	if *flagVisibility == "" {
258		flag.Usage()
259		panic("No visibility specified")
260	}
261	if *flagInstance == "" {
262		flag.Usage()
263		panic("No instance specified")
264	}
265	if *flagVisibility != "public" && *flagVisibility != "unlisted" && *flagVisibility != "private" && *flagVisibility != "direct" {
266		flag.Usage()
267		panic("Invalid visibility specified")
268	}
269}