Read RSS and Atom feeds, and speak about them only when asked (#258)
internal/rss parses RSS 2.0 and Atom, and polls each configured feed on its own interval; internal/webfetch is the one door either of them uses to touch the network. The poller writes items as notes with source "rss:<feed>" and nothing else: the answer path reads them back when he asks "что нового в лентах?", and nothing is announced on arrival. A feed that dispatched would be a nag, which is why the plan's breaking-news rule was left out rather than built. webfetch is where the limits live, as code rather than a paragraph: http(s) only, an allowlist (the configured feeds' hosts) and a denylist, a 2 MiB body cap, a 3-redirect cap, one request per host per second, and a refusal to connect to any private address — checked in the dialer's Control hook so it holds for every resolved address and every redirect hop, not just for a literal IP. Off unless configured: no "feeds" block, no poller, no outbound request. How far a feed was read is a config fact (rss:latest:<name>), so a restart does not re-note yesterday's headlines.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
// buildFeedHandler — a handler with the given feed notes already stored. No
|
||||
// embedder: the feed source answers from recent notes by source, which is what
|
||||
// makes it work for notes written before an embedder existed.
|
||||
func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHandler {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
for i, n := range notes {
|
||||
ts := now.Add(time.Duration(i) * time.Minute)
|
||||
if _, err := st.WriteNote(ctx, ts, n.Text, nil, n.Source); err != nil {
|
||||
t.Fatalf("WriteNote: %v", err)
|
||||
}
|
||||
}
|
||||
return &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phraser.NewStub(),
|
||||
now: func() time.Time { return now },
|
||||
feedsOn: feedsOn,
|
||||
embedder: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func askFeeds(t *testing.T, h *reactiveHandler, q string) (string, bool) {
|
||||
t.Helper()
|
||||
return h.queryFeeds(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||
})
|
||||
}
|
||||
|
||||
func TestQueryFeedsReadsFeedNotes(t *testing.T) {
|
||||
h := buildFeedHandler(t, true,
|
||||
ipc.Note{Text: "Новая уязвимость в ядре [технологии]\nпатч вышел\nhttps://example.org/a", Source: "rss:habr"},
|
||||
ipc.Note{Text: "что-то он сам сказал", Source: "tap:voice"},
|
||||
)
|
||||
reply, ok := askFeeds(t, h, "что нового в лентах?")
|
||||
if !ok {
|
||||
t.Fatal("the feed source did not claim the question")
|
||||
}
|
||||
if !strings.Contains(reply, "уязвимость") {
|
||||
t.Errorf("reply = %q, want the headline", reply)
|
||||
}
|
||||
if strings.Contains(reply, "он сам сказал") {
|
||||
t.Errorf("a note he dictated leaked into the feed answer: %q", reply)
|
||||
}
|
||||
// She reads the headline, not the summary and not the URL.
|
||||
if strings.Contains(reply, "https://") || strings.Contains(reply, "патч вышел") {
|
||||
t.Errorf("reply = %q, want the title line only", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFeedsByCategory(t *testing.T) {
|
||||
h := buildFeedHandler(t, true,
|
||||
ipc.Note{Text: "Релиз ядра [технологии]", Source: "rss:habr"},
|
||||
ipc.Note{Text: "Выборы отложены [политика]", Source: "rss:news"},
|
||||
)
|
||||
reply, ok := askFeeds(t, h, "что нового по технологиям?")
|
||||
if !ok {
|
||||
t.Fatal("not claimed")
|
||||
}
|
||||
if !strings.Contains(reply, "ядра") || strings.Contains(reply, "Выборы") {
|
||||
t.Fatalf("reply = %q, want only the технологии item", reply)
|
||||
}
|
||||
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
||||
if !strings.Contains(reply, "ничего") {
|
||||
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// "не настроены" and "ничего нового" are different truths, and neither may be
|
||||
// answered by the model inventing a bulletin.
|
||||
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
||||
off := buildFeedHandler(t, false)
|
||||
reply, ok := askFeeds(t, off, "что нового?")
|
||||
if !ok || !strings.Contains(reply, "не настроены") {
|
||||
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
on := buildFeedHandler(t, true)
|
||||
reply, ok = askFeeds(t, on, "что нового?")
|
||||
if !ok || !strings.Contains(reply, "ничего нового") {
|
||||
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
||||
h := buildFeedHandler(t, true)
|
||||
if reply, ok := askFeeds(t, h, "напомни полить цветы"); ok {
|
||||
t.Fatalf("claimed an unrelated question with %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The mark is what stops a restart from re-noting yesterday's headlines, so the
|
||||
// fact round-trip is worth a test of its own.
|
||||
func TestFactMarksRoundTrip(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
m := &factMarks{api: ipc.NewStoreAPI(st)}
|
||||
ctx := context.Background()
|
||||
|
||||
at, err := m.LastMark(ctx, "habr")
|
||||
if err != nil || !at.IsZero() {
|
||||
t.Fatalf("no mark yet: got %v, %v — want zero time and no error", at, err)
|
||||
}
|
||||
want := time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC)
|
||||
if err := m.SetMark(ctx, "habr", want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := m.LastMark(ctx, "habr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("mark = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Off unless configured, checked at the wiring seam: no `feeds` block ⇒ no
|
||||
// worker ⇒ no outbound request is possible.
|
||||
func TestNewFeedWorkerOffByDefault(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
if w := newFeedWorker(api, nil, &config.Config{}); w != nil {
|
||||
t.Fatal("a config with no feeds block wired a feed worker")
|
||||
}
|
||||
// An empty sources list is normalised to "off" by config.Load; the worker
|
||||
// refuses it too, so a hand-built Config cannot switch it on by accident.
|
||||
if w := newFeedWorker(api, nil, &config.Config{Feeds: &config.FeedsConfig{}}); w != nil {
|
||||
t.Fatal("an empty sources list wired a feed worker")
|
||||
}
|
||||
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
|
||||
{Name: "habr", URL: "https://example.org/rss"},
|
||||
}}}
|
||||
w := newFeedWorker(api, nil, cfg)
|
||||
if w == nil {
|
||||
t.Fatal("a configured feed did not wire a worker")
|
||||
}
|
||||
if got := w.poller.Feeds(); len(got) != 1 || got[0].Name != "habr" {
|
||||
t.Fatalf("feeds = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The fetcher the worker builds must be allowlisted to the configured feeds and
|
||||
// nothing else — the crawler's SSRF guards are only worth as much as the
|
||||
// allowlist handed to them.
|
||||
func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
|
||||
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
|
||||
{Name: "habr", URL: "https://feeds.example.org/rss"},
|
||||
}}}
|
||||
w := newFeedWorker(ipc.NewStoreAPI(newTestStore(t)), nil, cfg)
|
||||
if w == nil {
|
||||
t.Fatal("no worker")
|
||||
}
|
||||
// PollFeed goes through the guarded fetcher; a feed URL pointing at the box
|
||||
// itself must fail rather than be read.
|
||||
_, err := w.poller.PollFeed(context.Background(), rss.FeedConfig{
|
||||
Name: "evil", URL: "http://127.0.0.1:9100/mcp",
|
||||
}, time.Now())
|
||||
if err == nil {
|
||||
t.Fatal("the poller fetched a private address")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user