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	skippedTagsMaps := make(map[string]int)
 52	for i := range skippedTags {
 53		skippedTagsMaps[skippedTags[i]] = 0
 54	}
 55	var i int
 56	tags := strings.Split(feed.Items[i].Categories[0], "/")
 57	for i, tag := range tags {
 58		if _, ok := skippedTagsMaps[tag]; ok {
 59			i++
 60			continue
 61		}
 62		break
 63	}
 64
 65	post := post{
 66		Title: feed.Items[i].Title,
 67		Link:  feed.Items[i].Link,
 68	}
 69
 70	if feed.Items[i].Image != nil {
 71		post.Image = feed.Items[i].Image.URL
 72	}
 73
 74	for i, tag := range tags {
 75		tag = "#" + strings.ReplaceAll(tag, " ", "")
 76		if i == 0 {
 77			post.Tags = tag
 78			continue
 79		}
 80		post.Tags += " " + tag
 81	}
 82
 83	description, _ := htmlToPlaintext(feed.Items[i].Description)
 84	description = strings.ReplaceAll(description, "\n\n", " ")
 85	description = strings.ReplaceAll(description, "\n", " ")
 86	splitDesc := strings.Split(description, " ")
 87	if len(splitDesc) > 50 {
 88		description = strings.Join(splitDesc[:50], " ")
 89	}
 90	post.Description = description + "..."
 91
 92	tFile, err := os.ReadFile(*flagTemplate)
 93	if err != nil {
 94		fmt.Println(err)
 95	}
 96	tmpl, err := template.New("template").Parse(string(tFile))
 97	if err != nil {
 98		fmt.Println(err)
 99	}
100	buffer := new(strings.Builder)
101	err = tmpl.Execute(buffer, post)
102	if err != nil {
103		fmt.Println(err)
104	}
105
106	post.Description = buffer.String()
107	post.Sensitive = *flagSensitive
108	post.Spoiler = *flagSpoiler
109	post.Visibility = *flagVisibility
110	postBytes, err := json.Marshal(post)
111	if err != nil {
112		fmt.Println(err)
113		os.Exit(1)
114	}
115
116	client := &http.Client{}
117	req, err := http.NewRequest("POST", "https://"+*flagInstance+"/api/v1/statuses", bytes.NewBuffer(postBytes))
118	if err != nil {
119		log.Fatal(err)
120	}
121	req.Header.Set("Authorization", "Bearer "+os.Getenv("RSS2FEDI_TOKEN"))
122	req.Header.Set("Content-Type", "application/json")
123	resp, err := client.Do(req)
124	if err != nil {
125		log.Fatal(err)
126	}
127	defer resp.Body.Close()
128	respData, err := io.ReadAll(resp.Body)
129	if err != nil {
130		log.Fatal(err)
131	}
132	respMap := make(map[string]string)
133	err = json.Unmarshal(respData, &respMap)
134	fmt.Println(respMap["url"])
135}
136
137func extractText(node *html.Node) string {
138	if node.Type == html.TextNode {
139		return node.Data
140	}
141
142	var result string
143	for child := node.FirstChild; child != nil; child = child.NextSibling {
144		result += extractText(child)
145	}
146
147	return result
148}
149
150func htmlToPlaintext(htmlString string) (string, error) {
151	doc, err := html.Parse(strings.NewReader(htmlString))
152	if err != nil {
153		return "", err
154	}
155
156	return extractText(doc), nil
157}
158
159func authenticate() {
160	v := url.Values{}
161	v.Add("client_name", "RSS2Fedi")
162	v.Add("redirect_uris", "urn:ietf:wg:oauth:2.0:oob")
163	v.Add("scopes", "write:statuses")
164	v.Add("website", "https://git.sr.ht/~amolith/rss2fedi")
165	resp, err := http.PostForm("https://"+*flagInstance+"/api/v1/apps", v)
166	if err != nil {
167		fmt.Println(err)
168		os.Exit(1)
169	}
170	defer resp.Body.Close()
171
172	defer resp.Body.Close()
173	data, err := io.ReadAll(resp.Body)
174	if err != nil {
175		fmt.Println(err)
176		os.Exit(1)
177	}
178
179	var result map[string]string
180	err = json.Unmarshal(data, &result)
181
182	fmt.Println("Visit the following URL to authenticate:")
183	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")
184	fmt.Print("Paste the code you receive here then press enter: ")
185	var code string
186	fmt.Scanln(&code)
187	fmt.Println()
188
189	v = url.Values{}
190	v.Add("grant_type", "authorization_code")
191	v.Add("code", code)
192	v.Add("client_id", result["client_id"])
193	v.Add("client_secret", result["client_secret"])
194	v.Add("redirect_uri", "urn:ietf:wg:oauth:2.0:oob")
195	v.Add("scope", "write:statuses")
196	resp, err = http.PostForm("https://"+*flagInstance+"/oauth/token", v)
197
198	if err != nil {
199		fmt.Println(err)
200		os.Exit(1)
201	}
202	defer resp.Body.Close()
203
204	data, err = io.ReadAll(resp.Body)
205	if err != nil {
206		fmt.Println(err)
207		os.Exit(1)
208	}
209
210	var response map[string]string
211	err = json.Unmarshal(data, &response)
212
213	fmt.Println("Run the following command to set your token for this session:")
214	fmt.Println("	export RSS2FEDI_TOKEN=" + response["access_token"] + "")
215	fmt.Println("If using systemd, you can add the following to your service file in the [Service] section:")
216	fmt.Println("	Environment='RSS2FEDI_TOKEN=" + response["access_token"] + "'\n")
217	fmt.Println("You can now run RSS2Fedi without the authenticate flag! :)")
218
219	os.Exit(0)
220}
221
222func validateFlags() {
223	if *flagAuthenticate {
224		authenticate()
225	}
226	// Ensure the token is set
227	if os.Getenv("RSS2FEDI_TOKEN") == "" {
228		fmt.Println("No token set! Please run with the -a/--authenticate flag to obtain one.")
229		os.Exit(1)
230	}
231	if *flagFeed == "" {
232		flag.Usage()
233		panic("No feed URL specified")
234	}
235	if *flagTemplate == "" {
236		flag.Usage()
237		panic("No template file specified")
238	}
239	if *flagVisibility == "" {
240		flag.Usage()
241		panic("No visibility specified")
242	}
243	if *flagInstance == "" {
244		flag.Usage()
245		panic("No instance specified")
246	}
247	if *flagVisibility != "public" && *flagVisibility != "unlisted" && *flagVisibility != "private" && *flagVisibility != "direct" {
248		flag.Usage()
249		panic("Invalid visibility specified")
250	}
251}