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
1 change: 1 addition & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ plugins:
acronym: {enabled: true, data_file: "data/acronyms.txt", max_length: 320}
weather: {enabled: true, default_units: imperial}
steam: {enabled: true, timeout_seconds: 10, max_length: 360}
imdb: {enabled: true, timeout_seconds: 8, max_length: 320, max_results: 3, cooldown_seconds: 5}
news: {enabled: true, api_key: "", max_results: 3, max_length: 360}
# Search Assist and bounded public-result excerpts; Instant Answers/Wikidata remain fallbacks.
# No credentials are required.
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ plugins:
attack: {enabled: true}
weapons: {enabled: true, data_file: "data/weapons.txt", max_length: 240}
github: {enabled: true, timeout_seconds: 8, max_length: 360, token: ""}
imdb: {enabled: true, timeout_seconds: 8, max_length: 320, max_results: 3, cooldown_seconds: 5}
reddit: {enabled: true, timeout_seconds: 8, max_length: 360}
daily: {enabled: true, bonus_xp: 25}
scramble: {enabled: true, data_file: "data/scramble.txt", timeout_minutes: 5, max_length: 240}
Expand Down
38 changes: 38 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Plugins are enabled or disabled under plugins.<name>.enabled in config.yaml.
- acronym: local operator-maintained acronym expansion
- weather: Open-Meteo weather, no key required
- steam: Steam game search, genre links, and most-played lookup, no key required
- imdb: keyless IMDb movie and film title search
- news: NewsAPI headlines and search
- ask: DuckDuckGo Search Assist with bounded public-result excerpts, Instant Answer, and Wikidata fallbacks
- wikipedia: English Wikipedia summaries
Expand Down Expand Up @@ -658,6 +659,43 @@ sends the full response by private message and tells the channel that it is
messaging the requester. Configure `plugins.steam.timeout_seconds` and
`plugins.steam.max_length` as needed.

## IMDb title search

Search IMDb movie, film, series, episode, and other title suggestions without
an API key:

~~~text
!imdb inception
!imdb the matrix
!imdb spirited away
~~~

GoBot uses IMDb's keyless JSON suggestion endpoint to return up to
`plugins.imdb.max_results` matches with the title, year, type, principal cast
when available, and a direct IMDb link. Results are kept to one IRC line and
bounded by `plugins.imdb.max_length`. The `🎥` marker is deliberately a single
Unicode code point without a U+FE0F variation selector, which avoids an
avoidable source of width differences in some terminal clients, including
older Mosh terminal combinations.

This is not HTML scraping and it is not IMDb's official GraphQL API. IMDb's
official non-commercial datasets are bulk downloads, while the official API
is a separate subscription product that requires credentials and an API key.
The keyless suggestion endpoint is undocumented and may change; if it is
unavailable, GoBot returns a single-line temporary-unavailable response.

Optional settings:

~~~yaml
plugins:
imdb:
enabled: true
timeout_seconds: 8
max_length: 320
max_results: 3
cooldown_seconds: 5
~~~

## Horoscope

Fetch today's horoscope by zodiac sign. The sign is saved for your nickname:
Expand Down
247 changes: 247 additions & 0 deletions plugins/imdb.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
package plugins

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"

"github.com/variablenix/GoBot/bot"
"github.com/variablenix/GoBot/storage"
)

// Keep this marker to a single code point. In particular, do not append
// U+FE0F: some terminal stacks calculate emoji presentation sequences with
// inconsistent cell widths.
const imdbPrefix = "🎥"

const (
defaultIMDbMaxLength = 320
defaultIMDbResults = 3
)

type IMDb struct {
cfg bot.PluginConfig
cooldown scopedCooldown
}

type imdbTitle struct {
ID string `json:"id"`
Label string `json:"l"`
Kind string `json:"q"`
KindID string `json:"qid"`
Stars string `json:"s"`
Year int `json:"y"`
YearRaw string `json:"yr"`
}

type imdbSuggestionResponse struct {
Titles []imdbTitle `json:"d"`
}

func (p *IMDb) Name() string { return "imdb" }
func (p *IMDb) Commands() []string { return []string{"imdb"} }
func (p *IMDb) Help() string {
return "!imdb <movie or film> — search IMDb titles; no API key required"
}

func (p *IMDb) Init(c bot.PluginConfig, _ *storage.DB) error {
p.cfg = c
p.cooldown.configure(c.Int("cooldown_seconds", 5), 5)
return nil
}

