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, "&", "&")
141 s = strings.ReplaceAll(s, "<", "<")
142 s = strings.ReplaceAll(s, ">", ">")
143 s = strings.ReplaceAll(s, """, "\"")
144 s = strings.ReplaceAll(s, "'", "'")
145 s = strings.ReplaceAll(s, "'", "'")
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}