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