Add round-robin posting mode

One story per interval_hours (default 4), cycling through enabled sources
in config order. Empty sources are skipped and the pointer advances to
whichever source actually posted. State persists across restarts.

Duplicate-flagged stories now get a _duplicate sentinel channel so they
stay out of the rotation pool alongside _discarded.
This commit is contained in:
prosolis
2026-05-22 19:58:46 -07:00
parent 69967b25c6
commit 3baec4c8bc
7 changed files with 451 additions and 2 deletions

View File

@@ -1,7 +1,9 @@
package storage
import (
"database/sql"
"encoding/json"
"errors"
"log/slog"
"time"
)
@@ -221,6 +223,55 @@ func InsertReaction(postGUID, channel, eventID, emoji, userID string, reactedAt
postGUID, channel, eventID, emoji, userID, reactedAt)
}
// GetNewestPostableStory returns the newest classified story from a source
// that has a real (non-sentinel) channel and has not yet been posted (no
// row in post_log). Returns (nil, nil) when nothing qualifies.
func GetNewestPostableStory(source string) (*Story, error) {
row := Get().QueryRow(
`SELECT guid, headline, lede, image_url, article_url, source, feed_hint, platforms, channel, seen_at
FROM stories
WHERE classified = 1
AND source = ?
AND channel IS NOT NULL
AND channel NOT IN ('_discarded', '_duplicate')
AND guid NOT IN (SELECT guid FROM post_log)
ORDER BY seen_at DESC
LIMIT 1`, source)
var s Story
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.FeedHint, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &s, nil
}
// GetRoundRobinState returns the last source posted by the round-robin
// scheduler and the timestamp of that tick. Empty string + 0 if no state yet.
func GetRoundRobinState() (lastSource string, lastTickAt int64, err error) {
row := Get().QueryRow(`SELECT last_source, last_tick_at FROM round_robin_state WHERE id = 1`)
var ls *string
if scanErr := row.Scan(&ls, &lastTickAt); scanErr != nil {
if errors.Is(scanErr, sql.ErrNoRows) {
return "", 0, nil
}
return "", 0, scanErr
}
if ls != nil {
lastSource = *ls
}
return lastSource, lastTickAt, nil
}
// SetRoundRobinState persists the last-posted source and tick timestamp.
func SetRoundRobinState(lastSource string, tickAt int64) {
exec("set round_robin_state",
`INSERT INTO round_robin_state (id, last_source, last_tick_at) VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET last_source = excluded.last_source, last_tick_at = excluded.last_tick_at`,
lastSource, tickAt)
}
// MarshalPlatforms converts a string slice to a JSON array string for storage.
func MarshalPlatforms(platforms []string) string {
if len(platforms) == 0 {

View File

@@ -40,6 +40,12 @@ CREATE TABLE IF NOT EXISTS post_log (
posted_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS round_robin_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_source TEXT,
last_tick_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_guid TEXT NOT NULL,