From afe2ef996b4a26c6d98c5706e069816f210c66f6 Mon Sep 17 00:00:00 2001
From: prosolis <5590409+prosolis@users.noreply.github.com>
Date: Sun, 24 May 2026 09:14:37 -0700
Subject: [PATCH] =?UTF-8?q?Fix=20!post=20in=20round-robin=20mode,=20?=
=?UTF-8?q?=E2=9D=93=20reaction=20VS16,=20image=20label?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- !post falls back to newest unposted story for the channel when the
in-memory queue is empty (the steady state under round-robin).
- Accept ❓️/❔️ (U+FE0F variation selector) as question reactions —
the bare codepoints alone missed clients that render the colored emoji.
- Rewrite Guardian i.guim.co.uk thumbnails to width=1200 so we stop
rejecting real images as "tracking pixels"; relabel the size warning.
- Log decrypt failures and reactions on events not in post_log so future
silent drops surface instead of vanishing.
---
internal/explainer/explainer.go | 2 ++
internal/explainer/explainer_test.go | 2 +-
internal/ingestion/image.go | 23 ++++++++++++++++++++-
internal/ingestion/parser.go | 2 +-
internal/matrix/client.go | 7 +++++++
internal/poster/queue.go | 7 +++++++
internal/poster/tracker.go | 3 ++-
internal/storage/queries.go | 23 +++++++++++++++++++++
main.go | 30 +++++++++++++++++++++++++---
9 files changed, 92 insertions(+), 7 deletions(-)
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)
}
})