eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package bot
2
3// Feed discovery — the builder's answer to "regular people don't know RSS".
4// A member names a publication ("the Irish Times"); the builder translates
5// well-known outlets from its own knowledge, and for the long tail calls
6// discover_feeds: fetch the site the MEMBER just named and read the feed
7// URLs its HTML head advertises (<link rel="alternate" type="…rss/atom…">).
8// The drafting table's dry run then proves the chosen feeds actually work
9// before anything is created — a wrong guess fails visibly, not silently.
10//
11// Bounds (this fetch is NOT allowlist-gated — the member named the site, in
12// a DM, to the builder; that ask is the consent): https only, public
13// addresses only (the same dialer guard as task fetches), HTML head sized
14// (256 KiB), one host, no cross-host redirects.
15
16import (
17 "fmt"
18 "io"
19 "net/http"
20 "net/url"
21 "regexp"
22 "strings"
23)
24
25const maxDiscoverBytes = 256 * 1024
26
27// discoverFeeds fetches a site's homepage and returns the feed URLs it
28// advertises, absolute, deduped, capped at 8.
29func discoverFeeds(raw string) ([]string, error) {
30 raw = strings.TrimSpace(raw)
31 if raw == "" {
32 return nil, fmt.Errorf("no site given")
33 }
34 if !strings.Contains(raw, "://") {
35 raw = "https://" + raw
36 }
37 u, err := url.Parse(raw)
38 if err != nil || u.Host == "" {
39 return nil, fmt.Errorf("that doesn't look like a site address")
40 }
41 if u.Scheme != "https" && !allowPrivateFetch() {
42 return nil, fmt.Errorf("only https sites can be read")
43 }
44 client := fetchClient([]string{}) // the SSRF-guarded dialer; no allowlist here
45 client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
46 if len(via) >= 3 {
47 return fmt.Errorf("too many redirects")
48 }
49 // The www dance is the same site: members type "bbc.com", the apex
50 // redirects to www (or vice versa). Anything further off-host is a
51 // real departure and stays blocked.
52 if !sameSiteHost(req.URL.Host, u.Host) && !allowPrivateFetch() {
53 return fmt.Errorf("site redirected off-host")
54 }
55 return nil
56 }
57 req, err := http.NewRequest(http.MethodGet, u.String(), nil)
58 if err != nil {
59 return nil, err
60 }
61 req.Header.Set("User-Agent", "lchat-bot/1.0 (+https://elevenmessenger.com)")
62 req.Header.Set("Accept", "text/html")
63 resp, err := client.Do(req)
64 if err != nil {
65 return nil, fmt.Errorf("couldn't reach %s: %w", u.Host, err)
66 }
67 defer resp.Body.Close()
68 if resp.StatusCode != http.StatusOK {
69 return nil, fmt.Errorf("%s answered with status %d", u.Host, resp.StatusCode)
70 }
71 body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiscoverBytes))
72 if err != nil {
73 return nil, err
74 }
75 feeds := feedLinksFromHTML(string(body), resp.Request.URL)
76 if len(feeds) == 0 {
77 return nil, fmt.Errorf("%s doesn't advertise a feed on its homepage", u.Host)
78 }
79 return feeds, nil
80}
81
82// sameSiteHost treats a host and its www. variant as one site (either
83// direction), for the redirect guard above. Pure; pinned by unit test.
84func sameSiteHost(a, b string) bool {
85 return a == b || a == "www."+b || b == "www."+a
86}
87
88// linkTagRE matches <link …> tags; attribute order varies wildly in the
89// field, so attributes are re-scanned per tag rather than positionally.
90var linkTagRE = regexp.MustCompile(`(?is)<link\b[^>]*>`)
91var attrRE = regexp.MustCompile(`(?is)([a-z-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))`)
92
93// feedLinksFromHTML pulls rel="alternate" rss/atom link hrefs out of an HTML
94// document, resolved against base. Pure — pinned by unit tests.
95func feedLinksFromHTML(html string, base *url.URL) []string {
96 var out []string
97 seen := map[string]bool{}
98 for _, tag := range linkTagRE.FindAllString(html, 200) {
99 attrs := map[string]string{}
100 for _, m := range attrRE.FindAllStringSubmatch(tag, -1) {
101 val := m[3]
102 if val == "" {
103 val = m[4]
104 }
105 if val == "" {
106 val = m[5]
107 }
108 attrs[strings.ToLower(m[1])] = val
109 }
110 rel := strings.ToLower(attrs["rel"])
111 typ := strings.ToLower(attrs["type"])
112 href := strings.TrimSpace(attrs["href"])
113 if href == "" || !strings.Contains(rel, "alternate") {
114 continue
115 }
116 if !strings.Contains(typ, "rss") && !strings.Contains(typ, "atom") {
117 continue
118 }
119 ref, err := url.Parse(href)
120 if err != nil {
121 continue
122 }
123 abs := base.ResolveReference(ref).String()
124 if seen[abs] {
125 continue
126 }
127 seen[abs] = true
128 out = append(out, abs)
129 if len(out) == 8 {
130 break
131 }
132 }
133 return out
134}
135
136// discoverFeedsTool is the builder's information tool (a RESULT tool — the
137// brain round-trips its output, unlike chip tools; see modelReply's loop).
138func discoverFeedsTool() anthTool {
139 return anthTool{
140 Name: "discover_feeds",
141 Description: "Find the RSS/Atom feed URLs a website advertises. Give it the site a member " +
142 "named (a domain or homepage URL, e.g. irishtimes.com) and it returns the feed URLs from " +
143 "the site's own HTML. Use this whenever you aren't CERTAIN of an outlet's feed URL — " +
144 "never ask a member for a feed URL, and never guess one you can't vouch for.",
145 InputSchema: map[string]any{
146 "type": "object",
147 "properties": map[string]any{"site": map[string]any{"type": "string", "description": "Domain or homepage URL"}},
148 "required": []any{"site"},
149 },
150 }
151}
152
153// runDiscoverFeeds executes the tool, formatting for a tool_result.
154func runDiscoverFeeds(input map[string]any) string {
155 site, _ := input["site"].(string)
156 feeds, err := discoverFeeds(site)
157 if err != nil {
158 return "discovery failed: " + err.Error()
159 }
160 return "feeds advertised by the site:\n" + strings.Join(feeds, "\n")
161}
162