aboutsummaryrefslogtreecommitdiff
path: root/main.go
diff options
context:
space:
mode:
authorMiquel Sabaté Solà <mssola@mssola.com>2026-10-26 22:31:34 +0100
committerMiquel Sabaté Solà <mssola@mssola.com>2026-10-26 22:31:34 +0100
commit55ca9eb060fb9e55c33f4ec35f4c6d1e27873d8b (patch)
tree1fcc48a634bc6ba0a0eb0deb11b78b7020f0df2c /main.go
downloadpiupiu-55ca9eb060fb9e55c33f4ec35f4c6d1e27873d8b.tar.gz
piupiu-55ca9eb060fb9e55c33f4ec35f4c6d1e27873d8b.zip
Initial commit
Signed-off-by: Miquel Sabaté Solà <mssola@mssola.com>
Diffstat (limited to 'main.go')
-rw-r--r--main.go214
1 files changed, 214 insertions, 0 deletions
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..847508d
--- /dev/null
+++ b/main.go
@@ -0,0 +1,214 @@
+// piupiu: grab tweets and put them into RSS feeds.
+// Copyright (C) 2026-Ω Miquel Sabaté Solà <mssola@mssola.com>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+package main
+
+import (
+ "encoding/json"
+ "encoding/xml"
+ "flag"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+type Tweet struct {
+ ID string `json:"id"`
+ Text string `json:"text"`
+ CreatedAt string `json:"created_at"`
+}
+
+type TweetResponse struct {
+ Data []Tweet `json:"data"`
+}
+
+type RSS struct {
+ XMLName xml.Name `xml:"rss"`
+ Version string `xml:"version,attr"`
+ Channel RSSChannel `xml:"channel"`
+}
+
+type RSSChannel struct {
+ Title string `xml:"title"`
+ Link string `xml:"link"`
+ Description string `xml:"description"`
+ Items []RSSItem `xml:"item"`
+}
+
+type RSSItem struct {
+ Title string `xml:"title"`
+ Link string `xml:"link"`
+ Description string `xml:"description"`
+ PubDate string `xml:"pubDate"`
+ GUID string `xml:"guid"`
+}
+
+// Given the directory 'path' and the 'username', create a
+// '<path>/<username>.xml' file with the 'tweets' in RSS format.
+func tweetsToRss(username, path string, tweets []Tweet) error {
+ // XML definition of this whole thing.
+ rss := RSS{
+ Version: "2.0",
+ Channel: RSSChannel{
+ Title: fmt.Sprintf("piu piu: @%s", username),
+ Link: fmt.Sprintf("https://x.com/%s", username),
+ Description: fmt.Sprintf("@%s", username),
+ Items: make([]RSSItem, 0),
+ },
+ }
+
+ // Now iterate on the given tweets and transform them into RSSItem's.
+ for _, tweet := range tweets {
+ tweetURL := fmt.Sprintf("https://x.com/%s/status/%s", username, tweet.ID)
+
+ // Remember how the X API required a specific timestamp format? Well,
+ // RSS wants another one. Because timestamp ISO's are how programmers
+ // become (even) more cynical.
+ t, err := time.Parse(time.RFC3339, tweet.CreatedAt)
+ pubDate := tweet.CreatedAt
+ if err == nil {
+ pubDate = t.Format(time.RFC1123Z)
+ }
+
+ // Use the first few words or characters as the RSS item title.
+ title := tweet.Text
+ if len(title) > 50 {
+ title = title[:47] + "..."
+ }
+
+ // Voila!
+ rss.Channel.Items = append(rss.Channel.Items, RSSItem{
+ Title: title,
+ Link: tweetURL,
+ Description: tweet.Text,
+ PubDate: pubDate,
+ GUID: tweetURL,
+ })
+ }
+
+ // Marshal into XML with a standard header.
+ output, err := xml.MarshalIndent(rss, "", " ")
+ if err != nil {
+ return err
+ }
+ fullXML := []byte(xml.Header + string(output))
+
+ // And write the damn thing.
+ filename := fmt.Sprintf("%s.xml", username)
+ filePath := filepath.Join(path, filename)
+ return os.WriteFile(filePath, fullXML, 0644)
+}
+
+// Given a user 'id' and the API 'token', fetch all the tweets from the last 24
+// hours.
+func fetch(id, token string) ([]Tweet, error) {
+ apiURL := fmt.Sprintf("https://api.x.com/2/users/%v/tweets", id)
+ u, err := url.Parse(apiURL)
+ if err != nil {
+ return nil, err
+ }
+
+ q := u.Query()
+ startTime := time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
+ q.Set("start_time", startTime)
+ q.Set("tweet.fields", "created_at,text")
+ u.RawQuery = q.Encode()
+
+ req, err := http.NewRequest("GET", u.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("API error: %v (%v)", resp.Status, resp.StatusCode)
+ }
+
+ var object TweetResponse
+ if err := json.NewDecoder(resp.Body).Decode(&object); err != nil {
+ return nil, err
+ }
+ return object.Data, nil
+}
+
+func main() {
+ // First of all, enforce the stupid ass API token.
+ token := os.Getenv("PIUPIU_TOKEN")
+ if token == "" {
+ fmt.Println("error: you need to provide a token via the 'PIUPIU_TOKEN' environment variable.")
+ os.Exit(1)
+ }
+
+ // Behold, argument parsing!
+ query := flag.String("q", "", "Comma-separated list of '<@user_name>:<user_id>' values.")
+ output := flag.String("o", ".", "Directory where RSS files will be dropped")
+ flag.Parse()
+
+ // This, of course, cursed in unholy ways, as path sanitization shenanigans
+ // have proved since the existence of the concept of a path in operating
+ // systems. That being said, I'm the sole user of this, so whatever...
+ if *output != "." {
+ if err := os.MkdirAll(*output, 0755); err != nil {
+ fmt.Printf("error: cannot create directory: %w", err)
+ os.Exit(1)
+ }
+ }
+
+ ids := strings.Split(*query, ",")
+ exit := 0
+ for _, id := range ids {
+ // I could've gone with something friendlier, but this was faster, and I
+ // just didn't care. If you don't recall the format, just RTFM (in this
+ // case just -h, as manuals are for people who actually want to make a
+ // living out of ones and zeroes :P).
+ vals := strings.Split(id, ":")
+ if len(vals) != 2 {
+ fmt.Printf("error: bad argument format\n\n")
+ flag.PrintDefaults()
+ os.Exit(1)
+ }
+
+ // Fetch the tweet data from the API.
+ name := strings.TrimSpace(vals[0])
+ id = strings.TrimSpace(vals[1])
+ tweets, err := fetch(id, token)
+ if err != nil {
+ fmt.Printf("error: %v\n", err)
+ exit = 2
+ continue
+ }
+
+ // Generate the RSS file for this user.
+ err = tweetsToRss(name, *output, tweets)
+ if err != nil {
+ fmt.Printf("error: %v\n", err)
+ exit = 2
+ }
+ }
+
+ os.Exit(exit)
+}