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:
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/weather"
|
||||
)
|
||||
|
||||
@@ -67,6 +68,11 @@ var querySources = []querySource{
|
||||
// answer it from whatever he once said about spending. Its matcher needs a
|
||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||
{"money", (*reactiveHandler).queryMoney},
|
||||
// Before the recall sources and before general knowledge: "что нового?" is
|
||||
// a question about the feeds she reads, and general knowledge would answer
|
||||
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||||
// "у меня новая лента в инстаграме" is untouched.
|
||||
{"feeds", (*reactiveHandler).queryFeeds},
|
||||
{"calendar", (*reactiveHandler).queryCalendar},
|
||||
{"weather", (*reactiveHandler).queryWeather},
|
||||
{"embed", (*reactiveHandler).queryEmbed},
|
||||
@@ -180,6 +186,63 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
|
||||
return profile.FormatOverallRU(), true
|
||||
}
|
||||
|
||||
// feedNoteWindow — how many recent notes are scanned for feed items, and
|
||||
// feedReadOut — how many headlines she actually reads back. She summarises the
|
||||
// top of the pile, she does not recite a river.
|
||||
const (
|
||||
feedNoteWindow = 200
|
||||
feedReadOut = 3
|
||||
)
|
||||
|
||||
// queryFeeds — "что нового в лентах?", "что нового по технологиям?"
|
||||
// (Vikunja #258).
|
||||
//
|
||||
// This is the ONLY way a feed item reaches him. The poller writes notes and
|
||||
// never speaks; asking is the trigger. If that ever changes, the thing that
|
||||
// changed is "Maven is not a nag", not a detail of this file.
|
||||
func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
q, ok := router.ParseFeedQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if !h.feedsOn {
|
||||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
||||
// letting general knowledge answer "что нового?" would be an invented
|
||||
// news bulletin.
|
||||
return "я пока не читаю ленты — они не настроены.", true
|
||||
}
|
||||
notes, err := h.api.RecentNotes(ctx, feedNoteWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: feeds: recent notes: %v", err)
|
||||
return "не получилось посмотреть ленты.", true
|
||||
}
|
||||
var picked []string
|
||||
for _, n := range notes {
|
||||
if !strings.HasPrefix(n.Source, rss.SourcePrefix) {
|
||||
continue
|
||||
}
|
||||
if !router.CategoryMatches(n.Text, q.Category) {
|
||||
continue
|
||||
}
|
||||
// The note carries title, summary and link; she reads the title.
|
||||
title := n.Text
|
||||
if i := strings.IndexByte(title, '\n'); i > 0 {
|
||||
title = title[:i]
|
||||
}
|
||||
picked = append(picked, strings.TrimSpace(title))
|
||||
if len(picked) == feedReadOut {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(picked) == 0 {
|
||||
if q.Category != "" {
|
||||
return "по этой теме в лентах пока ничего.", true
|
||||
}
|
||||
return "в лентах пока ничего нового.", true
|
||||
}
|
||||
return "вот что нового: " + strings.Join(picked, "; "), true
|
||||
}
|
||||
|
||||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||||
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
||||
// source can be tested at a fixed time like the rest.
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// mavend/feeds.go — the driver for RSS/Atom reading (Vikunja #258,
|
||||
// docs/plans/13-rss-news-feeds.md). The reader itself is pure and lives in
|
||||
// internal/rss; this is the impure half: a ticker, the guarded fetcher, and the
|
||||
// two adapters that let a pure package talk to the store.
|
||||
//
|
||||
// Why in-core rather than its own daemon like mavmaild and mavpoll: those two
|
||||
// hold a CREDENTIAL (an IMAP password, a zenmoney token), and the reason they
|
||||
// are separate processes is that core must never see it. A feed URL is public,
|
||||
// there is no secret to isolate, and a whole extra binary and compose service
|
||||
// would buy nothing. The other half of the mavpoll precedent — off unless
|
||||
// configured — is kept: no `feeds` block, no poller, no outbound request.
|
||||
//
|
||||
// It is its own goroutine, not a step on the tick: the tick has a delivery
|
||||
// deadline behind it, and a feed read is a network round-trip that nobody is
|
||||
// waiting on.
|
||||
//
|
||||
// Nothing here dispatches. A feed that announced itself would be a nag, so the
|
||||
// only output is notes with source "rss:<feed>", which the answer path reads
|
||||
// when he asks ("что нового в лентах?" — see queryFeeds in actions_query.go).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// feedWorker — ticker + poller.
|
||||
type feedWorker struct {
|
||||
poller *rss.Poller
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// feedTickInterval — how often the worker asks the poller what is due. Per-feed
|
||||
// cadence is the poller's business; this is just the granularity.
|
||||
const feedTickInterval = 5 * time.Minute
|
||||
|
||||
// newFeedWorker wires feed reading, or returns nil when it must not run:
|
||||
// no `feeds` block (the normal case), or nothing valid in it. Every caller
|
||||
// checks for nil.
|
||||
func newFeedWorker(api ipc.CoreAPI, emb router.Embedder, cfg *config.Config) *feedWorker {
|
||||
if cfg.Feeds == nil {
|
||||
return nil
|
||||
}
|
||||
fc := cfg.Feeds
|
||||
|
||||
feeds := make([]rss.FeedConfig, 0, len(fc.Sources))
|
||||
hosts := append([]string(nil), fc.AllowHosts...)
|
||||
for _, s := range fc.Sources {
|
||||
feeds = append(feeds, rss.FeedConfig{
|
||||
Name: s.Name,
|
||||
URL: s.URL,
|
||||
Category: s.Category,
|
||||
Interval: time.Duration(s.Interval),
|
||||
Include: s.Include,
|
||||
Exclude: s.Exclude,
|
||||
})
|
||||
// Each configured feed's own host is allowed. The allowlist is then
|
||||
// exactly "the feeds he asked for", so a redirect off to somewhere else
|
||||
// is refused by the fetcher rather than followed.
|
||||
if u, err := url.Parse(s.URL); err == nil && u.Hostname() != "" {
|
||||
hosts = append(hosts, u.Hostname())
|
||||
}
|
||||
}
|
||||
|
||||
fetcher := webfetch.New(webfetch.Config{
|
||||
AllowHosts: hosts,
|
||||
Timeout: time.Duration(fc.Timeout),
|
||||
MaxBytes: fc.MaxBytes,
|
||||
})
|
||||
poller := rss.NewPoller(feeds, &feedFetcher{f: fetcher}, api, &factMarks{api: api},
|
||||
embedderFor(emb), nil, rss.Config{
|
||||
DefaultInterval: time.Duration(fc.PollInterval),
|
||||
MaxItems: fc.MaxItems,
|
||||
MaxAge: time.Duration(fc.MaxAge),
|
||||
})
|
||||
if poller == nil {
|
||||
log.Printf("feeds: configured but nothing pollable — feed reading disabled")
|
||||
return nil
|
||||
}
|
||||
log.Printf("feeds: reading %d feed(s), checking what is due every %s", len(feeds), feedTickInterval)
|
||||
return &feedWorker{poller: poller, interval: feedTickInterval}
|
||||
}
|
||||
|
||||
// run polls what is due until ctx is canceled. The first round runs immediately
|
||||
// so a restart does not blind her for the first interval; it writes notes only,
|
||||
// so an early round cannot startle anyone.
|
||||
func (w *feedWorker) run(ctx context.Context) {
|
||||
w.poller.PollDue(ctx, time.Now())
|
||||
t := time.NewTicker(w.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-t.C:
|
||||
w.poller.PollDue(ctx, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// embedderOf — the voice wiring's embedder, or nil when voice is not wired.
|
||||
// Feed notes are embedded with the SAME model the rest of the store uses, or not
|
||||
// at all; a second embedder would write vectors nothing can search.
|
||||
func embedderOf(w *voiceWiring) router.Embedder {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.embedder
|
||||
}
|
||||
|
||||
// feedFetcher adapts webfetch to rss.Fetcher — the pure package names the two
|
||||
// fields it needs and stays free of net/http.
|
||||
type feedFetcher struct{ f *webfetch.Fetcher }
|
||||
|
||||
func (a *feedFetcher) Get(ctx context.Context, u string) (*rss.Body, error) {
|
||||
resp, err := a.f.Get(ctx, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rss.Body{Bytes: resp.Body}, nil
|
||||
}
|
||||
|
||||
// factMarks stores "how far this feed was read" as a config fact, the same
|
||||
// mechanism the plan named and the same one the pattern tick uses for its own
|
||||
// bookkeeping. Durable, inspectable on /dash, and cheap.
|
||||
type factMarks struct{ api ipc.CoreAPI }
|
||||
|
||||
func markKey(feed string) string { return "rss:latest:" + feed }
|
||||
|
||||
func (m *factMarks) LastMark(ctx context.Context, feed string) (time.Time, error) {
|
||||
f, err := m.api.LatestFact(ctx, markKey(feed))
|
||||
if err != nil {
|
||||
// No mark yet is not an error worth propagating: the poller treats a
|
||||
// zero time as a cold start.
|
||||
return time.Time{}, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, f.Value)
|
||||
if err != nil {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (m *factMarks) SetMark(ctx context.Context, feed string, at time.Time) error {
|
||||
_, err := m.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: time.Now(),
|
||||
Kind: "config",
|
||||
Key: markKey(feed),
|
||||
Value: at.UTC().Format(time.RFC3339),
|
||||
Source: "poll:rss",
|
||||
Confidence: 1.0,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// embedderFor adapts router.Embedder to rss.Embedder, and returns nil when
|
||||
// there is none — a note without a vector is still a note the recent-notes path
|
||||
// can read.
|
||||
//
|
||||
// EmbedPassage, not Embed: a feed item is text being searched FOR, and the e5
|
||||
// embedder is asymmetric. Getting this backwards makes the item unfindable by
|
||||
// the question that should have matched it.
|
||||
func embedderFor(emb router.Embedder) rss.Embedder {
|
||||
if emb == nil {
|
||||
return nil
|
||||
}
|
||||
return passageEmbedder{emb}
|
||||
}
|
||||
|
||||
type passageEmbedder struct{ e router.Embedder }
|
||||
|
||||
func (p passageEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
return router.EmbedPassage(ctx, p.e, text)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,7 @@ func run(args []string) error {
|
||||
eco *ecosystemWiring
|
||||
factWorker *factEnrichmentWorker
|
||||
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
|
||||
feedWkr *feedWorker // nil ⇒ no feed is read (the default)
|
||||
)
|
||||
|
||||
if !locked {
|
||||
@@ -265,6 +266,7 @@ func run(args []string) error {
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
||||
|
||||
coreAPI = &daemonAPI{
|
||||
CoreAPI: ipc.NewStoreAPI(st),
|
||||
@@ -451,6 +453,7 @@ func run(args []string) error {
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
||||
|
||||
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
||||
newAPI := &daemonAPI{
|
||||
@@ -496,6 +499,13 @@ func run(args []string) error {
|
||||
}()
|
||||
}
|
||||
|
||||
// Start feed reading (nil unless configured).
|
||||
if feedWkr != nil {
|
||||
go func() {
|
||||
feedWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
dl.unlock()
|
||||
log.Printf("mavend: unlocked via passkey assertion")
|
||||
return nil
|
||||
@@ -541,6 +551,13 @@ func run(args []string) error {
|
||||
evalWorker.run(ctx)
|
||||
}()
|
||||
}
|
||||
if feedWkr != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
feedWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
@@ -82,6 +82,11 @@ type reactiveHandler struct {
|
||||
replier voice.Replier
|
||||
now func() time.Time
|
||||
|
||||
// feedsOn — whether any RSS feed is configured (config.Feeds). It changes
|
||||
// only what she SAYS when asked and nothing is there: "ленты не настроены"
|
||||
// instead of "ничего нового", which are different truths.
|
||||
feedsOn bool
|
||||
|
||||
weatherProvider weather.Provider
|
||||
weatherLocation string // default location for weather queries
|
||||
|
||||
|
||||
@@ -211,6 +211,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
replier: replier,
|
||||
phraser: phr,
|
||||
now: time.Now,
|
||||
feedsOn: cfg.Feeds != nil,
|
||||
weatherProvider: weatherProvider,
|
||||
weatherLocation: weatherLocation,
|
||||
memStore: memStore,
|
||||
|
||||
Reference in New Issue
Block a user