Skip to content
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ go.work
go.work.sum
/robot
/robot.exe
.idea/
45 changes: 45 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,25 @@ func mergere(global, ch string) (*regexp.Regexp, error) {
return regexp.Compile(re)
}

func (robo *Robot) SetDiscordChannels(global Global, discord Discord) error {
for _, config := range discord.Configs {
blk, err := mergere(global.Block, config.Block)
if err != nil {
return fmt.Errorf("bad global or channel block expression for discord.%s: %w", config.Server, err)
}
c := &channel.Channel{
Name: config.Server,
Learn: config.Learn,
Send: config.Send,
Responses: config.Responses,
Rate: rate.NewLimiter(rate.Every(fseconds(config.Rate.Every)), config.Rate.Num),
Block: blk,
}
robo.channels.Store(config.Server, c)
}
return nil
}

// SetTwitchChannels initializes Twitch channel configuration.
// It must be called after SetTMI.
func (robo *Robot) SetTwitchChannels(ctx context.Context, global Global, channels map[string]*ChannelCfg) error {
Expand Down Expand Up @@ -503,6 +522,31 @@ type Config struct {
// Twitch is the set of channel configurations for twitch. Each key
// represents a group of one or more channels sharing a config.
Twitch map[string]*ChannelCfg `toml:"twitch"`
// Discord is the discord client configuration options
Copy link
Owner

Choose a reason for hiding this comment

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

Comments should usually end with punctuation.
Discord is the Discord client configuration.

Discord Discord `toml:"discord"`
}

type Discord struct {
// Token is the discord bot token
Copy link
Owner

Choose a reason for hiding this comment

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

This must be the path to a file containing the token. Secrets do not live in the config.

Token string `toml:"token"`
// Configs are the discord server configs
Configs []DiscordCfg `toml:"configs"`
}

type DiscordCfg struct {
// Server is the discord server ID for this configuration
Server string `toml:"server"`
// Learn is the tag used for learning from these channels.
Learn string `toml:"learn"`
// Send is the tag used for generating messages for these channels.
Send string `toml:"send"`
// Responses is the probability of generating a random message when
// a non-command message is received.
Responses float64 `toml:"responses"`
// Rate is the rate limit for interactions.
Rate Rate `toml:"rate"`
// Block is a regular expression of messages to ignore.
Block string `toml:"block"`
}

// ChannelCfg is the configuration for a channel.
Expand Down Expand Up @@ -641,6 +685,7 @@ func expandcfg(cfg *Config, expand func(s string) string) {
&cfg.TMI.TokenFile,
&cfg.TMI.Owner.Name,
&cfg.TMI.Owner.ID,
&cfg.Discord.Token,
}
for _, f := range fields {
*f = os.Expand(*f, expand)
Expand Down
266 changes: 266 additions & 0 deletions discord.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
package main

import (
"context"
"fmt"
"github.com/bwmarrin/discordgo"
"github.com/zephyrtronium/robot/brain"
Copy link
Owner

Choose a reason for hiding this comment

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

Honestly unsure how you got imports in this order. bwmarrin/discordgo should be in a separate group after the stdlib imports, and zephyrtronium/robot/brain should be in another separate group after that.

"log/slog"
"math/rand/v2"
"regexp"
"strconv"
"strings"
"time"
)

type MessageReceiver struct {
session *discordgo.Session
}

func (robo *Robot) NewDiscord(ctx context.Context, token string) (*MessageReceiver, error) {
Copy link
Owner

Choose a reason for hiding this comment

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

This function should take a log *slog.Logger as an argument to do all its logging.

session, err := discordgo.New("Bot " + token)
if err != nil {
return nil, fmt.Errorf("failed to create Discord session: %w", err)
}

receiver := &MessageReceiver{
session: session,
}

// Enable message receive intent
session.Identify.Intents = discordgo.IntentGuilds | discordgo.IntentGuildBans | discordgo.IntentsGuildMessages

// Register message handler
session.AddHandler(func(session *discordgo.Session, event *discordgo.MessageCreate) {
robo.onDiscordMessage(ctx, session, event)
Copy link
Owner

Choose a reason for hiding this comment

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

Using ctx here is suspicious because this callback outlives the scope the context is given to. Does discordgo.Session or discordgo.MessageCreate have a context.Context?

})

// Register slash commands on startup
session.AddHandler(func(session *discordgo.Session, event *discordgo.Ready) {
permission := int64(discordgo.PermissionManageMessages)
commands := []*discordgo.ApplicationCommand{
{
Name: "say",
Description: "Say something",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "prompt",
Description: "Prompt to use for generating a message",
Required: true,
},
},
},
{
Name: "forget",
DefaultMemberPermissions: &permission,
Description: "Forget something",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "prompt",
Description: "Prompt to search for and delete messages",
Required: true,
},
},
},
}
// Register as global commands
_, err := session.ApplicationCommandBulkOverwrite(event.Application.ID, "", commands)
if err != nil {
_ = fmt.Errorf("failed to update slash commands: %w", err)
Copy link
Owner

Choose a reason for hiding this comment

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

Making an error and assigning it to _ does nothing. If it's ok for the program to live when this happens, then the error should be logged. If it isn't, then this error should be a panic.

}
})

