mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
New plugins: encrypted Markov chain generation (9 commands), emojiboard reaction stats (5 views), Miniflux RSS feed integration (8 commands), and 48 new achievements across 9 categories. Adventure startup now unconditionally resets daily actions and respawns expired deaths to handle missed resets from SQLite contention. README updated with all new features. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
745 lines
22 KiB
Go
745 lines
22 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// MinifluxPlugin polls a self-hosted Miniflux RSS instance for new feed entries
|
|
// and routes them to configured Matrix rooms.
|
|
type MinifluxPlugin struct {
|
|
Base
|
|
httpClient *http.Client
|
|
|
|
mu sync.Mutex
|
|
enabled bool
|
|
baseURL string
|
|
apiKey string
|
|
pollInterval int // minutes
|
|
defaultRoom string
|
|
maxPerPoll int
|
|
feedFailCounts map[int64]int // per-feed consecutive failure counter
|
|
pollingDisabled bool // set true on 401
|
|
}
|
|
|
|
func NewMinifluxPlugin(client *mautrix.Client) *MinifluxPlugin {
|
|
return &MinifluxPlugin{
|
|
Base: NewBase(client),
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (p *MinifluxPlugin) Name() string { return "miniflux" }
|
|
|
|
func (p *MinifluxPlugin) Commands() []CommandDef {
|
|
return []CommandDef{
|
|
{Name: "rss", Description: "Miniflux RSS feed routing", Usage: "!rss feeds · !rss subscribe <feed_id> [#room] · !rss unsubscribe <feed_id> · !rss subscriptions · !rss latest <feed_id> · !rss pause <feed_id> · !rss resume <feed_id> · !rss status", Category: "Automation"},
|
|
}
|
|
}
|
|
|
|
func (p *MinifluxPlugin) Init() error {
|
|
p.baseURL = strings.TrimRight(os.Getenv("MINIFLUX_URL"), "/")
|
|
p.apiKey = os.Getenv("MINIFLUX_API_KEY")
|
|
|
|
if p.baseURL == "" || p.apiKey == "" {
|
|
slog.Info("miniflux: disabled (MINIFLUX_URL or MINIFLUX_API_KEY not set)")
|
|
return nil
|
|
}
|
|
|
|
p.pollInterval = 15
|
|
if v := os.Getenv("MINIFLUX_POLL_INTERVAL"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
p.pollInterval = n
|
|
}
|
|
}
|
|
|
|
p.defaultRoom = os.Getenv("MINIFLUX_DEFAULT_ROOM")
|
|
if p.defaultRoom == "" {
|
|
p.defaultRoom = os.Getenv("BROADCAST_ROOMS")
|
|
if idx := strings.Index(p.defaultRoom, ","); idx > 0 {
|
|
p.defaultRoom = p.defaultRoom[:idx]
|
|
}
|
|
}
|
|
p.defaultRoom = strings.TrimSpace(p.defaultRoom)
|
|
|
|
p.maxPerPoll = 5
|
|
if v := os.Getenv("MINIFLUX_MAX_PER_POLL"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
p.maxPerPoll = n
|
|
}
|
|
}
|
|
|
|
p.feedFailCounts = make(map[int64]int)
|
|
p.enabled = true
|
|
slog.Info("miniflux: initialized", "url", p.baseURL, "poll_interval", p.pollInterval, "max_per_poll", p.maxPerPoll)
|
|
return nil
|
|
}
|
|
|
|
func (p *MinifluxPlugin) OnReaction(_ ReactionContext) error { return nil }
|
|
|
|
// PollInterval returns the configured polling interval in minutes.
|
|
func (p *MinifluxPlugin) PollInterval() int { return p.pollInterval }
|
|
|
|
func (p *MinifluxPlugin) OnMessage(ctx MessageContext) error {
|
|
if !p.IsCommand(ctx.Body, "rss") {
|
|
return nil
|
|
}
|
|
|
|
if !p.enabled {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "RSS feed routing is disabled (MINIFLUX_URL not configured).")
|
|
}
|
|
|
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "rss"))
|
|
if args == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
|
"Usage: `!rss feeds` · `!rss subscribe <feed_id> [#room]` · `!rss unsubscribe <feed_id>` · `!rss subscriptions` · `!rss latest <feed_id>` · `!rss pause <feed_id>` · `!rss resume <feed_id>` · `!rss status`")
|
|
}
|
|
|
|
parts := strings.Fields(args)
|
|
sub := strings.ToLower(parts[0])
|
|
|
|
switch sub {
|
|
case "feeds":
|
|
go func() {
|
|
if err := p.cmdFeeds(ctx); err != nil {
|
|
slog.Error("miniflux: feeds error", "err", err)
|
|
}
|
|
}()
|
|
return nil
|
|
case "subscribe":
|
|
if !p.IsAdmin(ctx.Sender) {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can manage subscriptions.")
|
|
}
|
|
return p.cmdSubscribe(ctx, parts[1:])
|
|
case "unsubscribe":
|
|
if !p.IsAdmin(ctx.Sender) {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can manage subscriptions.")
|
|
}
|
|
return p.cmdUnsubscribe(ctx, parts[1:])
|
|
case "subscriptions":
|
|
return p.cmdSubscriptions(ctx)
|
|
case "latest":
|
|
go func() {
|
|
if err := p.cmdLatest(ctx, parts[1:]); err != nil {
|
|
slog.Error("miniflux: latest error", "err", err)
|
|
}
|
|
}()
|
|
return nil
|
|
case "pause":
|
|
if !p.IsAdmin(ctx.Sender) {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can manage subscriptions.")
|
|
}
|
|
return p.cmdPause(ctx, parts[1:])
|
|
case "resume":
|
|
if !p.IsAdmin(ctx.Sender) {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can manage subscriptions.")
|
|
}
|
|
return p.cmdResume(ctx, parts[1:])
|
|
case "status":
|
|
if !p.IsAdmin(ctx.Sender) {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can view polling status.")
|
|
}
|
|
return p.cmdStatus(ctx)
|
|
default:
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Unknown subcommand `%s`. Try `!rss` for usage.", parts[0]))
|
|
}
|
|
}
|
|
|
|
// ── Miniflux API Types ──────────────────────────────────────────────────────
|
|
|
|
// errMinifluxUnauthorized is returned on 401 to allow reliable type checking.
|
|
var errMinifluxUnauthorized = fmt.Errorf("miniflux: 401 unauthorized")
|
|
|
|
type minifluxFeed struct {
|
|
ID int64 `json:"id"`
|
|
Title string `json:"title"`
|
|
SiteURL string `json:"site_url"`
|
|
Category struct {
|
|
Title string `json:"title"`
|
|
} `json:"category"`
|
|
}
|
|
|
|
type minifluxEntry struct {
|
|
ID int64 `json:"id"`
|
|
FeedID int64 `json:"feed_id"`
|
|
Title string `json:"title"`
|
|
URL string `json:"url"`
|
|
Content string `json:"content"`
|
|
Author string `json:"author"`
|
|
PublishedAt string `json:"published_at"`
|
|
Feed struct {
|
|
Title string `json:"title"`
|
|
} `json:"feed"`
|
|
}
|
|
|
|
type minifluxFeedsResponse []minifluxFeed
|
|
|
|
type minifluxEntriesResponse struct {
|
|
Total int `json:"total"`
|
|
Entries []minifluxEntry `json:"entries"`
|
|
}
|
|
|
|
// ── API Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
func (p *MinifluxPlugin) minifluxGet(endpoint string, result interface{}) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
|
defer cancel()
|
|
|
|
url := p.baseURL + endpoint
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("X-Auth-Token", p.apiKey)
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("miniflux API error: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
return errMinifluxUnauthorized
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("miniflux API returned %d", resp.StatusCode)
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
|
|
return fmt.Errorf("miniflux decode error: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *MinifluxPlugin) fetchFeeds() ([]minifluxFeed, error) {
|
|
var feeds minifluxFeedsResponse
|
|
if err := p.minifluxGet("/v1/feeds", &feeds); err != nil {
|
|
return nil, err
|
|
}
|
|
return feeds, nil
|
|
}
|
|
|
|
func (p *MinifluxPlugin) fetchEntries(feedID int64, limit int) ([]minifluxEntry, error) {
|
|
var resp minifluxEntriesResponse
|
|
endpoint := fmt.Sprintf("/v1/feeds/%d/entries?status=unread&limit=%d&order=published_at&direction=asc", feedID, limit)
|
|
if err := p.minifluxGet(endpoint, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Entries, nil
|
|
}
|
|
|
|
// ── Command Handlers ────────────────────────────────────────────────────────
|
|
|
|
func (p *MinifluxPlugin) cmdFeeds(ctx MessageContext) error {
|
|
feeds, err := p.fetchFeeds()
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not fetch feeds: %v", err))
|
|
}
|
|
if len(feeds) == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No feeds found in Miniflux.")
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("**Miniflux Feeds**\n\n")
|
|
for _, f := range feeds {
|
|
cat := f.Category.Title
|
|
if cat == "" {
|
|
cat = "Uncategorized"
|
|
}
|
|
sb.WriteString(fmt.Sprintf("**%d** — %s _%s_\n", f.ID, f.Title, cat))
|
|
}
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|
|
|
|
func (p *MinifluxPlugin) cmdSubscribe(ctx MessageContext, args []string) error {
|
|
if len(args) < 1 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!rss subscribe <feed_id> [#room]`")
|
|
}
|
|
|
|
feedID, err := strconv.ParseInt(args[0], 10, 64)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid feed ID `%s`.", args[0]))
|
|
}
|
|
|
|
roomID := string(ctx.RoomID)
|
|
if len(args) >= 2 {
|
|
roomID = args[1]
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
d := db.Get()
|
|
_, err = d.Exec(
|
|
`INSERT INTO miniflux_subscriptions (feed_id, room_id, paused, created_at) VALUES (?, ?, 0, ?)
|
|
ON CONFLICT(feed_id, room_id) DO UPDATE SET paused = 0`,
|
|
feedID, roomID, now,
|
|
)
|
|
if err != nil {
|
|
slog.Error("miniflux: subscribe insert", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save subscription.")
|
|
}
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Subscribed feed **%d** to room `%s`.", feedID, roomID))
|
|
}
|
|
|
|
func (p *MinifluxPlugin) cmdUnsubscribe(ctx MessageContext, args []string) error {
|
|
if len(args) < 1 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!rss unsubscribe <feed_id>`")
|
|
}
|
|
|
|
feedID, err := strconv.ParseInt(args[0], 10, 64)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid feed ID `%s`.", args[0]))
|
|
}
|
|
|
|
res, err := db.Get().Exec(
|
|
`DELETE FROM miniflux_subscriptions WHERE feed_id = ? AND room_id = ?`,
|
|
feedID, string(ctx.RoomID),
|
|
)
|
|
if err != nil {
|
|
slog.Error("miniflux: unsubscribe delete", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove subscription.")
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No subscription found for feed **%d** in this room.", feedID))
|
|
}
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Unsubscribed feed **%d** from this room.", feedID))
|
|
}
|
|
|
|
func (p *MinifluxPlugin) cmdSubscriptions(ctx MessageContext) error {
|
|
d := db.Get()
|
|
rows, err := d.Query(
|
|
`SELECT feed_id, room_id, paused, created_at FROM miniflux_subscriptions WHERE room_id = ?`,
|
|
string(ctx.RoomID),
|
|
)
|
|
if err != nil {
|
|
slog.Error("miniflux: query subscriptions", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to query subscriptions.")
|
|
}
|
|
defer rows.Close()
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("**RSS Subscriptions for this room**\n\n")
|
|
count := 0
|
|
for rows.Next() {
|
|
var feedID int64
|
|
var roomID string
|
|
var paused int
|
|
var createdAt int64
|
|
if err := rows.Scan(&feedID, &roomID, &paused, &createdAt); err != nil {
|
|
continue
|
|
}
|
|
status := "active"
|
|
if paused == 1 {
|
|
status = "paused"
|
|
}
|
|
t := time.Unix(createdAt, 0).UTC().Format("Jan 2, 2006")
|
|
sb.WriteString(fmt.Sprintf("Feed **%d** — %s _(since %s)_\n", feedID, status, t))
|
|
count++
|
|
}
|
|
if count == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No RSS subscriptions for this room. Use `!rss subscribe <feed_id>` to add one.")
|
|
}
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|
|
|
|
func (p *MinifluxPlugin) cmdLatest(ctx MessageContext, args []string) error {
|
|
if len(args) < 1 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!rss latest <feed_id>`")
|
|
}
|
|
|
|
feedID, err := strconv.ParseInt(args[0], 10, 64)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid feed ID `%s`.", args[0]))
|
|
}
|
|
|
|
entries, err := p.fetchEntries(feedID, 1)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not fetch entries: %v", err))
|
|
}
|
|
if len(entries) == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No unread entries for this feed.")
|
|
}
|
|
|
|
msg := formatEntry(entries[0])
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
|
}
|
|
|
|
func (p *MinifluxPlugin) cmdPause(ctx MessageContext, args []string) error {
|
|
if len(args) < 1 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!rss pause <feed_id>`")
|
|
}
|
|
|
|
feedID, err := strconv.ParseInt(args[0], 10, 64)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid feed ID `%s`.", args[0]))
|
|
}
|
|
|
|
res, err := db.Get().Exec(
|
|
`UPDATE miniflux_subscriptions SET paused = 1 WHERE feed_id = ? AND room_id = ?`,
|
|
feedID, string(ctx.RoomID),
|
|
)
|
|
if err != nil {
|
|
slog.Error("miniflux: pause update", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to pause subscription.")
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No subscription found for feed **%d** in this room.", feedID))
|
|
}
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Paused feed **%d** in this room.", feedID))
|
|
}
|
|
|
|
func (p *MinifluxPlugin) cmdResume(ctx MessageContext, args []string) error {
|
|
if len(args) < 1 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!rss resume <feed_id>`")
|
|
}
|
|
|
|
feedID, err := strconv.ParseInt(args[0], 10, 64)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid feed ID `%s`.", args[0]))
|
|
}
|
|
|
|
res, err := db.Get().Exec(
|
|
`UPDATE miniflux_subscriptions SET paused = 0 WHERE feed_id = ? AND room_id = ?`,
|
|
feedID, string(ctx.RoomID),
|
|
)
|
|
if err != nil {
|
|
slog.Error("miniflux: resume update", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to resume subscription.")
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No subscription found for feed **%d** in this room.", feedID))
|
|
}
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Resumed feed **%d** in this room.", feedID))
|
|
}
|
|
|
|
func (p *MinifluxPlugin) cmdStatus(ctx MessageContext) error {
|
|
p.mu.Lock()
|
|
disabled := p.pollingDisabled
|
|
totalFailing := 0
|
|
for _, v := range p.feedFailCounts {
|
|
if v > 0 {
|
|
totalFailing++
|
|
}
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
status := "active"
|
|
if disabled {
|
|
status = "DISABLED (401 unauthorized)"
|
|
} else if totalFailing > 0 {
|
|
status = fmt.Sprintf("degraded (%d feeds with errors)", totalFailing)
|
|
}
|
|
|
|
// Count subscriptions
|
|
var total, active, paused int
|
|
d := db.Get()
|
|
_ = d.QueryRow(`SELECT COUNT(*) FROM miniflux_subscriptions`).Scan(&total)
|
|
_ = d.QueryRow(`SELECT COUNT(*) FROM miniflux_subscriptions WHERE paused = 0`).Scan(&active)
|
|
_ = d.QueryRow(`SELECT COUNT(*) FROM miniflux_subscriptions WHERE paused = 1`).Scan(&paused)
|
|
|
|
var seenCount int
|
|
_ = d.QueryRow(`SELECT COUNT(*) FROM miniflux_seen`).Scan(&seenCount)
|
|
|
|
msg := fmt.Sprintf("**RSS Polling Status**\n\n"+
|
|
"Status: **%s**\n"+
|
|
"Poll interval: %d minutes\n"+
|
|
"Max per poll: %d\n"+
|
|
"Subscriptions: %d total (%d active, %d paused)\n"+
|
|
"Seen entries tracked: %d",
|
|
status, p.pollInterval, p.maxPerPoll, total, active, paused, seenCount)
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
|
}
|
|
|
|
// ── Entry Formatting ────────────────────────────────────────────────────────
|
|
|
|
func formatEntry(e minifluxEntry) string {
|
|
feedName := e.Feed.Title
|
|
if feedName == "" {
|
|
feedName = "RSS"
|
|
}
|
|
|
|
summary := stripHTMLTags(e.Content)
|
|
summary = truncateAtWordBoundary(summary, 280)
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("📰 %s\n\n", feedName))
|
|
sb.WriteString(fmt.Sprintf("**%s**\n", e.Title))
|
|
sb.WriteString(e.URL + "\n\n")
|
|
if summary != "" {
|
|
sb.WriteString(fmt.Sprintf("> %s\n\n", summary))
|
|
}
|
|
|
|
// Build attribution line
|
|
var attr []string
|
|
if e.Author != "" {
|
|
attr = append(attr, e.Author)
|
|
}
|
|
if e.PublishedAt != "" {
|
|
if t, err := time.Parse(time.RFC3339, e.PublishedAt); err == nil {
|
|
attr = append(attr, timeAgo(t))
|
|
}
|
|
}
|
|
if len(attr) > 0 {
|
|
sb.WriteString("— " + strings.Join(attr, " · "))
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// truncateAtWordBoundary truncates s to at most maxLen characters, breaking at
|
|
// a word boundary, and appends an ellipsis if truncated.
|
|
func truncateAtWordBoundary(s string, maxLen int) string {
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
// Find last space before maxLen
|
|
truncated := s[:maxLen]
|
|
if idx := strings.LastIndex(truncated, " "); idx > 0 {
|
|
truncated = truncated[:idx]
|
|
}
|
|
return truncated + "…"
|
|
}
|
|
|
|
// timeAgo returns a human-readable relative time string.
|
|
func timeAgo(t time.Time) string {
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
m := int(d.Minutes())
|
|
if m == 1 {
|
|
return "1m ago"
|
|
}
|
|
return fmt.Sprintf("%dm ago", m)
|
|
case d < 24*time.Hour:
|
|
h := int(d.Hours())
|
|
if h == 1 {
|
|
return "1h ago"
|
|
}
|
|
return fmt.Sprintf("%dh ago", h)
|
|
default:
|
|
days := int(d.Hours() / 24)
|
|
if days == 1 {
|
|
return "1d ago"
|
|
}
|
|
return fmt.Sprintf("%dd ago", days)
|
|
}
|
|
}
|
|
|
|
// ── Polling (called by scheduler) ───────────────────────────────────────────
|
|
|
|
// MinifluxPoll runs one poll cycle: fetches new entries for all active
|
|
// subscriptions and posts them to the configured rooms. Safe to call from
|
|
// the scheduler goroutine.
|
|
func MinifluxPoll(client *mautrix.Client) {
|
|
p := findMinifluxPlugin(client)
|
|
if p == nil {
|
|
return
|
|
}
|
|
p.poll()
|
|
}
|
|
|
|
// minifluxPluginInstance caches the singleton so the scheduler can find it.
|
|
var (
|
|
minifluxPluginInstance *MinifluxPlugin
|
|
minifluxPluginInstanceMu sync.Mutex
|
|
)
|
|
|
|
// RegisterMinifluxPlugin stores the plugin instance for scheduler access.
|
|
func RegisterMinifluxPlugin(p *MinifluxPlugin) {
|
|
minifluxPluginInstanceMu.Lock()
|
|
minifluxPluginInstance = p
|
|
minifluxPluginInstanceMu.Unlock()
|
|
}
|
|
|
|
func findMinifluxPlugin(_ *mautrix.Client) *MinifluxPlugin {
|
|
minifluxPluginInstanceMu.Lock()
|
|
defer minifluxPluginInstanceMu.Unlock()
|
|
return minifluxPluginInstance
|
|
}
|
|
|
|
func (p *MinifluxPlugin) poll() {
|
|
if !p.enabled {
|
|
return
|
|
}
|
|
|
|
p.mu.Lock()
|
|
if p.pollingDisabled {
|
|
p.mu.Unlock()
|
|
return
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
// Get all active (non-paused) subscriptions
|
|
d := db.Get()
|
|
rows, err := d.Query(`SELECT DISTINCT feed_id, room_id FROM miniflux_subscriptions WHERE paused = 0`)
|
|
if err != nil {
|
|
slog.Error("miniflux: poll query subscriptions", "err", err)
|
|
return
|
|
}
|
|
|
|
type sub struct {
|
|
feedID int64
|
|
roomID string
|
|
}
|
|
var subs []sub
|
|
for rows.Next() {
|
|
var s sub
|
|
if err := rows.Scan(&s.feedID, &s.roomID); err != nil {
|
|
continue
|
|
}
|
|
subs = append(subs, s)
|
|
}
|
|
rows.Close() // close explicitly before HTTP work
|
|
|
|
if len(subs) == 0 {
|
|
return
|
|
}
|
|
|
|
// Group subscriptions by feed ID
|
|
feedRooms := make(map[int64][]string)
|
|
for _, s := range subs {
|
|
feedRooms[s.feedID] = append(feedRooms[s.feedID], s.roomID)
|
|
}
|
|
|
|
for feedID, rooms := range feedRooms {
|
|
entries, err := p.fetchEntries(feedID, p.maxPerPoll)
|
|
if err != nil {
|
|
p.handlePollError(feedID, err)
|
|
slog.Error("miniflux: poll fetch entries", "feed_id", feedID, "err", err)
|
|
continue
|
|
}
|
|
|
|
// Reset consecutive failures for this feed on success
|
|
p.mu.Lock()
|
|
delete(p.feedFailCounts, feedID)
|
|
p.mu.Unlock()
|
|
|
|
for _, entry := range entries {
|
|
// Check if already seen
|
|
if p.isEntrySeen(feedID, entry.ID) {
|
|
continue
|
|
}
|
|
|
|
// Check if this is a first-time subscription and entry is older than 24h
|
|
if p.isEntryTooOld(entry) {
|
|
p.markEntrySeen(feedID, entry.ID)
|
|
continue
|
|
}
|
|
|
|
// Post to all subscribed rooms
|
|
msg := formatEntry(entry)
|
|
for _, roomID := range rooms {
|
|
if err := p.SendMessage(id.RoomID(roomID), msg); err != nil {
|
|
slog.Error("miniflux: post entry", "feed_id", feedID, "entry_id", entry.ID, "room", roomID, "err", err)
|
|
}
|
|
}
|
|
|
|
p.markEntrySeen(feedID, entry.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *MinifluxPlugin) handlePollError(feedID int64, err error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
// On 401, disable polling immediately and alert admins
|
|
if errors.Is(err, errMinifluxUnauthorized) {
|
|
p.pollingDisabled = true
|
|
slog.Error("miniflux: 401 unauthorized, disabling all polling")
|
|
go p.alertAdmins("RSS polling has been **disabled** due to a 401 Unauthorized response from Miniflux. Check MINIFLUX_API_KEY.")
|
|
return
|
|
}
|
|
|
|
p.feedFailCounts[feedID]++
|
|
if p.feedFailCounts[feedID] == 5 {
|
|
slog.Error("miniflux: 5 consecutive poll failures for feed", "feed_id", feedID)
|
|
go p.alertAdmins(fmt.Sprintf("RSS feed **%d** has failed **5 consecutive polls**. Latest error: %v", feedID, err))
|
|
}
|
|
}
|
|
|
|
func (p *MinifluxPlugin) alertAdmins(msg string) {
|
|
admins := os.Getenv("ADMIN_USERS")
|
|
if admins == "" {
|
|
return
|
|
}
|
|
for _, a := range strings.Split(admins, ",") {
|
|
userID := id.UserID(strings.TrimSpace(a))
|
|
if userID == "" {
|
|
continue
|
|
}
|
|
if err := p.SendDM(userID, msg); err != nil {
|
|
slog.Error("miniflux: failed to DM admin", "admin", userID, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *MinifluxPlugin) isEntrySeen(feedID, entryID int64) bool {
|
|
var count int
|
|
err := db.Get().QueryRow(
|
|
`SELECT COUNT(*) FROM miniflux_seen WHERE feed_id = ? AND entry_id = ?`,
|
|
feedID, entryID,
|
|
).Scan(&count)
|
|
return err == nil && count > 0
|
|
}
|
|
|
|
func (p *MinifluxPlugin) markEntrySeen(feedID, entryID int64) {
|
|
db.Exec("miniflux mark seen",
|
|
`INSERT OR IGNORE INTO miniflux_seen (feed_id, entry_id, seen_at) VALUES (?, ?, ?)`,
|
|
feedID, entryID, time.Now().Unix(),
|
|
)
|
|
}
|
|
|
|
// isEntryTooOld returns true if the entry was published more than 24h ago.
|
|
// Used to skip old entries on first subscription.
|
|
func (p *MinifluxPlugin) isEntryTooOld(entry minifluxEntry) bool {
|
|
if entry.PublishedAt == "" {
|
|
return false
|
|
}
|
|
t, err := time.Parse(time.RFC3339, entry.PublishedAt)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return time.Since(t) > 24*time.Hour
|
|
}
|
|
|
|
// ── Maintenance ─────────────────────────────────────────────────────────────
|
|
|
|
// MinifluxPurgeSeen removes miniflux_seen entries older than 7 days.
|
|
// Intended to be called from the maintenance scheduler.
|
|
func MinifluxPurgeSeen() {
|
|
cutoff := time.Now().Add(-7 * 24 * time.Hour).Unix()
|
|
res, err := db.Get().Exec(`DELETE FROM miniflux_seen WHERE seen_at < ?`, cutoff)
|
|
if err != nil {
|
|
slog.Error("miniflux: purge seen", "err", err)
|
|
return
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n > 0 {
|
|
slog.Info("miniflux: purged seen entries", "rows", n)
|
|
}
|
|
}
|