Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework Replies #20

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
- name: Lint
uses: golangci/golangci-lint-action@v2
with:
version: v1.45.2
version: v1.51.1
test:
name: Test and Build
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ RUN go mod download
COPY . .
RUN mkdir bin && go build -o ./bin/ ./cmd/...

FROM alpine:3.15
FROM alpine:3.17

RUN apk update && apk add --no-cache ca-certificates

Expand Down
82 changes: 75 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ PostgreSQL database to store the various required data to function. Notable
features include:

* Posting random (or specific) jokes (from the database)
* Posting random (or specific) pictures (from the database)
* Posting random (or specific) audio taunts (from the database)
* Posting random (or specific) media files (from the database)
* Posting replies (from the database) on regex matches

## Setup

Expand All @@ -17,14 +17,82 @@ untested). The schema definition is in the [initdb](initdb/) directory. If using
[Docker](https://www.docker.com/), you should be able to use it as a volume
mount for the [initialization of the schema](https://github.com/docker-library/docs/tree/master/postgres#initialization-scripts).
Once initialized, use [eelbot-ui](https://github.com/emseers/eelbot-ui) to
prep/modify the database. Modifying the database directly is not recommended.
prep/modify the database. Alternatively, steps for manually updating the
database is described below.

### Jokes

The `jokes` table in the `public` schema contains simple jokes. Jokes can either
be a single line, or a joke with a leadup and a punchline. For single line
jokes, only the `text` needs to be set. Otherwise, both `text` and `punchline`
can be set to make it a joke with a punchline. The `id` can be any unique value,
but is required to contain gapless sequential values. This table can safely be
modified at runtime without requiring a restart.

Jokes can be posted using the `/badjoke me` command for a random joke or
`/badjoke <id>` for a specific joke.

### Images/Taunts

The `images` and `taunts` tables in the `public` schema have the same format.
Their difference is in name only (and by extension their trigger command) and
otherwise work exactly the same. An entry can be added by adding the bytes of a
`file` with a corresponding `name`. The `id` can be any unique value, but is
required to contain gapless sequential values. These tables can safely be
modified at runtime without requiring a restart.

Images can be posted using the `/eel me` command for a random file or
`/eel <id>` for a specific file. Taunts can be posted using the `/taunt me`
command for a random file or `/taunt <id>` for a specific file.

### Replies

Custom replies can be triggered on regex matches, but requires some setup. For
each type of reply, there needs to be:

* one or more regular expressions specified that determine if it's a match
* one or more values to choose from for the reply
* a trigger chance (percentage) on whether a reply will be sent on a match
* a min and max treshold for time to wait before sending the reply
* a cooldown period during which this reply can't be triggered again

The following steps are required to add a reply type:

1. Create a table to contain the set of reply values in the `reply` schema:

```psql
CREATE TABLE reply.$name (id integer PRIMARY KEY, text text NOT NULL);
```

where `$name` is the name of the reply type.
1. Add the set of reply values to the table. The `text` can be any string and
the `id` can be any unique value, but is required to contain gapless
sequential values.
1. Add an entry to the config file under the `replies` section with the
following values:

* `table_name`: the name of the reply type (should match the name of the
table created in the `reply` schema)
* `regexps`: a list of regular expressions to use to determine if the reply
should be triggered (values should be valid golang regex flavor)
* `percent`: the trigger chance for the reply (0 - 100)
* `min_delay`: minimum time to wait before sending the reply
* `max_delay`: maximum time to wait before sending the reply
* `timeout`: timeout (in seconds) for consequetive replies of this type

The reply type should now be able to be triggered by receiving any text that
matches one of the match regex values. The values in the config file are loaded
once at startup, but the values in the custom tables in the `reply`
schema are read lazily at runtime. Therefore, any changes to the config file
will require a restart to take effect, whereas the contents of the tables in the
`reply` schema can safely be modified at runtime without requiring a restart.

## Build

Install [golang](https://golang.org/) 1.20 or later. Clone the project and build
with:

```
```sh
mkdir bin
go build -o ./bin/ ./cmd/...
```
Expand All @@ -34,7 +102,7 @@ go build -o ./bin/ ./cmd/...
Grab the config template from `configs/eelbot/config.yaml` and update it to
match your setup, then save it. Run the bot executable in the `bin` folder:

```
```sh
./eelbot -c <path/to/config/file> -t <discord-bot-token>
```

Expand All @@ -48,13 +116,13 @@ As an alternative to building and running eelbot locally, you can build and run
it in [Docker](https://www.docker.com/). Build the image with the provided
Dockerfile:

```
```sh
docker build -t eelbot:latest .
```

You need to mount all required files and folders to run the container:

```
```sh
docker run \
-it \
--name eelbot \
Expand Down
35 changes: 26 additions & 9 deletions cmd/eelbot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,8 @@ func main() {
mustDo(yaml.Unmarshal(config, &yOpts))

// Unlike json, yaml allows for non-string keys. Therefore, convert the map[any]any to a map[string]any to be
// json compatible.
// json compatible. Also change all numeric types to float64 to match the jsin unmarshaler.
opts = toStrKeys(yOpts).(map[string]any)

// Since the yaml and json packages unmarshal numeric types differently, stick to the json way (always float64)
// by marshalling to json and unmarshalling back.
config = must(json.Marshal(opts))
mustDo(json.Unmarshal(config, &opts))
}

var (
Expand All @@ -104,8 +99,8 @@ func main() {
mustDo(commands.Register(bot, cmdOpts, db, dbTimeout))
}

if replyOpts, ok := opts["replies"].(map[string]any); ok {
mustDo(replies.Register(bot, replyOpts))
if replyOpts, ok := opts["replies"].([]any); ok {
mustDo(replies.Register(bot, replyOpts, db, dbTimeout))
}

mustDo(bot.Start())
Expand All @@ -120,7 +115,7 @@ func main() {
fmt.Println("goodbye")
}

// Converts a map[any]any to a map[string]any.
// Converts a map[any]any to a map[string]any and changes numeric types to float64.
func toStrKeys(a any) any {
switch ta := a.(type) {
case map[any]any:
Expand All @@ -145,6 +140,28 @@ func toStrKeys(a any) any {
s[i] = toStrKeys(v)
}
return s
case int:
return float64(ta)
case int8:
return float64(ta)
case int16:
return float64(ta)
case int32:
return float64(ta)
case int64:
return float64(ta)
case uint:
return float64(ta)
case uint8:
return float64(ta)
case uint16:
return float64(ta)
case uint32:
return float64(ta)
case uint64:
return float64(ta)
case float32:
return float64(ta)
default:
return ta
}
Expand Down
38 changes: 37 additions & 1 deletion commands/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,42 @@ func Register(bot *eelbot.Bot, opts map[string]any, db *sql.DB, dbTimeout time.D
return nil
}

func getDuration(v any, def time.Duration) (dur time.Duration, err error) {
switch d := v.(type) {
case int:
dur = time.Second * time.Duration(d)
case int8:
dur = time.Second * time.Duration(d)
case int16:
dur = time.Second * time.Duration(d)
case int32:
dur = time.Second * time.Duration(d)
case int64:
dur = time.Second * time.Duration(d)
case uint:
dur = time.Second * time.Duration(d)
case uint8:
dur = time.Second * time.Duration(d)
case uint16:
dur = time.Second * time.Duration(d)
case uint32:
dur = time.Second * time.Duration(d)
case uint64:
dur = time.Second * time.Duration(d)
case float32:
dur = time.Second * time.Duration(d)
case float64:
dur = time.Second * time.Duration(d)
case string:
dur, err = time.ParseDuration(d)
case time.Duration:
dur = d
default:
dur = def
}
return
}

func queryRow(db *sql.DB, dbTimeout time.Duration, query string, args ...any) (*sql.Row, context.CancelFunc) {
ctx, cancel := context.Background(), func() {}
if dbTimeout > 0 {
Expand All @@ -63,7 +99,7 @@ func queryRow(db *sql.DB, dbTimeout time.Duration, query string, args ...any) (*
// ordering all rows.
func randRowQuery(table string, cols []string) string {
return fmt.Sprintf(
"SELECT %s FROM %[2]s WHERE id=(SELECT (min(id) + trunc(random()*(max(id)-min(id)))::integer) FROM %[2]s);",
"SELECT %s FROM %[2]s WHERE id=(SELECT (MIN(id) + trunc(random()*(MAX(id)-MIN(id)))::integer) FROM %[2]s);",
strings.Join(cols, ", "),
table,
)
Expand Down
13 changes: 6 additions & 7 deletions commands/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (

var (
cfg = map[string]any{
"badjoke": map[string]any{"enable": true, "delay": float64(3)},
"badjoke": map[string]any{"enable": true, "delay": 3},
"eel": map[string]any{"enable": true},
"taunt": map[string]any{"enable": true},
"channel": map[string]any{"enable": true},
Expand All @@ -37,15 +37,9 @@ var (

func TestRegister(t *testing.T) {
bot := eelbot.New(newTestSession())

require.ErrorContains(t, commands.Register(bot, cfg, nil, 0), "command requires a database")
require.NoError(t, commands.Register(bot, cfg, db, time.Second))

// Should be fault tolerant with invalid keys.
cfg["badjoke"].(map[string]any)["delay"] = "foo"
cfg["channel"].(map[string]any)["enable"] = "bar"
require.NoError(t, commands.Register(bot, cfg, db, time.Second))

require.EqualError(t, commands.Register(bot, cfgJokeOnly, nil, 0), "/badjoke command requires a database")
require.NoError(t, commands.Register(bot, cfgJokeOnly, db, time.Second))

Expand All @@ -54,4 +48,9 @@ func TestRegister(t *testing.T) {

require.EqualError(t, commands.Register(bot, cfgTauntOnly, nil, 0), "/taunt command requires a database")
require.NoError(t, commands.Register(bot, cfgTauntOnly, db, time.Second))

cfgJokeOnly["badjoke"].(map[string]any)["delay"] = "foo"
require.ErrorContains(t, commands.Register(bot, cfgJokeOnly, db, time.Second), "invalid duration")
cfgTauntOnly["taunt"].(map[string]any)["enable"] = "bar" // Should treat non bool values as false.
require.NoError(t, commands.Register(bot, cfgTauntOnly, db, time.Second))
}
8 changes: 4 additions & 4 deletions commands/joke.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ func badjokeFromConfig(opts map[string]any, db *sql.DB, dbTimeout time.Duration)
if db == nil {
return nil, requiresDatabaseErr("badjoke")
}
delay, ok := opts["delay"].(float64)
if !ok {
delay = 3
delay, err := getDuration(opts["delay"], 3*time.Second)
if err != nil {
return nil, err
}
return JokeCommand(db, dbTimeout, time.Second*time.Duration(delay)), nil
return JokeCommand(db, dbTimeout, delay), nil
}

// JokeCommand returns an *eelbot.Command that reads and replies with a joke from the given db. Two line jokes use the
Expand Down
49 changes: 9 additions & 40 deletions configs/eelbot/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,43 +60,12 @@ commands:
enable: true

replies:
# Reply to all caps messages.
caps:
enable: true
min_len: 5 # Minimum number of characters in message to consider match (ie. avoid short acronyms).
percent: 17 # Percent chance to trigger reply on match.
min_delay: 3 # Minimum time (in seconds) to wait before replying.
max_delay: 6 # Maximum time (in seconds) to wait before replying.
timeout: 120 # Timeout (in seconds) for consequetive replies.

# Reply to hello messages.
hello:
enable: true
percent: 33 # Percent chance to trigger reply on match.
min_delay: 3 # Minimum time (in seconds) to wait before replying.
max_delay: 6 # Maximum time (in seconds) to wait before replying.
timeout: 600 # Timeout (in seconds) for consequetive replies.

# Reply to goodbye messages.
goodbye:
enable: true
percent: 33 # Percent chance to trigger reply on match.
min_delay: 3 # Minimum time (in seconds) to wait before replying.
max_delay: 6 # Maximum time (in seconds) to wait before replying.
timeout: 600 # Timeout (in seconds) for consequetive replies.

# Reply to laugh messages.
laugh:
enable: true
percent: 17 # Percent chance to trigger reply on match.
min_delay: 1 # Minimum time (in seconds) to wait before replying.
max_delay: 3 # Maximum time (in seconds) to wait before replying.
timeout: 10 # Timeout (in seconds) for consequetive replies.

# Reply to messages that only contain the questionmark character.
question:
enable: true
percent: 17 # Percent chance to trigger reply on match.
min_delay: 3 # Minimum time (in seconds) to wait before replying.
max_delay: 6 # Maximum time (in seconds) to wait before replying.
timeout: 10 # Timeout (in seconds) for consequetive replies.
# Add custom replies by creating a table under the `reply` schema in the database first, then adding an entry here to
# reference it. Check the README for more details.
- table_name: "myreply"
regexps:
- "a{3,6}"
percent: 33
min_delay: 3
max_delay: 6
timeout: 600
Comment on lines +63 to +71
Copy link
Member

@boarnoah boarnoah Jul 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are going to do this, IMO this needs to live in the DB not in config, it doesn't fully achieve the goal of being able to extend replies to -> easily / with eelbot-ui this way.

If you do this lets see if we can avoid having so many long type switches for all the numerical types in golang :P

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I did this is because the config is loaded once at startup whereas the DB tables can be changed at runtime (replies added/removed etc.). I felt like putting the config in the DB could imply that this too could be changed on the fly. I'll move the config to the DB now, and in a future MR perhaps, I think hot reloading of the config can be handled in the app by watching for changes (not sure how easy it is in Postgres).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(not sure how easy it is in Postgres).

Not difficult IIUC. But I figure what we will more likely want to consider is having eelbot-ui signal to eelbot about changes rather than a DB level event.

I felt like putting the config in the DB could imply that this too could be changed on the fly

Yep, suspected this was the reason. But I do think we should build this around the hopefully realistic future case where eelbot-ui is going to be used to enter in a lot of new reply types without having to re-deploy eelbot often.

1 change: 1 addition & 0 deletions initdb/replies.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE SCHEMA IF NOT EXISTS reply;
Loading