// Forget messages on deletion
session.AddHandler(func(session *discordgo.Session, event *discordgo.MessageDelete) {
ch, _ := robo.channels.Load(event.GuildID)
if ch == nil {
return
}
err := robo.brain.Forget(ctx, ch.Learn, event.Message.ID)
Copy link
Owner

Choose a reason for hiding this comment

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

Suspicious ctx.

if err != nil {
_ = fmt.Errorf("failed to delete message: %w", err)
Copy link
Owner

Choose a reason for hiding this comment

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

Log this error.

}
})

// Handle slash commands
session.AddHandler(func(session *discordgo.Session, event *discordgo.InteractionCreate) {
if event.Type != discordgo.InteractionApplicationCommand {
return
}
ch, _ := robo.channels.Load(event.GuildID)
if ch == nil {
return
}
data := event.ApplicationCommandData()
if data.Name == "say" {
Copy link
Owner

Choose a reason for hiding this comment

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

switch data.Name {

prompt := data.Options[0].StringValue()
response := robo.discordThink(ctx, ch.Learn, prompt)
_ = session.InteractionRespond(event.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: response,
AllowedMentions: nil,
},
})
} else if data.Name == "forget" {
prompt := data.Options[0].StringValue()
n := 64
messages := make([]brain.Message, n)
n, _, err := robo.brain.Recall(ctx, ch.Learn, "", messages)
if err != nil {
_ = session.InteractionRespond(event.Interaction, &discordgo.InteractionResponse{
Copy link
Owner

Choose a reason for hiding this comment

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

Assuming this is an error, check and log it, don't assign to _.

Copy link
Owner

Choose a reason for hiding this comment

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

Log the error before responding.

Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: err.Error(),
AllowedMentions: nil,
},
})
return
}
deleted := 0
for _, message := range messages {
if !strings.Contains(message.Text, prompt) {
continue
}
_ = robo.brain.Forget(ctx, ch.Learn, message.ID)
Copy link
Owner

Choose a reason for hiding this comment

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

Log the error if it happens.

deleted++
}
_ = session.InteractionRespond(event.Interaction, &discordgo.InteractionResponse{
Copy link
Owner

Choose a reason for hiding this comment

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

Check for and log the error.

Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "Deleted " + strconv.Itoa(deleted) + " messages from the database.",
AllowedMentions: nil,
},
})
}
})

return receiver, nil
}

func (robo *Robot) onDiscordMessage(ctx context.Context, session *discordgo.Session, event *discordgo.MessageCreate) {
// Ignore messages sent by bots
if event.Author.Bot {
return
}
log := slog.With(slog.String("trace", event.Message.ID), slog.String("in", event.GuildID))

// Find configuration for this server
Copy link
Owner

Choose a reason for hiding this comment

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

This comment doesn't add anything.

ch, _ := robo.channels.Load(event.GuildID)
if ch == nil {
return
}

// If this is a blocked message we don't want to interact
if ch.Block.MatchString(event.Content) {
return
Copy link
Owner

Choose a reason for hiding this comment

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

Log the blocked message at info level. This helps for diagnosing bad block regexes as well as detecting abuse.

}

// Check for the channel being silent. This prevents learning, copypasta,
Copy link
Owner

Choose a reason for hiding this comment

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

Per below, don't mention copypasta.

// and random speaking (among other things), which happens to be all the
// rest of this function.
if s := ch.SilentTime(); event.Timestamp.Before(s) {
log.DebugContext(ctx, "channel is silent", slog.Time("until", s))
return
}

// TODO: Learn from this user only if not disabled, not sure if applicable to discord
Copy link
Owner

Choose a reason for hiding this comment

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

DisableCommands permission is for known bots and annoying users. Agree it isn't relevant here, especially because Discord has a formal concept of bots, unlike Twitch. The TODO should be an explanation of why this is different from IRC handling.

// TODO: Meme detector stuff, possibly not necessary in discord?
Copy link
Owner

Choose a reason for hiding this comment

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

Agree meme detection (i.e. copypasta) is a poor fit for Discord. The TODO should be an explanation of why this is different from IRC handling.


// Empty messages could exist due to being a sticker or a file upload
if event.Content != "" {
robo.discordLearn(ctx, ch.Learn, event)
}

// Now we can check rate limits
t := time.Now()
r := ch.Rate.ReserveN(t, 1)
if d := r.DelayFrom(t); d > 0 {
log.InfoContext(ctx, "rate limited",
slog.String("action", "speak"),
slog.String("delay", d.String()),
)
r.CancelAt(t)
return
}

// Attempt to handle textual commands
Copy link
Owner

Choose a reason for hiding this comment

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

Commands on Discord should be only slash commands, no text ones. This block can go away.

if strings.HasPrefix(event.Content, session.State.User.Mention()) {
// Message starts by mentioning robot
trimmed := strings.TrimSpace(strings.TrimPrefix(event.Content, session.State.User.Mention()))
parser := regexp.MustCompile(`^(?i:say|generate)\s*(?i:something)?\s*(?i:starting)?\s*(?i:with)?\s+(.*)`)
matches := parser.FindStringSubmatch(trimmed)
if matches != nil && len(matches) > 1 {
Copy link
Owner

Choose a reason for hiding this comment

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

FYI len on a nil slice just gives 0. You don't need to explicitly check for non-nil.

prompt := matches[1]
robo.discordSend(ctx, session, event.Message.Reference(), event.ChannelID, ch.Send, prompt)
} else {
robo.discordSend(ctx, session, event.Message.Reference(), event.ChannelID, ch.Send, "")
}
return
}

// Not rate limited and not a command so check for random message chance
if rand.Float64() > ch.Responses {
return
}
robo.discordSend(ctx, session, nil, event.ChannelID, ch.Send, "")
}

