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.
|
// plain "?" so users don't have to hunt for the exact red question mark.
|
||||||
var questionReactions = map[string]bool{
|
var questionReactions = map[string]bool{
|
||||||
"❓": true, // U+2753 red question mark
|
"❓": true, // U+2753 red question mark
|
||||||
|
"❓️": true, // U+2753 + VS16 (emoji presentation)
|
||||||
"❔": true, // U+2754 white question mark
|
"❔": true, // U+2754 white question mark
|
||||||
|
"❔️": true, // U+2754 + VS16
|
||||||
"⁉": true, // U+2049 exclamation question mark
|
"⁉": true, // U+2049 exclamation question mark
|
||||||
"⁉️": true, // U+2049 + VS16 (emoji presentation)
|
"⁉️": true, // U+2049 + VS16 (emoji presentation)
|
||||||
"🤔": true, // U+1F914 thinking face
|
"🤔": true, // U+1F914 thinking face
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func TestFormatSummary_NoBulletsPassthrough(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIsQuestionReaction(t *testing.T) {
|
func TestIsQuestionReaction(t *testing.T) {
|
||||||
want := []string{"❓", "❔", "⁉", "⁉️", "🤔", "?", "?"}
|
want := []string{"❓", "❓️", "❔", "❔️", "⁉", "⁉️", "🤔", "?", "?"}
|
||||||
for _, k := range want {
|
for _, k := range want {
|
||||||
if !IsQuestionReaction(k) {
|
if !IsQuestionReaction(k) {
|
||||||
t.Errorf("expected %q to trigger explain", k)
|
t.Errorf("expected %q to trigger explain", k)
|
||||||
|
|||||||
@@ -3,11 +3,32 @@ package ingestion
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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{
|
var imageClient = &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
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 != "" {
|
if cl := resp.Header.Get("Content-Length"); cl != "" {
|
||||||
size, err := strconv.ParseInt(cl, 10, 64)
|
size, err := strconv.ParseInt(cl, 10, 64)
|
||||||
if err == nil && size <= 5120 {
|
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
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func FetchFeed(feedURL string) ([]FeedItem, error) {
|
|||||||
GUID: itemGUID(item),
|
GUID: itemGUID(item),
|
||||||
Headline: strings.TrimSpace(item.Title),
|
Headline: strings.TrimSpace(item.Title),
|
||||||
Lede: extractLede(item.Description),
|
Lede: extractLede(item.Description),
|
||||||
ImageURL: extractImageURL(item),
|
ImageURL: NormalizeImageURL(extractImageURL(item)),
|
||||||
ArticleURL: strings.TrimSpace(item.Link),
|
ArticleURL: strings.TrimSpace(item.Link),
|
||||||
}
|
}
|
||||||
if fi.GUID == "" || fi.ArticleURL == "" {
|
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)
|
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
|
// LoginAs enables the cryptohelper to re-login if the token expires
|
||||||
ch.LoginAs = &mautrix.ReqLogin{
|
ch.LoginAs = &mautrix.ReqLogin{
|
||||||
Type: mautrix.AuthTypePassword,
|
Type: mautrix.AuthTypePassword,
|
||||||
|
|||||||
@@ -195,6 +195,13 @@ func (q *Queue) drainChannel(channel string) {
|
|||||||
q.postItem(item)
|
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) {
|
func (q *Queue) postItem(item QueueItem) {
|
||||||
// Last-mile dedup: if this canonical URL was already posted to this channel
|
// Last-mile dedup: if this canonical URL was already posted to this channel
|
||||||
// within the cooldown window, drop silently. Catches "same article, different
|
// 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) {
|
func HandleReaction(roomID id.RoomID, eventID id.EventID, targetEventID id.EventID, emoji string, userID id.UserID) {
|
||||||
guid, channel, found := storage.LookupPostGUID(string(targetEventID))
|
guid, channel, found := storage.LookupPostGUID(string(targetEventID))
|
||||||
if !found {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -247,6 +247,29 @@ func GetNewestPostableStory(source string) (*Story, error) {
|
|||||||
return &s, nil
|
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
|
// GetRoundRobinState returns the last source posted by the round-robin
|
||||||
// scheduler and the timestamp of that tick. Empty string + 0 if no state yet.
|
// scheduler and the timestamp of that tick. Empty string + 0 if no state yet.
|
||||||
func GetRoundRobinState() (lastSource string, lastTickAt int64, err error) {
|
func GetRoundRobinState() (lastSource string, lastTickAt int64, err error) {
|
||||||
|
|||||||
30
main.go
30
main.go
@@ -92,11 +92,35 @@ func main() {
|
|||||||
if queue.ForcePost(channel) {
|
if queue.ForcePost(channel) {
|
||||||
return
|
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,
|
if err := mx.PostThreadedReply(channel, eventID,
|
||||||
"nothing queued for "+channel,
|
"nothing available for "+channel,
|
||||||
"nothing queued for <code>"+channel+"</code>",
|
"nothing available for <code>"+channel+"</code>",
|
||||||
); err != nil {
|
); 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