Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.

Expand Down
93 changes: 82 additions & 11 deletions plugins/reddit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Reddit post URL|r/subreddit> — show one compact Reddit result (alias: !r)"
return "!reddit [best|hot|new|top|rising] <Reddit post URL|r/subreddit> — 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 }

Expand All @@ -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 <Reddit post URL|r/subreddit>"))
b.Send(m.ReplyTarget(), ircColor(ircYellow, "usage: !reddit [best|hot|new|top|rising] <r/subreddit|Reddit post URL>"))
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)
}
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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/") {
Expand Down Expand Up @@ -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/") {
Expand All @@ -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
Expand All @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions plugins/reddit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down