M config.go => config.go +28 -2
@@ 1,5 1,11 @@
package main
+import (
+ "encoding/json"
+ "log"
+ "os"
+)
+
type Config struct {
IRC IRCConfig `json:"kwargs"`
Channels []string `json:"channels"`
@@ 18,8 24,9 @@ type IRCConfig struct {
}
type HandlebotCfg struct {
- Database string `json:"database"`
- APIKeys APIKeys `json:"keys"`
+ CommandPrefix string `json:"command_prefix"`
+ Database string `json:"database"`
+ APIKeys APIKeys `json:"keys"`
}
type APIKeys struct {
@@ 28,3 35,22 @@ type APIKeys struct {
MapQuest string `json:"mapquest"`
OpenWeatherMap string `json:"openweathermap"`
}
+
+func parseConfig(path string) *Config {
+ var cfg *Config
+
+ file, err := os.Open(path)
+ if err != nil {
+ log.Fatal("couldn't open the config file: ", err)
+ }
+ defer file.Close()
+
+ if err := json.NewDecoder(file).Decode(&cfg); err != nil {
+ log.Fatal("couldn't parse the config file: ", err)
+ }
+
+ if cfg.CommandPrefix == "" {
+ cfg.CommandPrefix = "."
+ }
+ return cfg
+}
D context.go => context.go +0 -52
@@ 1,52 0,0 @@
-package main
-
-import (
- "context"
- "errors"
-
- "git.sr.ht/~handlerug/girc"
-)
-
-var configCtxKey = &contextKey{"handlebotConfig"}
-var gircCtxKey = &contextKey{"gircClient"}
-var eventCtxKey = &contextKey{"gircEvent"}
-
-type contextKey struct {
- name string
-}
-
-func GircContext(ctx context.Context, client *girc.Client) context.Context {
- return context.WithValue(ctx, gircCtxKey, client)
-}
-
-func GircForContext(ctx context.Context) *girc.Client {
- client, ok := ctx.Value(gircCtxKey).(*girc.Client)
- if !ok {
- panic(errors.New("Invalid girc client context"))
- }
- return client
-}
-
-func EventContext(ctx context.Context, event *girc.Event) context.Context {
- return context.WithValue(ctx, eventCtxKey, event)
-}
-
-func EventForContext(ctx context.Context) *girc.Event {
- event, ok := ctx.Value(eventCtxKey).(*girc.Event)
- if !ok {
- panic(errors.New("Invalid girc event context"))
- }
- return event
-}
-
-func ConfigContext(ctx context.Context, config *Config) context.Context {
- return context.WithValue(ctx, configCtxKey, config)
-}
-
-func ConfigForContext(ctx context.Context) *Config {
- config, ok := ctx.Value(configCtxKey).(*Config)
- if !ok {
- panic(errors.New("Invalid config context"))
- }
- return config
-}
M database/database.go => database/database.go +5 -5
@@ 8,11 8,11 @@ import (
var ErrNoData = errors.New("data not found")
type Database interface {
- Migrate(context.Context) (int, int, error)
+ Migrate(ctx context.Context) (int, int, error)
- GetUserLocation(server string, user string) (string, error)
- PutUserLocation(server string, user string, location string) error
+ GetUserLocation(ctx context.Context, server string, user string) (string, error)
+ PutUserLocation(ctx context.Context, server string, user string, location string) error
- GetGeocodeResult(query string) (float64, float64, string, error)
- PutGeocodeResult(query string, lat, lng float64, location string) error
+ GetGeocodeResult(ctx context.Context, query string) (float64, float64, string, error)
+ PutGeocodeResult(ctx context.Context, query string, lat, lng float64, location string) error
}
M database/sqlite.go => database/sqlite.go +9 -12
@@ 13,20 13,17 @@ type sqliteDB struct {
db *sql.DB
}
-func NewSqliteDB(ctx context.Context, connString string) (*sqliteDB, error) {
+func NewSqliteDB(connString string) (*sqliteDB, error) {
db, err := sql.Open("sqlite3", connString)
if err != nil {
return nil, err
}
- if err := db.PingContext(ctx); err != nil {
- return nil, err
- }
return &sqliteDB{db: db}, nil
}
-func (d *sqliteDB) GetUserLocation(server string, nick string) (string, error) {
+func (d *sqliteDB) GetUserLocation(ctx context.Context, server string, nick string) (string, error) {
var location string
- err := d.db.QueryRow(`SELECT location FROM location_cache
+ err := d.db.QueryRowContext(ctx, `SELECT location FROM location_cache
WHERE server = $1 AND nick = $2`, server, nick).Scan(&location)
if errors.Is(err, sql.ErrNoRows) {
@@ 37,16 34,16 @@ func (d *sqliteDB) GetUserLocation(server string, nick string) (string, error) {
return location, nil
}
-func (d *sqliteDB) PutUserLocation(server string, nick string, location string) error {
- _, err := d.db.Exec(`INSERT OR REPLACE INTO location_cache
+func (d *sqliteDB) PutUserLocation(ctx context.Context, server string, nick string, location string) error {
+ _, err := d.db.ExecContext(ctx, `INSERT OR REPLACE INTO location_cache
(server, nick, location) VALUES ($1, $2, $3)`, server, nick, location)
return err
}
-func (d *sqliteDB) GetGeocodeResult(query string) (float64, float64, string, error) {
+func (d *sqliteDB) GetGeocodeResult(ctx context.Context, query string) (float64, float64, string, error) {
var lat, lng float64
var location string
- err := d.db.QueryRow(`SELECT latitude, longitude, reverse_location
+ err := d.db.QueryRowContext(ctx, `SELECT latitude, longitude, reverse_location
FROM geocode_cache WHERE location = $1`, query).Scan(&lat, &lng, &location)
if errors.Is(err, sql.ErrNoRows) {
@@ 57,8 54,8 @@ func (d *sqliteDB) GetGeocodeResult(query string) (float64, float64, string, err
return lat, lng, location, nil
}
-func (d *sqliteDB) PutGeocodeResult(query string, lat, lng float64, location string) error {
- _, err := d.db.Exec(`INSERT INTO geocode_cache
+func (d *sqliteDB) PutGeocodeResult(ctx context.Context, query string, lat, lng float64, location string) error {
+ _, err := d.db.ExecContext(ctx, `INSERT INTO geocode_cache
(location, latitude, longitude, reverse_location)
VALUES ($1, $2, $3, $4)`, query, lat, lng, location)
return err
M go.mod => go.mod +0 -2
@@ 5,8 5,6 @@ go 1.16
require (
git.sr.ht/~adnano/go-gemini v0.2.3
git.sr.ht/~handlerug/girc v0.0.0-20220616184319-578d288e8b96
- git.sr.ht/~sircmpwn/getopt v1.0.0
- github.com/buger/jsonparser v1.1.1
github.com/dustin/go-humanize v1.0.0
github.com/mattn/go-sqlite3 v1.14.13
golang.org/x/net v0.0.0-20220615171555-694bf12d69de
M go.sum => go.sum +0 -24
@@ 1,54 1,30 @@
-git.sr.ht/~adnano/go-gemini v0.2.2 h1:p2owKzrQ1wTgvPS5CZCPYArQyNUL8ZgYOHHrTjH9sdI=
-git.sr.ht/~adnano/go-gemini v0.2.2/go.mod h1:hQ75Y0i5jSFL+FQ7AzWVAYr5LQsaFC7v3ZviNyj46dY=
git.sr.ht/~adnano/go-gemini v0.2.3 h1:oJ+Y0/mheZ4Vg0ABjtf5dlmvq1yoONStiaQvmWWkofc=
git.sr.ht/~adnano/go-gemini v0.2.3/go.mod h1:hQ75Y0i5jSFL+FQ7AzWVAYr5LQsaFC7v3ZviNyj46dY=
-git.sr.ht/~handlerug/girc v0.0.0-20211002081855-2b535802766b h1:CwcT6GX4c8AjfQhOVLmBiAQLbdEPxJCdDcGaWI4K/D0=
-git.sr.ht/~handlerug/girc v0.0.0-20211002081855-2b535802766b/go.mod h1:pFPIeU20C0HuoNALwr3G3pzRQLem7pBB8a5HbtJYW3s=
git.sr.ht/~handlerug/girc v0.0.0-20220616184319-578d288e8b96 h1:MHsd0+855r4ca/rEcYZt2eaIWSjutT3UAzvgEFUVSsY=
git.sr.ht/~handlerug/girc v0.0.0-20220616184319-578d288e8b96/go.mod h1:DFGlvqayBuw5pUHKpwdo1zRWOQ7mavJruoLmr8YxNJM=
-git.sr.ht/~sircmpwn/getopt v1.0.0 h1:/pRHjO6/OCbBF4puqD98n6xtPEgE//oq5U8NXjP7ROc=
-git.sr.ht/~sircmpwn/getopt v1.0.0/go.mod h1:wMEGFFFNuPos7vHmWXfszqImLppbc0wEhh6JBfJIUgw=
-github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
-github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
-github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU=
-github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.13 h1:1tj15ngiFfcZzii7yd82foL+ks+ouQcj8j/TPq3fk1I=
github.com/mattn/go-sqlite3 v1.14.13/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210908191846-a5e095526f91 h1:E8wdt+zBjoxD3MA65wEc3pl25BsTi7tbkpwc4ANThjc=
-golang.org/x/net v0.0.0-20210908191846-a5e095526f91/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220615171555-694bf12d69de h1:ogOG2+P6LjO2j55AkRScrkB2BFpd+Z8TY2wcM0Z3MGo=
golang.org/x/net v0.0.0-20220615171555-694bf12d69de/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
-mvdan.cc/xurls/v2 v2.3.1-0.20210930193301-2c03c21edb87 h1:c+AVQyHwiPYSPY1VHqwn7owQB1PnZ8ro8JinuMQrdE0=
-mvdan.cc/xurls/v2 v2.3.1-0.20210930193301-2c03c21edb87/go.mod h1:AjuTy7gEiUArFMjgBBDU4SMxlfUYsRokpJQgNWOt3e4=
mvdan.cc/xurls/v2 v2.4.0 h1:tzxjVAj+wSBmDcF6zBB7/myTy3gX9xvi8Tyr28AuQgc=
mvdan.cc/xurls/v2 v2.4.0/go.mod h1:+GEjq9uNjqs8LQfM9nVnM8rff0OQ5Iash5rzX+N1CSg=
A handler.go => handler.go +175 -0
@@ 0,0 1,175 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "strings"
+
+ "git.sr.ht/~handlerug/girc"
+)
+
+type PublicError interface {
+ Public()
+}
+
+type HandlerFunc func(ctx context.Context, req HandlerRequest) (string, error)
+
+type Handlers struct {
+ client *girc.Client
+ cmdPrefix string
+ serverHost string
+
+ cmdNames []string
+ cmds map[string]command
+}
+
+type HandlersOpts struct {
+ Client *girc.Client
+ ServerHost string
+ CmdPrefix string
+}
+
+type HandlerRequest struct {
+ Client *girc.Client
+ Event girc.Event
+ ServerHost string
+ Args string
+}
+
+type command struct {
+ name string
+ aliases []string
+ handler HandlerFunc
+ help string
+}
+
+func NewHandlers(opts HandlersOpts) Handlers {
+ return Handlers{
+ client: opts.Client,
+ cmdPrefix: opts.CmdPrefix,
+ serverHost: opts.ServerHost,
+ cmdNames: make([]string, 0),
+ cmds: make(map[string]command),
+ }
+}
+
+func (h *Handlers) Add(name string, aliases []string, handler HandlerFunc, help string) {
+ cmd := command{
+ name: name,
+ aliases: aliases,
+ handler: handler,
+ help: help,
+ }
+ if help != "" {
+ h.cmdNames = append(h.cmdNames, name)
+ }
+ h.cmds[name] = cmd
+ for _, alias := range aliases {
+ h.cmds[alias] = cmd
+ }
+}
+
+func (h *Handlers) Handle(ctx context.Context, e girc.Event) bool {
+ text := strings.TrimSpace(e.Last())
+ if strings.HasPrefix(text, h.cmdPrefix) {
+ var cmdName, args string
+ parts := strings.SplitN(text[len(h.cmdPrefix):], " ", 2)
+ if len(parts) >= 1 {
+ cmdName = parts[0]
+ }
+ if len(parts) >= 2 {
+ args = strings.TrimSpace(parts[1])
+ }
+
+ req := HandlerRequest{
+ Client: h.client,
+ Event: e,
+ ServerHost: h.serverHost,
+ Args: args,
+ }
+ if cmdName == "help" {
+ h.helpHandler(ctx, req)
+ return true
+ }
+ if cmd, ok := h.cmds[cmdName]; ok {
+ resp, err := cmd.handler(ctx, req)
+ resp = handleError(resp, err)
+ if resp != "" {
+ h.client.Cmd.Reply(e, resp)
+ }
+ return true
+ }
+ }
+
+ return false
+}
+
+func (h *Handlers) helpHandler(ctx context.Context, req HandlerRequest) {
+ resp := h.getHelp(req.Args)
+ for len(resp) > 0 {
+ var line string
+ if idx := strings.Index(resp, "\n"); idx >= 0 {
+ line = resp[:idx]
+ resp = resp[len(line)+1:]
+ } else {
+ line = resp
+ resp = resp[:0]
+ }
+ line = strings.TrimSpace(line)
+ req.Client.Cmd.Reply(req.Event, fmt.Sprintf("%s: %s", BotName, line))
+ }
+}
+
+func (h *Handlers) getHelp(args string) string {
+ if args == "" {
+ var b strings.Builder
+ for i, name := range h.cmdNames {
+ if i > 0 {
+ b.WriteString(", ")
+ }
+ b.WriteString(h.cmdPrefix + name)
+ }
+ fmt.Fprintf(&b, "\nRequest help for a specific command "+
+ "using %shelp <command>", h.cmdPrefix)
+ return b.String()
+ }
+ args = strings.TrimPrefix(args, h.cmdPrefix)
+ cmd, ok := h.cmds[args]
+ if !ok {
+ // We don't have this command, but there's a chance it was
+ // addressed to another bot, so don't complain loudly
+ return ""
+ }
+
+ var b strings.Builder
+ fmt.Fprintf(&b, "Help for command %s%s", h.cmdPrefix, cmd.name)
+ for _, alias := range cmd.aliases {
+ fmt.Fprintf(&b, ", %s%s", h.cmdPrefix, alias)
+ }
+ b.WriteString(":\n")
+ b.WriteString(strings.TrimSpace(cmd.help))
+ return b.String()
+}
+
+func handleError(resp string, err error) string {
+ if err != nil {
+ if resp == "" {
+ if errors.Is(err, context.DeadlineExceeded) {
+ resp = errorMsg("Command timed out", nil)
+ } else {
+ resp = errorMsg("An error occured during handler execution", err)
+ }
+ }
+ log.Println(err)
+ }
+ return resp
+}
+
+func errorMsg(msg string, err error) string {
+ if _, ok := err.(PublicError); ok {
+ return fmt.Sprintf("%s: %v", msg, err)
+ }
+ return fmt.Sprintf("%s; check the logs for more info.", msg)
+}
M handlers.go => handlers.go +74 -110
@@ 5,139 5,103 @@ import (
"errors"
"fmt"
"log"
- "strings"
- "git.sr.ht/~handlerug/girc"
+ "git.sr.ht/~handlerug/handlebot/database"
+ "git.sr.ht/~handlerug/handlebot/jisho"
+ "git.sr.ht/~handlerug/handlebot/presentation"
+ "git.sr.ht/~handlerug/handlebot/weather"
+ "git.sr.ht/~handlerug/handlebot/wolframalpha"
)
-const CmdPrefix = "."
+const firstUsageMsg = "You have never used `.weather` before; invoke it with location first."
+const invalidRangeMsg = "Weather data is only available for the next 168h or 7d."
+const invalidSyntaxMsg = "Invalid command syntax."
+const unimplementedMsg = "Forecasts have not been implemented yet. But you're always welcome to help with that! " + ForgeLink
+const quotaReachedMsg = "Sorry, the API key seems to have reached its max quota."
-type handlerFunc func(ctx context.Context, args string) (string, error)
+const databaseErrMsg = "An error occured while working with the database"
+const geocoderErrMsg = "An error occured while geocoding the address"
+const forecasterErrMsg = "An error occured while fetching the weather"
-type command struct {
- name string
- aliases []string
- handler handlerFunc
- help string
-}
+var announceText = fmt.Sprintf("Serving text/gemini since 2021, yours truly — %s %s, multi-purpose IRC bot %s", BotName, BotVersion, ForgeLink)
-type handlers struct {
- cmdNames []string
- cmds map[string]command
+func handleBots(ctx context.Context, req HandlerRequest) (string, error) {
+ return announceText, nil
}
-func NewHandlers() handlers {
- return handlers{
- cmdNames: make([]string, 0),
- cmds: make(map[string]command),
+func handleJisho(ctx context.Context, req HandlerRequest) (string, error) {
+ result, err := (&jisho.Client{}).Query(ctx, req.Args)
+ if err != nil {
+ return "", err
}
+ return presentation.Jisho(result), nil
}
-func (h *handlers) Add(name string, aliases []string, handler handlerFunc, help string) {
- cmd := command{
- name: name,
- aliases: aliases,
- handler: handler,
- help: help,
- }
- if help != "" {
- h.cmdNames = append(h.cmdNames, name)
- }
- h.cmds[name] = cmd
- for _, alias := range aliases {
- h.cmds[alias] = cmd
+func HandleWeather(ctx context.Context, req HandlerRequest) (string, error) {
+ db := database.ForContext(ctx)
+ f := weather.ForContext(ctx)
+ if f == nil {
+ return "Weather commands are disabled. To enable, set " +
+ "OpenWeatherMap and MapQuest API keys in the " +
+ "configuration.", nil
}
-}
-func (h *handlers) Handle(ctx context.Context) bool {
- e := EventForContext(ctx)
- text := strings.TrimSpace(e.Last())
- if strings.HasPrefix(text, CmdPrefix) {
- var cmdName, args string
- parts := strings.SplitN(text[len(CmdPrefix):], " ", 2)
- if len(parts) >= 1 {
- cmdName = parts[0]
- }
- if len(parts) >= 2 {
- args = strings.TrimSpace(parts[1])
- }
- if cmdName == "help" {
- h.helpHandler(ctx, args)
- return true
+ query := req.Args
+ server := req.ServerHost
+ nick := req.Event.Source.Name
+ if query == "" {
+ var err error
+ query, err = db.GetUserLocation(ctx, server, nick)
+
+ if errors.Is(err, database.ErrNoData) {
+ return firstUsageMsg, nil
+ } else if err != nil {
+ return errorMsg(databaseErrMsg, err), err
}
- if cmd, ok := h.cmds[cmdName]; ok {
- resp, err := cmd.handler(ctx, args)
- if err != nil {
- if errors.Is(err, context.DeadlineExceeded) {
- resp = "Command timed out."
- } else {
- resp = ""
- }
- log.Println(err)
- }
- if resp != "" {
- GircForContext(ctx).Cmd.Reply(
- *EventForContext(ctx), resp)
- }
- return true
+
+ } else {
+ if err := db.PutUserLocation(ctx, server, nick, query); err != nil {
+ return errorMsg(databaseErrMsg, err), err
}
}
- return false
-}
+ lat, lng, location, err := db.GetGeocodeResult(ctx, query)
+ if errors.Is(err, database.ErrNoData) {
+ lat, lng, location, err = f.Geocode(ctx, query)
+ if err != nil {
+ return errorMsg(geocoderErrMsg, err), err
+ }
-func (h *handlers) helpHandler(ctx context.Context, args string) {
- resp := h.getHelp(args)
- for len(resp) > 0 {
- var line string
- if idx := strings.Index(resp, "\n"); idx >= 0 {
- line = resp[:idx]
- resp = resp[len(line)+1:]
- } else {
- line = resp
- resp = resp[:0]
+ if err := db.PutGeocodeResult(ctx, query, lat, lng, location); err != nil {
+ log.Println("failed to save geocoding results to DB: ", err)
}
- line = strings.TrimSpace(line)
- e := EventForContext(ctx)
- tags := girc.Tags{}
- tags.Set("+draft/channel-context", e.Params[0])
- GircForContext(ctx).Send(&girc.Event{
- Command: girc.NOTICE,
- Params: []string{
- e.Source.Name,
- fmt.Sprintf("%s: %s", BotName, line),
- },
- Tags: tags,
- })
+
+ } else if err != nil {
+ return databaseErrMsg, err
}
-}
-func (h *handlers) getHelp(args string) string {
- if args == "" {
- var b strings.Builder
- for i, name := range h.cmdNames {
- if i > 0 {
- b.WriteString(", ")
- }
- b.WriteString(CmdPrefix + name)
- }
- fmt.Fprintf(&b, "\nRequest help for a specific command "+
- "using %shelp <command w/o prefix>", CmdPrefix)
- return b.String()
+ w, err := f.Weather(ctx, lat, lng)
+ if err != nil {
+ return errorMsg(forecasterErrMsg, err), err
}
- cmd, ok := h.cmds[args]
- if !ok {
- // We don't have this command, but there's a chance it was
- // addressed to another bot, so don't complain loudly
- return ""
+ return presentation.Weather(w, location), nil
+}
+
+func handleWolframAlpha(ctx context.Context, req HandlerRequest) (string, error) {
+ client := wolframalpha.ForContext(ctx)
+ if client == nil {
+ return "Wolfram|Alpha is disabled. To enable it, set the " +
+ "API key in the configuration.", nil
}
- var b strings.Builder
- fmt.Fprintf(&b, "Help for command %s%s", CmdPrefix, cmd.name)
- for _, alias := range cmd.aliases {
- fmt.Fprintf(&b, ", %s%s", CmdPrefix, alias)
+ resp, err := client.Query(ctx, req.Args)
+ if err != nil {
+ if _, ok := err.(PublicError); ok {
+ return err.Error(), nil
+ } else {
+ log.Println(err)
+ return "Wolfram|Alpha API call failed; check the logs for more info.", nil
+ }
}
- b.WriteString(":\n")
- b.WriteString(strings.TrimSpace(cmd.help))
- return b.String()
+ return presentation.WolframAlpha(resp), err
}
M jisho/jisho.go => jisho/jisho.go +0 -66
@@ 6,7 6,6 @@ import (
"fmt"
"net/http"
"net/url"
- "strings"
)
type Response struct {
@@ 14,71 13,6 @@ type Response struct {
Data []DataPoint `json:"data"`
}
-func (result *Response) String() string {
- limit := 3
- if len(result.Data) == 0 {
- return "No results."
- } else if len(result.Data) < limit {
- limit = len(result.Data)
- }
-
- var b strings.Builder
- for i, dp := range result.Data[:limit] {
- if i > 0 {
- b.WriteString("; ")
- }
- fmt.Fprintf(&b, "%d: ", i+1)
-
- if len(dp.Japanese) > 0 {
- if dp.Japanese[0].Word != nil {
- b.WriteString(*dp.Japanese[0].Word)
- if dp.Japanese[0].Reading != nil {
- fmt.Fprintf(&b, "(%s)", *dp.Japanese[0].Reading)
- }
- } else if dp.Japanese[0].Reading != nil {
- b.WriteString(*dp.Japanese[0].Reading)
- }
- }
-
- if dp.Common {
- b.WriteString(" Common")
- } else {
- b.WriteString(" Uncommon")
- }
-
- if len(dp.Tags) > 0 {
- b.WriteString(" (")
- b.WriteString(strings.Join(dp.Tags, ", "))
- b.WriteString(")")
- }
-
- slimit := 3
- if len(dp.Senses) < slimit {
- slimit = len(dp.Senses)
- }
- for j, sense := range dp.Senses[:slimit] {
- if len(sense.EnglishDefs) < 1 {
- continue
- }
- if j == 0 {
- partsOfSpeech := strings.Join(sense.PartsOfSpeech, ", ")
- if partsOfSpeech != "" {
- partsOfSpeech += ": "
- }
- fmt.Fprintf(&b, " [%s%s", partsOfSpeech, sense.EnglishDefs[0])
- } else {
- fmt.Fprintf(&b, ", %s", sense.EnglishDefs[0])
- }
- }
-
- if len(dp.Senses) > 0 {
- b.WriteString("]")
- }
- }
-
- return b.String()
-}
-
type Meta struct {
Status uint64 `json:"status"`
}
M main.go => main.go +59 -79
@@ 2,8 2,7 @@ package main
import (
"context"
- "encoding/json"
- "fmt"
+ "flag"
"io"
"log"
"net/url"
@@ 13,62 12,32 @@ import (
"time"
"git.sr.ht/~handlerug/girc"
- "git.sr.ht/~sircmpwn/getopt"
_ "github.com/mattn/go-sqlite3"
"mvdan.cc/xurls/v2"
"git.sr.ht/~handlerug/handlebot/database"
- "git.sr.ht/~handlerug/handlebot/jisho"
"git.sr.ht/~handlerug/handlebot/urlpreview"
+ "git.sr.ht/~handlerug/handlebot/weather"
+ "git.sr.ht/~handlerug/handlebot/wolframalpha"
+ "git.sr.ht/~handlerug/handlebot/youtube"
)
const BotName = "handlebot"
const BotVersion = "0.0.6"
const ForgeLink = "https://sr.ht/~handlerug/handlebot"
-var AnnounceText = fmt.Sprintf("Serving text/gemini since 2021, yours truly — %s %s, multi-purpose IRC bot %s", BotName, BotVersion, ForgeLink)
-
var urlRegexp = xurls.Strict()
func main() {
- log.SetFlags(0)
-
- opts, _, err := getopt.Getopts(os.Args, "C:")
- if err != nil {
- log.Fatal(err)
- }
-
- var cfgPath string
- for _, opt := range opts {
- cfgPath = opt.Value
- }
-
- var cfgBytes []byte
- if cfgPath != "" {
- cfgBytes, err = os.ReadFile(cfgPath)
- if err != nil {
- log.Fatal("Couldn't open the config file: ", err)
- }
- } else {
- cfgBytes, err = os.ReadFile("./config.json")
- if err != nil {
- cfgBytes, err = os.ReadFile("/etc/wormy.json")
- if err != nil {
- log.Fatal("Couldn't find the config file (./config.json or " +
- "/etc/wormy.json). You may specify the path directly " +
- "using the -C option.")
- }
- }
- }
+ cfgPath := flag.String("config", "/etc/wormy.json", "path to the config file")
+ flag.Parse()
+ cfg := parseConfig(*cfgPath)
- var cfg Config
- if err := json.Unmarshal(cfgBytes, &cfg); err != nil {
- log.Fatal(err)
- }
+ log.Println("handlebot starting")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- db, err := database.NewSqliteDB(ctx, cfg.Database)
+ db, err := database.NewSqliteDB(cfg.Database)
if err != nil {
log.Fatal(err)
}
@@ 82,35 51,59 @@ func main() {
cancel()
- log.SetFlags(log.LstdFlags)
- log.Println("handlebot starting up")
-
var debugWriter io.Writer
if val, ok := os.LookupEnv("TRACE"); val != "" && ok {
debugWriter = os.Stdout
}
- previewer := &urlpreview.Previewer{
- Credentials: urlpreview.APICredentials{
- WolframAlpha: cfg.APIKeys.WolframAlpha,
- YouTube: cfg.APIKeys.YouTube,
- },
+ var (
+ previewer *urlpreview.Previewer
+ forecaster *weather.Forecaster
+ waClient *wolframalpha.Client
+ ytClient *youtube.Client
+ )
+ previewer = &urlpreview.Previewer{}
+ if cfg.APIKeys.OpenWeatherMap != "" && cfg.APIKeys.MapQuest != "" {
+ forecaster = &weather.Forecaster{
+ Credentials: weather.APICredentials{
+ OpenWeatherMap: cfg.APIKeys.OpenWeatherMap,
+ MapQuest: cfg.APIKeys.MapQuest,
+ },
+ }
+ }
+ if cfg.APIKeys.WolframAlpha != "" {
+ waClient = &wolframalpha.Client{AppID: cfg.APIKeys.WolframAlpha}
+ }
+ if cfg.APIKeys.YouTube != "" {
+ ytClient = &youtube.Client{APIKey: cfg.APIKeys.YouTube}
}
- h := NewHandlers()
+ client := girc.New(girc.Config{
+ Server: cfg.IRC.Server,
+ ServerPass: cfg.IRC.Password,
+ SSL: cfg.IRC.TLS,
+ Port: cfg.IRC.Port,
+ Nick: cfg.IRC.Nick,
+ Name: cfg.IRC.Name,
+ User: cfg.IRC.User,
+ Debug: debugWriter,
+ RecoverFunc: girc.DefaultRecoverHandler,
+ AllowFlood: true,
+ })
+
+ h := NewHandlers(HandlersOpts{
+ Client: client,
+ ServerHost: cfg.IRC.Server,
+ CmdPrefix: cfg.CommandPrefix,
+ })
h.Add("weather", []string{"w"}, HandleWeather, `
Prints current weather. If location is omitted, the previous location is used.
+ Forecasts are not implemented (i.e. only the current weather is shown).
Examples: ".w amsterdam", ".w" (uses "amsterdam")
`)
- h.Add("jisho", nil, func(ctx context.Context, args string) (string, error) {
- result, err := (&jisho.Client{}).Query(ctx, args)
- if err != nil {
- return "", err
- }
- return result.String(), nil
- }, `
+ h.Add("jisho", nil, handleJisho, `
Runs a Jisho.org search.
Examples: ".jisho himitsu", ".jisho 秘密"
`)
@@ 120,26 113,13 @@ func main() {
Examples: ".wa 2+2", ".wa circumference of the moon"
`)
- h.Add("bots", nil, func(ctx context.Context, args string) (string, error) {
- return AnnounceText, nil
- }, "")
-
- client := girc.New(girc.Config{
- Server: cfg.IRC.Server,
- ServerPass: cfg.IRC.Password,
- SSL: cfg.IRC.TLS,
- Port: cfg.IRC.Port,
- Nick: cfg.IRC.Nick,
- Name: cfg.IRC.Name,
- User: cfg.IRC.User,
- Debug: debugWriter,
- RecoverFunc: girc.DefaultRecoverHandler,
- AllowFlood: true,
- })
+ h.Add("bots", nil, handleBots, "")
- ctx = ConfigContext(context.Background(), &cfg)
+ ctx = context.Background()
ctx = database.Context(ctx, db)
- ctx = GircContext(ctx, client)
+ ctx = weather.Context(ctx, forecaster)
+ ctx = wolframalpha.Context(ctx, waClient)
+ ctx = youtube.Context(ctx, ytClient)
client.Handlers.Add(girc.CONNECTED, func(c *girc.Client, e girc.Event) {
log.Println("connected to IRC")
@@ 154,16 134,16 @@ func main() {
})
client.Handlers.AddBg(girc.PRIVMSG, func(c *girc.Client, e girc.Event) {
- ctx, cancel := context.WithTimeout(EventContext(ctx, &e), 10*time.Second)
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Normal command dispatching
- if h.Handle(ctx) {
+ if h.Handle(ctx, e) {
return
}
// Ensure the message is not a command
- if strings.HasPrefix(e.Last(), CmdPrefix) {
+ if strings.HasPrefix(e.Last(), cfg.CommandPrefix) {
return
}
@@ 189,13 169,13 @@ func main() {
wg.Wait()
})
- // TODO: Handle SIGINT and SIGTERM
for {
if err := client.Connect(); err != nil {
- log.Printf("error: %s", err)
+ log.Printf("error connecting to IRC: %s", err)
log.Println("reconnecting in 30 seconds...")
time.Sleep(30 * time.Second)
} else {
+ log.Println("stopped")
return
}
}
A presentation/presentation.go => presentation/presentation.go +137 -0
@@ 0,0 1,137 @@
+package presentation
+
+import (
+ "fmt"
+ "math"
+ "strings"
+
+ "git.sr.ht/~handlerug/girc"
+
+ "git.sr.ht/~handlerug/handlebot/jisho"
+ "git.sr.ht/~handlerug/handlebot/weather"
+ "git.sr.ht/~handlerug/handlebot/wolframalpha"
+)
+
+func Jisho(result *jisho.Response) string {
+ limit := 3
+ if len(result.Data) == 0 {
+ return "(no results)"
+ } else if len(result.Data) < limit {
+ limit = len(result.Data)
+ }
+
+ var b strings.Builder
+ for i, dp := range result.Data[:limit] {
+ if i > 0 {
+ b.WriteString("; ")
+ }
+ fmt.Fprintf(&b, girc.Fmt("{b}%d:{b} "), i+1)
+
+ if len(dp.Japanese) > 0 {
+ if dp.Japanese[0].Word != nil {
+ b.WriteString(*dp.Japanese[0].Word)
+ if dp.Japanese[0].Reading != nil {
+ fmt.Fprintf(&b, "(%s)", *dp.Japanese[0].Reading)
+ }
+ } else if dp.Japanese[0].Reading != nil {
+ b.WriteString(*dp.Japanese[0].Reading)
+ }
+ }
+
+ if dp.Common {
+ b.WriteString(" Common")
+ } else {
+ b.WriteString(" Uncommon")
+ }
+
+ if len(dp.Tags) > 0 {
+ b.WriteString(" (")
+ b.WriteString(strings.Join(dp.Tags, ", "))
+ b.WriteString(")")
+ }
+
+ slimit := 3
+ if len(dp.Senses) < slimit {
+ slimit = len(dp.Senses)
+ }
+ for j, sense := range dp.Senses[:slimit] {
+ if len(sense.EnglishDefs) < 1 {
+ continue
+ }
+ if j == 0 {
+ partsOfSpeech := strings.Join(sense.PartsOfSpeech, ", ")
+ if partsOfSpeech != "" {
+ partsOfSpeech += ": "
+ }
+ fmt.Fprintf(&b, " [%s%s", partsOfSpeech, sense.EnglishDefs[0])
+ } else {
+ fmt.Fprintf(&b, ", %s", sense.EnglishDefs[0])
+ }
+ }
+
+ if len(dp.Senses) > 0 {
+ b.WriteString("]")
+ }
+ }
+
+ return b.String()
+}
+
+func Weather(w *weather.WeatherInfo, location string) string {
+ var (
+ b strings.Builder
+
+ temperature float64
+ precipProb float64
+ condition string
+ )
+
+ temperature = math.Round(w.Current.Temperature)
+ if temperature == 0 && math.Signbit(temperature) {
+ temperature = 0
+ }
+ if len(w.Hourly) > 0 {
+ precipProb = w.Hourly[0].PrecipProb
+ }
+ if len(w.Current.Conditions) > 0 {
+ condition = fmt.Sprintf(" is %s", w.Current.Conditions[0].Description)
+ }
+
+ location = girc.TrimFmt(location)
+ condition = girc.TrimFmt(condition)
+ fmt.Fprintf(&b, girc.Fmt("Current weather in %s%s: {b}%.0f{b}°C"), location, condition, temperature)
+ if w.Current.CloudCover > 0 {
+ fmt.Fprintf(&b, "; %d%% cloud cover", w.Current.CloudCover)
+ }
+ if w.Current.Humidity > 0 {
+ fmt.Fprintf(&b, "; %d%% humidity", w.Current.Humidity)
+ }
+ if precipProb > 0 {
+ fmt.Fprintf(&b, "; %.0f%% chance of precipitation", precipProb*100.0)
+ }
+ if w.Current.WindSpeed > 0.1 {
+ fmt.Fprintf(&b, "; %.1fm/s wind speed", w.Current.WindSpeed)
+ }
+ return b.String()
+}
+
+func WolframAlpha(resp *wolframalpha.Result) string {
+ var interpretation string
+ var result string
+
+ for _, pod := range resp.Pods {
+ if pod.ID == "Input" && len(pod.Subpods) > 0 {
+ interpretation = pod.Subpods[0].PlainText
+ } else if pod.ID == "Result" && len(pod.Subpods) > 0 {
+ result = pod.Subpods[0].PlainText
+ }
+ }
+
+ if result == "" {
+ result = "(no result)"
+ }
+ if interpretation == "" {
+ return result
+ }
+ return fmt.Sprintf("[%s] %s", interpretation, result)
+}
M urlpreview/generic.go => urlpreview/generic.go +2 -2
@@ 42,7 42,7 @@ func (p *Previewer) generic(ctx context.Context, u *url.URL) (string, error) {
return "", ErrBadResponse
}
- lr := io.LimitedReader{R: resp.Body, N: 100 * 1024}
+ lr := io.LimitedReader{R: resp.Body, N: 400 * 1024}
var body io.Reader = bufio.NewReader(&lr)
body, err = decodeHTML(body, resp.Header.Get("Content-Type"))
@@ 62,7 62,7 @@ func (p *Previewer) generic(ctx context.Context, u *url.URL) (string, error) {
if strings.HasSuffix(title, "- Invidious") {
ytUrl := *u
ytUrl.Host = "www.youtube.com"
- if res, err := p.youtube(ctx, &ytUrl); res != "" && err == nil {
+ if res, err := p.Preview(ctx, &ytUrl); res != "" && err == nil {
return res, err
}
}
M urlpreview/jisho.go => urlpreview/jisho.go +2 -1
@@ 6,6 6,7 @@ import (
"strings"
"git.sr.ht/~handlerug/handlebot/jisho"
+ "git.sr.ht/~handlerug/handlebot/presentation"
)
func (p *Previewer) jisho(ctx context.Context, u *url.URL) (string, error) {
@@ 30,5 31,5 @@ func (p *Previewer) jisho(ctx context.Context, u *url.URL) (string, error) {
return "", err
}
- return result.String(), nil
+ return presentation.Jisho(result), nil
}
M urlpreview/urlpreview.go => urlpreview/urlpreview.go +0 -6
@@ 10,16 10,10 @@ import (
)
type Previewer struct {
- Credentials APICredentials
HttpClient http.Client
GeminiClient gemini.Client
}
-type APICredentials struct {
- YouTube string
- WolframAlpha string
-}
-
func (p *Previewer) Preview(ctx context.Context, u *url.URL) (string, error) {
hostname := u.Hostname()
if hostname == "localhost" {
M urlpreview/wolframalpha.go => urlpreview/wolframalpha.go +10 -7
@@ 5,21 5,24 @@ import (
"net/url"
"strings"
- wa "git.sr.ht/~handlerug/handlebot/wolframalpha"
+ "git.sr.ht/~handlerug/handlebot/presentation"
+ "git.sr.ht/~handlerug/handlebot/wolframalpha"
)
func (p *Previewer) wolframalpha(ctx context.Context, u *url.URL) (string, error) {
- if p.Credentials.WolframAlpha == "" ||
- !strings.HasSuffix(u.Host, "wolframalpha.com") ||
+ if !strings.HasSuffix(u.Host, "wolframalpha.com") ||
u.Path != "/input" || !u.Query().Has("i") {
return "", nil
}
- // TODO: move this out to the context or somewhere else
- c := &wa.Client{AppID: p.Credentials.WolframAlpha}
- resp, err := c.Query(ctx, u.Query().Get("i"))
+ client := wolframalpha.ForContext(ctx)
+ if client == nil {
+ return "", nil
+ }
+
+ resp, err := client.Query(ctx, u.Query().Get("i"))
if err != nil {
return "", nil
}
- return resp.String(), nil
+ return presentation.WolframAlpha(resp), nil
}
M urlpreview/youtube.go => urlpreview/youtube.go +21 -96
@@ 2,24 2,17 @@ package urlpreview
import (
"context"
- "errors"
"fmt"
- "io"
- "net/http"
"net/url"
- "strconv"
"strings"
"time"
- "github.com/buger/jsonparser"
"github.com/dustin/go-humanize"
+
+ "git.sr.ht/~handlerug/handlebot/youtube"
)
func (p *Previewer) youtube(ctx context.Context, u *url.URL) (string, error) {
- if p.Credentials.YouTube == "" {
- return "", nil
- }
-
var vid string
switch {
case strings.HasSuffix(u.Host, "youtube.com") && u.Path == "/watch":
@@ 31,106 24,38 @@ func (p *Previewer) youtube(ctx context.Context, u *url.URL) (string, error) {
return "", nil
}
- // API request
-
- req, _ := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/youtube/v3/videos", nil)
- q := url.Values{}
- q.Set("part", "status,snippet,contentDetails,statistics")
- q.Set("key", p.Credentials.YouTube)
- q.Set("id", vid)
- req.URL.RawQuery = q.Encode()
-
- resp, err := p.HttpClient.Do(req)
- if err != nil {
- return "", err
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return "", err
- }
-
- errorMsg, err := jsonparser.GetString(body, "error", "message")
- if err == nil {
- return "", errors.New(errorMsg)
- }
-
- var title, channel, pubDateStr, duration, definition, dimension, viewsStr string
- var restricted, ratingsEnabled bool
- var pubDate time.Time
- var views int64
-
- // Parsing
-
- item, _, _, err := jsonparser.Get(body, "items", "[0]")
- if err == nil {
- title, err = jsonparser.GetString(item, "snippet", "title")
- }
- if err == nil {
- channel, err = jsonparser.GetString(item, "snippet", "channelTitle")
- }
- if err == nil {
- pubDateStr, err = jsonparser.GetString(item, "snippet", "publishedAt")
- }
- if err == nil {
- pubDate, err = time.Parse(time.RFC3339, pubDateStr)
- }
- if err == nil {
- duration, err = jsonparser.GetString(item, "contentDetails", "duration")
- }
- if err == nil {
- definition, err = jsonparser.GetString(item, "contentDetails", "definition")
- }
- if err == nil {
- dimension, err = jsonparser.GetString(item, "contentDetails", "dimension")
- }
- if err == nil {
- restricted, _ = jsonparser.GetBoolean(item, "contentDetails", "regionRestrictions", "blocked")
- }
- if err == nil {
- ratingsEnabled, err = jsonparser.GetBoolean(item, "status", "publicStatsViewable")
- }
- if err == nil {
- viewsStr, err = jsonparser.GetString(item, "statistics", "viewCount")
- }
- if err == nil {
- views, err = strconv.ParseInt(viewsStr, 10, 64)
+ yt := youtube.ForContext(ctx)
+ if yt == nil {
+ return "", nil
}
+ video, err := yt.GetVideo(ctx, vid)
if err != nil {
- return "", err
+ return "", nil
}
- // Processing data
-
- postfix := ""
- if restricted && !ratingsEnabled {
- postfix = " [Region restricted|Ratings disabled]"
- } else if restricted {
+ var postfix string
+ if len(video.ContentDetails.RegionRestriction.Blocked) > 0 {
postfix = " [Region restricted]"
- } else if !ratingsEnabled {
- postfix = " [Ratings disabled]"
}
- if pubDate.Add(time.Hour * 24 * 30).Before(time.Now()) {
- pubDateStr = pubDate.Format("2006-01-02")
+ var pubDate string
+ if video.Snippet.PublishedAt.Add(time.Hour * 24 * 30).Before(time.Now()) {
+ pubDate = video.Snippet.PublishedAt.Format("2006-01-02")
} else {
- pubDateStr = humanize.Time(pubDate)
+ pubDate = humanize.Time(video.Snippet.PublishedAt)
}
- definition = strings.ToUpper(definition)
-
- if dimension != "2d" {
+ var dimension string
+ if video.ContentDetails.Dimension != "2d" {
dimension = " (" + strings.ToUpper(dimension) + ")"
- } else {
- dimension = ""
}
- duration = strings.ToLower(strings.TrimPrefix(duration, "PT"))
-
- viewsStr = humanize.Comma(views)
+ duration := strings.ToLower(strings.TrimPrefix(video.ContentDetails.Duration, "PT"))
- // Formatting
- return fmt.Sprintf("%s [%s] %s (%s) %s views %s%s%s", title, duration, pubDateStr, channel, viewsStr, definition, dimension, postfix), nil
+ return fmt.Sprintf("%s [%s] %s (%s) %s views %s%s%s",
+ video.Snippet.Title, duration, pubDate, video.Snippet.ChannelTitle,
+ humanize.Comma(int64(video.Statistics.ViewCount)),
+ strings.ToUpper(video.ContentDetails.Definition),
+ dimension, postfix), nil
}
D weather.go => weather.go +0 -340
@@ 1,340 0,0 @@
-package main
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "log"
- "math"
- "net/http"
- "net/url"
- "regexp"
- "strconv"
- "strings"
-
- "git.sr.ht/~handlerug/girc"
- "git.sr.ht/~handlerug/handlebot/database"
- "github.com/buger/jsonparser"
-)
-
-type args struct {
- rangeStart int
- rangeEnd int
- digits int
- hours int
- days int
- location string
-}
-
-type weatherInfo struct {
- Current struct {
- Temperature float64 `json:"temp"`
- CloudCover int `json:"clouds"`
- Humidity int `json:"humidity"`
- WindSpeed float64 `json:"wind_speed"`
- Conditions []struct {
- Description string `json:"description"`
- } `json:"weather"`
- } `json:"current"`
-
- Hourly []struct {
- PrecipProb float64 `json:"pop"`
- } `json:"hourly"`
-
- PrecipProb float64 `json:"-"`
-}
-
-var cmdRegexp = regexp.MustCompile(
- `\s{0,}` +
- `(?:` +
- `(?:(?:(?P<range_x>\d+)-(?P<range_y>\d+))` +
- `|(?P<digits>\d+))` +
- `\s{0,}` +
- `(?:(?P<h>h)|(?P<d>d))` +
- `\s{0,}` +
- `(?P<inner_location>.+){0,1}` +
- `)|` +
- `(?P<outer_location>.+)`,
-)
-
-var rangeXIdx = cmdRegexp.SubexpIndex("range_x")
-var rangeYIdx = cmdRegexp.SubexpIndex("range_y")
-var digitsIdx = cmdRegexp.SubexpIndex("digits")
-var hoursIdx = cmdRegexp.SubexpIndex("h")
-var daysIdx = cmdRegexp.SubexpIndex("d")
-var innerLocIdx = cmdRegexp.SubexpIndex("inner_location")
-var outerLocIdx = cmdRegexp.SubexpIndex("outer_location")
-
-var errRange = errors.New("Weather data is only available for the next 168h or 7d.")
-var errInvalidSyntax = errors.New("Invalid `.weather` syntax")
-var errUnimplemented = errors.New("Forecasts have not been implemented yet. But you're always welcome to help with that! https://sr.ht/~handlerug/handlebot")
-var errQuota = errors.New("Sorry, the API key seems to have reached its max quota.")
-
-var firstUsageMsg = "You have never used `.weather` before, invoke it with location first"
-
-func HandleWeather(ctx context.Context, command string) (string, error) {
- var query string
- cfg := ConfigForContext(ctx)
- db := database.ForContext(ctx)
- event := EventForContext(ctx)
- server := cfg.IRC.Server
- nick := event.Source.Name
-
- if command == "" {
- var err error
- query, err = db.GetUserLocation(server, nick)
-
- if errors.Is(err, database.ErrNoData) {
- return firstUsageMsg, nil
- } else if err != nil {
- return "", err
- }
-
- } else {
- args, err := parseCommand(command)
- if err != nil {
- return err.Error(), nil
- }
- query = args.location
-
- if err := db.PutUserLocation(server, nick, query); err != nil {
- log.Println("failed to save new location to DB: ", err)
- }
- }
-
- lat, lng, location, err := db.GetGeocodeResult(query)
- if errors.Is(err, database.ErrNoData) {
- lat, lng, location, err = geocode(ctx, query)
- if err != nil {
- return "", err
- }
-
- if err := db.PutGeocodeResult(query, lat, lng, location); err != nil {
- log.Println("failed to save geocoding results to DB: ", err)
- }
-
- } else if err != nil {
- return "", err
- }
-
- // Weather
- q := url.Values{}
- q.Set("lat", strconv.FormatFloat(lat, 'f', 6, 64))
- q.Set("lon", strconv.FormatFloat(lng, 'f', 6, 64))
- q.Set("units", "metric")
- q.Set("exclude", "minutely,daily,alerts")
- q.Set("appid", cfg.APIKeys.OpenWeatherMap)
-
- body, err := doHttp(ctx, "GET", "https://api.openweathermap.org/data/2.5/onecall", &q)
- if err != nil {
- return "", err
- }
-
- var w weatherInfo
- if err := json.Unmarshal(body, &w); err != nil {
- return "", fmt.Errorf("Failed to parse JSON: %w", err)
- }
-
- if len(w.Hourly) > 0 {
- w.PrecipProb = w.Hourly[0].PrecipProb
- }
-
- var condition string
- if len(w.Current.Conditions) > 0 {
- condition = fmt.Sprintf(" is %s", w.Current.Conditions[0].Description)
- }
-
- var b strings.Builder
- fmt.Fprintf(&b, girc.Fmt("{b}%.0f{b}°C"), math.Round(w.Current.Temperature))
- if w.Current.CloudCover > 0 {
- fmt.Fprintf(&b, "; %d%% cloud cover", w.Current.CloudCover)
- }
- if w.Current.Humidity > 0 {
- fmt.Fprintf(&b, "; %d%% humidity", w.Current.Humidity)
- }
- if w.PrecipProb > 0 {
- fmt.Fprintf(&b, "; %.0f%% chance of precipitation", w.PrecipProb*100.0)
- }
- if w.Current.WindSpeed > 0.1 {
- fmt.Fprintf(&b, "; %.1fm/s wind speed", w.Current.WindSpeed)
- }
-
- format := "Current weather in %s%s: %s"
- return fmt.Sprintf(format, location, condition, b.String()), nil
-}
-
-// TODO: Rewrite to not use return "variables"?
-func geocode(ctx context.Context, query string) (
- lat float64,
- lng float64,
- location string,
- err error,
-) {
- // First we geocode the address, then we reverse geocode the coordinates.
- // Doing it this way somehow yields better results.
-
- q := url.Values{}
- q.Set("location", query)
- q.Set("key", ConfigForContext(ctx).APIKeys.MapQuest)
-
- body, err := doHttp(ctx, "GET", "https://www.mapquestapi.com/geocoding/v1/address", &q)
- if err != nil {
- return
- }
-
- err = getMapQuestError(body)
- if err != nil {
- return
- }
-
- item, _, _, err := jsonparser.Get(body, "results", "[0]", "locations", "[0]")
- if err == nil {
- lat, err = jsonparser.GetFloat(item, "latLng", "lat")
- }
- if err == nil {
- lng, err = jsonparser.GetFloat(item, "latLng", "lng")
- }
- if err != nil {
- err = fmt.Errorf("Failed to geocode the location: %w", err)
- return
- }
-
- // Reverse geocode
- q.Set("location", fmt.Sprintf("%f,%f", lat, lng))
- body, err = doHttp(ctx, "GET", "https://www.mapquestapi.com/geocoding/v1/reverse", &q)
- if err != nil {
- return
- }
-
- err = getMapQuestError(body)
- if err != nil {
- return
- }
-
- var country string
- item, _, _, err = jsonparser.Get(body, "results", "[0]", "locations", "[0]")
- if err == nil {
- country, err = jsonparser.GetString(item, "adminArea1")
- }
- if err != nil {
- err = fmt.Errorf("Failed to reverse geocode the location: %w", err)
- return
- }
-
- var b strings.Builder
- if city, err := jsonparser.GetString(item, "adminArea5"); err == nil && city != "" {
- fmt.Fprintf(&b, "%s, ", city)
- }
- if county, err := jsonparser.GetString(item, "adminArea4"); err == nil && county != "" {
- fmt.Fprintf(&b, "%s, ", county)
- }
- if state, err := jsonparser.GetString(item, "adminArea3"); err == nil && state != "" {
- fmt.Fprintf(&b, "%s, ", state)
- }
- b.WriteString(country)
-
- location = b.String()
- err = nil
- return
-}
-
-func parseCommand(command string) (args, error) {
- var args args
-
- matches := cmdRegexp.FindStringSubmatch(command)
- if matches == nil {
- return args, errInvalidSyntax
- }
-
- captures := struct {
- rangeX string
- rangeY string
- digits string
- hours string
- days string
- innerLoc string
- outerLoc string
- }{
- rangeX: matches[rangeXIdx],
- rangeY: matches[rangeYIdx],
- digits: matches[digitsIdx],
- hours: matches[hoursIdx],
- days: matches[daysIdx],
- innerLoc: matches[innerLocIdx],
- outerLoc: matches[outerLocIdx],
- }
-
- // Invariants: verified by the regexp
- if captures.rangeX != "" {
- args.rangeStart, _ = strconv.Atoi(captures.rangeX)
- }
- if captures.rangeY != "" {
- args.rangeEnd, _ = strconv.Atoi(captures.rangeY)
- }
- if captures.digits != "" {
- args.digits, _ = strconv.Atoi(captures.digits)
- }
-
- if captures.hours != "" && captures.days != "" {
- return args, errInvalidSyntax
- }
- if captures.hours != "" || captures.days != "" {
- return args, errUnimplemented
- }
-
- if captures.innerLoc != "" {
- args.location = captures.innerLoc
- } else {
- args.location = captures.outerLoc
- }
-
- if args.location == "" {
- return args, errInvalidSyntax
- }
-
- return args, nil
-}
-
-func doHttp(ctx context.Context, method string, path string, query *url.Values) ([]byte, error) {
- req, _ := http.NewRequestWithContext(ctx, method, path, nil)
- req.URL.RawQuery = query.Encode()
-
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("%d status code from the API", resp.StatusCode)
- }
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, errors.New("Failed to read the response body")
- }
-
- return body, nil
-}
-
-func getMapQuestError(body []byte) error {
- status, _ := jsonparser.GetInt(body, "info", "statuscode")
- if status != 0 {
- messages := make([]string, 0)
- jsonparser.ArrayEach(body, func(value []byte, dataType jsonparser.ValueType, offset int, err error) {
- if dataType == jsonparser.String {
- messages = append(messages, string(value))
- }
- }, "info", "messages")
- if status == 403 {
- log.Printf("MapQuest quota reached?: %q\n", messages)
- return errQuota
- } else {
- err := fmt.Errorf("Geocoding request failed (%d): %q", status, messages)
- return err
- }
- }
- return nil
-}
A weather/context.go => weather/context.go +24 -0
@@ 0,0 1,24 @@
+package weather
+
+import (
+ "context"
+ "errors"
+)
+
+var weatherCtxKey = &contextKey{"forecaster"}
+
+type contextKey struct {
+ name string
+}
+
+func Context(ctx context.Context, f *Forecaster) context.Context {
+ return context.WithValue(ctx, weatherCtxKey, f)
+}
+
+func ForContext(ctx context.Context) *Forecaster {
+ f, ok := ctx.Value(weatherCtxKey).(*Forecaster)
+ if !ok {
+ panic(errors.New("Invalid forecaster context"))
+ }
+ return f
+}
A weather/errors.go => weather/errors.go +59 -0
@@ 0,0 1,59 @@
+package weather
+
+import (
+ "fmt"
+ "strings"
+)
+
+type PublicError interface {
+ Public()
+}
+
+type ErrorMessages []string
+
+func (m ErrorMessages) String() string {
+ if len(m) == 0 {
+ return "(no messages)"
+ } else if len(m) == 1 {
+ return m[0]
+ } else {
+ var b strings.Builder
+ for i, msg := range m {
+ if i > 0 {
+ b.WriteString(", ")
+ }
+ fmt.Fprintf(&b, "%q", msg)
+ }
+ return b.String()
+ }
+}
+
+type QuotaReachedError struct {
+ APIName string
+ Messages ErrorMessages
+}
+
+func (e *QuotaReachedError) Error() string {
+ return fmt.Sprintf("%s API quota reached: %s", e.APIName, e.Messages)
+}
+
+func (e *QuotaReachedError) Public() {}
+
+type GeocodingError struct {
+ Status int
+ Messages ErrorMessages
+}
+
+func (e *GeocodingError) Error() string {
+ return fmt.Sprintf("geocoding failed with status %d: %s", e.Status, e.Messages)
+}
+
+func (e *GeocodingError) Public() {}
+
+type NoGeocodeResults struct{}
+
+func (e *NoGeocodeResults) Error() string {
+ return "no geocode results"
+}
+
+func (e *NoGeocodeResults) Public() {}
A weather/weather.go => weather/weather.go +173 -0
@@ 0,0 1,173 @@
+package weather
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+var ErrQuota = errors.New("Sorry, the API key seems to have reached its max quota.")
+
+type Forecaster struct {
+ Credentials APICredentials
+ HttpClient http.Client
+}
+
+type APICredentials struct {
+ OpenWeatherMap string
+ MapQuest string
+}
+
+type WeatherInfo struct {
+ Current struct {
+ Temperature float64 `json:"temp"`
+ CloudCover int `json:"clouds"`
+ Humidity int `json:"humidity"`
+ WindSpeed float64 `json:"wind_speed"`
+ Conditions []struct {
+ Description string `json:"description"`
+ } `json:"weather"`
+ } `json:"current"`
+
+ Hourly []struct {
+ PrecipProb float64 `json:"pop"`
+ } `json:"hourly"`
+}
+
+type GeocodeResult struct {
+ Info struct {
+ StatusCode int `json:"statuscode"`
+ Messages []string `json:"messages"`
+ } `json:"info"`
+ Results []struct {
+ Locations []struct {
+ Street string `json:"street"`
+ AdminArea6 string `json:"adminArea6"`
+ AdminArea6Type string `json:"adminArea6Type"`
+ AdminArea5 string `json:"adminArea5"`
+ AdminArea5Type string `json:"adminArea5Type"`
+ AdminArea4 string `json:"adminArea4"`
+ AdminArea4Type string `json:"adminArea4Type"`
+ AdminArea3 string `json:"adminArea3"`
+ AdminArea3Type string `json:"adminArea3Type"`
+ AdminArea1 string `json:"adminArea1"`
+ AdminArea1Type string `json:"adminArea1Type"`
+ PostalCode string `json:"postalCode"`
+ GeocodeQualityCode string `json:"geocodeQualityCode"`
+ GeocodeQuality string `json:"geocodeQuality"`
+ DragPoint bool `json:"dragPoint"`
+ SideOfStreet string `json:"sideOfStreet"`
+ LinkID string `json:"linkId"`
+ UnknownInput string `json:"unknownInput"`
+ Type string `json:"type"`
+ LatLng struct {
+ Lat float64 `json:"lat"`
+ Lng float64 `json:"lng"`
+ } `json:"latLng"`
+ DisplayLatLng struct {
+ Lat float64 `json:"lat"`
+ Lng float64 `json:"lng"`
+ } `json:"displayLatLng"`
+ MapURL string `json:"mapUrl"`
+ } `json:"locations"`
+ } `json:"results"`
+}
+
+func (f *Forecaster) Weather(ctx context.Context, lat float64, lng float64) (*WeatherInfo, error) {
+ q := url.Values{}
+ q.Set("lat", strconv.FormatFloat(lat, 'f', 6, 64))
+ q.Set("lon", strconv.FormatFloat(lng, 'f', 6, 64))
+ q.Set("units", "metric")
+ q.Set("exclude", "minutely,daily,alerts")
+ q.Set("appid", f.Credentials.OpenWeatherMap)
+
+ req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.openweathermap.org/data/2.5/onecall", nil)
+ req.URL.RawQuery = q.Encode()
+
+ resp, err := f.HttpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("%d status code from the API", resp.StatusCode)
+ }
+
+ var w WeatherInfo
+ if err := json.NewDecoder(resp.Body).Decode(&w); err != nil {
+ return nil, fmt.Errorf("failed to parse JSON: %w", err)
+ }
+ return &w, nil
+}
+
+func (f *Forecaster) Geocode(ctx context.Context, query string) (
+ lat float64,
+ lng float64,
+ location string,
+ err error,
+) {
+ q := url.Values{}
+ q.Set("location", query)
+ q.Set("key", f.Credentials.MapQuest)
+
+ req, _ := http.NewRequestWithContext(ctx, "GET", "https://www.mapquestapi.com/geocoding/v1/address", nil)
+ req.URL.RawQuery = q.Encode()
+
+ resp, err := f.HttpClient.Do(req)
+ if err != nil {
+ return
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ err = fmt.Errorf("%d status code from the API", resp.StatusCode)
+ return
+ }
+
+ var result *GeocodeResult
+ if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ err = fmt.Errorf("failed to parse JSON: %w", err)
+ return
+ }
+
+ if result.Info.StatusCode == 403 {
+ err = &QuotaReachedError{"MapQuest", result.Info.Messages}
+ return
+ } else if result.Info.StatusCode != 0 {
+ err = &GeocodingError{result.Info.StatusCode, result.Info.Messages}
+ return
+ }
+
+ if len(result.Results) < 1 || len(result.Results[0].Locations) < 1 {
+ err = &NoGeocodeResults{}
+ return
+ }
+
+ var (
+ b strings.Builder
+ loc = result.Results[0].Locations[0]
+
+ city = loc.AdminArea5
+ state = loc.AdminArea3
+ country = loc.AdminArea1
+ )
+
+ lat = loc.LatLng.Lat
+ lng = loc.LatLng.Lng
+ if city != "" {
+ fmt.Fprintf(&b, "%s, ", city)
+ }
+ if state != "" {
+ fmt.Fprintf(&b, "%s, ", state)
+ }
+ b.WriteString(country)
+ location = b.String()
+ err = nil
+ return
+}
D wolframalpha.go => wolframalpha.go +0 -32
@@ 1,32 0,0 @@
-package main
-
-import (
- "context"
- "log"
- "strings"
-
- "git.sr.ht/~handlerug/handlebot/wolframalpha"
-)
-
-func handleWolframAlpha(ctx context.Context, contents string) (string, error) {
- contents = strings.TrimSpace(contents)
- if contents == "" {
- return "", nil
- }
-
- cfg := ConfigForContext(ctx)
- c := wolframalpha.Client{AppID: cfg.APIKeys.WolframAlpha}
- resp, err := c.Query(ctx, contents)
- if err != nil {
- // TODO: Better mechanism for telling user-facing and internal errors apart
- if _, ok := err.(*wolframalpha.NoResultsError); ok {
- return err.Error(), nil
- } else if _, ok := err.(*wolframalpha.TimeoutError); ok {
- return err.Error(), nil
- } else {
- log.Println(err)
- return "Wolfram|Alpha API call failed: see logs for more info.", nil
- }
- }
- return resp.String(), err
-}
A wolframalpha/context.go => wolframalpha/context.go +24 -0
@@ 0,0 1,24 @@
+package wolframalpha
+
+import (
+ "context"
+ "errors"
+)
+
+var waCtxKey = &contextKey{"wolframalpha"}
+
+type contextKey struct {
+ name string
+}
+
+func Context(ctx context.Context, wa *Client) context.Context {
+ return context.WithValue(ctx, waCtxKey, wa)
+}
+
+func ForContext(ctx context.Context) *Client {
+ client, ok := ctx.Value(waCtxKey).(*Client)
+ if !ok {
+ panic(errors.New("Invalid Wolfram|Alpha client context"))
+ }
+ return client
+}
M wolframalpha/errors.go => wolframalpha/errors.go +6 -4
@@ 6,6 6,10 @@ import (
"strings"
)
+type PublicError interface {
+ Public()
+}
+
type NoResultsError struct {
Err error
DidYouMeans DidYouMeans
@@ 27,7 31,7 @@ func (e *NoResultsError) Unwrap() error {
return e.Err
}
-///
+func (e *NoResultsError) Public() {}
type TimeoutError struct{}
@@ 35,7 39,7 @@ func (e *TimeoutError) Error() string {
return "request to Wolfram|Alpha API timed out"
}
-///
+func (e *TimeoutError) Public() {}
type RequestError struct {
Err error
@@ 49,8 53,6 @@ func (e *RequestError) Unwrap() error {
return e.Err
}
-///
-
type ResponseError struct {
Err error
}
M wolframalpha/wolframalpha.go => wolframalpha/wolframalpha.go +5 -33
@@ 9,7 9,7 @@ import (
"strings"
)
-type QueryResult struct {
+type Result struct {
Success bool `json:"success"`
Input string `json:"inputstring"`
Pods []Pod `json:"pods"`
@@ 81,21 81,6 @@ type Client struct {
HttpClient http.Client
}
-type Result struct {
- Input string
- Output string
-}
-
-func (r *Result) String() string {
- if r == nil {
- return ""
- }
- if r.Input == "" {
- return r.Output
- }
- return fmt.Sprintf("[%s] %s", r.Input, r.Output)
-}
-
func (c *Client) Query(ctx context.Context, input string) (*Result, error) {
q := url.Values{}
q.Set("input", input)
@@ 121,29 106,16 @@ func (c *Client) Query(ctx context.Context, input string) (*Result, error) {
defer resp.Body.Close()
var apiResp struct {
- Result QueryResult `json:"queryresult"`
+ Result Result `json:"queryresult"`
}
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, &ResponseError{err}
}
- res := apiResp.Result
- if !res.Success {
+ if !apiResp.Result.Success {
return nil, &NoResultsError{
- DidYouMeans: res.DidYouMeans,
- }
- }
-
- var interpretation string
- var result string
-
- for _, pod := range res.Pods {
- if pod.ID == "Input" && len(pod.Subpods) > 0 {
- interpretation = pod.Subpods[0].PlainText
- } else if pod.ID == "Result" && len(pod.Subpods) > 0 {
- result = pod.Subpods[0].PlainText
+ DidYouMeans: apiResp.Result.DidYouMeans,
}
}
-
- return &Result{interpretation, result}, nil
+ return &apiResp.Result, nil
}
A youtube/context.go => youtube/context.go +24 -0
@@ 0,0 1,24 @@
+package youtube
+
+import (
+ "context"
+ "errors"
+)
+
+var ytCtxKey = &contextKey{"youtube"}
+
+type contextKey struct {
+ name string
+}
+
+func Context(ctx context.Context, yt *Client) context.Context {
+ return context.WithValue(ctx, ytCtxKey, yt)
+}
+
+func ForContext(ctx context.Context) *Client {
+ yt, ok := ctx.Value(ytCtxKey).(*Client)
+ if !ok {
+ panic(errors.New("Invalid youtube context"))
+ }
+ return yt
+}
A youtube/errors.go => youtube/errors.go +28 -0
@@ 0,0 1,28 @@
+package youtube
+
+import (
+ "fmt"
+)
+
+type PublicError interface {
+ Public()
+}
+
+type APIError struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+func (e *APIError) Error() string {
+ return fmt.Sprintf("%d %s", e.Code, e.Message)
+}
+
+func (e *APIError) Public() {}
+
+type NotFoundError struct{}
+
+func (e *NotFoundError) Error() string {
+ return "not found"
+}
+
+func (e *NotFoundError) Public() {}
A youtube/youtube.go => youtube/youtube.go +148 -0
@@ 0,0 1,148 @@
+package youtube
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "time"
+ "strconv"
+)
+
+type Client struct {
+ APIKey string
+ HttpClient http.Client
+}
+
+type Response struct {
+ Kind string `json:"kind"`
+ ETag string `json:"etag"`
+ NextPageToken string `json:"nextPageToken"`
+ PrevPageToken string `json:"prevPageToken"`
+ PageInfo PageInfo `json:"pageInfo"`
+ Items []*Video `json:"items"`
+ Error *APIError `json:"error"`
+}
+
+type PageInfo struct {
+ TotalResults int `json:"totalResults"`
+ ResultsPerPage int `json:"resultsPerPage"`
+}
+
+type Video struct {
+ Kind string `json:"kind"`
+ ETag string `json:"etag"`
+ ID string `json:"id"`
+ Snippet *Snippet `json:"snippet"`
+ ContentDetails *ContentDetails `json:"contentDetails"`
+ Statistics *Statistics `json:"statistics"`
+}
+
+type Snippet struct {
+ PublishedAt time.Time `json:"publishedAt"`
+ ChannelID string `json:"channelId"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Thumbnails map[string]Thumbnail `json:"thumbnails"`
+ ChannelTitle string `json:"channelTitle"`
+ Tags []string `json:"tags"`
+ CategoryID string `json:"categoryId"`
+ LiveBroadcastContent string `json:"liveBroadcastContent"`
+ DefaultLanguage string `json:"defaultLanguage"`
+ Localized struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ } `json:"localized"`
+ DefaultAudioLanguage string `json:"defaultAudioLanguage"`
+}
+
+type Thumbnail struct {
+ URL string `json:"url"`
+ Width uint `json:"width"`
+ Height uint `json:"height"`
+}
+
+type ContentDetails struct {
+ Duration string `json:"duration"`
+ Dimension string `json:"dimension"`
+ Definition string `json:"definition"`
+ Caption StrBool `json:"caption"`
+ LicensedContent bool `json:"licensedContent"`
+ RegionRestriction struct {
+ Allowed []string `json:"allowed"`
+ Blocked []string `json:"blocked"`
+ } `json:"regionRestriction"`
+ Projection string `json:"projection"`
+ HasCustomThumbnail bool `json:"hasCustomThumbnail"`
+
+ // skipped "contentRating"
+}
+
+type Statistics struct {
+ ViewCount StrUint64 `json:"viewCount"`
+ LikeCount StrUint64 `json:"likeCount"`
+ DislikeCount StrUint64 `json:"dislikeCount"`
+ CommentCount StrUint64 `json:"commentCount"`
+}
+
+type StrBool bool
+
+func (v *StrBool) UnmarshalJSON(b []byte) error {
+ if string(b) == "null" {
+ return nil
+ }
+ var s string
+ if err := json.Unmarshal(b, &s); err != nil {
+ return err
+ }
+ *v = s[0] == 't'
+ return nil
+}
+
+type StrUint64 uint64
+
+func (v *StrUint64) UnmarshalJSON(b []byte) error {
+ if string(b) == "null" {
+ return nil
+ }
+ var s string
+ if err := json.Unmarshal(b, &s); err != nil {
+ return err
+ }
+ num, err := strconv.ParseUint(s, 10, 64)
+ if err != nil {
+ return err
+ }
+ *v = StrUint64(num)
+ return nil
+}
+
+func (c *Client) GetVideo(ctx context.Context, id string) (*Video, error) {
+ req, _ := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/youtube/v3/videos", nil)
+ q := url.Values{}
+ q.Set("part", "snippet,contentDetails,statistics")
+ q.Set("key", c.APIKey)
+ q.Set("id", id)
+ req.URL.RawQuery = q.Encode()
+
+ resp, err := c.HttpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var res *Response
+ if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
+ return nil, err
+ }
+
+ if res.Error != nil {
+ return nil, res.Error
+ }
+
+ if len(res.Items) == 0 {
+ return nil, &NotFoundError{}
+ }
+
+ return res.Items[0], nil
+}