From ac6df50ddfa3aa3d7527aa173155a857978a3fd4 Mon Sep 17 00:00:00 2001 From: AK Date: Sat, 8 Aug 2026 06:38:17 -0700 Subject: [PATCH] feat: add Reddit subreddit sort lookups --- docs/plugins.md | 8 ++++ plugins/reddit.go | 93 +++++++++++++++++++++++++++++++++++++----- plugins/reddit_test.go | 48 ++++++++++++++++++++++ 3 files changed, 138 insertions(+), 11 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 2ba1c10..0409e2c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -614,6 +614,8 @@ post from a subreddit: !reddit r/linux !reddit https://www.reddit.com/r/linux/ !r r/golang +!r top r/linuxmemes +!r r/linuxmemes rising ~~~ The response contains the post title, author, subreddit, score, comment count, @@ -624,6 +626,12 @@ temporarily blocked or throttled. RSS does not reliably include scores or comment counts, so those fields are omitted rather than shown as false zeroes. For individual posts, a final oEmbed title fallback can still provide the title when Reddit rate-limits both metadata endpoints. +For subreddit lookups, an optional sort selects the first post from Reddit's +`best`, `hot`, `new`, `top`, or `rising` listing. Put the sort before or after +the subreddit; for example, `!r top r/linuxmemes` returns the current #1 post +from the subreddit’s top listing, while plain `!r r/linuxmemes` remains the +newest-post lookup. Sort modifiers are only for subreddit lookups, not +individual post URLs. Explicit non-default sorts are labeled in the response. Only recognized Reddit hosts and paths are accepted; arbitrary URL fetching is not performed by this command. diff --git a/plugins/reddit.go b/plugins/reddit.go index 5a1aa1c..41d7ce2 100644 --- a/plugins/reddit.go +++ b/plugins/reddit.go @@ -20,7 +20,7 @@ type Reddit struct{ cfg bot.PluginConfig } func (p *Reddit) Name() string { return "reddit" } func (p *Reddit) Commands() []string { return []string{"reddit", "r"} } func (p *Reddit) Help() string { - return "!reddit — show one compact Reddit result (alias: !r)" + return "!reddit [best|hot|new|top|rising] — show one compact Reddit result; sort may follow subreddit (alias: !r)" } func (p *Reddit) Init(c bot.PluginConfig, _ *storage.DB) error { p.cfg = c; return nil } @@ -29,21 +29,22 @@ func (p *Reddit) Handle(b *bot.Bot, m bot.Message) bool { if !ok || !isRedditCommand(cmd) { return false } - postURL, endpoint, ok := redditLookupEndpoint(strings.TrimSpace(arg)) + target, sort, ok := parseRedditLookupArg(arg) + postURL, endpoint, ok := redditLookupEndpointWithSort(target, sort) if !ok { - b.Send(m.ReplyTarget(), ircColor(ircYellow, "usage: !reddit ")) + b.Send(m.ReplyTarget(), ircColor(ircYellow, "usage: !reddit [best|hot|new|top|rising] ")) return true } ctx, cancel := context.WithTimeout(context.Background(), redditTimeout(p.cfg)) defer cancel() post, ok := fetchRedditPost(ctx, endpoint) if !ok { - if rssEndpoint, rssOK := redditRSSEndpoint(strings.TrimSpace(arg)); rssOK { + if rssEndpoint, rssOK := redditRSSEndpointWithSort(target, sort); rssOK { post, ok = fetchRedditRSS(ctx, rssEndpoint) } } if !ok { - if oembedEndpoint, oembedOK := redditOEmbedEndpoint(strings.TrimSpace(arg)); oembedOK { + if oembedEndpoint, oembedOK := redditOEmbedEndpoint(target); oembedOK { post, ok = fetchRedditOEmbed(ctx, oembedEndpoint, postURL) } } @@ -55,7 +56,7 @@ func (p *Reddit) Handle(b *bot.Bot, m bot.Message) bool { if maxLength < 120 { maxLength = 120 } - result := formatRedditResult(post, postURL) + result := formatRedditResultWithSort(post, postURL, sort) b.Send(m.ReplyTarget(), truncateRunes(result, maxLength)) return true } @@ -70,7 +71,15 @@ type redditPost struct { } func formatRedditResult(post redditPost, postURL string) string { - result := "[Reddit] " + cleanExternalText(post.Title) + return formatRedditResultWithSort(post, postURL, "") +} + +func formatRedditResultWithSort(post redditPost, postURL, sort string) string { + prefix := "[Reddit]" + if sort != "" && sort != "new" { + prefix = "[Reddit " + sort + "]" + } + result := prefix + " " + cleanExternalText(post.Title) if author := cleanExternalText(post.Author); author != "" { result += " | u/" + author } @@ -291,13 +300,32 @@ func redditPostEndpoint(raw string) (string, string, bool) { } func redditLookupEndpoint(raw string) (string, string, bool) { + return redditLookupEndpointWithSort(raw, "new") +} + +func redditLookupEndpointWithSort(raw, sort string) (string, string, bool) { + sort = normalizeRedditSort(sort) + if sort == "" { + return "", "", false + } if postURL, endpoint, ok := redditPostEndpoint(raw); ok { + if sort != "new" { + return "", "", false + } return postURL, endpoint, true } - return redditSubredditEndpoint(raw) + return redditSubredditEndpointWithSort(raw, sort) } func redditSubredditEndpoint(raw string) (string, string, bool) { + return redditSubredditEndpointWithSort(raw, "new") +} + +func redditSubredditEndpointWithSort(raw, sort string) (string, string, bool) { + sort = normalizeRedditSort(sort) + if sort == "" { + return "", "", false + } value := strings.TrimSpace(raw) name := "" if strings.HasPrefix(strings.ToLower(value), "r/") { @@ -326,14 +354,22 @@ func redditSubredditEndpoint(raw string) (string, string, bool) { endpointURL := &url.URL{ Scheme: "https", Host: "www.reddit.com", - Path: "/r/" + name + "/new.json", + Path: "/r/" + name + "/" + sort + ".json", RawQuery: "raw_json=1&limit=1", } return postURL, endpointURL.String(), true } func redditRSSEndpoint(raw string) (string, bool) { - if _, _, ok := redditSubredditEndpoint(raw); ok { + return redditRSSEndpointWithSort(raw, "new") +} + +func redditRSSEndpointWithSort(raw, sort string) (string, bool) { + sort = normalizeRedditSort(sort) + if sort == "" { + return "", false + } + if _, _, ok := redditSubredditEndpointWithSort(raw, sort); ok { value := strings.TrimSpace(raw) name := "" if strings.HasPrefix(strings.ToLower(value), "r/") { @@ -346,10 +382,16 @@ func redditRSSEndpoint(raw string) (string, bool) { } name = strings.TrimSuffix(name, "/") if validRedditSubreddit(name) { - return "https://www.reddit.com/r/" + name + ".rss?limit=1", true + if sort == "new" { + return "https://www.reddit.com/r/" + name + ".rss?limit=1", true + } + return "https://www.reddit.com/r/" + name + "/" + sort + ".rss?limit=1", true } } if postURL, _, ok := redditPostEndpoint(raw); ok { + if sort != "new" { + return "", false + } parsed, err := url.Parse(postURL) if err != nil { return "", false @@ -359,6 +401,35 @@ func redditRSSEndpoint(raw string) (string, bool) { return "", false } +func parseRedditLookupArg(raw string) (target, sort string, ok bool) { + fields := strings.Fields(strings.TrimSpace(raw)) + if len(fields) == 1 { + if normalizeRedditSort(fields[0]) != "" { + return "", "", false + } + return fields[0], "new", true + } + if len(fields) != 2 { + return "", "", false + } + if normalized := normalizeRedditSort(fields[0]); normalized != "" { + return fields[1], normalized, true + } + if normalized := normalizeRedditSort(fields[1]); normalized != "" { + return fields[0], normalized, true + } + return "", "", false +} + +func normalizeRedditSort(sort string) string { + switch strings.ToLower(strings.TrimSpace(sort)) { + case "best", "hot", "new", "top", "rising": + return strings.ToLower(strings.TrimSpace(sort)) + default: + return "" + } +} + func subredditFromRedditURL(raw string) string { parsed, err := url.Parse(raw) if err != nil { diff --git a/plugins/reddit_test.go b/plugins/reddit_test.go index 6cf4235..ce6023b 100644 --- a/plugins/reddit_test.go +++ b/plugins/reddit_test.go @@ -41,6 +41,46 @@ func TestRedditSubredditEndpoint(t *testing.T) { } } +func TestRedditSortedSubredditEndpoint(t *testing.T) { + postURL, endpoint, ok := redditSubredditEndpointWithSort("r/linux", "top") + wantURL := "https://www.reddit.com/r/linux/" + wantEndpoint := "https://www.reddit.com/r/linux/top.json?raw_json=1&limit=1" + if !ok || postURL != wantURL || endpoint != wantEndpoint { + t.Fatalf("unexpected sorted subreddit URLs: %q, %q, %v", postURL, endpoint, ok) + } + if _, _, ok := redditSubredditEndpointWithSort("r/linux", "controversial"); ok { + t.Fatal("unsupported Reddit sort accepted") + } +} + +func TestParseRedditLookupArg(t *testing.T) { + tests := []struct { + arg, target, sort string + ok bool + }{ + {arg: "r/linuxmemes", target: "r/linuxmemes", sort: "new", ok: true}, + {arg: "top r/linuxmemes", target: "r/linuxmemes", sort: "top", ok: true}, + {arg: "r/linuxmemes rising", target: "r/linuxmemes", sort: "rising", ok: true}, + {arg: "hot https://www.reddit.com/r/linux/", target: "https://www.reddit.com/r/linux/", sort: "hot", ok: true}, + {arg: "top", target: "", sort: "", ok: false}, + {arg: "top r/linuxmemes week", target: "", sort: "", ok: false}, + } + for _, test := range tests { + target, sort, ok := parseRedditLookupArg(test.arg) + if target != test.target || sort != test.sort || ok != test.ok { + t.Errorf("parseRedditLookupArg(%q) = %q, %q, %v; want %q, %q, %v", test.arg, target, sort, ok, test.target, test.sort, test.ok) + } + } +} + +func TestRedditSortedRSSEndpoint(t *testing.T) { + got, ok := redditRSSEndpointWithSort("r/linux", "hot") + want := "https://www.reddit.com/r/linux/hot.rss?limit=1" + if !ok || got != want { + t.Fatalf("unexpected sorted RSS endpoint: %q, %v", got, ok) + } +} + func TestRedditLookupEndpointAcceptsPostsAndSubreddits(t *testing.T) { if _, _, ok := redditLookupEndpoint("r/golang"); !ok { t.Fatal("subreddit lookup was rejected") @@ -80,6 +120,14 @@ func TestFormatRedditResultOmitsUnavailableStats(t *testing.T) { } } +func TestFormatRedditResultIncludesExplicitSort(t *testing.T) { + got := formatRedditResultWithSort(redditPost{Title: "A title"}, "https://www.reddit.com/r/linux/", "top") + want := "[Reddit top] A title — https://www.reddit.com/r/linux/" + if got != want { + t.Fatalf("unexpected sorted Reddit result: %q", got) + } +} + func TestFetchRedditRSS(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/rss+xml")