func (robo *Robot) discordSend(ctx context.Context, session *discordgo.Session, reference *discordgo.MessageReference, channelId string, tag string, prompt string) {
response := robo.discordThink(ctx, tag, prompt)
if response == "" {
return
}
message := &discordgo.MessageSend{
Content: response,
AllowedMentions: nil,
Reference: reference,
}
_, err := session.ChannelMessageSendComplex(
channelId,
message,
)
if err != nil {
_ = fmt.Errorf("failed to send discord message: %w", err)
Copy link
Owner

Choose a reason for hiding this comment

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

Log the error.

}
}

func (robo *Robot) discordThink(ctx context.Context, tag string, prompt string) string {
Copy link
Owner

Choose a reason for hiding this comment

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

This function doesn't do anything except call another function. It shouldn't exist.

m, _, err := brain.Think(ctx, robo.brain, tag, prompt)
if err != nil {
_ = fmt.Errorf("couldn't think: %w", err)
return ""
}
return m
}

func (robo *Robot) discordLearn(ctx context.Context, tag string, event *discordgo.MessageCreate) {
userHash := robo.hashes().Hash(event.Author.ID, event.GuildID, event.Timestamp)
message := brain.Message{
Sender: userHash,
ID: event.Message.ID,
To: event.GuildID,
Text: event.Content,
Timestamp: event.Timestamp.UnixMilli(),
IsModerator: event.Member.Permissions&discordgo.PermissionManageMessages != 0,
IsElevated: false, // TODO: Possibly some other flag?
Copy link
Owner

Choose a reason for hiding this comment

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

Eventually this would be a check for some role configured for the server, but that's out of scope for this PR. (It's still out of scope for Twitch, even; nothing reads this field.)

}
err := brain.Learn(ctx, robo.brain, tag, &message)
if err != nil {
_ = fmt.Errorf("failed to learn message: %w", err)
Copy link
Owner

Choose a reason for hiding this comment

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

Log the error.

}
}

// Start opens the Discord websocket connection.
func (mr *MessageReceiver) Start() error {
return mr.session.Open()
Copy link
Owner

Choose a reason for hiding this comment

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

Probably a good idea to log that we're attempting to open the session at the start of this function.

}

// Close closes the Discord websocket connection.
func (mr *MessageReceiver) Close() error {
return mr.session.Close()
}
18 changes: 15 additions & 3 deletions example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ meme = '^\S*$'
# Currently, the only entry in it is twitch.
[global.privileges]
twitch = [
{ name = 'nightbot', level = 'ignore' },
{ name = 'streamelementsbot', level = 'ignore' },
{ name = 'nightbot', level = 'ignore' },
{ name = 'streamelementsbot', level = 'ignore' },
]

[tmi]
Expand Down Expand Up @@ -134,11 +134,23 @@ meme = '^\S*$'
# moderator privileges.
# Unlike most strings, these are not expanded with environment variables.
privileges = [
{ name = 'zephyrtronium', level = 'moderator' },
{ name = 'zephyrtronium', level = 'moderator' },
]

[twitch.bocchi.emotes]
'btw make sure to stretch, hydrate, and take care of yourself <3' = 1

[twitch.bocchi.effects]
'AAAAA' = 44444

[discord]
Copy link
Owner

Choose a reason for hiding this comment

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

Update config_test.go to test that this loads as expected.

# Discord bot token, requires the Message Content intent enabled.
token = ''
Copy link
Owner

Choose a reason for hiding this comment

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

token = '$CREDENTIALS_DIRECTORY/discord_token'

# Server configurations, each server/guild must have a config section here
# this is because we may want to learn from both twitch and discord
# however only use discord messages in discord due to how pings are
# formatted, emotes, etc...
# Other parameters work the same way as the twitch configurations.
configs = [
Copy link
Owner

Choose a reason for hiding this comment

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

Instead of an inline array, this should either use [[discord.configs]] syntax or work the same way as Twitch channel config.

{ server = "859946655310413844", learn = "bocchi", send = "bocchi", responses = 0.02, rate = { every = 10.1, num = 2 } }
]
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ require (

require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bwmarrin/discordgo v0.28.1 // indirect
Copy link
Owner

Choose a reason for hiding this comment

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

Run go mod tidy.

github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.0.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/flatbuffers v24.12.23+incompatible // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
Expand Down
Loading