~handlerug/handlebot

75e54bf7205e66e482dcdf9f8d46be80074b060a — Umar Getagazov 16 days ago 3c6caa4
Touch code harder

It was great. Thank you

Fixes: https://todo.sr.ht/~handlerug/handlebot/17
Fixes: https://todo.sr.ht/~handlerug/handlebot/18
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
}