Fix !post in round-robin mode, reaction VS16, image label

- !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.
This commit is contained in:
prosolis
2026-05-24 09:14:37 -07:00
parent 23fffdda3c
commit afe2ef996b
9 changed files with 92 additions and 7 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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 == "" {

View File

@@ -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,

View File

@@ -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

View File

@@ -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
}

View File

@@ -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) {

30
main.go
View File

@@ -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 <code>"+channel+"</code>",
"nothing available for "+channel,
"nothing available for <code>"+channel+"</code>",
); err != nil {
slog.Warn("!post: failed to send empty-queue reply", "err", err)
slog.Warn("!post: failed to send empty reply", "err", err)
}
})