diff --git a/internal/explainer/explainer.go b/internal/explainer/explainer.go index 3a4d67c..d9196cf 100644 --- a/internal/explainer/explainer.go +++ b/internal/explainer/explainer.go @@ -25,7 +25,9 @@ import ( // plain "?" so users don't have to hunt for the exact red question mark. var questionReactions = map[string]bool{ "❓": true, // U+2753 red question mark + "❓️": true, // U+2753 + VS16 (emoji presentation) "❔": true, // U+2754 white question mark + "❔️": true, // U+2754 + VS16 "⁉": true, // U+2049 exclamation question mark "⁉️": true, // U+2049 + VS16 (emoji presentation) "🤔": true, // U+1F914 thinking face diff --git a/internal/explainer/explainer_test.go b/internal/explainer/explainer_test.go index 4d1b5d4..fa121cd 100644 --- a/internal/explainer/explainer_test.go +++ b/internal/explainer/explainer_test.go @@ -95,7 +95,7 @@ func TestFormatSummary_NoBulletsPassthrough(t *testing.T) { } func TestIsQuestionReaction(t *testing.T) { - want := []string{"❓", "❔", "⁉", "⁉️", "🤔", "?", "?"} + want := []string{"❓", "❓️", "❔", "❔️", "⁉", "⁉️", "🤔", "?", "?"} for _, k := range want { if !IsQuestionReaction(k) { t.Errorf("expected %q to trigger explain", k) diff --git a/internal/ingestion/image.go b/internal/ingestion/image.go index b8f10cf..e5e1bc3 100644 --- a/internal/ingestion/image.go +++ b/internal/ingestion/image.go @@ -3,11 +3,32 @@ package ingestion import ( "log/slog" "net/http" + "net/url" "strconv" "strings" "time" ) +// NormalizeImageURL rewrites known CDN thumbnail URLs to a higher-resolution +// variant. Currently handles Guardian's i.guim.co.uk, whose RSS feeds hand out +// 140-px-wide thumbnails by default. Unrecognized hosts pass through unchanged. +func NormalizeImageURL(raw string) string { + if raw == "" { + return raw + } + u, err := url.Parse(raw) + if err != nil || u.Host != "i.guim.co.uk" { + return raw + } + q := u.Query() + if q.Get("width") == "" { + return raw + } + q.Set("width", "1200") + u.RawQuery = q.Encode() + return u.String() +} + var imageClient = &http.Client{ Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -54,7 +75,7 @@ func ValidateImageURL(url string) bool { if cl := resp.Header.Get("Content-Length"); cl != "" { size, err := strconv.ParseInt(cl, 10, 64) if err == nil && size <= 5120 { - slog.Warn("image validation: too small (likely tracking pixel)", "url", url, "size", size) + slog.Warn("image validation: small image (≤5KB), skipping", "url", url, "size", size) return false } } diff --git a/internal/ingestion/parser.go b/internal/ingestion/parser.go index f81dde0..9e1cada 100644 --- a/internal/ingestion/parser.go +++ b/internal/ingestion/parser.go @@ -55,7 +55,7 @@ func FetchFeed(feedURL string) ([]FeedItem, error) { GUID: itemGUID(item), Headline: strings.TrimSpace(item.Title), Lede: extractLede(item.Description), - ImageURL: extractImageURL(item), + ImageURL: NormalizeImageURL(extractImageURL(item)), ArticleURL: strings.TrimSpace(item.Link), } if fi.GUID == "" || fi.ArticleURL == "" { diff --git a/internal/matrix/client.go b/internal/matrix/client.go index 7594d9c..865de52 100644 --- a/internal/matrix/client.go +++ b/internal/matrix/client.go @@ -133,6 +133,13 @@ func New(cfg config.MatrixConfig) (*Client, error) { return nil, fmt.Errorf("init crypto helper: %w", err) } + // Surface decrypt failures — the default is a silent no-op, which hides + // missing-megolm-session errors (the usual cause of "Pete ignores reactions"). + ch.DecryptErrorCallback = func(evt *event.Event, err error) { + slog.Warn("matrix: failed to decrypt incoming event", + "room", evt.RoomID, "event_id", evt.ID, "sender", evt.Sender, "err", err) + } + // LoginAs enables the cryptohelper to re-login if the token expires ch.LoginAs = &mautrix.ReqLogin{ Type: mautrix.AuthTypePassword, diff --git a/internal/poster/queue.go b/internal/poster/queue.go index ab6a91c..55435e8 100644 --- a/internal/poster/queue.go +++ b/internal/poster/queue.go @@ -195,6 +195,13 @@ func (q *Queue) drainChannel(channel string) { q.postItem(item) } +// PostNow sends a story immediately, bypassing the in-memory queue and all +// pacing limits. Last-mile canonical-URL dedup still applies. Used by !post +// to satisfy on-demand requests with stories pulled directly from storage. +func (q *Queue) PostNow(item QueueItem) { + q.postItem(item) +} + func (q *Queue) postItem(item QueueItem) { // Last-mile dedup: if this canonical URL was already posted to this channel // within the cooldown window, drop silently. Catches "same article, different diff --git a/internal/poster/tracker.go b/internal/poster/tracker.go index 80e8efb..5058615 100644 --- a/internal/poster/tracker.go +++ b/internal/poster/tracker.go @@ -32,7 +32,8 @@ func SetReactionCallback(fn ReactionCallback) { func HandleReaction(roomID id.RoomID, eventID id.EventID, targetEventID id.EventID, emoji string, userID id.UserID) { guid, channel, found := storage.LookupPostGUID(string(targetEventID)) if !found { - // Reaction on a message we didn't post — ignore + slog.Info("reaction on event not in post_log, ignoring", + "target_event", targetEventID, "emoji", emoji, "user", userID) return } diff --git a/internal/storage/queries.go b/internal/storage/queries.go index fe0f2f4..5459bf0 100644 --- a/internal/storage/queries.go +++ b/internal/storage/queries.go @@ -247,6 +247,29 @@ func GetNewestPostableStory(source string) (*Story, error) { return &s, nil } +// GetNewestPostableStoryByChannel returns the newest classified story routed +// to the given channel that has not yet been posted. Used by !post to satisfy +// on-demand requests in round-robin mode (where the in-memory queue is empty +// between ticks). Returns (nil, nil) when nothing qualifies. +func GetNewestPostableStoryByChannel(channel 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 channel = ? + AND guid NOT IN (SELECT guid FROM post_log) + ORDER BY seen_at DESC + LIMIT 1`, channel) + 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) { diff --git a/main.go b/main.go index 98371b5..1f61530 100644 --- a/main.go +++ b/main.go @@ -92,11 +92,35 @@ func main() { if queue.ForcePost(channel) { return } + // In-memory queue empty (common in round-robin mode). Fall back to the + // newest classified, not-yet-posted story for this channel. + story, err := storage.GetNewestPostableStoryByChannel(channel) + if err != nil { + slog.Error("!post: db lookup failed", "channel", channel, "err", err) + return + } + if story != nil { + imageURL := "" + if story.ImageURL != "" && ingestion.ValidateImageURL(story.ImageURL) { + imageURL = story.ImageURL + } + queue.PostNow(poster.QueueItem{ + GUID: story.GUID, + Headline: story.Headline, + Lede: story.Lede, + ImageURL: imageURL, + ArticleURL: story.ArticleURL, + Source: story.Source, + Channel: story.Channel, + Platforms: storage.UnmarshalPlatforms(story.Platforms), + }) + return + } if err := mx.PostThreadedReply(channel, eventID, - "nothing queued for "+channel, - "nothing queued for "+channel+"", + "nothing available for "+channel, + "nothing available for "+channel+"", ); err != nil { - slog.Warn("!post: failed to send empty-queue reply", "err", err) + slog.Warn("!post: failed to send empty reply", "err", err) } })