diff --git a/.env.example b/.env.example index 86ab7fd..65879f3 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,7 @@ BOT_NEWS_API_KEY=replace-with-your-newsapi-key BOT_YOUTUBE_API_KEY=replace-with-your-youtube-data-api-key BOT_LASTFM_API_KEY=replace-with-your-lastfm-api-key BOT_GITHUB_TOKEN=replace-with-an-optional-github-token +BOT_GENIUS_ACCESS_TOKEN=replace-with-your-genius-client-access-token # Optional Opengist paste integration. Keep the token out of config.yaml. BOT_PASTE_BASE_URL=https://paste.example.net BOT_PASTE_TOKEN=replace-with-your-opengist-token diff --git a/config.yaml b/config.yaml index 8758be0..820e481 100644 --- a/config.yaml +++ b/config.yaml @@ -85,6 +85,8 @@ plugins: steam: {enabled: true, timeout_seconds: 10, max_length: 360} # IMDb max_length is UTF-8 response bytes; the IRC 512-byte wire cap is also enforced. imdb: {enabled: true, timeout_seconds: 8, max_length: 320, max_results: 3, cooldown_seconds: 5} + # Requires a Genius client access token in BOT_GENIUS_ACCESS_TOKEN. + lyrics: {enabled: true, timeout_seconds: 8, max_length: 320, 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. diff --git a/docs/configuration.md b/docs/configuration.md index 143f787..cec732a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -107,6 +107,8 @@ plugins: github: {enabled: true, timeout_seconds: 8, max_length: 360, token: ""} # IMDb max_length is UTF-8 response bytes; the IRC 512-byte wire cap is also enforced. imdb: {enabled: true, timeout_seconds: 8, max_length: 320, max_results: 3, cooldown_seconds: 5} + # Requires BOT_GENIUS_ACCESS_TOKEN in .env or the service environment. + lyrics: {enabled: true, timeout_seconds: 8, max_length: 320, 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} diff --git a/docs/plugins.md b/docs/plugins.md index d02514a..d05ba16 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -17,6 +17,7 @@ Plugins are enabled or disabled under plugins..enabled in config.yaml. - 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 +- lyrics: Genius song and lyrics-page search - news: NewsAPI headlines and search - ask: DuckDuckGo Search Assist with bounded public-result excerpts, Instant Answer, and Wikidata fallbacks - wikipedia: English Wikipedia summaries @@ -699,6 +700,44 @@ plugins: cooldown_seconds: 5 ~~~ +## Lyrics search + +Find a song on Genius and return its canonical lyrics page as one bounded IRC +line: + +~~~text +!lyrics Paranoid Android +!lyric artist - song title +!genius song title +~~~ + +The command searches Genius's public API, selects a song result, validates the +returned host, and provides one `https://genius.com/...` link. GoBot does not +download or reproduce lyric text. The Genius API requires a client access +token; keep it in `.env` or the service environment rather than +`config.yaml`: + +~~~text +BOT_GENIUS_ACCESS_TOKEN=your-genius-client-access-token +~~~ + +Missing credentials, authentication failures, empty results, upstream errors, +invalid URLs, and malformed third-party text each produce a clear single-line +response. Queries and returned metadata reject control characters, invisible +formatting characters, variation selectors, and line breaks. Responses are +bounded by `plugins.lyrics.max_length` and the IRC 512-byte wire-line limit. + +Optional settings: + +~~~yaml +plugins: + lyrics: + enabled: true + timeout_seconds: 8 + max_length: 320 + cooldown_seconds: 5 +~~~ + ## Horoscope Fetch today's horoscope by zodiac sign. The sign is saved for your nickname: diff --git a/plugins/lyrics.go b/plugins/lyrics.go new file mode 100644 index 0000000..09a29df --- /dev/null +++ b/plugins/lyrics.go @@ -0,0 +1,270 @@ +package plugins + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/variablenix/GoBot/bot" + "github.com/variablenix/GoBot/storage" +) + +const ( + defaultLyricsMaxLength = 320 + lyricsIRCMaxLineBytes = 512 + geniusSearchEndpoint = "https://api.genius.com/search" +) + +type Lyrics struct { + cfg bot.PluginConfig + cooldown scopedCooldown +} + +type geniusSearchResponse struct { + Response struct { + Hits []struct { + Result geniusSong `json:"result"` + } `json:"hits"` + } `json:"response"` +} + +type geniusSong struct { + Type string `json:"type"` + Title string `json:"title"` + FullTitle string `json:"full_title"` + ArtistNames string `json:"artist_names"` + URL string `json:"url"` + PrimaryArtist struct { + Name string `json:"name"` + } `json:"primary_artist"` +} + +func (p *Lyrics) Name() string { return "lyrics" } +func (p *Lyrics) Commands() []string { return []string{"lyrics", "lyric", "genius"} } +func (p *Lyrics) Help() string { + return "!lyrics — find a Genius lyrics page; aliases: !lyric, !genius" +} + +func (p *Lyrics) Init(c bot.PluginConfig, _ *storage.DB) error { + p.cfg = c + p.cooldown.configure(c.Int("cooldown_seconds", 5), 5) + return nil +} + +func (p *Lyrics) Handle(b *bot.Bot, m bot.Message) bool { + cmd, arg, ok := bot.IsCommand(m, b.Config.CommandPrefix) + if !ok || !isLyricsCommand(cmd) { + return false + } + + query := strings.TrimSpace(arg) + if !validLyricsQuery(query) { + p.send(b, m.ReplyTarget(), "usage: !lyrics ") + return true + } + + key := scopedKey(b.Config.NetworkName, m.ReplyTarget(), pluginIdentity(m)) + if !p.cooldown.allow(key) { + p.send(b, m.ReplyTarget(), "lyrics search is cooling down — please wait a moment") + return true + } + + token := strings.TrimSpace(os.Getenv("BOT_GENIUS_ACCESS_TOKEN")) + if !validGeniusToken(token) { + p.send(b, m.ReplyTarget(), "lyrics search is not configured (set BOT_GENIUS_ACCESS_TOKEN)") + return true + } + + ctx, cancel := context.WithTimeout(context.Background(), lyricsTimeout(p.cfg)) + defer cancel() + song, err := lookupGeniusSong(ctx, query, token) + if err != nil { + switch { + case errors.Is(err, errGeniusNotFound): + p.send(b, m.ReplyTarget(), fmt.Sprintf("no Genius lyrics found for %q", query)) + case errors.Is(err, errGeniusUnauthorized): + p.send(b, m.ReplyTarget(), "lyrics search authentication failed; check BOT_GENIUS_ACCESS_TOKEN") + default: + p.send(b, m.ReplyTarget(), "lyrics search is temporarily unavailable") + } + return true + } + + p.send(b, m.ReplyTarget(), formatLyricsResult(song)) + return true +} + +func (p *Lyrics) send(b *bot.Bot, target, text string) { + b.Send(target, boundLyricsReply(target, text, lyricsMaxLength(p.cfg))) +} + +var ( + errGeniusNotFound = errors.New("Genius song not found") + errGeniusUnauthorized = errors.New("Genius authentication failed") +) + +func isLyricsCommand(command string) bool { + switch strings.ToLower(command) { + case "lyrics", "lyric", "genius": + return true + default: + return false + } +} + +func validLyricsQuery(query string) bool { + if query == "" || !utf8.ValidString(query) || len([]rune(query)) > 160 { + return false + } + for _, r := range query { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || + (r >= 0xFE00 && r <= 0xFE0F) || + (r >= 0xE0100 && r <= 0xE01EF) { + return false + } + } + return true +} + +func validGeniusToken(token string) bool { + if token == "" || len(token) > 4096 || !utf8.ValidString(token) { + return false + } + for _, r := range token { + if unicode.IsControl(r) { + return false + } + } + return true +} + +func lyricsTimeout(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 lyricsMaxLength(c bot.PluginConfig) int { + max := c.Int("max_length", defaultLyricsMaxLength) + if max < 120 || max > 500 { + max = defaultLyricsMaxLength + } + return max +} + +func boundLyricsReply(target, text string, configuredMax int) string { + text = cleanLyricsText(text) + wireLimit := lyricsIRCMaxLineBytes - len("PRIVMSG ") - len([]byte(target)) - len(" :") - len("\r\n") + if wireLimit < 1 { + wireLimit = 1 + } + if configuredMax < 1 || configuredMax > wireLimit { + configuredMax = wireLimit + } + return truncateUTF8Bytes(text, configuredMax) +} + +func cleanLyricsText(text string) string { + text = cleanExternalText(text) + var cleaned strings.Builder + cleaned.Grow(len(text)) + for _, r := range text { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || + (r >= 0xFE00 && r <= 0xFE0F) || + (r >= 0xE0100 && r <= 0xE01EF) { + continue + } + cleaned.WriteRune(r) + } + return strings.Join(strings.Fields(cleaned.String()), " ") +} + +func lookupGeniusSong(ctx context.Context, query, token string) (geniusSong, error) { + if !validLyricsQuery(query) { + return geniusSong{}, errGeniusNotFound + } + endpoint, err := url.Parse(geniusSearchEndpoint) + if err != nil { + return geniusSong{}, err + } + values := endpoint.Query() + values.Set("q", query) + endpoint.RawQuery = values.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return geniusSong{}, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", "GoBot/1.0 (IRC bot; Genius lyrics link lookup)") + + res, err := apiHTTPClient.Do(req) + if err != nil { + return geniusSong{}, err + } + defer res.Body.Close() + if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden { + return geniusSong{}, errGeniusUnauthorized + } + if res.StatusCode == http.StatusNotFound { + return geniusSong{}, errGeniusNotFound + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return geniusSong{}, fmt.Errorf("Genius returned HTTP %d", res.StatusCode) + } + + var payload geniusSearchResponse + if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&payload); err != nil { + return geniusSong{}, err + } + for _, hit := range payload.Response.Hits { + if !strings.EqualFold(strings.TrimSpace(hit.Result.Type), "song") { + continue + } + if !validGeniusSongURL(hit.Result.URL) { + continue + } + return hit.Result, nil + } + return geniusSong{}, errGeniusNotFound +} + +func validGeniusSongURL(raw string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || !strings.EqualFold(parsed.Scheme, "https") || parsed.User != nil || parsed.Path == "" { + return false + } + return strings.EqualFold(parsed.Hostname(), "genius.com") || + strings.EqualFold(parsed.Hostname(), "www.genius.com") +} + +func formatLyricsResult(song geniusSong) string { + artist := cleanLyricsText(song.ArtistNames) + if artist == "" { + artist = cleanLyricsText(song.PrimaryArtist.Name) + } + title := cleanLyricsText(song.Title) + if title == "" { + title = cleanLyricsText(song.FullTitle) + } + link := strings.TrimSpace(song.URL) + if artist != "" && title != "" { + return fmt.Sprintf("[lyrics] %s - %s | %s", artist, title, link) + } + if title != "" { + return fmt.Sprintf("[lyrics] %s | %s", title, link) + } + return "[lyrics] Genius song | " + link +} diff --git a/plugins/lyrics_test.go b/plugins/lyrics_test.go new file mode 100644 index 0000000..6cc5e1f --- /dev/null +++ b/plugins/lyrics_test.go @@ -0,0 +1,188 @@ +package plugins + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + "testing" + "unicode/utf8" + + "github.com/variablenix/GoBot/bot" +) + +func TestLyricsCommandsAndHelp(t *testing.T) { + plugin := &Lyrics{} + if got := plugin.Commands(); len(got) != 3 || got[0] != "lyrics" || got[1] != "lyric" || got[2] != "genius" { + t.Fatalf("commands = %#v, want [lyrics lyric genius]", got) + } + if !strings.Contains(plugin.Help(), "!lyrics ") { + t.Fatalf("help = %q", plugin.Help()) + } +} + +func TestValidLyricsQuery(t *testing.T) { + for _, query := range []string{"Paranoid Android", `artist - "song title"`, "Beyoncé — Halo"} { + if !validLyricsQuery(query) { + t.Fatalf("validLyricsQuery(%q) = false", query) + } + } + for _, query := range []string{"", "hello\nworld", "hidden\u200djoiner", "emoji\ufe0f", strings.Repeat("x", 161)} { + if validLyricsQuery(query) { + t.Fatalf("validLyricsQuery(%q) = true", query) + } + } +} + +func TestValidGeniusSongURL(t *testing.T) { + for _, raw := range []string{ + "https://genius.com/Radiohead-paranoid-android-lyrics", + "https://www.genius.com/songs/example", + } { + if !validGeniusSongURL(raw) { + t.Errorf("validGeniusSongURL(%q) = false", raw) + } + } + for _, raw := range []string{ + "http://genius.com/song", + "https://evil.example/genius.com/song", + "https://genius.com.evil.example/song", + "https://user:pass@genius.com/song", + "https://genius.com", + } { + if validGeniusSongURL(raw) { + t.Errorf("validGeniusSongURL(%q) = true", raw) + } + } +} + +func TestLookupGeniusSongUsesTokenAndSelectsSong(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.Path != "/search" || r.URL.Query().Get("q") != `artist - "song"` { + t.Fatalf("unexpected Genius request: %s", r.URL) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Fatalf("Authorization = %q", got) + } + return newPluginResponse(http.StatusOK, `{"response":{"hits":[ + {"result":{"type":"artist","title":"Artist","url":"https://genius.com/artists/artist"}}, + {"result":{"type":"song","title":"Song","artist_names":"Artist","url":"https://genius.com/Artist-song-lyrics"}} + ]}}`), nil + })} + + song, err := lookupGeniusSong(t.Context(), `artist - "song"`, "test-token") + if err != nil { + t.Fatalf("lookupGeniusSong() error = %v", err) + } + if song.Title != "Song" || song.URL != "https://genius.com/Artist-song-lyrics" { + t.Fatalf("song = %#v", song) + } +} + +func TestLookupGeniusSongRejectsInvalidResults(t *testing.T) { + old := apiHTTPClient + t.Cleanup(func() { apiHTTPClient = old }) + apiHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(*http.Request) (*http.Response, error) { + return newPluginResponse(http.StatusOK, `{"response":{"hits":[ + {"result":{"type":"artist","title":"Artist","url":"https://genius.com/artists/artist"}}, + {"result":{"type":"song","title":"Bad host","url":"https://evil.example/song"}} + ]}}`), nil + })} + if _, err := lookupGeniusSong(t.Context(), "song", "token"); !errors.Is(err, errGeniusNotFound) { + t.Fatalf("lookupGeniusSong() error = %v, want not found", err) + } +} + +func TestFormatLyricsResultCleansMetadata(t *testing.T) { + got := formatLyricsResult(geniusSong{ + Title: "Song\ufe0f\u200d\u202e", + ArtistNames: "Artist\x03 Name", + URL: "https://genius.com/Artist-song-lyrics", + }) + if got != "[lyrics] Artist Name - Song | https://genius.com/Artist-song-lyrics" { + t.Fatalf("formatLyricsResult() = %q", got) + } + if strings.ContainsAny(got, "\r\n") || strings.ContainsAny(got, "\ufe0f\u200d\u202e") { + t.Fatalf("formatted result contains unsafe text: %q", got) + } +} + +func TestBoundLyricsReplyUsesUTF8WireByteLimit(t *testing.T) { + target := "#international-music" + reply := boundLyricsReply(target, "[lyrics] "+strings.Repeat("界", 300)+"\r\nsecond line", 500) + wire := "PRIVMSG " + target + " :" + reply + "\r\n" + if len([]byte(wire)) > lyricsIRCMaxLineBytes { + t.Fatalf("wire line is %d bytes, want at most %d", len([]byte(wire)), lyricsIRCMaxLineBytes) + } + if !utf8.ValidString(reply) || strings.ContainsAny(reply, "\r\n") { + t.Fatalf("reply is not a valid single UTF-8 line: %q", reply) + } +} + +func TestLyricsHandleReturnsOneBoundedLine(t *testing.T) { + old := apiHTTPClient + t.Cleanup(func() { apiHTTPClient = old }) + t.Setenv("BOT_GENIUS_ACCESS_TOKEN", "test-token") + apiHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(*http.Request) (*http.Response, error) { + return newPluginResponse(http.StatusOK, `{"response":{"hits":[{"result":{"type":"song","title":"Song\ufe0f","artist_names":"Artist\u200d","url":"https://genius.com/Artist-song-lyrics"}}]}}`), nil + })} + + sent := make(chan bot.Outgoing, 2) + b := &bot.Bot{ + Config: bot.Config{NetworkName: "test", CommandPrefix: "!"}, + Queue: bot.NewQueue(1000, 20, func(message bot.Outgoing) { sent <- message }), + } + plugin := &Lyrics{} + if err := plugin.Init(bot.PluginConfig{"max_length": 500, "cooldown_seconds": 1}, nil); err != nil { + t.Fatal(err) + } + if !plugin.Handle(b, bot.Message{Nick: "Alice", Target: "#music", IsChannel: true, Text: "!lyric Artist - Song"}) { + t.Fatal("lyrics command was not consumed") + } + b.Queue.Drain(context.Background()) + if len(sent) != 1 { + t.Fatalf("sent %d messages, want exactly one", len(sent)) + } + outgoing := <-sent + if !strings.Contains(outgoing.Text, "https://genius.com/Artist-song-lyrics") { + t.Fatalf("reply = %q", outgoing.Text) + } + wire := "PRIVMSG " + outgoing.Target + " :" + outgoing.Text + "\r\n" + if len([]byte(wire)) > lyricsIRCMaxLineBytes || !utf8.ValidString(outgoing.Text) || strings.ContainsAny(outgoing.Text, "\r\n") { + t.Fatalf("reply is not a bounded UTF-8 wire line: %q", outgoing.Text) + } +} + +func TestLyricsHandleReportsMissingConfiguration(t *testing.T) { + t.Setenv("BOT_GENIUS_ACCESS_TOKEN", "") + sent := make(chan bot.Outgoing, 1) + b := &bot.Bot{ + Config: bot.Config{NetworkName: "test", CommandPrefix: "!"}, + Queue: bot.NewQueue(1000, 20, func(message bot.Outgoing) { sent <- message }), + } + plugin := &Lyrics{} + if err := plugin.Init(bot.PluginConfig{"cooldown_seconds": 1}, nil); err != nil { + t.Fatal(err) + } + plugin.Handle(b, bot.Message{Nick: "Alice", Target: "#music", IsChannel: true, Text: "!lyrics Song"}) + b.Queue.Drain(context.Background()) + if got := (<-sent).Text; got != "lyrics search is not configured (set BOT_GENIUS_ACCESS_TOKEN)" { + t.Fatalf("reply = %q", got) + } +} + +func TestLyricsSearchURLQueryEscapesInput(t *testing.T) { + endpoint, err := url.Parse(geniusSearchEndpoint) + if err != nil { + t.Fatal(err) + } + values := endpoint.Query() + values.Set("q", `artist - "song"`) + endpoint.RawQuery = values.Encode() + if endpoint.Query().Get("q") != `artist - "song"` { + t.Fatalf("query round trip failed: %s", endpoint) + } +} diff --git a/plugins/plugins.go b/plugins/plugins.go index 4cefd14..998df17 100644 --- a/plugins/plugins.go +++ b/plugins/plugins.go @@ -15,6 +15,7 @@ func All() []bot.Plugin { &Weather{}, &Steam{}, &IMDb{}, + &Lyrics{}, &News{}, &Ask{}, &Wikipedia{},