Skip to content

Commit

Permalink
feat: integrate slack
Browse files Browse the repository at this point in the history
  • Loading branch information
motoki317 committed Jun 29, 2024
1 parent 4daa08e commit 46ae05b
Show file tree
Hide file tree
Showing 8 changed files with 333 additions and 33 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/dghubble/sling v1.4.2
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/samber/lo v1.39.0
github.com/slack-go/slack v0.13.0
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.2
github.com/traPtitech/go-traq v0.0.0-20240420012203-0152d96098b0
Expand Down
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
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/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
Expand All @@ -17,10 +19,12 @@ github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
Expand Down Expand Up @@ -51,6 +55,8 @@ github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6g
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/slack-go/slack v0.13.0 h1:7my/pR2ubZJ9912p9FtvALYpbt0cQPAqkRy2jaSI1PQ=
github.com/slack-go/slack v0.13.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
Expand All @@ -66,6 +72,7 @@ github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMV
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
Expand Down
20 changes: 17 additions & 3 deletions pkg/bot/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package bot
import (
"context"
"fmt"
"github.com/traPtitech/DevOpsBot/pkg/bot/slack"
"github.com/traPtitech/DevOpsBot/pkg/config"
"github.com/traPtitech/DevOpsBot/pkg/domain"

"go.uber.org/zap"

Expand All @@ -24,9 +27,20 @@ func Run(ctx context.Context) error {
}

// Initialize bot
bot, err := traq.NewBot(cmds, logger)
if err != nil {
return fmt.Errorf("creating bot: %w", err)
var bot domain.Bot
switch config.C.Mode {
case "traq":
bot, err = traq.NewBot(cmds, logger)
if err != nil {
return fmt.Errorf("creating traq bot: %w", err)
}
case "slack":
bot, err = slack.NewBot(cmds, logger)
if err != nil {
return fmt.Errorf("creating slack bot: %w", err)
}
default:
return fmt.Errorf("unknown bot mode: %s", config.C.Mode)
}

// Start bot
Expand Down
172 changes: 172 additions & 0 deletions pkg/bot/slack/bot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package slack

import (
"context"
"fmt"
"github.com/kballard/go-shellquote"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/slack-go/slack/socketmode"
"github.com/traPtitech/DevOpsBot/pkg/config"
"github.com/traPtitech/DevOpsBot/pkg/domain"
"go.uber.org/zap"
"strings"
)

const slashPrefix = "/"

type slackBot struct {
api *slack.Client
sock *socketmode.Client
rootCmd domain.Command
logger *zap.Logger
}

func NewBot(rootCmd domain.Command, logger *zap.Logger) (domain.Bot, error) {
// Prepare socket mode bot
api := slack.New(config.C.Slack.OAuthToken, slack.OptionAppLevelToken(config.C.Slack.AppToken))
sock := socketmode.New(api)

return &slackBot{
api: api,
sock: sock,
rootCmd: rootCmd,
logger: logger,
}, nil
}

func (s *slackBot) Start(ctx context.Context) error {
// Join channel
_, _, _, err := s.api.JoinConversation(config.C.Slack.ChannelID)
if err != nil {
return err
}

go func() {
for e := range s.sock.Events {
err := s.handle(e)
if err != nil {
s.logger.Error("failed to process event", zap.Error(err))
}
}
}()
return s.sock.RunContext(ctx)
}

func (s *slackBot) handle(e socketmode.Event) error {
switch e.Type {
case socketmode.EventTypeConnecting:
s.logger.Info("Connecting to Slack with Socket Mode...")

case socketmode.EventTypeConnectionError:
s.logger.Info("Connection failed. Retrying later...")

case socketmode.EventTypeConnected:
s.logger.Info("Connected to Slack with Socket Mode.")

case socketmode.EventTypeEventsAPI:
eventsE, ok := e.Data.(slackevents.EventsAPIEvent)
if !ok {
return fmt.Errorf("failed to parse events api type")
}

// Acknowledge the event
s.sock.Ack(*e.Request)

// Process the event
err := s.handleEventsAPI(&eventsE)
if err != nil {
return fmt.Errorf("failed to process events api event: %w", err)
}

case socketmode.EventTypeSlashCommand:
slashE, ok := e.Data.(slack.SlashCommand)
if !ok {
return fmt.Errorf("failed to parse slash command type")
}

// Acknowledge the event
s.sock.Ack(*e.Request, map[string]any{
"response_type": "in_channel",
})

// Process the event
err := s.handleSlashEvent(&slashE)
if err != nil {
return fmt.Errorf("failed to process slash event: %w", err)
}
}

return nil
}

func (s *slackBot) handleEventsAPI(e *slackevents.EventsAPIEvent) error {
switch ev := e.InnerEvent.Data.(type) {
case *slackevents.MessageEvent:
// Validate command execution context
if ev.BotID != "" {
return nil // Ignore bots
}
if ev.Channel != config.C.Slack.ChannelID {
return nil // Ignore messages not from the specified channel
}
if !strings.HasPrefix(ev.Text, config.C.Prefix) {
return nil // Command prefix does not match
}

// Execute
messageRef := slack.ItemRef{
Channel: ev.Channel,
Timestamp: ev.TimeStamp,
}
commandText := strings.Trim(ev.Text, config.C.Prefix)
return s.executeCommand(commandText, messageRef, ev.User)
default:
return nil
}
}

func (s *slackBot) handleSlashEvent(e *slack.SlashCommand) error {
// Validate command execution context
if e.ChannelID != config.C.Slack.ChannelID {
return nil // Ignore messages not from the specified channel
}

// Prepare a new message to add reaction to
commandText := fmt.Sprintf("%s %s", e.Command, e.Text)
responseText := fmt.Sprintf("%s <@%s|%s> used slash command: %s",
e.UserName, e.UserID, e.UserName,
commandText)
_, ts, err := s.sock.PostMessage(e.ChannelID, slack.MsgOptionText(responseText, false))
if err != nil {
return fmt.Errorf("failed to post message in response to slash command: %w", err)
}

// Execute
messageRef := slack.ItemRef{
Channel: e.ChannelID,
Timestamp: ts,
}
commandText = strings.TrimPrefix(commandText, slashPrefix)
return s.executeCommand(commandText, messageRef, e.UserID)
}

func (s *slackBot) executeCommand(commandText string, messageRef slack.ItemRef, executorID string) error {
// Prepare command args
ctx := &slackContext{
Context: context.Background(),
api: s.api,
logger: s.logger,
message: messageRef,
executorID: executorID,
args: nil,
}
args, err := shellquote.Split(commandText)
if err != nil {
return ctx.ReplyBad(fmt.Sprintf("failed to parse arguments: %v", err))
}
ctx.args = args

// Execute
return s.rootCmd.Execute(ctx)
}
96 changes: 96 additions & 0 deletions pkg/bot/slack/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package slack

import (
"context"
"github.com/slack-go/slack"
"github.com/traPtitech/DevOpsBot/pkg/config"
"github.com/traPtitech/DevOpsBot/pkg/domain"
"github.com/traPtitech/DevOpsBot/pkg/utils"
"go.uber.org/zap"
"strings"
)

type slackContext struct {
context.Context
api *slack.Client
logger *zap.Logger

message slack.ItemRef
executorID string
args []string
}

func (ctx *slackContext) Executor() string {
return ctx.executorID
}

func (ctx *slackContext) Args() []string {
return ctx.args
}

func (ctx *slackContext) ShiftArgs() domain.Context {
newCtx := *ctx
newCtx.args = newCtx.args[1:]
return &newCtx
}

func (ctx *slackContext) L() *zap.Logger {
return ctx.logger.With(
zap.String("executor", ctx.Executor()),
//zap.String("command", s.p.Message.PlainText),
//zap.Time("datetime", s.p.EventTime),
)
}

func (ctx *slackContext) sendSlackMessage(channelID string, text string) error {
api := ctx.api
return utils.WithRetry(ctx, 10, func(ctx context.Context) error {
_, _, err := api.PostMessage(channelID, slack.MsgOptionText(text, false))
return err
})
}

func (ctx *slackContext) pushSlackReaction(message slack.ItemRef, stampID string) error {
api := ctx.api
return utils.WithRetry(ctx, 10, func(ctx context.Context) error {
return api.AddReaction(stampID, message)
})
}

func (ctx *slackContext) reply(message ...string) error {
return ctx.sendSlackMessage(ctx.message.Channel, strings.Join(message, "\n"))
}

func (ctx *slackContext) replyWithStamp(stamp string, message ...string) error {
err := ctx.pushSlackReaction(ctx.message, stamp)
if err != nil {
return err
}
if len(message) > 0 {
err = ctx.reply(message...)
if err != nil {
return err
}
}
return nil
}

func (ctx *slackContext) ReplyBad(message ...string) error {
return ctx.replyWithStamp(config.C.Stamps.BadCommand, message...)
}

func (ctx *slackContext) ReplyForbid(message ...string) error {
return ctx.replyWithStamp(config.C.Stamps.Forbid, message...)
}

func (ctx *slackContext) ReplySuccess(message ...string) error {
return ctx.replyWithStamp(config.C.Stamps.Success, message...)
}

func (ctx *slackContext) ReplyFailure(message ...string) error {
return ctx.replyWithStamp(config.C.Stamps.Failure, message...)
}

func (ctx *slackContext) ReplyRunning(message ...string) error {
return ctx.replyWithStamp(config.C.Stamps.Running, message...)
}
Loading

0 comments on commit 46ae05b

Please sign in to comment.