// piupiu: grab tweets and put them into RSS feeds. // Copyright (C) 2026-Ω Miquel Sabaté Solà // // 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 . 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 // '/.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") q.Set("exclude", "replies") 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>:' values.") output := flag.String("o", ".", "Directory where RSS files will be dropped") flag.Parse() // This is 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) }