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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user