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	flagPostIDs      *bool   = flag.BoolP("postids", "p", false, "Print all post IDs, necessary for skipping old posts (optional)")
 31	flagSkip         *string = flag.StringP("skipids", "I", "ids.txt", "Path to file containing post IDs to skip (optional)")
 32)
 33
 34type post struct {
 35	Title       string
 36	Description string `json:"status"`
 37	Tags        string
 38	Image       string `json:"media_attachments"`
 39	Link        string
 40	Visibility  string `json:"visibility"`
 41	Sensitive   bool   `json:"sensitive"`
 42	Spoiler     string `json:"spoiler_text"`
 43}
 44
 45func main() {
 46	flag.Parse()
 47	validateFlags()
 48
 49	fp := gofeed.NewParser()
 50	feed, _ := fp.ParseURL(*flagFeed)
 51
 52	if *flagPostIDs {
 53		for i := range feed.Items {
 54			fmt.Println(feed.Items[i].GUID)
 55		}
 56		os.Exit(0)
 57	}
 58
 59	// If *flagSkip doesn't exist, create it
 60	if _, err := os.Stat(*flagSkip); os.IsNotExist(err) {
 61		f, err := os.Create(*flagSkip)
 62		if err != nil {
 63			fmt.Println(err)
 64			os.Exit(1)
 65		}
 66		f.Close()
 67	}
 68
 69	f, err := os.ReadFile(*flagSkip)
 70	if err != nil {
 71		fmt.Println(err)
 72		os.Exit(1)
 73	}
 74
 75	skippedTags := strings.Split(*flagTagsSkip, ",")
 76	skippedTagsMap := make(map[string]int)
 77	for i := range skippedTags {
 78		skippedTagsMap[skippedTags[i]] = 0
 79	}
 80
 81	i := 0
 82	c := false
 83	if feed.Items[i].Categories != nil {
 84		for i = range feed.Items {
 85			if feed.Items[i].Categories == nil {
 86				feed.Items[i].Categories = []string{feed.Title}
 87			}
 88			c = false
 89			tags := strings.Split(feed.Items[i].Categories[0], "/")
 90			for _, tag := range tags {
 91				_, ok := skippedTagsMap[tag]
 92				if ok || bytes.Contains(f, []byte(feed.Items[i].GUID)) {
 93					c = true
 94					break
 95				}
 96			}
 97			if c {
 98				continue
 99			}
100			break
101		}
102	} else {
103		feed.Items[i].Categories = []string{feed.Title}
104	}
105
106	if c {
107		fmt.Println("No new posts")
108		os.Exit(0)
109	}
110
111	post := post{}
112
113	tags := strings.Split(feed.Items[i].Categories[0], "/")
114	for i, tag := range tags {
115		tag = "#" + strings.ReplaceAll(tag, " ", "")
116		tag = strings.ReplaceAll(tag, ".", "")
117		tag = strings.ReplaceAll(tag, "-", "")
118		if i == 0 {
119			post.Tags = tag
120			continue
121		}
122		post.Tags += " " + tag
123	}
124
125	post.Title = feed.Items[i].Title
126	post.Link = feed.Items[i].Link
127
128	if feed.Items[i].Image != nil {
129		post.Image = feed.Items[i].Image.URL
130	}
131
132	description, _ := htmlToPlaintext(feed.Items[i].Description)
133	description = strings.ReplaceAll(description, "\n\n", " ")
134	description = strings.ReplaceAll(description, "\n", " ")
135	splitDesc := strings.Split(description, " ")
136	if len(splitDesc) > 50 {
137		description = strings.Join(splitDesc[:50], " ")
138	}
139	post.Description = description + "..."
140
141	tFile, err := os.ReadFile(*flagTemplate)
142	if err != nil {
143		fmt.Println(err)
144	}
145	tmpl, err := template.New("template").Parse(string(tFile))
146	if err != nil {
147		fmt.Println(err)
148	}
149	buffer := new(strings.Builder)
150	err = tmpl.Execute(buffer, post)
151	if err != nil {
152		fmt.Println(err)
153	}
154
155	post.Description = buffer.String()
156	post.Sensitive = *flagSensitive
157	post.Spoiler = *flagSpoiler
158	post.Visibility = *flagVisibility
159	postBytes, err := json.Marshal(post)
160	if err != nil {
161		fmt.Println(err)
162		os.Exit(1)
163	}
164
165	client := &http.Client{}
166	req, err := http.NewRequest("POST", "https://"+*flagInstance+"/api/v1/statuses", bytes.NewBuffer(postBytes))
167	if err != nil {
168		log.Fatal(err)
169	}
170	req.Header.Set("Authorization", "Bearer "+os.Getenv("RSS2FEDI_TOKEN"))
171	req.Header.Set("Content-Type", "application/json")
172	resp, err := client.Do(req)
173	if err != nil {
174		log.Fatal(err)
175	}
176	defer resp.Body.Close()
177	respData, err := io.ReadAll(resp.Body)
178	if err != nil {
179		log.Fatal(err)
180	}
181	respMap := make(map[string]string)
182	err = json.Unmarshal(respData, &respMap)
183	url, ok := respMap["url"]
184	if ok {
185		// Write the post ID to the skip file
186		f, err := os.OpenFile(*flagSkip, os.O_APPEND|os.O_WRONLY, 0o644)
187		if err != nil {
188			fmt.Println(err)
189			os.Exit(1)
190		}
191		defer f.Close()
192
193		if _, err := f.WriteString(feed.Items[i].GUID + "\n"); err != nil {
194			fmt.Println(err)
195			os.Exit(1)
196		}
197		fmt.Println("Posted to", url)
198	} else {
199		fmt.Println(string(respData))
200	}
201}
202
203func extractText(node *html.Node) string {
204	if node.Type == html.TextNode {
205		return node.Data
206	}
207
208	var result string
209	for child := node.FirstChild; child != nil; child = child.NextSibling {
210		result += extractText(child)
211	}
212
213	return result
214}
215
216func htmlToPlaintext(htmlString string) (string, error) {
217	doc, err := html.Parse(strings.NewReader(htmlString))
218	if err != nil {
219		return "", err
220	}
221
222	return extractText(doc), nil
223}
224
225func authenticate() {
226	v := url.Values{}
227	v.Add("client_name", "RSS2Fedi")
228	v.Add("redirect_uris", "urn:ietf:wg:oauth:2.0:oob")
229	v.Add("scopes", "write:statuses")
230	v.Add("website", "https://git.sr.ht/~amolith/rss2fedi")
231	resp, err := http.PostForm("https://"+*flagInstance+"/api/v1/apps", v)
232	if err != nil {
233		fmt.Println(err)
234		os.Exit(1)
235	}
236	defer resp.Body.Close()
237
238	defer resp.Body.Close()
239	data, err := io.ReadAll(resp.Body)
240	if err != nil {
241		fmt.Println(err)
242		os.Exit(1)
243	}
244
245	var result map[string]string
246	err = json.Unmarshal(data, &result)
247
248	fmt.Println("Visit the following URL to authenticate:")
249	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")
250	fmt.Print("Paste the code you receive here then press enter: ")
251	var code string
252	fmt.Scanln(&code)
253	fmt.Println()
254
255	v = url.Values{}
256	v.Add("grant_type", "authorization_code")
257	v.Add("code", code)
258	v.Add("client_id", result["client_id"])
259	v.Add("client_secret", result["client_secret"])
260	v.Add("redirect_uri", "urn:ietf:wg:oauth:2.0:oob")
261	v.Add("scope", "write:statuses")
262	resp, err = http.PostForm("https://"+*flagInstance+"/oauth/token", v)
263
264	if err != nil {
265		fmt.Println(err)
266		os.Exit(1)
267	}
268	defer resp.Body.Close()
269
270	data, err = io.ReadAll(resp.Body)
271	if err != nil {
272		fmt.Println(err)
273		os.Exit(1)
274	}
275
276	var response map[string]string
277	err = json.Unmarshal(data, &response)
278
279	fmt.Println("Run the following command to set your token for this session:")
280	fmt.Println("	export RSS2FEDI_TOKEN=" + response["access_token"] + "")
281	fmt.Println("If using systemd, you can add the following to your service file in the [Service] section:")
282	fmt.Println("	Environment='RSS2FEDI_TOKEN=" + response["access_token"] + "'\n")
283	fmt.Println("You can now run RSS2Fedi without the authenticate flag! :)")
284
285	os.Exit(0)
286}
287
288func validateFlags() {
289	if *flagAuthenticate {
290		authenticate()
291	}
292	if *flagFeed == "" {
293		flag.Usage()
294		fmt.Println("No feed URL specified")
295		os.Exit(1)
296	}
297	if *flagPostIDs {
298		return
299	}
300	if os.Getenv("RSS2FEDI_TOKEN") == "" {
301		fmt.Println("No token set! Please run with the -a/--authenticate flag to obtain one.")
302		os.Exit(1)
303	}
304	if *flagTemplate == "" {
305		flag.Usage()
306		fmt.Println("No template file specified")
307		os.Exit(1)
308	}
309	if *flagVisibility == "" {
310		flag.Usage()
311		fmt.Println("No visibility specified")
312		os.Exit(1)
313	}
314	if *flagInstance == "" {
315		flag.Usage()
316		fmt.Println("No instance specified")
317		os.Exit(1)
318	}
319	if *flagVisibility != "public" && *flagVisibility != "unlisted" && *flagVisibility != "private" && *flagVisibility != "direct" {
320		flag.Usage()
321		fmt.Println("Invalid visibility specified")
322		os.Exit(1)
323	}
324}