diff --git a/Gopkg.lock b/Gopkg.lock deleted file mode 100644 index 6072d85..0000000 --- a/Gopkg.lock +++ /dev/null @@ -1,28 +0,0 @@ -# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. - - -[[projects]] - branch = "master" - digest = "1:ad65c21cad052ae0b994f7124129b26e8125d2a06c2316ee9e97cb23ba75f527" - name = "github.com/sbstjn/allot" - packages = ["."] - pruneopts = "UT" - revision = "1f2349af5ccd74c1a8d8fe4d1bf688645b628321" - -[[projects]] - branch = "master" - digest = "1:7427036b6d926b3d1fa0773771820735b35554a98296f1074df440ee30a97296" - name = "golang.org/x/net" - packages = ["websocket"] - pruneopts = "UT" - revision = "8a410e7b638dca158bf9e766925842f6651ff828" - -[solve-meta] - analyzer-name = "dep" - analyzer-version = 1 - input-imports = [ - "github.com/sbstjn/allot", - "golang.org/x/net/websocket", - ] - solver-name = "gps-cdcl" - solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml deleted file mode 100644 index 1b2bc3a..0000000 --- a/Gopkg.toml +++ /dev/null @@ -1,38 +0,0 @@ -# Gopkg.toml example -# -# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html -# for detailed Gopkg.toml documentation. -# -# required = ["github.com/user/thing/cmd/thing"] -# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] -# -# [[constraint]] -# name = "github.com/user/project" -# version = "1.0.0" -# -# [[constraint]] -# name = "github.com/user/project2" -# branch = "dev" -# source = "github.com/myfork/project2" -# -# [[override]] -# name = "github.com/x/y" -# version = "2.4.0" -# -# [prune] -# non-go = false -# go-tests = true -# unused-packages = true - - -[[constraint]] - branch = "master" - name = "github.com/sbstjn/allot" - -[[constraint]] - branch = "master" - name = "golang.org/x/net" - -[prune] - go-tests = true - unused-packages = true diff --git a/Makefile b/Makefile index c7101a5..752ba02 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,8 @@ COVERAGE_FILE ?= c.out +gotest: + go test -v ./... -race + test: @ ginkgo -cover -coverprofile=$(COVERAGE_FILE) $(RACE) ./... diff --git a/README.md b/README.md index 27706c9..a0be5bd 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Read Tutorial](https://badgen.now.sh/badge/Read/Tutorial/orange)](https://sbstjn.com/host-golang-slackbot-on-heroku-with-hanu.html) [![Code Example](https://badgen.now.sh/badge/Code/Example/cyan)](https://github.com/sbstjn/hanu-example) -The `Go` framework **hanu** is your best friend to create [Slack](https://slackhq.com) bots! **hanu** uses [allot](https://github.com/sbstjn/allot) for easy command and request parsing (e.g. `whisper `) and runs fine as a [Heroku worker](https://devcenter.heroku.com/articles/background-jobs-queueing). All you need is a [Slack API token](https://api.slack.com/bot-users) and you can create your first bot within seconds! Just have a look at the [hanu-example](https://github.com/sbstjn/hanu-example) bot or [read my tutorial](https://sbstjn.com/host-golang-slackbot-on-heroku-with-hanu.html) … +The `Go` framework **hanu** is your best friend to create [Slack](https://slackhq.com) bots! **hanu** uses [allot](https://github.com/sbstjn/allot) for easy command and request parsing (e.g. `whisper `) and runs fine as a [Heroku worker](https://devcenter.heroku.com/articles/background-jobs-queueing). All you need is a [Slack Bot token](https://api.slack.com/authentication/token-types#bot) and [Slack App Token](https://api.slack.com/authentication/token-types#app) for the Slack [Socket Mode](https://api.slack.com/apis/connections/socket-implement) connection. Under the hood it uses [github.com/slack-go/slack](https://github.com/slack-go/slack) by [nlopes](https://github.com/nlopes). ### Features @@ -14,7 +14,7 @@ The `Go` framework **hanu** is your best friend to create [Slack](https://slackh - Auto-Generated command list for `help` - Works fine as a **worker** on Heroku -## Usage +## V1 Usage Use the following example code or the [hanu-example](https://github.com/sbstjn/hanu-example) bot to get started. @@ -29,33 +29,33 @@ import ( ) func main() { - slack, err := hanu.New("SLACK_BOT_API_TOKEN") + slack, err := hanu.New(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) if err != nil { log.Fatal(err) } - Version := "0.0.1" + version := "0.0.1" - slack.Command("shout ", func(conv hanu.ConversationInterface) { - str, _ := conv.String("word") - conv.Reply(strings.ToUpper(str)) + slack.Command("shout ", func(c hanu.Convo) { + str, _ := c.String("word") + c.Reply(strings.ToUpper(str)) }) - slack.Command("whisper ", func(conv hanu.ConversationInterface) { - str, _ := conv.String("word") - conv.Reply(strings.ToLower(str)) + slack.Command("whisper ", func(c hanu.Convo) { + str, _ := c.String("word") + c.Reply(strings.ToLower(str)) }) - slack.Command("version", func(conv hanu.ConversationInterface) { - conv.Reply("Thanks for asking! I'm running `%s`", Version) + slack.Command("version", func(c hanu.Convo) { + c.Reply("Thanks for asking! I'm running `%s`", version) }) slack.Listen() } ``` -The example code above connects to Slack using `SLACK_BOT_API_TOKEN` as the bot's token and can respond to direct messages and mentions for the commands `shout ` , `whisper ` and `version`. +The example code above connects to Slack using the tokens and can respond to direct messages and mentions for the commands `shout ` , `whisper ` and `version`. You don't have to care about `help` requests, **hanu** has it built in and will respond with a list of all defined commands on direct messages like this: @@ -69,8 +69,6 @@ Of course this works fine with mentioning you bot's username as well: @hanu help ``` -### Slack - Use direct messages for communication: ``` @@ -83,12 +81,69 @@ Or use the bot in a public channel: @hanu version ``` +You can set the command prefix, if you like using those: + +```go +bot.SetCommandPrefix("!") +bot.SetReplyOnly(false) +``` + +This will make it so you have to type: + +``` +!whisper I love turtles +``` + +The bot can also now talk arbitrarily and has a Channel object that is easy to +interface with since it's one function: + +```go +bot.Say("UGHXISDF324", "I like %s", "turtles") + +devops := bot.Channel("UGHXISDF324") +devops.Say("Host called %s is not responding to pings", "bobsburgers01") +``` + +You can print the help message whenever you want: + +```go +bot.Say("UGHXISDF324", bot.BuildHelpText()) +``` + +And there is an unknown command handler, but it only works when in reply only mode: + +```go +bot.SetReplyOnly(true).UnknownCommand(func(c hanu.Convo) { + c.Reply(slack.BuildHelpText()) +}) +``` + +Finally there is the ability to read messages that come into the channel in real time: + +```go +devops := bot.Channel("UGHXISDF324") +ctx, cancel := context.WithCancel(context.Background()) +defer cancel() + +for { + select { + case msg := <-devops.Messages(): + if msg.IsFrom("bob") { + bot.Say("shutup <@bob>") + } + case <-ctx.Done(): + break + } +} +``` + ## Dependencies - [github.com/sbstjn/allot](https://github.com/sbstjn/allot) for parsing `cmd ` strings -- [golang.org/x/net/websocket](http://golang.org/x/net/websocket) for websocket communication with Slack +- [github.com/slack-go/slack](http://github.com/slack-go/slack) by nlopes for real time communication with Slack ## Credits - [Host Go Slackbot on Heroku](https://sbstjn.com/host-golang-slackbot-on-heroku-with-hanu.html) - [OpsDash article about Slack Bot](https://www.opsdash.com/blog/slack-bot-in-golang.html) +- [A Simple Slack Bot in Go - The Bot](ttps://dev.to/shindakun/a-simple-slack-bot-in-go---the-bot-4olg) \ No newline at end of file diff --git a/bot.go b/bot.go new file mode 100644 index 0000000..f6fe7fd --- /dev/null +++ b/bot.go @@ -0,0 +1,317 @@ +package hanu + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "os" + "strings" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" +) + +// Bot is the main object +type Bot struct { + client *socketmode.Client + ID string + Commands []CommandInterface + ReplyOnly bool + CmdPrefix string + unknownCmdHandler Handler + msgs map[string]chan Message + + connectedWaiter chan bool +} + +// New creates a new bot +func New(botToken, appToken string) (*Bot, error) { + if !strings.HasPrefix(botToken, "xoxb-") { + return nil, errors.New("bot token must have the prefix \"xoxb-\"") + } + + if !strings.HasPrefix(appToken, "xapp-") { + return nil, errors.New("app token must have the prefix \"xapp-\"") + } + + api := slack.New( + botToken, + slack.OptionDebug(true), + slack.OptionLog(log.New(os.Stdout, "api: ", log.Lshortfile|log.LstdFlags)), + slack.OptionAppLevelToken(appToken), + ) + + client := socketmode.New( + api, + socketmode.OptionDebug(true), + socketmode.OptionLog(log.New(os.Stdout, "socketmode: ", log.Lshortfile|log.LstdFlags)), + ) + + bot := &Bot{client: client, msgs: make(map[string]chan Message)} + bot.connectedWaiter = make(chan bool) + + return bot, nil +} + +// WaitForConnection will block until the bot is connected to the RTM +func (b *Bot) WaitForConnection() { + if b.connectedWaiter == nil { + return + } + <-b.connectedWaiter +} + +// SetCommandPrefix will set thing that must be prefixed to the command, +// there is no prefix by default but one could set it to "!" for instance +func (b *Bot) SetCommandPrefix(pfx string) *Bot { + b.CmdPrefix = pfx + return b +} + +// SetReplyOnly will make the bot only respond to messages it is mentioned in +func (b *Bot) SetReplyOnly(ro bool) *Bot { + b.ReplyOnly = ro + return b +} + +func (b *Bot) notify(msg Message) { + chnl := msg.Channel() + ch, found := b.msgs[chnl] + if !found { + return + } + + if cap(ch) == len(ch) { + return + } + + ch <- msg +} + +// Process incoming message +func (b *Bot) process(msg Message) { + // Strip @BotName from public message + msg.SetText(msg.StripMention(b.ID)) + // Strip Slack's link markup + msg.SetText(msg.StripLinkMarkup()) + + // Only send auto-generated help command list if directly mentioned + if msg.IsRelevantFor(b.ID) && msg.IsHelpRequest() { + b.sendHelp(msg) + return + } + + // if bot can only reply, ensure we were mentioned + if b.ReplyOnly && !msg.IsRelevantFor(b.ID) { + return + } + + handled := b.searchCommand(msg) + if !handled && b.ReplyOnly { + if b.unknownCmdHandler != nil { + b.unknownCmdHandler(NewConversation(dummyMatch{}, msg, b)) + } + } +} + +// Search for a command matching the message +func (b *Bot) searchCommand(msg Message) bool { + var cmd CommandInterface + + for i := 0; i < len(b.Commands); i++ { + cmd = b.Commands[i] + + match, err := cmd.Get().Match(msg.Text()) + if err == nil { + cmd.Handle(NewConversation(match, msg, b)) + return true + } + } + + return false +} + +// Channel will return a channel that the bot can talk in +func (b *Bot) Channel(id string) Channel { + return Channel{b, id} +} + +// Say will cause the bot to say something in the specified channel +func (b *Bot) Say(channel, msg string, a ...interface{}) { + b.send(Message{ChannelID: channel, Message: fmt.Sprintf(msg, a...)}) +} + +func (b *Bot) send(msg MessageInterface) { + b.client.PostMessage( + msg.Channel(), + slack.MsgOptionText(msg.Text(), false), + ) +} + +// BuildHelpText will build the help text +func (b *Bot) BuildHelpText() string { + var cmd CommandInterface + help := "The available commands are:\n\n" + + for i := 0; i < len(b.Commands); i++ { + cmd = b.Commands[i] + + help = help + "`" + b.CmdPrefix + cmd.Get().Text() + "`" + if cmd.Description() != "" { + help = help + " *–* " + cmd.Description() + } + + help = help + "\n" + } + + return help +} + +// sendHelp will send help to the channel and user in the given message +func (b *Bot) sendHelp(msg MessageInterface) { + help := b.BuildHelpText() + + if !msg.IsDirectMessage() { + help = "<@" + msg.User() + ">: " + help + } + + b.Say(msg.Channel(), help) +} + +func (b *Bot) handleEvent(evt *socketmode.Event) { + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + return + } + + fmt.Printf("Event received: %+v\n", eventsAPIEvent) + + b.client.Ack(*evt.Request) + + switch eventsAPIEvent.Type { + case slackevents.CallbackEvent: + innerEvent := eventsAPIEvent.InnerEvent + switch ev := innerEvent.Data.(type) { + case *slackevents.AppMentionEvent: + _, _, err := b.client.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) + if err != nil { + fmt.Printf("failed posting message: %v", err) + } + go b.process(NewMessage(ev)) + go b.notify(NewMessage(ev)) + case *slackevents.MessageEvent: + if os.Getenv("HANU_DEBUG") != "" { + data, _ := json.MarshalIndent(evt, "", " ") + log.Println("NEW MSG ", string(data)) + } + go b.process(NewMessage(ev)) + go b.notify(NewMessage(ev)) + case *slackevents.MemberJoinedChannelEvent: + fmt.Printf("user %q joined to channel %q", ev.User, ev.Channel) + } + default: + b.client.Debugf("unsupported Events API event received") + } +} + +// Listen for message on socket +func (b *Bot) Listen(ctx context.Context) { + for { + select { + case evt := <-b.client.Events: + switch evt.Type { + case socketmode.EventTypeConnecting: + fmt.Println("Connecting to Slack with Socket Mode...") + case socketmode.EventTypeConnectionError: + fmt.Println("Connection failed. Retrying later...") + case socketmode.EventTypeConnected: + fmt.Println("Connected to Slack with Socket Mode.") + b.ID = evt.Request.ConnectionInfo.AppID + case socketmode.EventTypeEventsAPI: + b.handleEvent(&evt) + case socketmode.EventTypeInteractive: + callback, ok := evt.Data.(slack.InteractionCallback) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + + continue + } + + fmt.Printf("Interaction received: %+v\n", callback) + + var payload interface{} + + switch callback.Type { + case slack.InteractionTypeBlockActions: + // See https://api.slack.com/apis/connections/socket-implement#button + + b.client.Debugf("button clicked!") + case slack.InteractionTypeShortcut: + case slack.InteractionTypeViewSubmission: + // See https://api.slack.com/apis/connections/socket-implement#modal + case slack.InteractionTypeDialogSubmission: + default: + + } + + b.client.Ack(*evt.Request, payload) + case socketmode.EventTypeSlashCommand: + cmd, ok := evt.Data.(slack.SlashCommand) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + + continue + } + + b.client.Debugf("Slash command received: %+v", cmd) + + payload := map[string]interface{}{ + "blocks": []slack.Block{ + slack.NewSectionBlock( + &slack.TextBlockObject{ + Type: slack.MarkdownType, + Text: "foo", + }, + nil, + slack.NewAccessory( + slack.NewButtonBlockElement( + "", + "somevalue", + &slack.TextBlockObject{ + Type: slack.PlainTextType, + Text: "bar", + }, + ), + ), + ), + }, + } + + b.client.Ack(*evt.Request, payload) + } + + case <-ctx.Done(): + return + } + } +} + +// Command adds a new command with custom handler +func (b *Bot) Command(cmd string, handler Handler) { + b.Commands = append(b.Commands, NewCommand(b.CmdPrefix+cmd, "", handler)) +} + +// UnknownCommand will be called when the user calls a command that is unknown, +// but it will only work when the bot is in reply only mode +func (b *Bot) UnknownCommand(h Handler) { + b.unknownCmdHandler = h +} + +// Register registers a Command +func (b *Bot) Register(cmd CommandInterface) { + b.Commands = append(b.Commands, cmd) +} diff --git a/channel.go b/channel.go new file mode 100644 index 0000000..aec1921 --- /dev/null +++ b/channel.go @@ -0,0 +1,24 @@ +package hanu + +// Channel is an object that allows a bot to say things without +// specifying the channel in every function call +type Channel struct { + bot *Bot + ID string +} + +// Say will cause the bot to say something in the channel +func (ch *Channel) Say(msg string, a ...interface{}) { + ch.bot.Say(ch.ID, msg, a...) +} + +// Messages returns a channel out of which comes +// any messages sent in the channel +func (ch *Channel) Messages() chan Message { + c, found := ch.bot.msgs[ch.ID] + if !found { + c = make(chan Message, 10) + ch.bot.msgs[ch.ID] = c + } + return c +} diff --git a/command.go b/command.go index 624b399..2164612 100644 --- a/command.go +++ b/command.go @@ -5,7 +5,7 @@ import ( ) // Handler is the interface for the handler function -type Handler func(ConversationInterface) +type Handler func(Convo) // CommandInterface defines a command interface type CommandInterface interface { diff --git a/command_test.go b/command_test.go index 2030c0e..6c89386 100644 --- a/command_test.go +++ b/command_test.go @@ -8,7 +8,7 @@ func TestCommand(t *testing.T) { cmd := NewCommand( "cmd ", "Description", - func(conv ConversationInterface) { + func(conv Convo) { }, ) @@ -26,7 +26,7 @@ func TestHandle(t *testing.T) { cmd := NewCommand( "cmd ", "Description", - func(conv ConversationInterface) { + func(conv Convo) { str, _ := conv.String("key") if str != "name" { t.Errorf("param should have value \"name\"") diff --git a/conversation.go b/conversation.go index 52cfd41..d5b2ab3 100644 --- a/conversation.go +++ b/conversation.go @@ -1,13 +1,12 @@ package hanu import ( - "fmt" - - "golang.org/x/net/websocket" - "github.com/sbstjn/allot" ) +// Convo is a shorthand for ConversationInterface +type Convo ConversationInterface + // ConversationInterface is the interface for a conversation type ConversationInterface interface { Integer(name string) (int, error) @@ -15,15 +14,11 @@ type ConversationInterface interface { Reply(text string, a ...interface{}) Match(position int) (string, error) Message() MessageInterface - - SetConnection(connection Connection) - - send(msg MessageInterface) } -// Connection is the needed interface for a connection -type Connection interface { - Send(ws *websocket.Conn, v interface{}) (err error) +// Sayer is an object that can talk in the channel +type Sayer interface { + Say(string, string, ...interface{}) } // Conversation stores message, command and socket information and is passed @@ -31,38 +26,23 @@ type Connection interface { type Conversation struct { message Message match allot.MatchInterface - socket *websocket.Conn - - connection Connection + bot Sayer } +// Message returns the convos message func (c *Conversation) Message() MessageInterface { return c.message } -func (c *Conversation) send(msg MessageInterface) { - if c.socket != nil { - c.connection.Send(c.socket, msg) - } -} - -// SetConnection sets the conversation connection -func (c *Conversation) SetConnection(connection Connection) { - c.connection = connection -} - // Reply sends message using the socket to Slack func (c *Conversation) Reply(text string, a ...interface{}) { prefix := "" if !c.message.IsDirectMessage() { - prefix = "<@" + c.message.User() + ">: " + prefix = "<@" + c.message.UserID() + ">: " } - msg := c.message - msg.SetText(prefix + fmt.Sprintf(text, a...)) - - c.send(msg) + c.bot.Say(c.Message().Channel(), prefix+text, a...) } // String return string paramter @@ -81,14 +61,12 @@ func (c Conversation) Match(position int) (string, error) { } // NewConversation returns a Conversation struct -func NewConversation(match allot.MatchInterface, msg Message, socket *websocket.Conn) ConversationInterface { +func NewConversation(match allot.MatchInterface, msg Message, bot Sayer) ConversationInterface { conv := &Conversation{ message: msg, match: match, - socket: socket, + bot: bot, } - conv.SetConnection(websocket.JSON) - return conv } diff --git a/conversation_test.go b/conversation_test.go index 10e91eb..219564e 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -3,28 +3,30 @@ package hanu import ( "testing" - "golang.org/x/net/websocket" - "github.com/sbstjn/allot" ) -type ConnectionMock struct{} +type SayerMock struct { + msg string + ch string + args []interface{} +} -func (c ConnectionMock) Send(ws *websocket.Conn, v interface{}) (err error) { - return nil +func (sm SayerMock) Say(ch, msg string, a ...interface{}) { + sm.ch = ch + sm.msg = msg + sm.args = a } func TestConversation(t *testing.T) { command := allot.New("cmd test ") - msg := Message{ - ID: 0, - } + msg := Message{} msg.SetText("cmd test value") match, _ := command.Match(msg.Text()) - conv := NewConversation(match, msg, nil) + conv := NewConversation(match, msg, &SayerMock{}) str, err := conv.String("param") @@ -42,15 +44,12 @@ func TestConversation(t *testing.T) { func TestConnect(t *testing.T) { cmd := allot.New("cmd test ") - msg := Message{ - ID: 0, - } + msg := Message{} msg.SetText("cmd test value") match, _ := cmd.Match(msg.Text()) - conv := NewConversation(match, msg, &websocket.Conn{}) - conv.SetConnection(ConnectionMock{}) + conv := NewConversation(match, msg, &SayerMock{}) conv.Reply("example") } diff --git a/dummy_match.go b/dummy_match.go new file mode 100644 index 0000000..3963279 --- /dev/null +++ b/dummy_match.go @@ -0,0 +1,22 @@ +package hanu + +import "github.com/sbstjn/allot" + +type dummyMatch struct { +} + +func (m dummyMatch) String(name string) (string, error) { + return "", nil +} + +func (m dummyMatch) Integer(name string) (int, error) { + return 0, nil +} + +func (m dummyMatch) Parameter(param allot.ParameterInterface) (string, error) { + return "", nil +} + +func (m dummyMatch) Match(position int) (string, error) { + return "", nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..453cddf --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/penguinpowernz/hanu + +go 1.19 + +require ( + github.com/sbstjn/allot v0.0.0-20161025071122-1f2349af5ccd + github.com/slack-go/slack v0.12.2 + golang.org/x/net v0.9.0 +) + +require github.com/gorilla/websocket v1.4.2 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..060eb74 --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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/sbstjn/allot v0.0.0-20161025071122-1f2349af5ccd h1:pPVLmVQ04S5EVUIq5tKji0R44+8tFdti39j/KAELXG8= +github.com/sbstjn/allot v0.0.0-20161025071122-1f2349af5ccd/go.mod h1:iG+7705MYmR2HzLYNPE7BhBjCMkNGhJCL8kzS8LYQH8= +github.com/slack-go/slack v0.12.2 h1:x3OppyMyGIbbiyFhsBmpf9pwkUzMhthJMRNmNlA4LaQ= +github.com/slack-go/slack v0.12.2/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go deleted file mode 100644 index c3cc4db..0000000 --- a/main.go +++ /dev/null @@ -1,165 +0,0 @@ -package hanu - -import ( - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "net/http" - - "golang.org/x/net/websocket" -) - -type handshakeResponseSelf struct { - ID string `json:"id"` -} - -type handshakeResponse struct { - Ok bool `json:"ok"` - Error string `json:"error"` - URL string `json:"url"` - Self handshakeResponseSelf `json:"self"` -} - -// Bot is the main object -type Bot struct { - Socket *websocket.Conn - Token string - ID string - Commands []CommandInterface -} - -// New creates a new bot -func New(token string) (*Bot, error) { - bot := Bot{ - Token: token, - } - - return bot.Handshake() -} - -// Handshake connects to the Slack API to get a socket connection -func (b *Bot) Handshake() (*Bot, error) { - // Check for HTTP error on connection - res, err := http.Get(fmt.Sprintf("https://slack.com/api/rtm.start?token=%s", b.Token)) - if err != nil { - return nil, errors.New("Failed to connect to Slack RTM API") - } - - // Check for HTTP status code - if res.StatusCode != 200 { - return nil, fmt.Errorf("Failed with HTTP Code: %d", res.StatusCode) - } - - // Read response body - body, err := ioutil.ReadAll(res.Body) - res.Body.Close() - if err != nil { - return nil, fmt.Errorf("Failed to read body from response") - } - - // Parse response - var response handshakeResponse - err = json.Unmarshal(body, &response) - if err != nil { - return nil, fmt.Errorf("Failed to unmarshal JSON: %s", body) - } - - // Check for Slack error - if !response.Ok { - return nil, errors.New(response.Error) - } - - // Assign Slack user ID - b.ID = response.Self.ID - - // Connect to websocket - b.Socket, err = websocket.Dial(response.URL, "", "https://api.slack.com/") - if err != nil { - return nil, errors.New("Failed to connect to Websocket") - } - - return b, nil -} - -// Process incoming message -func (b *Bot) process(message Message) { - if !message.IsRelevantFor(b.ID) { - return - } - - // Strip @BotName from public message - message.StripMention(b.ID) - // Strip Slack's link markup - message.StripLinkMarkup() - - // Check if the message requests the auto-generated help command list - // or if we need to search for a command matching the request - if message.IsHelpRequest() { - b.sendHelp(message) - } else { - b.searchCommand(message) - } -} - -// Search for a command matching the message -func (b *Bot) searchCommand(msg Message) { - var cmd CommandInterface - - for i := 0; i < len(b.Commands); i++ { - cmd = b.Commands[i] - - match, err := cmd.Get().Match(msg.Text()) - if err == nil { - cmd.Handle(NewConversation(match, msg, b.Socket)) - } - } -} - -// Send the response for a help request -func (b *Bot) sendHelp(msg Message) { - var cmd CommandInterface - help := "Thanks for asking! I can support you with those features:\n\n" - - for i := 0; i < len(b.Commands); i++ { - cmd = b.Commands[i] - - help = help + "`" + cmd.Get().Text() + "`" - if cmd.Description() != "" { - help = help + " *–* " + cmd.Description() - } - - help = help + "\n" - } - - if !msg.IsDirectMessage() { - help = "<@" + msg.User() + ">: " + help - } - - msg.SetText(help) - websocket.JSON.Send(b.Socket, msg) -} - -// Listen for message on socket -func (b *Bot) Listen() { - var msg Message - - for { - if websocket.JSON.Receive(b.Socket, &msg) == nil { - go b.process(msg) - - // Clean up message after processign it - msg = Message{} - } - } -} - -// Command adds a new command with custom handler -func (b *Bot) Command(cmd string, handler Handler) { - b.Commands = append(b.Commands, NewCommand(cmd, "", handler)) -} - -// Register registers a Command -func (b *Bot) Register(cmd CommandInterface) { - b.Commands = append(b.Commands, cmd) -} diff --git a/message.go b/message.go index ec9061b..b1244af 100644 --- a/message.go +++ b/message.go @@ -3,6 +3,8 @@ package hanu import ( "regexp" "strings" + + "github.com/slack-go/slack/slackevents" ) // MessageInterface defines the message interface @@ -16,15 +18,70 @@ type MessageInterface interface { Text() string User() string + UserID() string + Channel() string +} + +// NewMessage will create a new message object when given +// a slack message object +func NewMessage(ev interface{}) Message { + m := Message{} + sm := new(slackMessage) + switch v := ev.(type) { + case *slackevents.AppMentionEvent: + sm.AppMentionEvent = v + case *slackevents.MessageEvent: + sm.MessageEvent = v + } + m.Message = sm.Text() + m.ChannelID = sm.Channel() + m.Username = sm.User() + m.userID = sm.User() // why? + return m +} + +type slackMessage struct { + *slackevents.MessageEvent + *slackevents.AppMentionEvent +} + +func (m slackMessage) Text() string { + if m.MessageEvent != nil { + return m.MessageEvent.Text + } + if m.AppMentionEvent != nil { + return m.AppMentionEvent.Text + } + return "" +} + +func (m slackMessage) User() string { + if m.MessageEvent != nil { + return m.MessageEvent.User + } + if m.AppMentionEvent != nil { + return m.AppMentionEvent.User + } + return "" +} + +func (m slackMessage) Channel() string { + if m.MessageEvent != nil { + return m.MessageEvent.Channel + } + if m.AppMentionEvent != nil { + return m.AppMentionEvent.Channel + } + return "" } -// Message is the Message structure for received and sent messages using Slack +// Message is the Message structure for received and +// sent messages using Slack type Message struct { - ID uint64 `json:"id"` - Type string `json:"type"` - Channel string `json:"channel"` - UserID string `json:"user"` - Message string `json:"text"` + *slackMessage + Message string + ChannelID string + userID string } // Text returns the message text @@ -32,19 +89,28 @@ func (m Message) Text() string { return m.Message } -// User returns the message text +// Channel returns the channel ID +func (m Message) Channel() string { + return m.ChannelID +} + +func (m Message) UserID() string { //why? + return m.userID +} + +// User returns the name of the user who sent the message func (m Message) User() string { - return m.UserID + return m.Username } // IsMessage checks if it is a Message or some other kind of processing information func (m Message) IsMessage() bool { - return m.Type == "message" + return true } // IsFrom checks the sender of the message func (m Message) IsFrom(user string) bool { - return m.UserID == user + return m.User() == user } // SetText updates the text of a message @@ -53,18 +119,20 @@ func (m *Message) SetText(text string) { } // StripMention removes the mention from the message beginning -func (m *Message) StripMention(user string) { +func (m *Message) StripMention(user string) string { prefix := "<@" + user + "> " text := m.Text() if strings.HasPrefix(text, prefix) { - m.SetText(text[len(prefix):len(text)]) + m.Message = text[len(prefix):] } + + return m.Text() } // StripLinkMarkup converts into google.com etc. // https://api.slack.com/docs/message-formatting#how_to_display_formatted_messages -func (m *Message) StripLinkMarkup() { +func (m *Message) StripLinkMarkup() string { re := regexp.MustCompile("<(.*?)>") result := re.FindAllStringSubmatch(m.Text(), -1) text := m.Text() @@ -87,7 +155,8 @@ func (m *Message) StripLinkMarkup() { text = strings.Replace(text, "<"+link+">", url, -1) } - m.SetText(text) + m.Message = text + return text } // IsHelpRequest checks if the user requests the help command @@ -97,12 +166,12 @@ func (m Message) IsHelpRequest() bool { // IsDirectMessage checks if the message is received using a direct messaging channel func (m Message) IsDirectMessage() bool { - return strings.HasPrefix(m.Channel, "D") + return strings.HasPrefix(m.Channel(), "D") } // IsMentionFor checks if the given user was mentioned with the message func (m Message) IsMentionFor(user string) bool { - return strings.HasPrefix(m.Message, "<@"+user+">") + return strings.HasPrefix(m.MessageEvent.Text, "<@"+user+">") } // IsRelevantFor checks if the message is relevant for a user diff --git a/message_test.go b/message_test.go index a91f935..c125b91 100644 --- a/message_test.go +++ b/message_test.go @@ -1,14 +1,19 @@ package hanu -import "testing" +import ( + "testing" + + "github.com/slack-go/slack/slackevents" +) func TestMessage(t *testing.T) { - msg := Message{ - ID: 0, - UserID: "test", - Type: "message", - Message: "text", - } + msg := NewMessage( + &slackevents.MessageEvent{ + User: "test", + Type: "message", + Text: "text", + }, + ) if msg.User() != "test" { t.Errorf("User() should be \"test\"")