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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
30
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 <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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user