...
 
Commits (8)
templates/
*.json
*.log
valerius
......
......@@ -4,128 +4,9 @@ Valerius (stylized valerius) is a modular Discord bot for personal use.
## Usage
Valerius uses JSON to configure all of its commands from generic shells.
Valerius uses JSON to describe all of its commands from generic shells.
The program looks for `valerius.json` in the current working directory, but you can set the config file with `valerius -conf <path_to_configfile>`.
It's best to learn by example here. This is an example config similar to the one the author uses, using all of the currently available command types:
```json
{
"botToken": "YOUR_DISCORD_BOT_TOKEN_HERE",
"commands": [
{
"name": "Hello",
"type": "pingpong",
"options": {
"trigger": "!hello",
"response": "Hello world!"
}
},
{
"name": "RegExample",
"type": "pingpong",
"options": {
"triggerregex": "https?://[\\S]+",
"response": "Yep, that looks like a link."
}
},
{
"name": "Bangers",
"type": "pingpong",
"options": {
"triggers": [
"https://www.youtube.com/watch?v=kZf91MAwS7s",
"https://www.youtube.com/watch?v=UGymAxj8DYI",
"https://www.youtube.com/watch?v=6Wo-u8vQn4U",
"https://www.youtube.com/watch?v=u3GTsFwJ5Uo",
"https://www.youtube.com/watch?v=Emiu-xcLlJU",
"https://www.youtube.com/watch?v=l7PD62YHRQk",
"https://www.youtube.com/watch?v=B2jVbSI9H4o",
"https://www.youtube.com/watch?v=pcamjcoRmrQ",
"https://www.youtube.com/watch?v=B1lNhNHdoPI"
],
"responses": [
"https://i.kym-cdn.com/photos/images/original/001/331/773/518.gif",
"https://media.giphy.com/media/PSKAppO2LH56w/giphy.gif",
"https://i.kym-cdn.com/photos/images/newsfeed/000/427/549/b9d.gif",
"https://i.kym-cdn.com/photos/images/newsfeed/000/032/802/ninja-dance.gif",
"https://media1.tenor.com/images/e88f1c4b6d3ac98bde66db24fb73441d/tenor.gif?itemid=5586778",
"https://media.giphy.com/media/7isbcNAx367qU/200.gif",
"https://thumbs.gfycat.com/AgitatedGleefulEmperorshrimp-size_restricted.gif",
"https://media0.giphy.com/media/CDzdJSkC4iyLC/giphy.gif"
],
"responsePrefix": "🚨IT'S🚨A🚨BANGER🚨\n"
}
},
{
"name": "XKCD",
"type": "rest",
"options": {
"triggerregex": "^!xkcd ([0-9]+)$",
"endpoint": [
"http://xkcd.com/%s/info.0.json",
1
],
"method": "GET",
"response": [
"XKCD %v: %v\nAlt text: %v\n%v",
"num",
"safe_title",
"alt",
"img"
],
"errorMessage": "Couldn't find that one, sorry!"
}
},
{
"name": "Pokedex by name/number",
"type": "rest",
"options": {
"triggerregex": "^!pokemon ([\\-a-z]+|[0-9]+)$",
"endpoint": [
"https://pokeapi.co/api/v2/pokemon/%s/",
1
],
"responses": [
[
"#%v %s (%s/%s): %s",
"id",
"name",
"types.0.type.name",
"types.1.type.name",
"sprites.front_default"
],
[
"#%v %s (%s): %s",
"id",
"name",
"types.0.type.name",
"sprites.front_default"
]
]
}
},
{
"name": "IASIP",
"type": "iasip",
"options": {
"prefix": "!iasip",
"fontpath": "./textile.ttf",
"quality": 100
}
},
{
"name": "CommandReloader",
"type": "reload",
"userwhitelist": ["82984152671985664"],
"options": {
"trigger": "!reload"
}
}
]
}
```
In essence, a configuration will have a `botToken` property with (surprise) the Discord bot token to log in with, and a `commands` array which contains an array of command configuration objects, each with a `name` to call it by in logs. a `type` to base the command off of, and an `options` object to actually configure the command type with. Put the config file in the same directory as the executable and fire it up and you should have a working bot you can customize on-the-fly, without need for recompilation or source code editing.
You can also set a log file with the `-log` argument.
......@@ -3,7 +3,7 @@ module gitlab.com/bclindner/valerius/v0.3
require (
github.com/alecthomas/gometalinter v2.0.12+incompatible // indirect
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect
github.com/bclindner/iasipgenerator v0.0.0-20181217195122-d337ee89954c
github.com/bclindner/iasipgenerator v0.0.0-20181218024440-9e995f4ca2d0
github.com/bwmarrin/discordgo v0.19.0
github.com/cosiner/argv v0.0.1 // indirect
github.com/davidrjenni/reftools v0.0.0-20180914123528-654d0ba4f96d // indirect
......@@ -34,8 +34,8 @@ require (
golang.org/x/arch v0.0.0-20181203225421-5a4828bb7045 // indirect
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 // indirect
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
golang.org/x/sys v0.0.0-20181213200352-4d1cda033e06 // indirect
golang.org/x/tools v0.0.0-20181217182337-728ed46ae025 // indirect
golang.org/x/sys v0.0.0-20181217223516-dcdaa6325bcb // indirect
golang.org/x/tools v0.0.0-20181218020041-13ba8ad772df // indirect
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/yaml.v2 v2.2.2 // indirect
......
......@@ -10,6 +10,8 @@ github.com/bclindner/iasipgenerator v0.0.0-20181215041843-992a05812300 h1:y+bqlL
github.com/bclindner/iasipgenerator v0.0.0-20181215041843-992a05812300/go.mod h1:sqvGzUcCr3RHiTD+tB2vPDlkw4KgZKU6AJDnf1K/phU=
github.com/bclindner/iasipgenerator v0.0.0-20181217195122-d337ee89954c h1:PObpo7aM3l4G0wbCqYIfWFFp31DbtQzihrUV1Kpx8gk=
github.com/bclindner/iasipgenerator v0.0.0-20181217195122-d337ee89954c/go.mod h1:sqvGzUcCr3RHiTD+tB2vPDlkw4KgZKU6AJDnf1K/phU=
github.com/bclindner/iasipgenerator v0.0.0-20181218024440-9e995f4ca2d0 h1:VTAmmNtZ9U1LgsOMFC9BB30LwHRGP9lblTzI8KqF1s0=
github.com/bclindner/iasipgenerator v0.0.0-20181218024440-9e995f4ca2d0/go.mod h1:sqvGzUcCr3RHiTD+tB2vPDlkw4KgZKU6AJDnf1K/phU=
github.com/bwmarrin/discordgo v0.19.0 h1:kMED/DB0NR1QhRcalb85w0Cu3Ep2OrGAqZH1R5awQiY=
github.com/bwmarrin/discordgo v0.19.0/go.mod h1:O9S4p+ofTFwB02em7jkpkV8M3R0/PUVOwN61zSZ0r4Q=
github.com/cosiner/argv v0.0.1 h1:2iAFN+sWPktbZ4tvxm33Ei8VY66FPCxdOxpncUGpAXE=
......@@ -107,6 +109,8 @@ golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33 h1:I6FyU15t786LL7oL/hn43zqTu
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181213200352-4d1cda033e06 h1:0oC8rFnE+74kEmuHZ46F6KHsMr5Gx2gUQPuNz28iQZM=
golang.org/x/sys v0.0.0-20181213200352-4d1cda033e06/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181217223516-dcdaa6325bcb h1:zzdd4xkMwu/GRxhSUJaCPh4/jil9kAbsU7AUmXboO+A=
golang.org/x/sys v0.0.0-20181217223516-dcdaa6325bcb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180824175216-6c1c5e93cdc1/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181019005945-6adeb8aab2de/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
......@@ -116,6 +120,7 @@ golang.org/x/tools v0.0.0-20181201035826-d0ca3933b724/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20181207195948-8634b1ecd393/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181214171254-3c39ce7b6105/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181217182337-728ed46ae025/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181218020041-13ba8ad772df/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c h1:vTxShRUnK60yd8DZU+f95p1zSLj814+5CuEh7NjF2/Y=
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c/go.mod h1:3HH7i1SgMqlzxCcBmUHW657sD4Kvv9sC3HpL3YukzwA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
......
......@@ -100,26 +100,18 @@ func (b BaseCommand) Check(guildID, channelID, userID string) bool {
return true
}
// MessageHandler handles Discordgo messages, testing them against Valerius-compatible commands.
// The Handler handles Discordgo messages, testing them against Valerius-compatible commands.
// The struct itself only contains the list of commands.
type MessageHandler struct {
Handler
type Handler struct {
// List of commands to test.
commands []Command
// Command to disconnect the handler from the bot.
DestroySelf func()
}
// Handler is the interface for the bot message handler.
// Has Handle and Add functions that handle commands and add new ones.
type Handler interface {
// Handle a Discord command.
Handle(*discordgo.Session, *discordgo.MessageCreate)
}
// NewMessageHandler creates a new handler and binds it to a Session.
func NewMessageHandler(bot *discordgo.Session, commands []BaseCommand) (*MessageHandler, error) {
handler := MessageHandler{}
// NewHandler creates a new handler and binds it to a Session.
func NewHandler(bot *discordgo.Session, commands []BaseCommand) (*Handler, error) {
handler := Handler{}
// set variables for use in the loop
var (
err error
......@@ -156,7 +148,7 @@ func NewMessageHandler(bot *discordgo.Session, commands []BaseCommand) (*Message
// Handle handles a Discord message. This just runs the Test() function of each command,
// and if a command's test passes, the handler calls its Run() function, logging
// the action as well.
func (c *MessageHandler) Handle(bot *discordgo.Session, evt *discordgo.MessageCreate) {
func (c *Handler) Handle(bot *discordgo.Session, evt *discordgo.MessageCreate) {
// Run preliminary tests: is the user sending the message a bot?
if evt.Message.Author.Bot {
return
......@@ -203,6 +195,6 @@ func (c *MessageHandler) Handle(bot *discordgo.Session, evt *discordgo.MessageCr
}
// Add commands to the handler, validating whitelists/blacklists as well.
func (c *MessageHandler) Add(cmd Command) {
func (c *Handler) Add(cmd Command) {
c.commands = append(c.commands, cmd)
}
......@@ -49,7 +49,7 @@ func (c ReloadCommand) Run(bot *discordgo.Session, evt *discordgo.MessageCreate)
return err
}
// Try to make the new handler
newhandler, err := NewMessageHandler(bot, config.Commands)
newhandler, err := NewHandler(bot, config.Commands)
if err != nil {
bot.ChannelMessageSend(evt.Message.ChannelID, "Failed to reload commands.")
return err
......
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"github.com/bwmarrin/discordgo" // for running the bot
"github.com/gregjones/httpcache"
log "github.com/sirupsen/logrus" // logging suite
"github.com/tidwall/gjson" // for getting items in dot notation
"io/ioutil" // for opening response body
"net/http"
"net/url"
"regexp"
"text/template"
)
// RESTCommand base structure.
......@@ -21,19 +22,20 @@ type RESTCommand struct {
regexp *regexp.Regexp
endpointstring string
endpointgroups []int
template *template.Template
client http.Client
}
// RESTConfig is the configuration for the RESTCommand.
type RESTConfig struct {
TriggerRegex string `json:"triggerregex"`
Endpoint []interface{} `json:"endpoint"`
Method string `json:"method"`
Response []string `json:"response"`
Responses [][]string `json:"responses"`
ErrorMessage string `json:"errorMessage"`
Headers map[string]string `json:"headers"`
DisableCache bool `json:"disablecache"`
TriggerRegex string `json:"triggerregex"`
Endpoint []interface{} `json:"endpoint"`
Method string `json:"method"`
Response string `json:"response"`
ResponseFilepath string `json:"responseFile"`
ErrorMessage string `json:"errorMessage"`
Headers map[string]string `json:"headers"`
DisableCache bool `json:"disablecache"`
}
// NewRESTCommand generates a new RESTCommand.
......@@ -43,9 +45,25 @@ func NewRESTCommand(config BaseCommand) (command RESTCommand, err error) {
if err != nil {
return command, nil
}
// Ensure there is only one of Response or Responses
if len(options.Response) > 0 && len(options.Responses) > 0 {
return command, errors.New("Can only have one of 'response' or 'responses'")
// Ensure only one of Response and ResponseFilepath is set
if len(options.Response) > 0 && len(options.ResponseFilepath) > 0 {
return command, errors.New("Can only have one of response and responseFile")
}
// Get template text to use
var tmplstr string
if len(options.Response) > 0 {
tmplstr = options.Response
} else {
tmplbytes, err := ioutil.ReadFile(options.ResponseFilepath)
if err != nil {
return command, errors.New("Error reading response file: " + err.Error())
}
tmplstr = string(tmplbytes)
}
// Compile the template
tmpl, err := template.New(config.Name).Parse(tmplstr)
if err != nil {
return command, errors.New("Failed to compile template: " + err.Error())
}
// Ensure the endpoint and response commands are of their correct types.
endpoint, ok := options.Endpoint[0].(string)
......@@ -80,6 +98,7 @@ func NewRESTCommand(config BaseCommand) (command RESTCommand, err error) {
regexp: rgx,
endpointstring: endpoint,
endpointgroups: endpointgroups,
template: tmpl,
}
// set the client based on if this restcommand is cached
if options.DisableCache {
......@@ -148,44 +167,21 @@ func (r RESTCommand) Run(bot *discordgo.Session, evt *discordgo.MessageCreate) (
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
r.sendErrorMessage(bot, evt)
return errors.New("could not parse request body: " + err.Error())
}
// For single-response:
if len(r.Response) > 0 {
// Get the JSON objects needed to format the response
var respfmtgroups []interface{}
items := gjson.GetManyBytes(body, r.Response[1:]...)
for _, item := range items {
// Break if a particular lookup is missing
if !item.Exists() {
break
}
respfmtgroups = append(respfmtgroups, item.Value())
}
// Format and send the response
bot.ChannelMessageSend(evt.Message.ChannelID, fmt.Sprintf(r.Response[0], respfmtgroups...))
return nil
}
// For multi-response:
if len(r.Responses) > 0 {
ToNextResponse:
for _, response := range r.Responses {
// Get the JSON objects needed to format the response
var respfmtgroups []interface{}
items := gjson.GetManyBytes(body, response[1:]...)
for _, item := range items {
// Continue into the next response if a particular lookup is missing
if !item.Exists() {
continue ToNextResponse
}
respfmtgroups = append(respfmtgroups, item.Value())
}
// Format and send the response
bot.ChannelMessageSend(evt.Message.ChannelID, fmt.Sprintf(response[0], respfmtgroups...))
return nil
}
return errors.New("could not read request body: " + err.Error())
}
// Unmarshal the response JSON into an interface{}
var bodyjson interface{}
err = json.Unmarshal(body, &bodyjson)
if err != nil {
r.sendErrorMessage(bot, evt)
return errors.New("could not unmarshal request body: " + err.Error())
}
msgbuf := new(bytes.Buffer)
err = r.template.Execute(msgbuf, bodyjson)
if err != nil {
r.sendErrorMessage(bot, evt)
return errors.New("could not execute template: " + err.Error())
}
// If this code is reached, no response was valid, which probably shouldn't happen, so we'll throw an error
r.sendErrorMessage(bot, evt)
return errors.New("No valid response schema")
bot.ChannelMessageSend(evt.Message.ChannelID, msgbuf.String())
return nil
}
......@@ -24,7 +24,7 @@ type BotConfiguration struct {
var (
config BotConfiguration
handler *MessageHandler
handler *Handler
logPath = flag.String("log", "", "Path to the logfile, if used.")
configPath = flag.String("conf", "valerius.json", "Path to the config file.")
)
......@@ -90,7 +90,7 @@ func main() {
}
defer bot.Close()
// instantiate the handler
handler, err = NewMessageHandler(bot, config.Commands)
handler, err = NewHandler(bot, config.Commands)
if err != nil {
log.Fatal(err)
}
......