func (p *IMDb) Handle(b *bot.Bot, m bot.Message) bool {
cmd, arg, ok := bot.IsCommand(m, b.Config.CommandPrefix)
if !ok || !strings.EqualFold(cmd, "imdb") {
return false
}

query := strings.TrimSpace(arg)
if !validIMDbQuery(query) {
b.Send(m.ReplyTarget(), "usage: !imdb <movie or film>")
return true
}

key := scopedKey(b.Config.NetworkName, m.ReplyTarget(), pluginIdentity(m))
if !p.cooldown.allow(key) {
b.Send(m.ReplyTarget(), "IMDb search is cooling down — please wait a moment")
return true
}

ctx, cancel := context.WithTimeout(context.Background(), imdbTimeout(p.cfg))
defer cancel()
titles, err := lookupIMDbTitles(ctx, query)
if err != nil {
if err == errIMDbNotFound {
b.Send(m.ReplyTarget(), fmt.Sprintf("%s IMDb: no movie or film found for %q", imdbPrefix, cleanExternalText(query)))
} else {
b.Send(m.ReplyTarget(), "IMDb search is temporarily unavailable")
}
return true
}

result := formatIMDbResults(titles, imdbMaxResults(p.cfg))
b.Send(m.ReplyTarget(), truncateRunes(result, imdbMaxLength(p.cfg)))
return true
}

var errIMDbNotFound = errors.New("IMDb title not found")

func validIMDbQuery(query string) bool {
return query != "" && len([]rune(query)) <= 120 && !strings.ContainsAny(query, "\r\n\t")
}

func imdbTimeout(c bot.PluginConfig) time.Duration {
seconds := c.Int("timeout_seconds", 8)
if seconds < 1 || seconds > 30 {
seconds = 8
}
return time.Duration(seconds) * time.Second
}

func imdbMaxLength(c bot.PluginConfig) int {
max := c.Int("max_length", defaultIMDbMaxLength)
if max < 120 || max > 500 {
max = defaultIMDbMaxLength
}
return max
}

func imdbMaxResults(c bot.PluginConfig) int {
max := c.Int("max_results", defaultIMDbResults)
if max < 1 || max > 5 {
max = defaultIMDbResults
}
return max
}

func lookupIMDbTitles(ctx context.Context, query string) ([]imdbTitle, error) {
if !validIMDbQuery(query) {
return nil, errIMDbNotFound
}
endpoint := "https://v3.sg.media-imdb.com/suggestion/x/" + url.PathEscape(query) + ".json"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "GoBot/1.0 (IRC bot; IMDb lookup)")
res, err := apiHTTPClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotFound {
return nil, errIMDbNotFound
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("IMDb returned HTTP %d", res.StatusCode)
}

var payload imdbSuggestionResponse
if err := json.NewDecoder(io.LimitReader(res.Body, 2*1024*1024)).Decode(&payload); err != nil {
return nil, err
}
titles := make([]imdbTitle, 0, len(payload.Titles))
for _, title := range payload.Titles {
if !isIMDbTitle(title) || !validIMDbID(title.ID) || strings.TrimSpace(title.Label) == "" {
continue
}
titles = append(titles, title)
}
if len(titles) == 0 {
return nil, errIMDbNotFound
}
return titles, nil
}

func isIMDbTitle(title imdbTitle) bool {
// The suggestion endpoint also returns people. Keep this command focused on
// titles while accepting movies, series, episodes, shorts, and videos.
return !strings.EqualFold(title.KindID, "name") && !strings.EqualFold(title.Kind, "name")
}

func validIMDbID(id string) bool {
id = strings.TrimSpace(id)
if len(id) < 3 || !strings.HasPrefix(id, "tt") {
return false
}
for _, r := range id[2:] {
if r < '0' || r > '9' {
return false
}
}
return true
}

func formatIMDbResults(titles []imdbTitle, maxResults int) string {
if len(titles) == 0 {
return imdbPrefix + " IMDb: no movie or film found"
}
if maxResults < 1 {
maxResults = defaultIMDbResults
}
if len(titles) < maxResults {
maxResults = len(titles)
}
items := make([]string, 0, maxResults)
for _, title := range titles[:maxResults] {
label := cleanExternalText(title.Label)
details := make([]string, 0, 3)
if year := imdbYear(title); year != "" {
details = append(details, year)
}
if kind := imdbKind(title); kind != "" {
details = append(details, kind)
}
if stars := cleanExternalText(title.Stars); stars != "" {
details = append(details, stars)
}
if len(details) > 0 {
label += " (" + strings.Join(details, "; ") + ")"
}
id := cleanExternalText(title.ID)
items = append(items, label+" | https://www.imdb.com/title/"+id+"/")
}
result := imdbPrefix + " IMDb: " + strings.Join(items, " ; ")
if remaining := len(titles) - maxResults; remaining > 0 {
result += fmt.Sprintf(" + %d more", remaining)
}
return result
}

