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}