main
  1package flux
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"io"
  8	"net/http"
  9	"regexp"
 10	"strings"
 11	"time"
 12)
 13
 14// MastodonSource fetches public toots from a Mastodon account.
 15type MastodonSource struct {
 16	Instance string // e.g. "fosstodon.org"
 17	Username string // e.g. "vdemeester"
 18	Client   *http.Client
 19}
 20
 21func (m *MastodonSource) Name() string { return "mastodon" }
 22
 23type mastodonAccount struct {
 24	ID string `json:"id"`
 25}
 26
 27type mastodonStatus struct {
 28	ID        string    `json:"id"`
 29	CreatedAt time.Time `json:"created_at"`
 30	Content   string    `json:"content"` // HTML
 31	URL       string    `json:"url"`
 32	Tags      []struct {
 33		Name string `json:"name"`
 34	} `json:"tags"`
 35	Reblog      *mastodonStatus `json:"reblog"`
 36	InReplyToID *string         `json:"in_reply_to_id"`
 37	Visibility  string          `json:"visibility"` // public, unlisted, private, direct
 38	Account     struct {
 39		Acct        string `json:"acct"`
 40		DisplayName string `json:"display_name"`
 41	} `json:"account"`
 42}
 43
 44func (m *MastodonSource) client() *http.Client {
 45	if m.Client != nil {
 46		return m.Client
 47	}
 48	return &http.Client{Timeout: 15 * time.Second}
 49}
 50
 51// Fetch retrieves public toots (no auth needed).
 52func (m *MastodonSource) Fetch(ctx context.Context, since time.Time) ([]Entry, error) {
 53	// Look up account ID
 54	accountID, err := m.lookupAccount(ctx)
 55	if err != nil {
 56		return nil, fmt.Errorf("looking up account: %w", err)
 57	}
 58
 59	// Fetch statuses — original posts only, no replies or boosts
 60	u := fmt.Sprintf("https://%s/api/v1/accounts/%s/statuses?limit=40&exclude_replies=true",
 61		m.Instance, accountID)
 62
 63	req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
 64	if err != nil {
 65		return nil, err
 66	}
 67
 68	resp, err := m.client().Do(req)
 69	if err != nil {
 70		return nil, fmt.Errorf("fetching statuses: %w", err)
 71	}
 72	defer resp.Body.Close()
 73
 74	if resp.StatusCode != 200 {
 75		body, _ := io.ReadAll(resp.Body)
 76		return nil, fmt.Errorf("Mastodon API error %d: %s", resp.StatusCode, string(body))
 77	}
 78
 79	var statuses []mastodonStatus
 80	if err := json.NewDecoder(resp.Body).Decode(&statuses); err != nil {
 81		return nil, fmt.Errorf("decoding statuses: %w", err)
 82	}
 83
 84	var entries []Entry
 85	for _, s := range statuses {
 86		// Skip non-public
 87		if s.Visibility != "public" {
 88			continue
 89		}
 90
 91		// Filter by since
 92		if !since.IsZero() && s.CreatedAt.Before(since) {
 93			continue
 94		}
 95
 96		entries = append(entries, m.statusToEntry(s))
 97	}
 98
 99	return entries, nil
100}
101
102func (m *MastodonSource) lookupAccount(ctx context.Context) (string, error) {
103	u := fmt.Sprintf("https://%s/api/v1/accounts/lookup?acct=%s", m.Instance, m.Username)
104
105	req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
106	if err != nil {
107		return "", err
108	}
109
110	resp, err := m.client().Do(req)
111	if err != nil {
112		return "", err
113	}
114	defer resp.Body.Close()
115
116	if resp.StatusCode != 200 {
117		return "", fmt.Errorf("account lookup failed: HTTP %d", resp.StatusCode)
118	}
119
120	var account mastodonAccount
121	if err := json.NewDecoder(resp.Body).Decode(&account); err != nil {
122		return "", err
123	}
124
125	return account.ID, nil
126}
127
128// HTML tag stripper
129var htmlTagRe = regexp.MustCompile(`<[^>]*>`)
130var htmlBrRe = regexp.MustCompile(`<br\s*/?>`)
131var htmlPRe = regexp.MustCompile(`</p>\s*<p>`)
132
133func stripHTML(s string) string {
134	// Convert <br> and </p><p> to newlines first
135	s = htmlBrRe.ReplaceAllString(s, "\n")
136	s = htmlPRe.ReplaceAllString(s, "\n\n")
137	// Strip remaining tags
138	s = htmlTagRe.ReplaceAllString(s, "")
139	// Decode common entities
140	s = strings.ReplaceAll(s, "&amp;", "&")
141	s = strings.ReplaceAll(s, "&lt;", "<")
142	s = strings.ReplaceAll(s, "&gt;", ">")
143	s = strings.ReplaceAll(s, "&quot;", "\"")
144	s = strings.ReplaceAll(s, "&#39;", "'")
145	s = strings.ReplaceAll(s, "&apos;", "'")
146	return strings.TrimSpace(s)
147}
148
149// splitTitleBody extracts a title (first line, truncated) and body (remainder).
150// If the text is short enough to be the title, body is empty (no duplication).
151func splitTitleBody(text string) (title, body string) {
152	lines := strings.SplitN(text, "\n", 2)
153	title = strings.TrimSpace(lines[0])
154
155	if len(lines) > 1 {
156		body = strings.TrimSpace(lines[1])
157	}
158
159	if len(title) > 120 {
160		title = title[:117] + "…"
161		// Keep full text as body since title was truncated
162		body = text
163	}
164
165	// Don't repeat: if body starts with title content, keep only the rest
166	titleClean := strings.TrimSuffix(title, "…")
167	if strings.HasPrefix(body, titleClean) {
168		body = strings.TrimSpace(body[len(titleClean):])
169	}
170
171	return title, body
172}
173
174func (m *MastodonSource) statusToEntry(s mastodonStatus) Entry {
175	// Boosts: use the original status content
176	if s.Reblog != nil {
177		return m.boostToEntry(s)
178	}
179
180	text := stripHTML(s.Content)
181	title, body := splitTitleBody(text)
182
183	// Tags from hashtags
184	tags := []string{"mastodon"}
185	for _, t := range s.Tags {
186		tags = append(tags, strings.ToLower(t.Name))
187	}
188
189	return Entry{
190		ID:     fmt.Sprintf("mastodon-%s", s.ID),
191		Kind:   KindNote,
192		Title:  title,
193		URL:    s.URL,
194		Body:   body,
195		Tags:   tags,
196		Date:   s.CreatedAt,
197		Source: "mastodon",
198		Metadata: map[string]string{
199			"instance": m.Instance,
200		},
201	}
202}
203
204func (m *MastodonSource) boostToEntry(s mastodonStatus) Entry {
205	original := s.Reblog
206	text := stripHTML(original.Content)
207
208	// Title: first line, truncated
209	title, body := splitTitleBody(text)
210
211	// Tags from original hashtags
212	tags := []string{"mastodon", "boost"}
213	for _, t := range original.Tags {
214		tags = append(tags, strings.ToLower(t.Name))
215	}
216
217	// Attribution
218	author := original.Account.DisplayName
219	if author == "" {
220		author = original.Account.Acct
221	}
222
223	return Entry{
224		ID:     fmt.Sprintf("mastodon-boost-%s", s.ID),
225		Kind:   KindBookmark,
226		Title:  title,
227		URL:    original.URL,
228		Body:   body,
229		Tags:   tags,
230		Date:   s.CreatedAt,
231		Source: "mastodon",
232		Metadata: map[string]string{
233			"instance": m.Instance,
234			"author":   author,
235		},
236	}
237}