func imdbYear(title imdbTitle) string {
if title.Year > 0 {
return fmt.Sprintf("%d", title.Year)
}
return cleanExternalText(title.YearRaw)
}

func imdbKind(title imdbTitle) string {
switch strings.ToLower(strings.TrimSpace(title.KindID)) {
case "movie":
return "movie"
case "tvmovie":
return "TV movie"
case "tvseries":
return "series"
case "tvminiseries":
return "miniseries"
case "tvepisode":
return "episode"
case "short":
return "short"
case "video":
return "video"
case "videogame":
return "game"
default:
return cleanExternalText(title.Kind)
}
}
77 changes: 77 additions & 0 deletions plugins/imdb_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package plugins

import (
"net/http"
"strings"
"testing"
)

func TestIMDbCommandsAndHelp(t *testing.T) {
plugin := &IMDb{}
if got := plugin.Commands(); len(got) != 1 || got[0] != "imdb" {
t.Fatalf("commands = %#v, want [imdb]", got)
}
if !strings.Contains(plugin.Help(), "!imdb <movie or film>") {
t.Fatalf("help = %q", plugin.Help())
}
}

func TestIMDbPrefixHasNoVariationSelector(t *testing.T) {
if imdbPrefix != "\U0001F3A5" {
t.Fatalf("imdbPrefix = %q, want only U+1F3A5 without U+FE0F", imdbPrefix)
}
}

func TestValidIMDbQuery(t *testing.T) {
if !validIMDbQuery("The Matrix") {
t.Fatal("expected a normal title query to be valid")
}
for _, query := range []string{"", "hello\nworld", strings.Repeat("x", 121)} {
if validIMDbQuery(query) {
t.Fatalf("query %q should be invalid", query)
}
}
}

func TestLookupIMDbTitlesFiltersPeople(t *testing.T) {
old := apiHTTPClient
t.Cleanup(func() { apiHTTPClient = old })
apiHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(r *http.Request) (*http.Response, error) {
if r.URL.String() != "https://v3.sg.media-imdb.com/suggestion/x/inception.json" {
t.Fatalf("unexpected IMDb endpoint: %s", r.URL)
}
return newPluginResponse(http.StatusOK, `{"d":[{"id":"nm0000138","l":"Tom Hanks","q":"actor","qid":"name"},{"id":"tt1375666","l":"Inception","q":"feature","qid":"movie","s":"Leonardo DiCaprio, Joseph Gordon-Levitt","y":2010},{"id":"not-an-imdb-id","l":"Bad result","q":"feature","qid":"movie"}]}`), nil
})}
titles, err := lookupIMDbTitles(t.Context(), "inception")
if err != nil || len(titles) != 1 || titles[0].ID != "tt1375666" {
t.Fatalf("titles = %#v, error = %v", titles, err)
}
}

func TestValidIMDbID(t *testing.T) {
for _, id := range []string{"tt1375666", "tt0000001"} {
if !validIMDbID(id) {
t.Errorf("validIMDbID(%q) = false", id)
}
}
for _, id := range []string{"", "nm0000138", "tt", "tt12x", "tt/123"} {
if validIMDbID(id) {
t.Errorf("validIMDbID(%q) = true", id)
}
}
}

func TestFormatIMDbResultsIsBoundedAndIncludesMoreCount(t *testing.T) {
titles := []imdbTitle{
{ID: "tt1375666", Label: "Inception", KindID: "movie", Year: 2010, Stars: "Leonardo DiCaprio"},
{ID: "tt0133093", Label: "The Matrix", KindID: "movie", Year: 1999},
{ID: "tt0816692", Label: "Interstellar", KindID: "movie", Year: 2014},
{ID: "tt0110912", Label: "Pulp Fiction", KindID: "movie", Year: 1994},
}
got := formatIMDbResults(titles, 3)
for _, want := range []string{"🎥 IMDb:", "Inception (2010; movie; Leonardo DiCaprio)", "https://www.imdb.com/title/tt1375666/", "+ 1 more"} {
if !strings.Contains(got, want) {
t.Errorf("formatted result %q does not contain %q", got, want)
}
}
}
Loading