├── .gitignore ├── pkg ├── message │ ├── delegate.go │ ├── message_response.go │ └── message.go ├── http │ ├── service.go │ ├── meta_runtime_handler.go │ ├── server.go │ └── log_middleware.go ├── slack │ ├── event │ │ ├── processor.go │ │ ├── ginkgo_suite_test.go │ │ ├── sync_processor.go │ │ ├── parser.go │ │ └── parser_test.go │ ├── slash │ │ ├── processor.go │ │ ├── handler.go │ │ ├── handlers.go │ │ ├── sync_processor.go │ │ ├── help_handler.go │ │ └── show_handler.go │ ├── ginkgo_suite_test.go │ ├── rtm │ │ ├── ginkgo_suite_test.go │ │ ├── service.go │ │ ├── parser.go │ │ └── parser_test.go │ ├── slackutil │ │ ├── ginkgo_suite_test.go │ │ ├── timestamp.go │ │ ├── message_parser.go │ │ └── message_parser_test.go │ ├── http │ │ ├── service.go │ │ ├── slash_handler.go │ │ └── event_handler.go │ ├── user_lookup.go │ ├── responder.go │ ├── slackfakes │ │ ├── fake_user_lookup_slack_api.go │ │ └── fake_responder_slack_api.go │ └── responder_test.go ├── delegate │ ├── delegates │ │ ├── factory.go │ │ ├── package_test.go │ │ ├── coalesce │ │ │ ├── package_test.go │ │ │ ├── delegator.go │ │ │ ├── factory │ │ │ │ └── factory.go │ │ │ └── delegator_test.go │ │ ├── literalmap │ │ │ ├── package_test.go │ │ │ ├── delegator.go │ │ │ ├── factory │ │ │ │ └── factory.go │ │ │ └── delegator_test.go │ │ ├── topiclookup │ │ │ ├── package_test.go │ │ │ ├── factory │ │ │ │ └── factory.go │ │ │ ├── delegator.go │ │ │ ├── delegator_test.go │ │ │ └── topiclookupfakes │ │ │ │ └── fake_slack_api.go │ │ ├── utils.go │ │ ├── user │ │ │ ├── delegator.go │ │ │ └── factory │ │ │ │ └── factory.go │ │ ├── literal │ │ │ ├── delegator.go │ │ │ └── factory │ │ │ │ └── factory.go │ │ ├── usergroup │ │ │ ├── delegator.go │ │ │ └── factory │ │ │ │ └── factory.go │ │ ├── union │ │ │ ├── delegator.go │ │ │ └── factory │ │ │ │ └── factory.go │ │ ├── utils_test.go │ │ ├── conditional │ │ │ ├── delegator.go │ │ │ └── factory │ │ │ │ └── factory.go │ │ ├── lookup │ │ │ ├── factory │ │ │ │ └── factory.go │ │ │ └── delegator.go │ │ ├── pairist │ │ │ ├── delegator.go │ │ │ └── factory │ │ │ │ └── factory.go │ │ ├── pagerduty │ │ │ ├── delegator.go │ │ │ └── factory │ │ │ │ └── factory.go │ │ ├── emaillookupmap │ │ │ ├── factory │ │ │ │ └── factory.go │ │ │ └── delegator.go │ │ └── defaultfactory │ │ │ └── factory.go │ ├── delegator.go │ ├── provider │ │ ├── handler.go │ │ ├── yaml │ │ │ ├── schema.go │ │ │ ├── delegator.go │ │ │ └── parser.go │ │ ├── db │ │ │ ├── model │ │ │ │ ├── team_config.go │ │ │ │ └── channel_config.go │ │ │ ├── db.go │ │ │ └── delegator.go │ │ └── fs │ │ │ └── delegator.go │ ├── literal.go │ ├── package_test.go │ ├── user.go │ ├── usergroup.go │ ├── user_test.go │ ├── literal_test.go │ ├── usergroup_test.go │ └── delegatefakes │ │ └── fake_delegator.go ├── condition │ ├── conditions │ │ ├── factory.go │ │ ├── day │ │ │ ├── package_test.go │ │ │ ├── condition.go │ │ │ ├── condition_test.go │ │ │ └── factory │ │ │ │ └── factory.go │ │ ├── date │ │ │ ├── package_test.go │ │ │ ├── condition.go │ │ │ ├── factory │ │ │ │ └── factory.go │ │ │ └── condition_test.go │ │ ├── hours │ │ │ ├── package_test.go │ │ │ ├── condition.go │ │ │ ├── factory │ │ │ │ └── factory.go │ │ │ └── condition_test.go │ │ ├── boolnot │ │ │ ├── package_test.go │ │ │ ├── condition.go │ │ │ ├── condition_test.go │ │ │ └── factory │ │ │ │ └── factory.go │ │ ├── target │ │ │ ├── package_test.go │ │ │ ├── condition.go │ │ │ ├── condition_test.go │ │ │ └── factory │ │ │ │ └── factory.go │ │ ├── boolor │ │ │ ├── package_test.go │ │ │ ├── condition.go │ │ │ ├── factory │ │ │ │ └── factory.go │ │ │ └── condition_test.go │ │ ├── booland │ │ │ ├── package_test.go │ │ │ ├── condition.go │ │ │ ├── factory │ │ │ │ └── factory.go │ │ │ └── condition_test.go │ │ └── defaultfactory │ │ │ └── factory.go │ ├── condition.go │ └── conditionfakes │ │ └── fake_condition.go └── configutil │ ├── package_test.go │ ├── remarshal_yaml.go │ ├── kv_tuple.go │ ├── remarshal_yaml_test.go │ ├── kv_tuple_test.go │ └── segmented_secrets.go ├── Dockerfile ├── examples ├── cf-app │ ├── README.md │ ├── vars.example.yml │ └── concourse.yml └── k8s-app │ ├── deployment.yml │ └── README.md ├── docs ├── deployment.md ├── slack-setup.md └── handlers │ ├── conditions.md │ ├── yaml-config.md │ └── delegators.md ├── cmd └── delegatebot │ ├── cmd │ ├── validate_cmd.go │ ├── root.go │ ├── rtm_cmd.go │ ├── simulate_cmd.go │ └── api_cmd.go │ ├── args │ └── log_level.go │ ├── main.go │ └── opts │ └── root.go ├── .github └── workflows │ ├── test.yml │ └── publish.yml ├── LICENSE ├── go.mod ├── .goreleaser.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .envrc 3 | dist 4 | tmp 5 | -------------------------------------------------------------------------------- /pkg/message/delegate.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | type Delegate interface { 4 | String() string 5 | } 6 | -------------------------------------------------------------------------------- /pkg/http/service.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import "github.com/labstack/echo/v4" 4 | 5 | type Service interface { 6 | InstallService(e *echo.Echo) 7 | } 8 | -------------------------------------------------------------------------------- /pkg/slack/event/processor.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import "time" 4 | 5 | type Processor interface { 6 | Process(since time.Time, event string, payload []byte) error 7 | } 8 | -------------------------------------------------------------------------------- /pkg/slack/slash/processor.go: -------------------------------------------------------------------------------- 1 | package slash 2 | 3 | import "time" 4 | 5 | type Processor interface { 6 | Process(since time.Time, event string, payload []byte) error 7 | } 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | RUN apk update && apk --no-cache add tzdata 3 | ADD slack-delegate-bot /usr/local/bin/ 4 | ENTRYPOINT ["/usr/local/bin/slack-delegate-bot"] 5 | EXPOSE 8080 6 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/factory.go: -------------------------------------------------------------------------------- 1 | package delegates 2 | 3 | import "github.com/dpb587/slack-delegate-bot/pkg/delegate" 4 | 5 | type Factory interface { 6 | Create(name string, options interface{}) (delegate.Delegator, error) 7 | } 8 | -------------------------------------------------------------------------------- /pkg/condition/conditions/factory.go: -------------------------------------------------------------------------------- 1 | package conditions 2 | 3 | import "github.com/dpb587/slack-delegate-bot/pkg/condition" 4 | 5 | type Factory interface { 6 | Create(name string, config interface{}) (condition.Condition, error) 7 | } 8 | -------------------------------------------------------------------------------- /pkg/condition/condition.go: -------------------------------------------------------------------------------- 1 | package condition 2 | 3 | import "github.com/dpb587/slack-delegate-bot/pkg/message" 4 | 5 | //go:generate counterfeiter . Condition 6 | type Condition interface { 7 | Evaluate(message.Message) (bool, error) 8 | } 9 | -------------------------------------------------------------------------------- /examples/cf-app/README.md: -------------------------------------------------------------------------------- 1 | # slack-delegate-bot/cf-app 2 | 3 | An example Concourse pipeline for deploying the bot to Cloud Foundry. 4 | 5 | * [dpb587/cloudfoundry-slack-interrupt-bot](https://github.com/dpb587/cloudfoundry-slack-interrupt-bot) 6 | -------------------------------------------------------------------------------- /pkg/delegate/delegator.go: -------------------------------------------------------------------------------- 1 | package delegate 2 | 3 | import "github.com/dpb587/slack-delegate-bot/pkg/message" 4 | 5 | //go:generate counterfeiter . Delegator 6 | type Delegator interface { 7 | Delegate(message.Message) ([]message.Delegate, error) 8 | } 9 | -------------------------------------------------------------------------------- /pkg/delegate/provider/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import "github.com/dpb587/slack-delegate-bot/pkg/message" 4 | 5 | //go:generate counterfeiter . Handler 6 | type Handler interface { 7 | Execute(message.Message) (message.MessageResponse, error) 8 | } 9 | -------------------------------------------------------------------------------- /pkg/slack/slash/handler.go: -------------------------------------------------------------------------------- 1 | package slash 2 | 3 | import "github.com/slack-go/slack" 4 | 5 | type Handler interface { 6 | Handle(slack.SlashCommand) (bool, error) 7 | 8 | // TODO separate interface 9 | UsageHint() string 10 | ShortDescription() string 11 | } 12 | -------------------------------------------------------------------------------- /pkg/message/message_response.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | type MessageResponse struct { 4 | Delegates []Delegate 5 | EmptyMessage string 6 | } 7 | 8 | func (mr MessageResponse) IsUnset() bool { 9 | return len(mr.Delegates) == 0 && len(mr.EmptyMessage) == 0 10 | } 11 | -------------------------------------------------------------------------------- /pkg/delegate/literal.go: -------------------------------------------------------------------------------- 1 | package delegate 2 | 3 | import "github.com/dpb587/slack-delegate-bot/pkg/message" 4 | 5 | type Literal struct { 6 | Text string 7 | } 8 | 9 | var _ message.Delegate = &Literal{} 10 | 11 | func (i Literal) String() string { 12 | return i.Text 13 | } 14 | -------------------------------------------------------------------------------- /pkg/slack/ginkgo_suite_test.go: -------------------------------------------------------------------------------- 1 | package slack_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestSlack(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/pkg/slack") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/slack/rtm/ginkgo_suite_test.go: -------------------------------------------------------------------------------- 1 | package rtm_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestSlack(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/pkg/slack/rtm") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/delegate/package_test.go: -------------------------------------------------------------------------------- 1 | package delegate_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestTopiclookup(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/pkg/delegate") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/delegate/user.go: -------------------------------------------------------------------------------- 1 | package delegate 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/message" 7 | ) 8 | 9 | type User struct { 10 | ID string 11 | } 12 | 13 | var _ message.Delegate = &User{} 14 | 15 | func (i User) String() string { 16 | return fmt.Sprintf("<@%s>", i.ID) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/slack/event/ginkgo_suite_test.go: -------------------------------------------------------------------------------- 1 | package event_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestSlack(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/pkg/slack/event") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/configutil/package_test.go: -------------------------------------------------------------------------------- 1 | package configutil_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestTopiclookup(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/pkg/configutil") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/slack/slackutil/ginkgo_suite_test.go: -------------------------------------------------------------------------------- 1 | package slackutil_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestSlack(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/pkg/slack/slackutil") 13 | } 14 | -------------------------------------------------------------------------------- /docs/deployment.md: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | ## API Server 4 | 5 | **Environment Variables** 6 | 7 | * `SLACK_TOKEN` -- Bot User OAuth Access Token (starting with `xoxb-) 8 | * `SLACK_SIGNING_SECRET` -- from App Credentials 9 | 10 | ## Examples 11 | 12 | * [Cloud Foundry](examples/cf-app) 13 | * [Kubernetes](examples/k8s-app) 14 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/package_test.go: -------------------------------------------------------------------------------- 1 | package delegates_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestTopiclookup(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates") 13 | } 14 | -------------------------------------------------------------------------------- /cmd/delegatebot/cmd/validate_cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/cmd/delegatebot/opts" 5 | ) 6 | 7 | type ValidateCmd struct { 8 | *opts.Root `no-flags:"true"` 9 | } 10 | 11 | func (c *ValidateCmd) Execute(_ []string) error { 12 | _, err := c.Root.GetDelegator() 13 | 14 | return err 15 | } 16 | -------------------------------------------------------------------------------- /pkg/condition/conditions/day/package_test.go: -------------------------------------------------------------------------------- 1 | package day_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestTopiclookup(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/day") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/condition/conditions/date/package_test.go: -------------------------------------------------------------------------------- 1 | package date_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestTopiclookup(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/date") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/condition/conditions/hours/package_test.go: -------------------------------------------------------------------------------- 1 | package hours_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestTopiclookup(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/hours") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/condition/conditions/boolnot/package_test.go: -------------------------------------------------------------------------------- 1 | package boolnot_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestTopiclookup(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/boolnot") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/condition/conditions/target/package_test.go: -------------------------------------------------------------------------------- 1 | package target_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestTopiclookup(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/target") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/coalesce/package_test.go: -------------------------------------------------------------------------------- 1 | package coalesce_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestTopiclookup(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/coalesce") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/literalmap/package_test.go: -------------------------------------------------------------------------------- 1 | package literalmap_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestTopiclookup(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/literalmap") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/topiclookup/package_test.go: -------------------------------------------------------------------------------- 1 | package topiclookup_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestTopiclookup(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/topiclookup") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/condition/conditions/boolor/package_test.go: -------------------------------------------------------------------------------- 1 | package boolor_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestTopiclookup(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/cmd/delegatebot/logic/condition/conditions/boolor") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/condition/conditions/booland/package_test.go: -------------------------------------------------------------------------------- 1 | package booland_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestTopiclookup(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "github.com/dpb587/slack-delegate-bot/cmd/delegatebot/logic/condition/conditions/booland") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/utils.go: -------------------------------------------------------------------------------- 1 | package delegates 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/message" 7 | ) 8 | 9 | func Join(delegates []message.Delegate, sep string) string { 10 | var str []string 11 | 12 | for _, i := range delegates { 13 | str = append(str, i.String()) 14 | } 15 | 16 | return strings.Join(str, sep) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/delegate/usergroup.go: -------------------------------------------------------------------------------- 1 | package delegate 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/message" 7 | ) 8 | 9 | type UserGroup struct { 10 | ID string 11 | Alias string 12 | } 13 | 14 | var _ message.Delegate = &UserGroup{} 15 | 16 | func (i UserGroup) String() string { 17 | return fmt.Sprintf("", i.ID, i.Alias) 18 | } 19 | -------------------------------------------------------------------------------- /examples/cf-app/vars.example.yml: -------------------------------------------------------------------------------- 1 | app_api: https://api.run.pivotal.io 2 | app_username: somebody@example.com 3 | app_password: "...snip..." 4 | app_organization: community-tools 5 | app_space: development 6 | app_name: my-special-delegate-bot 7 | 8 | config_uri: https://github.com/example/my-special-delegate-bot.git 9 | config_private_key: ~ 10 | config_branch: master 11 | 12 | slack_token: xoxb-... 13 | -------------------------------------------------------------------------------- /pkg/delegate/user_test.go: -------------------------------------------------------------------------------- 1 | package delegate_test 2 | 3 | import ( 4 | . "github.com/dpb587/slack-delegate-bot/pkg/delegate" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | var _ = Describe("User", func() { 11 | Describe("String", func() { 12 | It("stringifys", func() { 13 | Expect(User{ID: "U12345678"}.String()).To(Equal("<@U12345678>")) 14 | }) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /pkg/delegate/literal_test.go: -------------------------------------------------------------------------------- 1 | package delegate_test 2 | 3 | import ( 4 | . "github.com/dpb587/slack-delegate-bot/pkg/delegate" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | var _ = Describe("Literal", func() { 11 | Describe("String", func() { 12 | It("is very literal", func() { 13 | Expect(Literal{Text: "one two"}.String()).To(Equal("one two")) 14 | }) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /cmd/delegatebot/args/log_level.go: -------------------------------------------------------------------------------- 1 | package args 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "github.com/sirupsen/logrus" 6 | ) 7 | 8 | type LogLevel logrus.Level 9 | 10 | func (ll *LogLevel) UnmarshalFlag(data string) error { 11 | parsed, err := logrus.ParseLevel(data) 12 | if err != nil { 13 | return errors.Wrap(err, "parsing log level") 14 | } 15 | 16 | *ll = LogLevel(parsed) 17 | 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /pkg/slack/slackutil/timestamp.go: -------------------------------------------------------------------------------- 1 | package slackutil 2 | 3 | import ( 4 | "math" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | func MustConvertTimestamp(timestamp string) time.Time { 10 | timeFloat, err := strconv.ParseFloat(timestamp, 10) 11 | if err != nil { 12 | panic(err) // TODO unpanic? 13 | } 14 | 15 | sec, dec := math.Modf(timeFloat) 16 | 17 | return time.Unix(int64(sec), int64(dec*(1e9))).In(time.UTC) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/condition/conditions/target/condition.go: -------------------------------------------------------------------------------- 1 | package target 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 5 | "github.com/dpb587/slack-delegate-bot/pkg/message" 6 | ) 7 | 8 | type Condition struct { 9 | Channel string 10 | } 11 | 12 | var _ condition.Condition = &Condition{} 13 | 14 | func (c Condition) Evaluate(m message.Message) (bool, error) { 15 | return m.TargetChannelID == c.Channel, nil 16 | } 17 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/user/delegator.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 5 | "github.com/dpb587/slack-delegate-bot/pkg/message" 6 | ) 7 | 8 | type Delegator struct { 9 | ID string 10 | } 11 | 12 | var _ delegate.Delegator = &Delegator{} 13 | 14 | func (i Delegator) Delegate(_ message.Message) ([]message.Delegate, error) { 15 | return []message.Delegate{delegate.User{ID: i.ID}}, nil 16 | } 17 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/literal/delegator.go: -------------------------------------------------------------------------------- 1 | package literal 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 5 | "github.com/dpb587/slack-delegate-bot/pkg/message" 6 | ) 7 | 8 | type Delegator struct { 9 | Text string 10 | } 11 | 12 | var _ delegate.Delegator = &Delegator{} 13 | 14 | func (i Delegator) Delegate(_ message.Message) ([]message.Delegate, error) { 15 | return []message.Delegate{delegate.Literal{Text: i.Text}}, nil 16 | } 17 | -------------------------------------------------------------------------------- /pkg/condition/conditions/boolnot/condition.go: -------------------------------------------------------------------------------- 1 | package boolnot 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 5 | "github.com/dpb587/slack-delegate-bot/pkg/message" 6 | ) 7 | 8 | type Condition struct { 9 | Condition condition.Condition 10 | } 11 | 12 | var _ condition.Condition = &Condition{} 13 | 14 | func (c Condition) Evaluate(m message.Message) (bool, error) { 15 | v, err := c.Condition.Evaluate(m) 16 | 17 | return !v, err 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | jobs: 5 | release: 6 | name: Run Tests 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Set up Go 10 | uses: actions/setup-go@v1 11 | with: 12 | go-version: 1.14.x 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | - name: Install Dependencies 16 | run: go mod download 17 | - name: Execute Tests 18 | run: go test ./... 19 | -------------------------------------------------------------------------------- /pkg/delegate/provider/yaml/schema.go: -------------------------------------------------------------------------------- 1 | package yaml 2 | 3 | type Schema struct { 4 | DelegateBot SchemaDelegateBot `yaml:"delegatebot"` 5 | } 6 | 7 | type SchemaDelegateBot struct { 8 | Watch []interface{} `yaml:"watch"` 9 | Delegate interface{} `yaml:"delegate"` 10 | Options SchemaDelegateBotWithOptions `yaml:"options"` 11 | } 12 | 13 | type SchemaDelegateBotWithOptions struct { 14 | EmptyMessage string `yaml:"empty_message"` 15 | } 16 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/usergroup/delegator.go: -------------------------------------------------------------------------------- 1 | package usergroup 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 5 | "github.com/dpb587/slack-delegate-bot/pkg/message" 6 | ) 7 | 8 | type Delegator struct { 9 | ID string 10 | Alias string 11 | } 12 | 13 | var _ delegate.Delegator = &Delegator{} 14 | 15 | func (i Delegator) Delegate(_ message.Message) ([]message.Delegate, error) { 16 | return []message.Delegate{delegate.UserGroup{ID: i.ID, Alias: i.Alias}}, nil 17 | } 18 | -------------------------------------------------------------------------------- /cmd/delegatebot/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/dpb587/slack-delegate-bot/cmd/delegatebot/opts" 4 | 5 | type Root struct { 6 | *opts.Root 7 | 8 | Validate *ValidateCmd `command:"validate" description:"For validating configuration"` 9 | Simulate *SimulateCmd `command:"simulate" description:"For simulating an incoming message"` 10 | API *APICmd `command:"api" description:"Run HTTP API server"` 11 | RTM *RTMCmd `command:"rtm" description:"Run RTM client"` 12 | } 13 | -------------------------------------------------------------------------------- /pkg/configutil/remarshal_yaml.go: -------------------------------------------------------------------------------- 1 | package configutil 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | yaml "gopkg.in/yaml.v2" 6 | ) 7 | 8 | func RemarshalYAML(from interface{}, to interface{}) error { 9 | bytes, err := yaml.Marshal(from) 10 | if err != nil { 11 | return errors.Wrap(err, "marshaling") 12 | } 13 | 14 | return UnmarshalYAMLStrict(bytes, to) 15 | } 16 | 17 | func UnmarshalYAMLStrict(from []byte, to interface{}) error { 18 | err := yaml.UnmarshalStrict(from, to) 19 | if err != nil { 20 | return errors.Wrap(err, "unmarshalling") 21 | } 22 | 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /pkg/delegate/usergroup_test.go: -------------------------------------------------------------------------------- 1 | package delegate_test 2 | 3 | import ( 4 | . "github.com/dpb587/slack-delegate-bot/pkg/delegate" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | var _ = Describe("UserGroup", func() { 11 | Describe("String", func() { 12 | It("stringifys", func() { 13 | Expect(UserGroup{ID: "G12345678", Alias: "fake-name"}.String()).To(Equal("")) 14 | }) 15 | 16 | It("stringifys without alias", func() { 17 | Expect(UserGroup{ID: "G12345678"}.String()).To(Equal("")) 18 | }) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /pkg/condition/conditions/booland/condition.go: -------------------------------------------------------------------------------- 1 | package booland 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 5 | "github.com/dpb587/slack-delegate-bot/pkg/message" 6 | ) 7 | 8 | type Condition struct { 9 | Conditions []condition.Condition 10 | } 11 | 12 | var _ condition.Condition = &Condition{} 13 | 14 | func (c Condition) Evaluate(m message.Message) (bool, error) { 15 | for _, c := range c.Conditions { 16 | v, err := c.Evaluate(m) 17 | if err != nil { 18 | return false, err 19 | } else if !v { 20 | return false, nil 21 | } 22 | } 23 | 24 | return true, nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/condition/conditions/boolor/condition.go: -------------------------------------------------------------------------------- 1 | package boolor 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 5 | "github.com/dpb587/slack-delegate-bot/pkg/message" 6 | ) 7 | 8 | type Condition struct { 9 | Conditions []condition.Condition 10 | } 11 | 12 | var _ condition.Condition = &Condition{} 13 | 14 | func (c Condition) Evaluate(m message.Message) (bool, error) { 15 | for _, c := range c.Conditions { 16 | v, err := c.Evaluate(m) 17 | if err != nil { 18 | return false, err 19 | } else if v { 20 | return true, nil 21 | } 22 | } 23 | 24 | return false, nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/condition/conditions/hours/condition.go: -------------------------------------------------------------------------------- 1 | package hours 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 7 | "github.com/dpb587/slack-delegate-bot/pkg/message" 8 | ) 9 | 10 | type Condition struct { 11 | Location *time.Location 12 | Start string 13 | End string 14 | } 15 | 16 | var _ condition.Condition = &Condition{} 17 | 18 | func (c Condition) Evaluate(m message.Message) (bool, error) { 19 | actual := m.Time.In(c.Location).Format("15:04") 20 | 21 | if actual >= c.Start && actual < c.End { 22 | return true, nil 23 | } 24 | 25 | return false, nil 26 | } 27 | -------------------------------------------------------------------------------- /pkg/condition/conditions/day/condition.go: -------------------------------------------------------------------------------- 1 | package day 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 7 | "github.com/dpb587/slack-delegate-bot/pkg/message" 8 | ) 9 | 10 | type Condition struct { 11 | Location *time.Location 12 | Days []string 13 | } 14 | 15 | var _ condition.Condition = &Condition{} 16 | 17 | func (c Condition) Evaluate(m message.Message) (bool, error) { 18 | actual := m.Time.In(c.Location).Format("Mon") 19 | 20 | for _, expected := range c.Days { 21 | if expected == actual { 22 | return true, nil 23 | } 24 | } 25 | 26 | return false, nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/condition/conditions/date/condition.go: -------------------------------------------------------------------------------- 1 | package date 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 7 | "github.com/dpb587/slack-delegate-bot/pkg/message" 8 | ) 9 | 10 | type Condition struct { 11 | Location *time.Location 12 | Dates []string 13 | } 14 | 15 | var _ condition.Condition = &Condition{} 16 | 17 | func (c Condition) Evaluate(m message.Message) (bool, error) { 18 | actual := m.Time.In(c.Location).Format("2006-01-02") 19 | 20 | for _, expected := range c.Dates { 21 | if actual == expected { 22 | return true, nil 23 | } 24 | } 25 | 26 | return false, nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/coalesce/delegator.go: -------------------------------------------------------------------------------- 1 | package coalesce 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 5 | "github.com/dpb587/slack-delegate-bot/pkg/message" 6 | ) 7 | 8 | type Delegator struct { 9 | Delegators []delegate.Delegator 10 | } 11 | 12 | var _ delegate.Delegator = &Delegator{} 13 | 14 | func (i Delegator) Delegate(m message.Message) ([]message.Delegate, error) { 15 | for _, sub := range i.Delegators { 16 | subr, err := sub.Delegate(m) 17 | if err != nil { 18 | return nil, err 19 | } else if len(subr) > 0 { 20 | return subr, nil 21 | } 22 | } 23 | 24 | return nil, nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/union/delegator.go: -------------------------------------------------------------------------------- 1 | package union 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 5 | "github.com/dpb587/slack-delegate-bot/pkg/message" 6 | ) 7 | 8 | type Delegator struct { 9 | Delegators []delegate.Delegator 10 | } 11 | 12 | var _ delegate.Delegator = &Delegator{} 13 | 14 | func (i Delegator) Delegate(m message.Message) ([]message.Delegate, error) { 15 | var r []message.Delegate 16 | 17 | for _, sub := range i.Delegators { 18 | subr, err := sub.Delegate(m) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | r = append(r, subr...) 24 | } 25 | 26 | return r, nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/http/meta_runtime_handler.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/labstack/echo/v4" 8 | ) 9 | 10 | type MetaRuntimeHandler struct{} 11 | 12 | var _ Service = MetaRuntimeHandler{} 13 | 14 | func (h MetaRuntimeHandler) InstallService(e *echo.Echo) { 15 | r := e.Group("/api/v1/meta/runtime") 16 | 17 | r.GET("/failure", h.Failure) 18 | r.GET("/success", h.Success) 19 | } 20 | 21 | func (h MetaRuntimeHandler) Failure(c echo.Context) error { 22 | return errors.New("failure") 23 | } 24 | 25 | func (h MetaRuntimeHandler) Success(c echo.Context) error { 26 | return c.String(http.StatusOK, "success") 27 | } 28 | -------------------------------------------------------------------------------- /pkg/slack/slash/handlers.go: -------------------------------------------------------------------------------- 1 | package slash 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "github.com/slack-go/slack" 6 | ) 7 | 8 | type Handlers []Handler 9 | 10 | var _ Handler = Handlers{} 11 | 12 | func (hh Handlers) UsageHint() string { 13 | return "" 14 | } 15 | 16 | func (hh Handlers) ShortDescription() string { 17 | return "" 18 | } 19 | 20 | func (hh Handlers) Handle(cmd slack.SlashCommand) (bool, error) { 21 | for hIdx, h := range hh { 22 | done, err := h.Handle(cmd) 23 | if err != nil { 24 | return false, errors.Wrapf(err, "processing handler %d", hIdx) 25 | } else if done { 26 | return true, nil 27 | } 28 | } 29 | 30 | return false, nil 31 | } 32 | -------------------------------------------------------------------------------- /pkg/delegate/provider/db/model/team_config.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | "github.com/jinzhu/gorm" 8 | ) 9 | 10 | type TeamConfig struct { 11 | ID uuid.UUID `gorm:"primary_key"` 12 | TeamID string `gorm:"unique_index:team_config_revision"` 13 | RevisionNum int `gorm:"unique_index:team_config_revision"` 14 | RevisionLatest bool 15 | 16 | UpdatedAt time.Time 17 | UpdatedByID string 18 | UpdatedByName string 19 | 20 | HelpText string 21 | DefaultConfig string 22 | DefaultConfigSecrets string 23 | } 24 | 25 | func (m *TeamConfig) BeforeCreate(scope *gorm.Scope) error { 26 | return scope.SetColumn("ID", uuid.New().String()) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/message/message.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type MessageType string 8 | 9 | // TODO rename to MentionMessageType 10 | const ChannelMessageType MessageType = "channel" 11 | const DirectMessageMessageType MessageType = "dm" 12 | 13 | type Message struct { 14 | // TODO these in separate context? 15 | ServiceAPI interface{} 16 | Delegator interface{} 17 | RecursionDepth int 18 | 19 | UserTeamID string 20 | UserID string 21 | 22 | ChannelTeamID string 23 | ChannelID string 24 | 25 | TargetChannelTeamID string 26 | TargetChannelID string 27 | 28 | RawText string 29 | RawTimestamp string 30 | RawThreadTimestamp string 31 | 32 | Time time.Time 33 | Type MessageType 34 | } 35 | -------------------------------------------------------------------------------- /pkg/http/server.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo/v4" 7 | "github.com/labstack/echo/v4/middleware" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | type Server struct { 12 | server *http.Server 13 | logger *zap.Logger 14 | } 15 | 16 | func NewServer(server *http.Server, logger *zap.Logger) *Server { 17 | return &Server{ 18 | server: server, 19 | logger: logger, 20 | } 21 | } 22 | 23 | func (s *Server) Run(services ...Service) error { 24 | e := echo.New() 25 | e.HideBanner = true 26 | e.HidePort = true 27 | 28 | e.Use(LogMiddleware(s.logger)) 29 | e.Use(middleware.Recover()) 30 | 31 | for _, service := range services { 32 | service.InstallService(e) 33 | } 34 | 35 | return e.StartServer(s.server) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/utils_test.go: -------------------------------------------------------------------------------- 1 | package delegates_test 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 5 | . "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 6 | "github.com/dpb587/slack-delegate-bot/pkg/message" 7 | 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | var _ = Describe("Utils", func() { 13 | Describe("Join", func() { 14 | It("stringifys and joins", func() { 15 | str := Join( 16 | []message.Delegate{ 17 | delegate.Literal{Text: "literal"}, 18 | delegate.User{ID: "U12345678"}, 19 | delegate.UserGroup{ID: "G12345678"}, 20 | }, 21 | " // ", 22 | ) 23 | 24 | Expect(str).To(Equal("literal // <@U12345678> // ")) 25 | }) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /cmd/delegatebot/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/dpb587/slack-delegate-bot/cmd/delegatebot/cmd" 7 | "github.com/dpb587/slack-delegate-bot/cmd/delegatebot/opts" 8 | "github.com/jessevdk/go-flags" 9 | ) 10 | 11 | func main() { 12 | opts := &opts.Root{} 13 | main := cmd.Root{ 14 | Root: opts, 15 | API: &cmd.APICmd{ 16 | Root: opts, 17 | }, 18 | Validate: &cmd.ValidateCmd{ 19 | Root: opts, 20 | }, 21 | Simulate: &cmd.SimulateCmd{ 22 | Root: opts, 23 | }, 24 | } 25 | 26 | var parser = flags.NewParser(&main, flags.Default) 27 | 28 | if _, err := parser.Parse(); err != nil { 29 | if flagsErr, ok := err.(*flags.Error); ok && flagsErr.Type == flags.ErrHelp { 30 | os.Exit(0) 31 | } else { 32 | os.Exit(1) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /pkg/delegate/provider/db/model/channel_config.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | "github.com/jinzhu/gorm" 8 | ) 9 | 10 | type ChannelConfig struct { 11 | ID uuid.UUID `gorm:"primary_key"` 12 | TeamID string `gorm:"unique_index:channel_config_revision"` 13 | ChannelID string `gorm:"unique_index:channel_config_revision"` 14 | RevisionNum int `gorm:"unique_index:channel_config_revision"` 15 | RevisionLatest bool 16 | 17 | UpdatedAt time.Time 18 | UpdatedByID string 19 | UpdatedByName string 20 | 21 | Config string 22 | ConfigSecrets string 23 | } 24 | 25 | func (m *ChannelConfig) BeforeCreate(scope *gorm.Scope) error { 26 | return scope.SetColumn("ID", uuid.New().String()) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/conditional/delegator.go: -------------------------------------------------------------------------------- 1 | package conditional 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 5 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 6 | "github.com/dpb587/slack-delegate-bot/pkg/message" 7 | ) 8 | 9 | type Delegator struct { 10 | When condition.Condition 11 | Then delegate.Delegator 12 | Else delegate.Delegator 13 | } 14 | 15 | var _ delegate.Delegator = &Delegator{} 16 | 17 | func (i Delegator) Delegate(m message.Message) ([]message.Delegate, error) { 18 | when, err := i.When.Evaluate(m) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | if when { 24 | if i.Then != nil { 25 | return i.Then.Delegate(m) 26 | } 27 | } else if i.Else != nil { 28 | return i.Else.Delegate(m) 29 | } 30 | 31 | return nil, nil 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | jobs: 7 | release: 8 | name: Publish Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | - name: Unshallow 14 | run: git fetch --prune --unshallow 15 | - name: Set up Go 16 | uses: actions/setup-go@v1 17 | with: 18 | go-version: 1.14.x 19 | - name: Registry Login 20 | run: | 21 | echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u ${{ github.actor }} --password-stdin 22 | - name: Run GoReleaser 23 | uses: goreleaser/goreleaser-action@v1 24 | with: 25 | version: latest 26 | args: release 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /pkg/delegate/provider/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/provider/db/model" 7 | "github.com/jinzhu/gorm" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | func OpenDB(adapter, profile string) (*gorm.DB, error) { 12 | if adapter == "mysql2" { 13 | profile = fmt.Sprintf("%s?charset=utf8&&parseTime=true", profile) // TODO ampersand 14 | } 15 | 16 | db, err := gorm.Open(adapter, profile) 17 | if err != nil { 18 | panic(errors.Wrap(err, "opening database")) 19 | } 20 | 21 | if err := db.AutoMigrate(&model.TeamConfig{}).Error; err != nil { 22 | return nil, errors.Wrap(err, "auto-migrating TeamConfig") 23 | } 24 | 25 | if err := db.AutoMigrate(&model.ChannelConfig{}).Error; err != nil { 26 | return nil, errors.Wrap(err, "auto-migrating ChannelConfig") 27 | } 28 | 29 | return db, nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/configutil/kv_tuple.go: -------------------------------------------------------------------------------- 1 | package configutil 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func KeyValueTuple(from interface{}) (string, interface{}, error) { 9 | fromMap, ok := from.(map[interface{}]interface{}) 10 | if !ok { 11 | return "", nil, fmt.Errorf("expected map[string]interface{}: found %v", from) 12 | } 13 | 14 | var keys []string 15 | 16 | for k := range fromMap { 17 | kStr, ok := k.(string) 18 | if !ok { 19 | return "", nil, fmt.Errorf("expected string key: found %v", k) 20 | } 21 | 22 | keys = append(keys, kStr) 23 | } 24 | 25 | if len(keys) == 0 { 26 | return "", nil, fmt.Errorf("expected exactly one key-value tuple: found none") 27 | } else if len(keys) > 1 { 28 | return "", nil, fmt.Errorf("expected exactly one key-value tuple: found multiple (%s)", strings.Join(keys, ", ")) 29 | } 30 | 31 | return keys[0], fromMap[keys[0]], nil 32 | } 33 | -------------------------------------------------------------------------------- /pkg/slack/http/service.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/http" 5 | "github.com/dpb587/slack-delegate-bot/pkg/slack/event" 6 | "github.com/dpb587/slack-delegate-bot/pkg/slack/slash" 7 | "github.com/labstack/echo/v4" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | type Service struct { 12 | EventProcessor event.Processor 13 | SlashProcessor slash.Processor 14 | SigningSecret string 15 | Logger *zap.Logger 16 | } 17 | 18 | var _ http.Service = &Service{} 19 | 20 | func (s *Service) InstallService(e *echo.Echo) { 21 | r := e.Group("/api/v1/slack") 22 | 23 | { 24 | r := r.Group("/event") 25 | h := NewEventHandler(s.EventProcessor, s.SigningSecret, s.Logger) 26 | 27 | r.POST("", h.Accept) 28 | } 29 | 30 | { 31 | r := r.Group("/slash") 32 | h := NewSlashHandler(s.SlashProcessor, s.SigningSecret, s.Logger) 33 | 34 | r.POST("", h.Accept) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /docs/slack-setup.md: -------------------------------------------------------------------------------- 1 | # Slack Setup 2 | 3 | This bot relies on its own [Slack App](https://api.slack.com/apps) with the following settings. 4 | 5 | ## Settings 6 | 7 | ### App Home 8 | 9 | * **Always Show My Bot as Online** -- enabled 10 | * **Home Tab** -- disabled 11 | * **Messages Tab** -- enabled 12 | 13 | ### OAuth & Permissions 14 | 15 | * **Bot Token Scopes** 16 | * **app_mentions:read** 17 | * **chat:write** 18 | * **im:history** 19 | * **mpim:history** 20 | * **users:read** 21 | * **Restrict API Token Usage** 22 | 23 | ### Event Subscriptions 24 | 25 | Only enable this after the bot is deployed and running. 26 | 27 | * **Events** -- enabled 28 | * **Request URL** -- `{botURL}/api/v1/slack/event` 29 | * **Bot Events** 30 | * **app_mention** 31 | * **message.im** 32 | * **message.mpim** 33 | 34 | ### User ID Translation 35 | 36 | * **Translate Global IDs** -- disabled 37 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/user/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 8 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 9 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/user" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type factory struct{} 14 | 15 | type Options struct { 16 | ID string `yaml:"id"` 17 | } 18 | 19 | func New() delegates.Factory { 20 | return &factory{} 21 | } 22 | 23 | func (f factory) Create(name string, options interface{}) (delegate.Delegator, error) { 24 | if name != "user" { 25 | return nil, fmt.Errorf("unsupported delegate: %s", name) 26 | } 27 | 28 | parsed := Options{} 29 | 30 | err := configutil.RemarshalYAML(options, &parsed) 31 | if err != nil { 32 | return nil, errors.Wrap(err, "remarshalling") 33 | } 34 | 35 | return &user.Delegator{ 36 | ID: parsed.ID, 37 | }, nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/condition/conditions/target/condition_test.go: -------------------------------------------------------------------------------- 1 | package target_test 2 | 3 | import ( 4 | . "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/target" 5 | "github.com/dpb587/slack-delegate-bot/pkg/message" 6 | 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | ) 10 | 11 | var _ = Describe("Condition", func() { 12 | var subject Condition 13 | var msg message.Message 14 | 15 | BeforeEach(func() { 16 | subject = Condition{Channel: "C12345678"} 17 | msg = message.Message{TargetChannelID: "C12345678"} 18 | }) 19 | 20 | Context("non-matching target", func() { 21 | BeforeEach(func() { 22 | msg = message.Message{TargetChannelID: "C98765432"} 23 | }) 24 | 25 | It("fails", func() { 26 | b, err := subject.Evaluate(msg) 27 | Expect(err).NotTo(HaveOccurred()) 28 | Expect(b).To(BeFalse()) 29 | }) 30 | }) 31 | 32 | It("succeeds", func() { 33 | b, err := subject.Evaluate(msg) 34 | Expect(err).NotTo(HaveOccurred()) 35 | Expect(b).To(BeTrue()) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/literal/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 8 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 9 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/literal" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type factory struct{} 14 | 15 | type Options struct { 16 | Text string `yaml:"text"` 17 | } 18 | 19 | func New() delegates.Factory { 20 | return &factory{} 21 | } 22 | 23 | func (f factory) Create(name string, options interface{}) (delegate.Delegator, error) { 24 | if name != "literal" { 25 | return nil, fmt.Errorf("unsupported delegate: %s", name) 26 | } 27 | 28 | parsed := Options{} 29 | 30 | err := configutil.RemarshalYAML(options, &parsed) 31 | if err != nil { 32 | return nil, errors.Wrap(err, "remarshalling") 33 | } 34 | 35 | return &literal.Delegator{ 36 | Text: parsed.Text, 37 | }, nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/lookup/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 8 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 9 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/lookup" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type factory struct{} 14 | 15 | type Options struct { 16 | Channel string `yaml:"channel"` 17 | } 18 | 19 | func New() delegates.Factory { 20 | return factory{} 21 | } 22 | 23 | func (f factory) Create(name string, options interface{}) (delegate.Delegator, error) { 24 | if name != "lookup" { 25 | return nil, fmt.Errorf("invalid delegate: %s", name) 26 | } 27 | 28 | parsed := Options{} 29 | 30 | err := configutil.RemarshalYAML(options, &parsed) 31 | if err != nil { 32 | return nil, errors.Wrap(err, "remarshalling") 33 | } 34 | 35 | return &lookup.Delegator{ 36 | Channel: parsed.Channel, 37 | }, nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/configutil/remarshal_yaml_test.go: -------------------------------------------------------------------------------- 1 | package configutil_test 2 | 3 | import ( 4 | . "github.com/dpb587/slack-delegate-bot/pkg/configutil" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | var _ = Describe("RemarshalYaml", func() { 11 | It("remarshals", func() { 12 | dst := struct { 13 | First string `yaml:"one"` 14 | Second []string `yaml:"two"` 15 | }{} 16 | 17 | err := RemarshalYAML( 18 | map[string]interface{}{"one": "primary", "two": []string{"secondary", "tertiary"}}, 19 | &dst, 20 | ) 21 | Expect(err).NotTo(HaveOccurred()) 22 | Expect(dst.First).To(Equal("primary")) 23 | Expect(dst.Second).To(Equal([]string{"secondary", "tertiary"})) 24 | }) 25 | 26 | It("unmarshals strictly", func() { 27 | dst := struct { 28 | First string `yaml:"one"` 29 | }{} 30 | 31 | err := RemarshalYAML(map[string]interface{}{"two": "missing"}, &dst) 32 | Expect(err).To(HaveOccurred()) 33 | Expect(err.Error()).To(ContainSubstring("unmarshalling")) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /pkg/condition/conditions/target/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 7 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions" 8 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/target" 9 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type factory struct{} 14 | 15 | type Options struct { 16 | Channel string `yaml:"channel"` 17 | } 18 | 19 | func New() conditions.Factory { 20 | return &factory{} 21 | } 22 | 23 | func (f factory) Create(name string, options interface{}) (condition.Condition, error) { 24 | if name != "target" { 25 | return nil, fmt.Errorf("invalid condition: %s", name) 26 | } 27 | 28 | parsed := Options{} 29 | 30 | err := configutil.RemarshalYAML(options, &parsed) 31 | if err != nil { 32 | return nil, errors.Wrap(err, "remarshalling") 33 | } 34 | 35 | return &target.Condition{ 36 | Channel: parsed.Channel, 37 | }, nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/topiclookup/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 8 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 9 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/topiclookup" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type factory struct{} 14 | 15 | type Options struct { 16 | Channel string `yaml:"channel"` 17 | } 18 | 19 | func New() delegates.Factory { 20 | return factory{} 21 | } 22 | 23 | func (f factory) Create(name string, options interface{}) (delegate.Delegator, error) { 24 | if name != "topiclookup" { 25 | return nil, fmt.Errorf("invalid delegate: %s", name) 26 | } 27 | 28 | parsed := Options{} 29 | 30 | err := configutil.RemarshalYAML(options, &parsed) 31 | if err != nil { 32 | return nil, errors.Wrap(err, "remarshalling") 33 | } 34 | 35 | return &topiclookup.Delegator{ 36 | Channel: parsed.Channel, 37 | }, nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/usergroup/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 8 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 9 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/usergroup" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type factory struct{} 14 | 15 | type Options struct { 16 | ID string `yaml:"id"` 17 | Alias string `yaml:"alias"` 18 | } 19 | 20 | func New() delegates.Factory { 21 | return &factory{} 22 | } 23 | 24 | func (f factory) Create(name string, options interface{}) (delegate.Delegator, error) { 25 | if name != "usergroup" { 26 | return nil, fmt.Errorf("unsupported delegate: %s", name) 27 | } 28 | 29 | parsed := Options{} 30 | 31 | err := configutil.RemarshalYAML(options, &parsed) 32 | if err != nil { 33 | return nil, errors.Wrap(err, "remarshalling") 34 | } 35 | 36 | return &usergroup.Delegator{ 37 | ID: parsed.ID, 38 | Alias: parsed.Alias, 39 | }, nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/pairist/delegator.go: -------------------------------------------------------------------------------- 1 | package pairist 2 | 3 | import ( 4 | "github.com/dpb587/go-pairist/api" 5 | "github.com/dpb587/go-pairist/denormalized" 6 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 7 | "github.com/dpb587/slack-delegate-bot/pkg/message" 8 | ) 9 | 10 | type Delegator struct { 11 | Client *api.Client 12 | 13 | Team string 14 | 15 | Role string 16 | Track string 17 | } 18 | 19 | var _ delegate.Delegator = &Delegator{} 20 | 21 | func (i Delegator) Delegate(_ message.Message) ([]message.Delegate, error) { 22 | curr, err := i.Client.GetTeamCurrent(i.Team) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | allLanes := denormalized.BuildLanes(curr) 28 | var lanes denormalized.Lanes 29 | 30 | if i.Track != "" { 31 | lanes = allLanes.ByTrack(i.Track) 32 | } else { 33 | lanes = allLanes.ByRole(i.Role) 34 | } 35 | 36 | var res []message.Delegate 37 | 38 | for _, lane := range lanes { 39 | for _, person := range lane.People { 40 | res = append(res, delegate.Literal{Text: person.Name}) 41 | } 42 | } 43 | 44 | return res, nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/delegate/provider/yaml/delegator.go: -------------------------------------------------------------------------------- 1 | package yaml 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 5 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 6 | "github.com/dpb587/slack-delegate-bot/pkg/message" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | type Delegator struct { 11 | condition condition.Condition 12 | delegator delegate.Delegator 13 | options SchemaDelegateBotWithOptions 14 | } 15 | 16 | var _ delegate.Delegator = &Delegator{} 17 | 18 | func (h Delegator) Delegate(m message.Message) ([]message.Delegate, error) { 19 | if h.condition != nil { 20 | tf, err := h.condition.Evaluate(m) 21 | if err != nil { 22 | return nil, errors.Wrap(err, "evaluating condition") 23 | } else if !tf { 24 | return nil, nil 25 | } 26 | } 27 | 28 | res, err := h.delegator.Delegate(m) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | if len(res) == 0 && len(h.options.EmptyMessage) > 0 { 34 | res = append( 35 | res, 36 | delegate.Literal{ 37 | Text: h.options.EmptyMessage, 38 | }, 39 | ) 40 | } 41 | 42 | return res, nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/condition/conditions/boolnot/condition_test.go: -------------------------------------------------------------------------------- 1 | package boolnot_test 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditionfakes" 5 | . "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/boolnot" 6 | "github.com/dpb587/slack-delegate-bot/pkg/message" 7 | 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | var _ = Describe("Condition", func() { 13 | var condition *conditionfakes.FakeCondition 14 | var msg message.Message 15 | 16 | BeforeEach(func() { 17 | condition = &conditionfakes.FakeCondition{} 18 | condition.EvaluateReturns(true, nil) 19 | }) 20 | 21 | Context("false", func() { 22 | BeforeEach(func() { 23 | condition.EvaluateReturns(false, nil) 24 | }) 25 | 26 | It("is true", func() { 27 | b, err := Condition{Condition: condition}.Evaluate(msg) 28 | Expect(err).NotTo(HaveOccurred()) 29 | Expect(b).To(BeTrue()) 30 | }) 31 | }) 32 | 33 | It("inverts true", func() { 34 | b, err := Condition{Condition: condition}.Evaluate(msg) 35 | Expect(err).NotTo(HaveOccurred()) 36 | Expect(b).To(BeFalse()) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /pkg/condition/conditions/boolnot/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 7 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions" 8 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/boolnot" 9 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type factory struct { 14 | factory conditions.Factory 15 | } 16 | 17 | func New(ff conditions.Factory) conditions.Factory { 18 | return &factory{ 19 | factory: ff, 20 | } 21 | } 22 | 23 | func (f factory) Create(name string, options interface{}) (condition.Condition, error) { 24 | if name != "not" { 25 | return nil, fmt.Errorf("invalid condition: %s", name) 26 | } 27 | 28 | key, value, err := configutil.KeyValueTuple(options) 29 | if err != nil { 30 | return nil, errors.Wrap(err, "parsing") 31 | } 32 | 33 | condition, err := f.factory.Create(key, value) 34 | if err != nil { 35 | return nil, errors.Wrap(err, "creating") 36 | } 37 | 38 | return &boolnot.Condition{ 39 | Condition: condition, 40 | }, nil 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Danny Berger 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /pkg/http/log_middleware.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/labstack/echo/v4" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | func LogMiddleware(logger *zap.Logger) echo.MiddlewareFunc { 11 | return func(next echo.HandlerFunc) echo.HandlerFunc { 12 | return func(c echo.Context) (err error) { 13 | req := c.Request() 14 | res := c.Response() 15 | 16 | begin := time.Now() 17 | if err = next(c); err != nil { 18 | c.Error(err) 19 | } 20 | 21 | end := time.Now() 22 | 23 | fields := []zap.Field{ 24 | zap.String("remote_addr", req.RemoteAddr), 25 | zap.String("host", req.Host), 26 | zap.String("method", req.Method), 27 | zap.String("uri", req.RequestURI), 28 | zap.String("user_agent", req.UserAgent()), 29 | zap.String("referer", req.Referer()), 30 | zap.Int("status", res.Status), 31 | zap.Int64("duration", int64(end.Sub(begin)/ time.Millisecond)), 32 | zap.Int64("bytes_out", res.Size), 33 | } 34 | 35 | if err != nil { 36 | fields = append(fields, zap.Error(err)) 37 | } 38 | 39 | logger.Info("http request finished", fields...) 40 | 41 | return 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /pkg/delegate/delegates/literalmap/delegator.go: -------------------------------------------------------------------------------- 1 | package literalmap 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 5 | "github.com/dpb587/slack-delegate-bot/pkg/message" 6 | ) 7 | 8 | type Delegator struct { 9 | From delegate.Delegator 10 | Users map[string]string 11 | Usergroups map[string]string 12 | } 13 | 14 | var _ delegate.Delegator = &Delegator{} 15 | 16 | func (i Delegator) Delegate(m message.Message) ([]message.Delegate, error) { 17 | inner, err := i.From.Delegate(m) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | var res []message.Delegate 23 | 24 | for _, innerInterrupt := range inner { 25 | literalInterrupt, ok := innerInterrupt.(delegate.Literal) 26 | if !ok { 27 | res = append(res, innerInterrupt) 28 | 29 | continue 30 | } 31 | 32 | var newres message.Delegate = literalInterrupt 33 | 34 | if mapped, found := i.Users[literalInterrupt.Text]; found { 35 | newres = delegate.User{ID: mapped} 36 | } else if mapped, found := i.Usergroups[literalInterrupt.Text]; found { 37 | newres = delegate.UserGroup{ID: mapped} 38 | } 39 | 40 | res = append(res, newres) 41 | } 42 | 43 | return res, nil 44 | } 45 | -------------------------------------------------------------------------------- /pkg/condition/conditions/day/condition_test.go: -------------------------------------------------------------------------------- 1 | package day_test 2 | 3 | import ( 4 | "time" 5 | 6 | . "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/day" 7 | "github.com/dpb587/slack-delegate-bot/pkg/message" 8 | 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | var _ = Describe("Condition", func() { 14 | var subject Condition 15 | 16 | BeforeEach(func() { 17 | subject = Condition{ 18 | Location: time.UTC, 19 | Days: []string{"Mon", "Fri"}, 20 | } 21 | }) 22 | 23 | mustParseRFC3339 := func(value string) time.Time { 24 | v, err := time.Parse(time.RFC3339, value) 25 | if err != nil { 26 | panic(err) 27 | } 28 | 29 | return v 30 | } 31 | 32 | Context("non-matching day", func() { 33 | It("fails", func() { 34 | b, err := subject.Evaluate(message.Message{Time: mustParseRFC3339("2006-01-03T12:04:05+07:00")}) 35 | Expect(err).NotTo(HaveOccurred()) 36 | Expect(b).To(BeFalse()) 37 | }) 38 | }) 39 | 40 | It("succeeds", func() { 41 | b, err := subject.Evaluate(message.Message{Time: mustParseRFC3339("2006-01-03T03:04:05+07:00")}) 42 | Expect(err).NotTo(HaveOccurred()) 43 | Expect(b).To(BeTrue()) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /pkg/condition/conditions/day/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 8 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions" 9 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/day" 10 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | type factory struct{} 15 | 16 | type Options struct { 17 | TZ string `yaml:"tz"` 18 | Days []string `yaml:"days"` 19 | } 20 | 21 | func New() conditions.Factory { 22 | return &factory{} 23 | } 24 | 25 | func (f factory) Create(name string, options interface{}) (condition.Condition, error) { 26 | if name != "day" { 27 | return nil, fmt.Errorf("invalid condition: %s", name) 28 | } 29 | 30 | parsed := Options{} 31 | 32 | err := configutil.RemarshalYAML(options, &parsed) 33 | if err != nil { 34 | return nil, errors.Wrap(err, "remarshalling") 35 | } 36 | 37 | loc, err := time.LoadLocation(parsed.TZ) 38 | if err != nil { 39 | return nil, errors.Wrap(err, "loading timezone") 40 | } 41 | 42 | return &day.Condition{ 43 | Location: loc, 44 | Days: parsed.Days, 45 | }, nil 46 | } 47 | -------------------------------------------------------------------------------- /pkg/condition/conditions/date/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 8 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions" 9 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/date" 10 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | type factory struct{} 15 | 16 | type Options struct { 17 | TZ string `yaml:"tz"` 18 | Dates []string `yaml:"dates"` 19 | } 20 | 21 | func New() conditions.Factory { 22 | return &factory{} 23 | } 24 | 25 | func (f factory) Create(name string, options interface{}) (condition.Condition, error) { 26 | if name != "date" { 27 | return nil, fmt.Errorf("invalid condition: %s", name) 28 | } 29 | 30 | parsed := Options{} 31 | 32 | err := configutil.RemarshalYAML(options, &parsed) 33 | if err != nil { 34 | return nil, errors.Wrap(err, "remarshalling") 35 | } 36 | 37 | loc, err := time.LoadLocation(parsed.TZ) 38 | if err != nil { 39 | return nil, errors.Wrap(err, "loading timezone") 40 | } 41 | 42 | return &date.Condition{ 43 | Location: loc, 44 | Dates: parsed.Dates, 45 | }, nil 46 | } 47 | -------------------------------------------------------------------------------- /pkg/slack/slackutil/message_parser.go: -------------------------------------------------------------------------------- 1 | package slackutil 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/message" 7 | ) 8 | 9 | var reMention = regexp.MustCompile(`<@([^>]+)>`) 10 | var reChannel = regexp.MustCompile(`<#([^|>]+)(\|([^>]+))?>`) 11 | var reChannelMention = regexp.MustCompile(`<#([^|>]+)(\|([^>]+))?>\s+<@([^>]+)>`) 12 | 13 | func ParseMessageForAnyChannelReference(msg message.Message) message.Message { 14 | match := reChannel.FindStringSubmatch(msg.RawText) 15 | if len(match) == 0 { 16 | return msg 17 | } 18 | 19 | msg.TargetChannelID = match[1] 20 | 21 | return msg 22 | } 23 | 24 | func ParseMessageForChannelReference(msg message.Message, isSelf func(string) bool) message.Message { 25 | for _, match := range reChannelMention.FindAllStringSubmatch(msg.RawText, 32) { 26 | if isSelf(match[4]) { 27 | msg.TargetChannelID = match[1] 28 | 29 | break 30 | } 31 | } 32 | 33 | return msg 34 | } 35 | 36 | func CheckMessageForMention(msg message.Message, isSelf func(string) bool) bool { 37 | for _, match := range reMention.FindAllStringSubmatch(msg.RawText, 32) { 38 | if isSelf(match[1]) { 39 | return true 40 | } 41 | } 42 | 43 | return false 44 | } 45 | -------------------------------------------------------------------------------- /pkg/condition/conditions/date/condition_test.go: -------------------------------------------------------------------------------- 1 | package date_test 2 | 3 | import ( 4 | "time" 5 | 6 | . "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/date" 7 | "github.com/dpb587/slack-delegate-bot/pkg/message" 8 | 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | var _ = Describe("Condition", func() { 14 | var subject Condition 15 | 16 | BeforeEach(func() { 17 | subject = Condition{ 18 | Location: time.UTC, 19 | Dates: []string{"2006-01-01", "2006-01-02"}, 20 | } 21 | }) 22 | 23 | mustParseRFC3339 := func(value string) time.Time { 24 | v, err := time.Parse(time.RFC3339, value) 25 | if err != nil { 26 | panic(err) 27 | } 28 | 29 | return v 30 | } 31 | 32 | Context("non-matching date", func() { 33 | It("fails", func() { 34 | b, err := subject.Evaluate(message.Message{Time: mustParseRFC3339("2006-01-03T12:04:05+07:00")}) 35 | Expect(err).NotTo(HaveOccurred()) 36 | Expect(b).To(BeFalse()) 37 | }) 38 | }) 39 | 40 | It("succeeds", func() { 41 | b, err := subject.Evaluate(message.Message{Time: mustParseRFC3339("2006-01-03T03:04:05+07:00")}) 42 | Expect(err).NotTo(HaveOccurred()) 43 | Expect(b).To(BeTrue()) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dpb587/slack-delegate-bot 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/PagerDuty/go-pagerduty v0.0.0-20191110014646-e96b2a192c5d 7 | github.com/dpb587/go-pairist v0.0.0-20191031224042-79df4efb281e 8 | github.com/go-playground/universal-translator v0.17.0 // indirect 9 | github.com/go-playground/validator v9.31.0+incompatible 10 | github.com/google/uuid v1.1.1 11 | github.com/gorilla/sessions v1.2.0 12 | github.com/gorilla/websocket v1.4.2 // indirect 13 | github.com/jessevdk/go-flags v1.4.0 14 | github.com/jinzhu/gorm v1.9.12 15 | github.com/labstack/echo v3.3.10+incompatible 16 | github.com/labstack/echo-contrib v0.9.0 17 | github.com/labstack/echo/v4 v4.1.16 18 | github.com/leodido/go-urn v1.2.0 // indirect 19 | github.com/lusis/slack-test v0.0.0-20190426140909-c40012f20018 // indirect 20 | github.com/nlopes/slack v0.5.0 21 | github.com/onsi/ginkgo v1.10.2 22 | github.com/onsi/gomega v1.7.0 23 | github.com/pkg/errors v0.9.1 24 | github.com/sirupsen/logrus v1.4.2 25 | github.com/slack-go/slack v0.6.4 26 | go.uber.org/zap v1.15.0 27 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 28 | gopkg.in/go-playground/assert.v1 v1.2.1 // indirect 29 | gopkg.in/yaml.v2 v2.2.7 30 | ) 31 | -------------------------------------------------------------------------------- /pkg/condition/conditions/hours/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 8 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions" 9 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/hours" 10 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | type factory struct{} 15 | 16 | type Options struct { 17 | TZ string `yaml:"tz"` 18 | Start string `yaml:"start"` 19 | End string `yaml:"end"` 20 | } 21 | 22 | func New() conditions.Factory { 23 | return &factory{} 24 | } 25 | 26 | func (f factory) Create(name string, options interface{}) (condition.Condition, error) { 27 | if name != "hours" { 28 | return nil, fmt.Errorf("invalid condition: %s", name) 29 | } 30 | 31 | parsed := Options{} 32 | 33 | err := configutil.RemarshalYAML(options, &parsed) 34 | if err != nil { 35 | return nil, errors.Wrap(err, "remarshalling") 36 | } 37 | 38 | loc, err := time.LoadLocation(parsed.TZ) 39 | if err != nil { 40 | return nil, errors.Wrap(err, "loading timezone") 41 | } 42 | 43 | return &hours.Condition{ 44 | Location: loc, 45 | Start: parsed.Start, 46 | End: parsed.End, 47 | }, nil 48 | } 49 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/pagerduty/delegator.go: -------------------------------------------------------------------------------- 1 | package pagerduty 2 | 3 | import ( 4 | pagerduty "github.com/PagerDuty/go-pagerduty" 5 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 6 | "github.com/dpb587/slack-delegate-bot/pkg/message" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | type Delegator struct { 11 | Client *pagerduty.Client 12 | EscalationPolicy string 13 | EscalationLevel uint 14 | } 15 | 16 | var _ delegate.Delegator = &Delegator{} 17 | 18 | func (i Delegator) Delegate(_ message.Message) ([]message.Delegate, error) { 19 | oncalls, err := i.Client.ListOnCalls(pagerduty.ListOnCallOptions{ 20 | Includes: []string{"users"}, 21 | EscalationPolicyIDs: []string{i.EscalationPolicy}, 22 | }) 23 | if err != nil { 24 | return nil, errors.Wrap(err, "listing on-calls") 25 | } 26 | 27 | scheduledUsers := map[string]struct{}{} 28 | 29 | for _, oncall := range oncalls.OnCalls { 30 | if i.EscalationLevel > 0 && i.EscalationLevel != oncall.EscalationLevel { 31 | continue 32 | } 33 | 34 | scheduledUsers[oncall.User.Email] = struct{}{} 35 | } 36 | 37 | var res []message.Delegate 38 | 39 | for scheduledUser := range scheduledUsers { 40 | res = append(res, delegate.Literal{Text: scheduledUser}) 41 | } 42 | 43 | return res, nil 44 | } 45 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | 5 | builds: 6 | - env: 7 | - CGO_ENABLED=0 8 | goos: 9 | - darwin 10 | - linux 11 | - windows 12 | goarch: 13 | - amd64 14 | dir: ./cmd/delegatebot 15 | 16 | dockers: 17 | - image_templates: 18 | - docker.pkg.github.com/dpb587/slack-delegate-bot/slack-delegate-bot:{{ .Tag }} 19 | - docker.pkg.github.com/dpb587/slack-delegate-bot/slack-delegate-bot:v{{ .Major }} 20 | - docker.pkg.github.com/dpb587/slack-delegate-bot/slack-delegate-bot:v{{ .Major }}.{{ .Minor }} 21 | - docker.pkg.github.com/dpb587/slack-delegate-bot/slack-delegate-bot:latest 22 | build_flag_templates: 23 | - "--pull" 24 | - "--label=org.opencontainers.image.created={{ .Date }}" 25 | - "--label=org.opencontainers.image.name={{ .ProjectName }}" 26 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 27 | - "--label=org.opencontainers.image.version={{ .Version }}" 28 | 29 | archives: 30 | - id: zip 31 | format: zip 32 | name_template: "{{ .ProjectName }}-{{ .Os }}" 33 | replacements: 34 | darwin: macos 35 | amd64: x86_64 36 | 37 | checksum: 38 | name_template: "{{ .ProjectName }}_checksums.txt" 39 | 40 | snapshot: 41 | name_template: "{{ .Tag }}-next" 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # slack-delegate-bot 2 | 3 | A conventional bot for pulling others into a conversation. 4 | 5 | ## User Experience 6 | 7 | Mention the bot and it will start a thread with its configured delegates: 8 | 9 | [#bosh] @dberger: @interrupt what is the answer to life, the universe, and everything? 10 | [#bosh] >> @interrupt: ^ @s4heid @langered 11 | 12 | If configured, the delegates can be channel-specific: 13 | 14 | [#cf-deployment] @dberger: @interrupt what is the point of 42? 15 | [#cf-deployment] >> @interrupt: ^ @cdutra @tv 16 | 17 | To pull in the interrupt of another channel, prefix the mention with the channel: 18 | 19 | [#cf-deployment] @dberger: can you deploy Deep Thought? 20 | [#cf-deployment] >> @tv: #bosh @interrupt can help 21 | [#cf-deployment] >> @interrupt: ^ @s4heid @langered 22 | 23 | For private interrupt lookup, direct message (with the channel, if relevant): 24 | 25 | [@interrupt] @dberger: #bosh 26 | [@interrupt] @interrupt: @s4heid @langered 27 | 28 | ## Docs 29 | 30 | * [Slack Setup](docs/slack-setup.md) 31 | * [Deployment](docs/deployment.md) 32 | * Configuration 33 | * [YAML Files](docs/handlers/yaml-config.md) 34 | * [Conditions](docs/handlers/conditions.md) 35 | * [Delegators](docs/handlers/delegators.md) 36 | 37 | ## License 38 | 39 | [MIT License](LICENSE) 40 | -------------------------------------------------------------------------------- /pkg/slack/slash/sync_processor.go: -------------------------------------------------------------------------------- 1 | package slash 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/pkg/errors" 11 | "github.com/slack-go/slack" 12 | ) 13 | 14 | type SyncProcessor struct { 15 | handler Handler 16 | } 17 | 18 | var _ Processor = &SyncProcessor{} 19 | 20 | func NewSyncProcessor(handler Handler) Processor { 21 | return &SyncProcessor{ 22 | handler: handler, 23 | } 24 | } 25 | 26 | func (p *SyncProcessor) Process(since time.Time, event string, payload []byte) error { 27 | switch event { 28 | case "/interrupt": 29 | return p.processInterruptCommand(since, payload) 30 | } 31 | 32 | return fmt.Errorf("unexpected slash command: %v", event) 33 | } 34 | 35 | func (p *SyncProcessor) processInterruptCommand(since time.Time, payload []byte) error { 36 | req, _ := http.NewRequest(http.MethodPost, "https:slack.local", ioutil.NopCloser(bytes.NewReader(payload))) 37 | req.Header.Set("content-type", "application/x-www-form-urlencoded") 38 | 39 | cmd, err := slack.SlashCommandParse(req) 40 | if err != nil { 41 | return errors.Wrap(err, "parsing payload") 42 | } 43 | 44 | done, err := p.handler.Handle(cmd) 45 | if err != nil { 46 | return errors.Wrap(err, "handling command") 47 | } 48 | 49 | if !done { 50 | // TODO respond with confusion? last handler, maybe 51 | } 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /cmd/delegatebot/cmd/rtm_cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/cmd/delegatebot/opts" 5 | "github.com/dpb587/slack-delegate-bot/pkg/slack" 6 | "github.com/dpb587/slack-delegate-bot/pkg/slack/rtm" 7 | _ "github.com/jinzhu/gorm/dialects/sqlite" 8 | slackapi "github.com/slack-go/slack" 9 | "go.uber.org/zap" 10 | "go.uber.org/zap/zapcore" 11 | ) 12 | 13 | type RTMCmd struct { 14 | *opts.Root `no-flags:"true"` 15 | 16 | SlackLogLevel zapcore.Level `long:"slack-log-level" description:"Log level for Slack client" env:"SLACK_LOG_LEVEL"` 17 | SlackToken string `long:"slack-token" description:"Slack Bot OAuth API token" env:"SLACK_TOKEN"` 18 | } 19 | 20 | func (c *RTMCmd) Execute(_ []string) error { 21 | api := c.slackAPI() 22 | 23 | h, err := c.GetDelegator() 24 | if err != nil { 25 | // TODO 26 | panic(err) 27 | } 28 | 29 | p := rtm.NewService(api, slack.NewResponder(api, h), c.GetLogger()) 30 | 31 | return p.Run() 32 | } 33 | 34 | func (c *RTMCmd) slackAPI() *slackapi.Client { 35 | var apiOpts []slackapi.Option 36 | 37 | if c.SlackLogLevel == zapcore.DebugLevel { 38 | ll, _ := zap.NewStdLogAt(c.Root.GetLogger(), zapcore.DebugLevel) 39 | 40 | apiOpts = append( 41 | apiOpts, 42 | slackapi.OptionDebug(true), 43 | slackapi.OptionLog(ll), 44 | ) 45 | } 46 | 47 | return slackapi.New(c.SlackToken, apiOpts...) 48 | } 49 | -------------------------------------------------------------------------------- /examples/k8s-app/deployment.yml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: slack-delegate-bot 6 | labels: 7 | app: delegatebot 8 | spec: 9 | replicas: 1 10 | selector: 11 | matchLabels: 12 | app: delegatebot 13 | template: 14 | metadata: 15 | labels: 16 | app: delegatebot 17 | spec: 18 | containers: 19 | - name: delegatebot 20 | image: docker.pkg.github.com/dpb587/slack-delegate-bot/slack-delegate-bot:latest 21 | imagePullPolicy: IfNotPresent 22 | command: 23 | - "/usr/local/bin/slack-delegate-bot" 24 | - "--config=/config/*.yml" 25 | - "--config=/config/default.delegatebot" 26 | - "run" 27 | livenessProbe: 28 | httpGet: 29 | path: "/ping" 30 | port: 8080 31 | timeoutSeconds: 5 32 | readinessProbe: 33 | httpGet: 34 | path: "/ping" 35 | port: 8080 36 | envFrom: 37 | - secretRef: 38 | name: slack-delegate-bot-env 39 | ports: 40 | - containerPort: 8080 41 | volumeMounts: 42 | - name: config 43 | mountPath: /config 44 | volumes: 45 | - name: config 46 | configMap: 47 | name: slack-delegate-bot-config 48 | -------------------------------------------------------------------------------- /pkg/slack/user_lookup.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/slack-go/slack" 8 | ) 9 | 10 | //go:generate counterfeiter . UserLookupSlackAPI 11 | type UserLookupSlackAPI interface { 12 | GetUserInfo(string) (*slack.User, error) 13 | } 14 | 15 | // TODO interface instead of SlackAPI 16 | type UserLookup struct { 17 | api UserLookupSlackAPI 18 | 19 | mappedUserApp map[string]string 20 | mappedUserAppSync sync.RWMutex 21 | } 22 | 23 | func NewUserLookup(api UserLookupSlackAPI) *UserLookup { 24 | return &UserLookup{ 25 | api: api, 26 | mappedUserApp: map[string]string{}, 27 | } 28 | } 29 | 30 | func (c *UserLookup) IsAppBot(appID, userID string) (bool, error) { 31 | c.mappedUserAppSync.RLock() 32 | appUserID, known := c.mappedUserApp[userID] 33 | c.mappedUserAppSync.RUnlock() 34 | 35 | if known { 36 | return appID == appUserID, nil 37 | } 38 | 39 | userInfo, err := c.api.GetUserInfo(userID) 40 | if err != nil { 41 | return false, errors.Wrap(err, "getting user info") 42 | } 43 | 44 | // weird; why is IsBot = false on test environment? 45 | 46 | var is string 47 | 48 | if userInfo.Profile.ApiAppID == appID { 49 | is = appID 50 | } 51 | 52 | // TODO occasional purge of non-apps 53 | c.mappedUserAppSync.Lock() 54 | c.mappedUserApp[userID] = is 55 | c.mappedUserAppSync.Unlock() 56 | 57 | return appID == is, nil 58 | } 59 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/emaillookupmap/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 8 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 9 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/emaillookupmap" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type factory struct { 14 | delegatesFactory delegates.Factory 15 | } 16 | 17 | type Options struct { 18 | From map[interface{}]interface{} `yaml:"from"` 19 | } 20 | 21 | func New(delegatesFactory delegates.Factory) delegates.Factory { 22 | return &factory{ 23 | delegatesFactory: delegatesFactory, 24 | } 25 | } 26 | 27 | func (f factory) Create(name string, options interface{}) (delegate.Delegator, error) { 28 | if name != "emaillookupmap" { 29 | return nil, fmt.Errorf("invalid delegate: %s", name) 30 | } 31 | 32 | parsed := Options{} 33 | 34 | err := configutil.RemarshalYAML(options, &parsed) 35 | if err != nil { 36 | return nil, errors.Wrap(err, "remarshalling") 37 | } 38 | 39 | fromName, fromOptions, err := configutil.KeyValueTuple(parsed.From) 40 | 41 | from, err := f.delegatesFactory.Create(fromName, fromOptions) 42 | if err != nil { 43 | return nil, errors.Wrap(err, "creating literalmap from") 44 | } 45 | 46 | return &emaillookupmap.Delegator{ 47 | From: from, 48 | }, nil 49 | } 50 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/emaillookupmap/delegator.go: -------------------------------------------------------------------------------- 1 | package emaillookupmap 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 7 | "github.com/dpb587/slack-delegate-bot/pkg/message" 8 | "github.com/slack-go/slack" 9 | ) 10 | 11 | type SlackAPI interface { 12 | GetUserByEmail(email string) (*slack.User, error) 13 | } 14 | 15 | type Delegator struct { 16 | From delegate.Delegator 17 | } 18 | 19 | var _ delegate.Delegator = &Delegator{} 20 | 21 | func (i Delegator) Delegate(m message.Message) ([]message.Delegate, error) { 22 | inner, err := i.From.Delegate(m) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | var res []message.Delegate 28 | 29 | for _, innerInterrupt := range inner { 30 | literalInterrupt, ok := innerInterrupt.(delegate.Literal) 31 | if !ok { 32 | res = append(res, innerInterrupt) 33 | 34 | continue 35 | } else if !strings.Contains(literalInterrupt.Text, "@") { 36 | res = append(res, innerInterrupt) 37 | 38 | continue 39 | } 40 | 41 | api, ok := m.ServiceAPI.(SlackAPI) 42 | if !ok { 43 | return nil, nil 44 | } 45 | 46 | user, err := api.GetUserByEmail(literalInterrupt.Text) 47 | if err != nil { 48 | // TODO warn? 49 | } 50 | 51 | if user == nil { 52 | res = append(res, innerInterrupt) 53 | 54 | continue 55 | } 56 | 57 | res = append(res, delegate.User{ID: user.ID}) 58 | } 59 | 60 | return res, nil 61 | } 62 | -------------------------------------------------------------------------------- /pkg/condition/conditions/hours/condition_test.go: -------------------------------------------------------------------------------- 1 | package hours_test 2 | 3 | import ( 4 | "time" 5 | 6 | . "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/hours" 7 | "github.com/dpb587/slack-delegate-bot/pkg/message" 8 | 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | var _ = Describe("Condition", func() { 14 | var subject Condition 15 | 16 | BeforeEach(func() { 17 | subject = Condition{ 18 | Location: time.UTC, 19 | Start: "08:00", 20 | End: "18:00", 21 | } 22 | }) 23 | 24 | mustParseRFC3339 := func(value string) time.Time { 25 | v, err := time.Parse(time.RFC3339, value) 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | return v 31 | } 32 | 33 | Context("non-matching hours", func() { 34 | It("fails before", func() { 35 | b, err := subject.Evaluate(message.Message{Time: mustParseRFC3339("2006-01-02T12:04:05+07:00")}) 36 | Expect(err).NotTo(HaveOccurred()) 37 | Expect(b).To(BeFalse()) 38 | }) 39 | 40 | It("fails after", func() { 41 | b, err := subject.Evaluate(message.Message{Time: mustParseRFC3339("2006-01-03T03:04:05+07:00")}) 42 | Expect(err).NotTo(HaveOccurred()) 43 | Expect(b).To(BeFalse()) 44 | }) 45 | }) 46 | 47 | It("succeeds", func() { 48 | b, err := subject.Evaluate(message.Message{Time: mustParseRFC3339("2006-01-02T22:04:05+07:00")}) 49 | Expect(err).NotTo(HaveOccurred()) 50 | Expect(b).To(BeTrue()) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/union/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 8 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 9 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/union" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type factory struct { 14 | factory delegates.Factory 15 | } 16 | 17 | type Options []interface{} 18 | 19 | func New(ff delegates.Factory) delegates.Factory { 20 | return &factory{ 21 | factory: ff, 22 | } 23 | } 24 | 25 | func (f factory) Create(name string, options interface{}) (delegate.Delegator, error) { 26 | if name != "union" { 27 | return nil, fmt.Errorf("invalid delegate: %s", name) 28 | } 29 | 30 | parsed := Options{} 31 | 32 | err := configutil.RemarshalYAML(options, &parsed) 33 | if err != nil { 34 | return nil, errors.Wrap(err, "remarshalling") 35 | } 36 | 37 | var ccds []delegate.Delegator 38 | 39 | for optionsIdx, options := range parsed { 40 | key, value, err := configutil.KeyValueTuple(options) 41 | if err != nil { 42 | return nil, errors.Wrapf(err, "parsing union delegate %d", optionsIdx) 43 | } 44 | 45 | delegate, err := f.factory.Create(key, value) 46 | if err != nil { 47 | return nil, errors.Wrapf(err, "creating union delegate %d", optionsIdx) 48 | } 49 | 50 | ccds = append(ccds, delegate) 51 | } 52 | 53 | return &union.Delegator{ 54 | Delegators: ccds, 55 | }, nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/condition/conditions/boolor/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 7 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions" 8 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/boolor" 9 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type factory struct { 14 | factory conditions.Factory 15 | } 16 | 17 | type Options []interface{} 18 | 19 | func New(ff conditions.Factory) conditions.Factory { 20 | return &factory{ 21 | factory: ff, 22 | } 23 | } 24 | 25 | func (f factory) Create(name string, options interface{}) (condition.Condition, error) { 26 | if name != "or" { 27 | return nil, fmt.Errorf("invalid condition: %s", name) 28 | } 29 | 30 | parsed := Options{} 31 | 32 | err := configutil.RemarshalYAML(options, &parsed) 33 | if err != nil { 34 | return nil, errors.Wrap(err, "remarshalling") 35 | } 36 | 37 | var ccds []condition.Condition 38 | 39 | for optionsIdx, options := range parsed { 40 | key, value, err := configutil.KeyValueTuple(options) 41 | if err != nil { 42 | return nil, errors.Wrapf(err, "parsing condition %d", optionsIdx) 43 | } 44 | 45 | condition, err := f.factory.Create(key, value) 46 | if err != nil { 47 | return nil, errors.Wrapf(err, "creating condition %d", optionsIdx) 48 | } 49 | 50 | ccds = append(ccds, condition) 51 | } 52 | 53 | return &boolor.Condition{ 54 | Conditions: ccds, 55 | }, nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/delegate/provider/fs/delegator.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "io/ioutil" 5 | "path/filepath" 6 | "sort" 7 | 8 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 9 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/coalesce" 10 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/provider/yaml" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | func BuildDelegator(parser *yaml.Parser, paths ...string) (delegate.Delegator, error) { 15 | var delegators []delegate.Delegator 16 | 17 | paths, err := squashPaths(paths) 18 | if err != nil { 19 | return nil, errors.Wrap(err, "squashing paths") 20 | } 21 | 22 | for _, path := range paths { 23 | pathBytes, err := ioutil.ReadFile(path) 24 | if err != nil { 25 | return nil, errors.Wrapf(err, "reading %s", path) 26 | } 27 | 28 | h, err := parser.ParseFull(pathBytes) 29 | if err != nil { 30 | return nil, errors.Wrapf(err, "parsing %s", path) 31 | } 32 | 33 | delegators = append(delegators, h) 34 | } 35 | 36 | if len(delegators) == 1 { 37 | return delegators[0], nil 38 | } 39 | 40 | return coalesce.Delegator{Delegators: delegators}, nil 41 | } 42 | 43 | func squashPaths(paths []string) ([]string, error) { 44 | var squashed []string 45 | 46 | for _, path := range paths { 47 | globbed, err := filepath.Glob(path) 48 | if err != nil { 49 | return nil, errors.Wrapf(err, "globbing %s", path) 50 | } 51 | 52 | sort.Strings(globbed) 53 | 54 | squashed = append(squashed, globbed...) 55 | } 56 | 57 | return squashed, nil 58 | } 59 | -------------------------------------------------------------------------------- /pkg/condition/conditions/booland/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 7 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions" 8 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/booland" 9 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type factory struct { 14 | factory conditions.Factory 15 | } 16 | 17 | type Options []interface{} 18 | 19 | func New(ff conditions.Factory) conditions.Factory { 20 | return &factory{ 21 | factory: ff, 22 | } 23 | } 24 | 25 | func (f factory) Create(name string, options interface{}) (condition.Condition, error) { 26 | if name != "and" { 27 | return nil, fmt.Errorf("invalid condition: %s", name) 28 | } 29 | 30 | parsed := Options{} 31 | 32 | err := configutil.RemarshalYAML(options, &parsed) 33 | if err != nil { 34 | return nil, errors.Wrap(err, "remarshalling") 35 | } 36 | 37 | var ccds []condition.Condition 38 | 39 | for optionsIdx, options := range parsed { 40 | key, value, err := configutil.KeyValueTuple(options) 41 | if err != nil { 42 | return nil, errors.Wrapf(err, "parsing condition %d", optionsIdx) 43 | } 44 | 45 | condition, err := f.factory.Create(key, value) 46 | if err != nil { 47 | return nil, errors.Wrapf(err, "creating condition %d", optionsIdx) 48 | } 49 | 50 | ccds = append(ccds, condition) 51 | } 52 | 53 | return &booland.Condition{ 54 | Conditions: ccds, 55 | }, nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/coalesce/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 8 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 9 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/coalesce" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type factory struct { 14 | factory delegates.Factory 15 | } 16 | 17 | type Options []interface{} 18 | 19 | func New(ff delegates.Factory) delegates.Factory { 20 | return &factory{ 21 | factory: ff, 22 | } 23 | } 24 | 25 | func (f factory) Create(name string, options interface{}) (delegate.Delegator, error) { 26 | if name != "coalesce" { 27 | return nil, fmt.Errorf("invalid delegate: %s", name) 28 | } 29 | 30 | parsed := Options{} 31 | 32 | err := configutil.RemarshalYAML(options, &parsed) 33 | if err != nil { 34 | return nil, errors.Wrap(err, "remarshalling") 35 | } 36 | 37 | var ccds []delegate.Delegator 38 | 39 | for optionsIdx, options := range parsed { 40 | key, value, err := configutil.KeyValueTuple(options) 41 | if err != nil { 42 | return nil, errors.Wrapf(err, "parsing coalesce delegate %d", optionsIdx) 43 | } 44 | 45 | delegate, err := f.factory.Create(key, value) 46 | if err != nil { 47 | return nil, errors.Wrapf(err, "creating coalesce delegate %d", optionsIdx) 48 | } 49 | 50 | ccds = append(ccds, delegate) 51 | } 52 | 53 | return &coalesce.Delegator{ 54 | Delegators: ccds, 55 | }, nil 56 | } 57 | -------------------------------------------------------------------------------- /docs/handlers/conditions.md: -------------------------------------------------------------------------------- 1 | # Conditions 2 | 3 | Conditions are used to require context-specific details for something can occur. 4 | 5 | 6 | ## `and` 7 | 8 | A list of conditionals, all of which must be satisfied. 9 | 10 | ```yaml 11 | and: 12 | - day: { days: [ Mon, Tue, Wed, Thu, Fri ] } 13 | - hours: { start: 09:00, end: 18:00 } 14 | ``` 15 | 16 | 17 | ## `date` 18 | 19 | Satisfied when the current date matches one of the listed dates (`YYYY-MM-DD` format). 20 | 21 | ```yaml 22 | date: 23 | tz: America/Los_Angeles 24 | dates: [ 2019-01-01, 2019-01-21, 2019-02-18, 2019-05-27 ] 25 | ``` 26 | 27 | 28 | ## `day` 29 | 30 | Satisfied when the current day matches one of the listed days (`Mon` format). 31 | 32 | ```yaml 33 | day: 34 | tz: Europe/Berlin 35 | days: [ Mon, Tue, Wed, Thu, Fri ] 36 | ``` 37 | 38 | 39 | ## `hours` 40 | 41 | Satisfied when the current time is within a start and end time (`HH:MM` 24h format). 42 | 43 | ```yaml 44 | hours: 45 | tz: America/Toronto 46 | start: 09:00 47 | end: 17:00 48 | ``` 49 | 50 | 51 | ## `not` 52 | 53 | Inverts another conditional's result. 54 | 55 | ```yaml 56 | not: 57 | day: { days: [ Mon, Tue, Wed, Thu, Fri ] } 58 | ``` 59 | 60 | 61 | ## `target` 62 | 63 | Satisfied when targeting a specific channel. 64 | 65 | ```yaml 66 | target: 67 | channel: C02HPPYQ2 68 | ``` 69 | 70 | 71 | ## `or` 72 | 73 | A list of conditionals, one of which must be satisfied. 74 | 75 | ```yaml 76 | or: 77 | - hours: { start: 09:00, end: 12:30 } 78 | - hours: { start: 13:30, end: 17:00 } 79 | ``` 80 | -------------------------------------------------------------------------------- /examples/k8s-app/README.md: -------------------------------------------------------------------------------- 1 | # slack-delegate-bot/k8s-app 2 | 3 | An example for deploying the bot to Kubernetes. 4 | 5 | ## Preparation 6 | 7 | Create a directory for bot configuration ([docs](../../README.md#Configuration)). See [cloudfoundry/slack-interrupt-bot](https://github.com/cloudfoundry/slack-interrupt-bot) for some real-world examples. 8 | 9 | ``` 10 | CONFIG_DIR=$HOME/workspace/slack-delegate-bot-config 11 | mkdir -p $CONFIG_DIR 12 | ``` 13 | 14 | Create a `.env` file with the required `SLACK_TOKEN` environment variable and any other needed variables: 15 | 16 | ```yaml 17 | SLACK_TOKEN=xoxb-...snip... 18 | PAIRIST_PASSWORD_$NAME=...snip... 19 | ``` 20 | 21 | ## Kubernetes 22 | 23 | This example uses the following resources: 24 | 25 | * `slack-delegate-bot-env` - Secret with runtime environment variables (e.g. from `.env`) 26 | * `slack-delegate-bot-config` - ConfigMap with the bot configuration files (e.g. from `$CONFIG_DIR`) 27 | * `slack-delegate-bot` - Deployment managing the bot 28 | 29 | You can create the resources with the following commands: 30 | 31 | ``` 32 | kubectl create secret generic slack-delegate-bot-env --from-env-file=.env 33 | kubectl create configmap slack-delegate-bot-config --from-file=$CONFIG_DIR 34 | kubectl apply -f deployment.yml 35 | ``` 36 | 37 | ## Docker 38 | 39 | Start the bot: 40 | 41 | ```console 42 | docker run --rm --env-file=.env --volume $CONFIG_DIR:/config \ 43 | docker.pkg.github.com/dpb587/slack-delegate-bot/slack-delegate-bot \ 44 | --config=/config/*.yml \ 45 | --config=/config/default.delegatebot \ 46 | run 47 | ``` 48 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/pagerduty/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | pagerdutyapi "github.com/PagerDuty/go-pagerduty" 9 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 10 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 11 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 12 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/pagerduty" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | type factory struct{} 17 | 18 | type Options struct { 19 | APIKey string `yaml:"api_key"` 20 | EscalationPolicy string `yaml:"escalation_policy"` 21 | EscalationLevel *uint `yaml:"escalation_level"` 22 | } 23 | 24 | func New() delegates.Factory { 25 | return &factory{} 26 | } 27 | 28 | func (f factory) Create(name string, options interface{}) (delegate.Delegator, error) { 29 | if name != "pagerduty" { 30 | return nil, fmt.Errorf("unsupported delegate: %s", name) 31 | } 32 | 33 | parsed := Options{} 34 | 35 | err := configutil.RemarshalYAML(options, &parsed) 36 | if err != nil { 37 | return nil, errors.Wrap(err, "remarshalling") 38 | } 39 | 40 | if parsed.EscalationLevel == nil { 41 | var r uint = 1 42 | parsed.EscalationLevel = &r 43 | } 44 | 45 | apiKey := parsed.APIKey 46 | 47 | if strings.HasPrefix(apiKey, "$") && len(apiKey) > 1 { 48 | apiKey = os.Getenv(apiKey[1:]) 49 | } 50 | 51 | return &pagerduty.Delegator{ 52 | Client: pagerdutyapi.NewClient(apiKey), 53 | EscalationPolicy: parsed.EscalationPolicy, 54 | EscalationLevel: *parsed.EscalationLevel, 55 | }, nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/literalmap/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 8 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 9 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/literalmap" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type factory struct { 14 | delegatesFactory delegates.Factory 15 | } 16 | 17 | type Options struct { 18 | From map[interface{}]interface{} `yaml:"from"` 19 | Users map[string]string `yaml:"users"` 20 | Usergroups map[string]string `yaml:"usergroups"` 21 | } 22 | 23 | func New(delegatesFactory delegates.Factory) delegates.Factory { 24 | return &factory{ 25 | delegatesFactory: delegatesFactory, 26 | } 27 | } 28 | 29 | func (f factory) Create(name string, options interface{}) (delegate.Delegator, error) { 30 | if name != "literalmap" { 31 | return nil, fmt.Errorf("unsupported delegate: %s", name) 32 | } 33 | 34 | parsed := Options{} 35 | 36 | err := configutil.RemarshalYAML(options, &parsed) 37 | if err != nil { 38 | return nil, errors.Wrap(err, "remarshalling") 39 | } 40 | 41 | fromName, fromOptions, err := configutil.KeyValueTuple(parsed.From) 42 | 43 | from, err := f.delegatesFactory.Create(fromName, fromOptions) 44 | if err != nil { 45 | return nil, errors.Wrap(err, "creating literalmap from") 46 | } 47 | 48 | return &literalmap.Delegator{ 49 | From: from, 50 | Users: parsed.Users, 51 | Usergroups: parsed.Usergroups, 52 | }, nil 53 | } 54 | -------------------------------------------------------------------------------- /docs/handlers/yaml-config.md: -------------------------------------------------------------------------------- 1 | # YAML Configuration 2 | 3 | The bot may be configured through one or more YAML configuration files. There must be a top-level `delegatebot` key which contains your configuration and inside it should contain the following keys: 4 | 5 | * `watch` - a list of [conditions](#conditions); if one or more match, it applies 6 | * `delegate` - a [delegator](#delegators) for who to contact 7 | * `options` 8 | * `empty_message` - a custom message to use if there was nobody to delegate to 9 | 10 | The following example demonstrates watching in a couple channels and, if mentioned, will respond with delegates from multiple sources and with some conditional behaviors. 11 | 12 | ```yaml 13 | delegatebot: 14 | # a list of events for this policy 15 | watch: 16 | - target: { channel: C1234567 } # global channel ID 17 | - target: { channel: C9876543 } # global channel ID 18 | 19 | # defining how to find the users to pull in 20 | delegate: 21 | # to support pulling from multiple sources 22 | union: 23 | 24 | # based on a role from pairist 25 | - pairist: 26 | team: bosh-director 27 | role: interrupt 28 | 29 | # or statically 30 | - user: 31 | id: U0FUK0EBH 32 | 33 | # or a static group 34 | - usergroup: 35 | id: S309JAD1P 36 | alias: openstack-cpi 37 | 38 | # or only during business hours 39 | - if: 40 | when: 41 | - hours: { tz: America/Los_Angeles, start: 09:00, end: 18:00 } 42 | - day: { tz: America/Los_Angeles, days: [ Mon, Tue, Wed, Thu, Fri ] } 43 | then: 44 | pairist: 45 | team: bosh-director 46 | role: interrupt 47 | ``` 48 | -------------------------------------------------------------------------------- /pkg/condition/conditions/defaultfactory/factory.go: -------------------------------------------------------------------------------- 1 | package defaultfactory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 7 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions" 8 | boolandfactory "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/booland/factory" 9 | boolnotfactory "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/boolnot/factory" 10 | boolorfactory "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/boolor/factory" 11 | datefactory "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/date/factory" 12 | dayfactory "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/day/factory" 13 | hoursfactory "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/hours/factory" 14 | targetfactory "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/target/factory" 15 | ) 16 | 17 | type factory struct { 18 | factory map[string]conditions.Factory 19 | } 20 | 21 | var _ conditions.Factory = &factory{} 22 | 23 | func New() conditions.Factory { 24 | f := &factory{ 25 | factory: map[string]conditions.Factory{}, 26 | } 27 | 28 | f.factory["and"] = boolandfactory.New(f) 29 | f.factory["not"] = boolnotfactory.New(f) 30 | f.factory["or"] = boolorfactory.New(f) 31 | f.factory["date"] = datefactory.New() 32 | f.factory["day"] = dayfactory.New() 33 | f.factory["hours"] = hoursfactory.New() 34 | f.factory["target"] = targetfactory.New() 35 | 36 | return f 37 | } 38 | 39 | func (f *factory) Create(name string, options interface{}) (condition.Condition, error) { 40 | ff, known := f.factory[name] 41 | if !known { 42 | return nil, fmt.Errorf("unsupported condition: %s", name) 43 | } 44 | 45 | return ff.Create(name, options) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/slack/event/sync_processor.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/dpb587/slack-delegate-bot/pkg/slack" 9 | "github.com/pkg/errors" 10 | "github.com/slack-go/slack/slackevents" 11 | ) 12 | 13 | type SyncProcessor struct { 14 | parser *Parser 15 | responder *slack.Responder 16 | } 17 | 18 | var _ Processor = &SyncProcessor{} 19 | 20 | func NewSyncProcessor(parser *Parser, responder *slack.Responder) Processor { 21 | return &SyncProcessor{ 22 | parser: parser, 23 | responder: responder, 24 | } 25 | } 26 | 27 | func (p *SyncProcessor) Process(since time.Time, event string, payload []byte) error { 28 | switch event { 29 | case "callback_event": 30 | return p.processCallbackEvent(since, payload) 31 | } 32 | 33 | return fmt.Errorf("unexpected event type: %v", event) 34 | } 35 | 36 | func (p *SyncProcessor) processCallbackEvent(since time.Time, payload []byte) error { 37 | event, err := slackevents.ParseEvent(json.RawMessage(payload), slackevents.OptionNoVerifyToken()) 38 | if err != nil { 39 | return errors.Wrap(err, "parsing event") 40 | } 41 | 42 | switch inner := event.InnerEvent.Data.(type) { 43 | case *slackevents.AppMentionEvent: 44 | msg, reply, err := p.parser.ParseAppMention(event, *inner) 45 | if err != nil { 46 | return errors.Wrap(err, "parsing app mention") 47 | } else if !reply { 48 | return nil 49 | } 50 | 51 | return p.responder.ProcessMessage(msg) 52 | case *slackevents.MessageEvent: 53 | msg, reply, err := p.parser.ParseMessage(event, *inner) 54 | if err != nil { 55 | return errors.Wrap(err, "parsing message") 56 | } else if !reply { 57 | return nil 58 | } 59 | 60 | return p.responder.ProcessMessage(msg) 61 | } 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/lookup/delegator.go: -------------------------------------------------------------------------------- 1 | package lookup 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 7 | "github.com/dpb587/slack-delegate-bot/pkg/message" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | type Delegator struct { 12 | Channel string 13 | } 14 | 15 | var _ delegate.Delegator = &Delegator{} 16 | 17 | func (i Delegator) Delegate(msg message.Message) ([]message.Delegate, error) { 18 | if msg.TargetChannelID == i.Channel { 19 | // no use 20 | return nil, nil 21 | } else if msg.RecursionDepth >= 3 { 22 | // no more 23 | return nil, fmt.Errorf("maximum recursion depth reached: %d", msg.RecursionDepth) 24 | } else if msg.Delegator == nil { 25 | return nil, errors.New("no delegator available from message context") 26 | } 27 | 28 | newmsg := message.Message{ 29 | ServiceAPI: msg.ServiceAPI, 30 | Delegator: msg.Delegator, 31 | RecursionDepth: msg.RecursionDepth + 1, // changed 32 | 33 | UserTeamID: msg.UserTeamID, 34 | UserID: msg.UserID, 35 | ChannelTeamID: msg.ChannelTeamID, 36 | ChannelID: msg.ChannelID, 37 | TargetChannelTeamID: msg.TargetChannelTeamID, 38 | TargetChannelID: i.Channel, // changed 39 | RawText: msg.RawText, 40 | RawTimestamp: msg.RawTimestamp, 41 | RawThreadTimestamp: msg.RawThreadTimestamp, 42 | Time: msg.Time, 43 | Type: msg.Type, 44 | } 45 | 46 | d, ok := newmsg.Delegator.(delegate.Delegator) 47 | if !ok { 48 | return nil, fmt.Errorf("expected type delegate.Delegator for recursion: got %T", newmsg.Delegator) 49 | } 50 | 51 | res, err := d.Delegate(newmsg) 52 | if err != nil { 53 | return nil, errors.Wrapf(err, "recursing lookup for %s", i.Channel) 54 | } 55 | 56 | return res, nil 57 | } 58 | -------------------------------------------------------------------------------- /pkg/configutil/kv_tuple_test.go: -------------------------------------------------------------------------------- 1 | package configutil_test 2 | 3 | import ( 4 | . "github.com/dpb587/slack-delegate-bot/pkg/configutil" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/ginkgo/extensions/table" 8 | . "github.com/onsi/gomega" 9 | ) 10 | 11 | var _ = Describe("KvTuple", func() { 12 | DescribeTable( 13 | "requires a map input", 14 | func(input interface{}) { 15 | _, _, err := KeyValueTuple(input) 16 | Expect(err).To(HaveOccurred()) 17 | Expect(err.Error()).To(ContainSubstring("expected map[string]interface{}")) 18 | }, 19 | Entry("string", "fake-data"), 20 | Entry("expected map[int]interface", map[int]interface{}{}), 21 | Entry("int", 123), 22 | ) 23 | 24 | It("errors if non-string keys are present", func() { 25 | _, _, err := KeyValueTuple(map[interface{}]interface{}{ 26 | "key1": nil, 27 | 123: nil, 28 | }) 29 | Expect(err).To(HaveOccurred()) 30 | Expect(err.Error()).To(ContainSubstring("expected string key")) 31 | }) 32 | 33 | It("errors if multiple keys are present", func() { 34 | _, _, err := KeyValueTuple(map[interface{}]interface{}{ 35 | "key1": nil, 36 | "key2": nil, 37 | }) 38 | Expect(err).To(HaveOccurred()) 39 | Expect(err.Error()).To(ContainSubstring("expected exactly one key-value tuple")) 40 | }) 41 | 42 | It("errors if no keys are present", func() { 43 | _, _, err := KeyValueTuple(map[interface{}]interface{}{}) 44 | Expect(err).To(HaveOccurred()) 45 | Expect(err.Error()).To(ContainSubstring("expected exactly one key-value tuple")) 46 | }) 47 | 48 | It("works", func() { 49 | key, value, err := KeyValueTuple(map[interface{}]interface{}{ 50 | "fake-key1": []string{"fake-value1"}, 51 | }) 52 | Expect(err).NotTo(HaveOccurred()) 53 | Expect(key).To(Equal("fake-key1")) 54 | Expect(value).To(Equal([]string{"fake-value1"})) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /pkg/slack/responder.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 8 | "github.com/dpb587/slack-delegate-bot/pkg/message" 9 | "github.com/pkg/errors" 10 | "github.com/slack-go/slack" 11 | ) 12 | 13 | //go:generate counterfeiter . ResponderSlackAPI 14 | type ResponderSlackAPI interface { 15 | PostMessage(string, ...slack.MsgOption) (string, string, error) 16 | } 17 | 18 | type Responder struct { 19 | api ResponderSlackAPI 20 | delegator delegate.Delegator 21 | } 22 | 23 | func NewResponder(api ResponderSlackAPI, delegator delegate.Delegator) *Responder { 24 | return &Responder{ 25 | api: api, 26 | delegator: delegator, 27 | } 28 | } 29 | 30 | func (m *Responder) ProcessMessage(msg message.Message) error { 31 | msg.ServiceAPI = m.api 32 | msg.Delegator = m.delegator 33 | 34 | dd, err := m.delegator.Delegate(msg) 35 | if err != nil { 36 | return errors.Wrap(err, "finding delegate") 37 | } 38 | 39 | if len(dd) == 0 { 40 | return nil 41 | } 42 | 43 | responseText := delegates.Join(dd, " ") 44 | 45 | if msg.Type == message.ChannelMessageType { 46 | responseText = fmt.Sprintf("^ %s", responseText) 47 | } 48 | 49 | opts := []slack.MsgOption{ 50 | slack.MsgOptionText(responseText, false), 51 | } 52 | 53 | if v := msg.RawThreadTimestamp; v != "" { 54 | // always stay in thread if one is started 55 | opts = append(opts, slack.MsgOptionTS(v)) 56 | } else if msg.Type == message.ChannelMessageType { 57 | // always use a thread if in a channel 58 | opts = append(opts, slack.MsgOptionTS(msg.RawTimestamp)) 59 | } 60 | 61 | _, _, err = m.api.PostMessage(msg.ChannelID, opts...) 62 | if err != nil { 63 | return errors.Wrap(err, "posting message") 64 | } 65 | 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/pairist/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "strings" 8 | 9 | "github.com/dpb587/go-pairist/api" 10 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 11 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 12 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 13 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/pairist" 14 | "github.com/pkg/errors" 15 | ) 16 | 17 | type factory struct{} 18 | 19 | type Options struct { 20 | Team string `yaml:"team"` 21 | Password string `yaml:"password"` 22 | 23 | Role string `yaml:"role"` 24 | Track string `yaml:"track"` 25 | } 26 | 27 | func New() delegates.Factory { 28 | return &factory{} 29 | } 30 | 31 | func (f factory) Create(name string, options interface{}) (delegate.Delegator, error) { 32 | if name != "pairist" { 33 | return nil, fmt.Errorf("unsupported delegate: %s", name) 34 | } 35 | 36 | parsed := Options{} 37 | 38 | err := configutil.RemarshalYAML(options, &parsed) 39 | if err != nil { 40 | return nil, errors.Wrap(err, "remarshalling") 41 | } 42 | 43 | if parsed.Role != "" && parsed.Track != "" { 44 | return nil, errors.New("only one of the following may be set: role, track") 45 | } 46 | 47 | var clientAuth *api.Auth 48 | 49 | if parsed.Password != "" { 50 | if strings.HasPrefix(parsed.Password, "$") && len(parsed.Password) > 1 { 51 | parsed.Password = os.Getenv(parsed.Password[1:]) 52 | } 53 | 54 | clientAuth = &api.Auth{ 55 | APIKey: api.DefaultFirebaseAPIKey, 56 | Team: parsed.Team, 57 | Password: parsed.Password, 58 | } 59 | } 60 | 61 | return &pairist.Delegator{ 62 | Client: api.NewClient(http.DefaultClient, api.DefaultFirebaseURL, clientAuth), 63 | Team: parsed.Team, 64 | Role: parsed.Role, 65 | Track: parsed.Track, 66 | }, nil 67 | } 68 | -------------------------------------------------------------------------------- /pkg/slack/http/slash_handler.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/dpb587/slack-delegate-bot/pkg/slack/slash" 11 | "github.com/labstack/echo/v4" 12 | "github.com/pkg/errors" 13 | "github.com/slack-go/slack" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | type SlashHandler struct { 18 | processor slash.Processor 19 | signingSecret string 20 | logger *zap.Logger 21 | } 22 | 23 | func NewSlashHandler(processor slash.Processor, signingSecret string, logger *zap.Logger) *SlashHandler { 24 | return &SlashHandler{ 25 | processor: processor, 26 | signingSecret: signingSecret, 27 | logger: logger, 28 | } 29 | } 30 | 31 | func (h SlashHandler) Accept(c echo.Context) error { 32 | at := time.Now() 33 | 34 | verifier, err := slack.NewSecretsVerifier(c.Request().Header, h.signingSecret) 35 | if err != nil { 36 | return errors.Wrap(err, "building secrets verifier") 37 | } 38 | 39 | body, err := ioutil.ReadAll(io.TeeReader(c.Request().Body, &verifier)) 40 | if err != nil { 41 | return errors.Wrap(err, "reading body") 42 | } 43 | 44 | if err = verifier.Ensure(); err != nil { 45 | h.logger.Debug("received unverified slack slash command", zap.ByteString("payload", body)) 46 | h.logger.Warn("unable to verify incoming slack slash command", zap.Error(err)) 47 | 48 | return c.String(http.StatusBadRequest, http.StatusText(http.StatusBadRequest)) 49 | } 50 | 51 | h.logger.Debug("received slack slash event", zap.ByteString("payload", body)) 52 | 53 | // TODO reconsider+refactor 54 | req := c.Request() 55 | req.Body = ioutil.NopCloser(bytes.NewReader(body)) 56 | 57 | cmd, err := slack.SlashCommandParse(req) 58 | if err != nil { 59 | return errors.Wrap(err, "parsing incoming command") 60 | } 61 | 62 | err = h.processor.Process(at, cmd.Command, body) 63 | if err != nil { 64 | return errors.Wrap(err, "processing") 65 | } 66 | 67 | return c.NoContent(http.StatusOK) 68 | } 69 | -------------------------------------------------------------------------------- /cmd/delegatebot/cmd/simulate_cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/dpb587/slack-delegate-bot/cmd/delegatebot/opts" 8 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 9 | "github.com/dpb587/slack-delegate-bot/pkg/message" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type SimulateCmd struct { 14 | *opts.Root `no-flags:"true"` 15 | 16 | Timestamp string `long:"timestamp" description:"Timestamp of the message (default: now; format: 2006-01-02T15:04:05Z07:00)"` 17 | Args SimulateArgs `positional-args:"true"` 18 | } 19 | 20 | type SimulateArgs struct { 21 | Origin string `positional-arg-name:"ORIGIN-ID" description:"Channel or DM ID sending the request" required:"true"` 22 | Message string `positional-arg-name:"MESSAGE" description:"Message sent"` 23 | } 24 | 25 | func (c *SimulateCmd) Execute(_ []string) error { 26 | if c.Timestamp == "" { 27 | c.Timestamp = time.Now().Format(time.RFC3339) 28 | } 29 | 30 | ts, err := time.Parse(time.RFC3339, c.Timestamp) 31 | if err != nil { 32 | return errors.Wrap(err, "parsing RFC3339 timestamp") 33 | } 34 | 35 | if c.Args.Message == "" { 36 | c.Args.Message = "<@U0000000>" 37 | } 38 | 39 | delegator, err := c.Root.GetDelegator() 40 | if err != nil { 41 | return err 42 | } 43 | 44 | msg := message.Message{ 45 | ChannelTeamID: "T1234567", 46 | ChannelID: "D1234567", 47 | UserTeamID: "T1234567", 48 | UserID: "U1234567", 49 | TargetChannelTeamID: "T1234567", 50 | TargetChannelID: c.Args.Origin, 51 | RawTimestamp: fmt.Sprintf("%d.0", ts.Unix()), 52 | RawText: c.Args.Message, 53 | Type: message.DirectMessageMessageType, 54 | } 55 | 56 | dd, err := delegator.Delegate(msg) 57 | if err != nil { 58 | return errors.Wrap(err, "evaluating a response") 59 | } 60 | 61 | if len(dd) == 0 { 62 | return nil 63 | } 64 | 65 | fmt.Printf("%s\n", delegates.Join(dd, " ")) 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /pkg/delegate/provider/yaml/parser.go: -------------------------------------------------------------------------------- 1 | package yaml 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions" 5 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 6 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 8 | "github.com/pkg/errors" 9 | yaml "gopkg.in/yaml.v2" 10 | ) 11 | 12 | type Parser struct { 13 | delegatorsFactory delegates.Factory 14 | conditionsFactory conditions.Factory 15 | } 16 | 17 | func NewParser(delegatorsFactory delegates.Factory, conditionsFactory conditions.Factory) *Parser { 18 | return &Parser{ 19 | delegatorsFactory: delegatorsFactory, 20 | conditionsFactory: conditionsFactory, 21 | } 22 | } 23 | 24 | func (l Parser) Parse(buf []byte) (delegate.Delegator, error) { 25 | var parsed Schema 26 | 27 | err := yaml.Unmarshal(buf, &parsed.DelegateBot) 28 | if err != nil { 29 | return nil, errors.Wrap(err, "unmarshalling") 30 | } 31 | 32 | return l.parse(parsed) 33 | } 34 | 35 | func (l Parser) ParseFull(buf []byte) (delegate.Delegator, error) { 36 | var parsed Schema 37 | 38 | err := yaml.Unmarshal(buf, &parsed) 39 | if err != nil { 40 | return nil, errors.Wrap(err, "unmarshalling") 41 | } 42 | 43 | return l.parse(parsed) 44 | } 45 | 46 | func (l Parser) parse(parsed Schema) (delegate.Delegator, error) { 47 | h := Delegator{} 48 | 49 | if parsed.DelegateBot.Watch != nil { 50 | watch, err := l.conditionsFactory.Create("or", parsed.DelegateBot.Watch) 51 | if err != nil { 52 | return nil, errors.Wrap(err, "building watch") 53 | } 54 | 55 | h.condition = watch 56 | } 57 | 58 | delegateKey, delegateOptions, err := configutil.KeyValueTuple(parsed.DelegateBot.Delegate) 59 | if err != nil { 60 | return nil, errors.Wrap(err, "parsing delegate") 61 | } 62 | 63 | delegate, err := l.delegatorsFactory.Create(delegateKey, delegateOptions) 64 | if err != nil { 65 | return nil, errors.Wrap(err, "building delegate") 66 | } 67 | 68 | h.delegator = delegate 69 | 70 | h.options = parsed.DelegateBot.Options 71 | 72 | return h, nil 73 | } 74 | -------------------------------------------------------------------------------- /pkg/slack/rtm/service.go: -------------------------------------------------------------------------------- 1 | package rtm 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | ourslack "github.com/dpb587/slack-delegate-bot/pkg/slack" 8 | "github.com/pkg/errors" 9 | "github.com/slack-go/slack" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | type Service struct { 14 | parser *Parser 15 | rtm *slack.RTM 16 | responder *ourslack.Responder 17 | logger *zap.Logger 18 | } 19 | 20 | func NewService(api *slack.Client, responder *ourslack.Responder, logger *zap.Logger) *Service { 21 | return &Service{ 22 | rtm: api.NewRTM(), 23 | responder: responder, 24 | logger: logger, 25 | } 26 | } 27 | 28 | func (s *Service) Run() error { 29 | go s.rtm.ManageConnection() 30 | 31 | for { 32 | select { 33 | case msg := <-s.rtm.IncomingEvents: 34 | switch ev := msg.Data.(type) { 35 | case *slack.ConnectedEvent: 36 | s.parser = NewParser(ev.Info.Team.ID, ev.Info.User.ID) 37 | 38 | s.logger.Info(fmt.Sprintf("connected as %s (%s)", ev.Info.User.Name, ev.Info.User.ID)) 39 | case *slack.MessageEvent: 40 | if s.parser == nil { 41 | // we assign parser only after we're connected 42 | s.logger.Warn("ignoring received message because no parser is ready") 43 | 44 | continue 45 | } 46 | 47 | msg, reply, err := s.parser.ParseMessage(ev.Msg) 48 | if err != nil { 49 | s.logger.Error("unable to parse message", zap.Error(err)) 50 | 51 | continue 52 | } else if !reply { 53 | continue 54 | } 55 | 56 | msgBuf, err := json.Marshal(ev.Msg) 57 | if err != nil { 58 | s.logger.Error("failed to dump incoming message for debugging", zap.Error(err)) 59 | } 60 | 61 | s.logger.Debug("received rtm message", zap.ByteString("payload", msgBuf)) 62 | 63 | err = s.responder.ProcessMessage(msg) 64 | if err != nil { 65 | s.logger.Error("failed to process message", zap.Error(err)) 66 | } 67 | case *slack.RTMError: 68 | s.logger.Error("rtm error occurred", zap.Error(ev)) 69 | case *slack.InvalidAuthEvent: 70 | return errors.New("invalid credentials") 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pkg/slack/slash/help_handler.go: -------------------------------------------------------------------------------- 1 | package slash 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/pkg/errors" 11 | "github.com/slack-go/slack" 12 | ) 13 | 14 | type HelpHandler struct { 15 | handlers *Handlers 16 | baseURL string 17 | } 18 | 19 | var _ Handler = HelpHandler{} 20 | 21 | func NewHelpHandler(handlers *Handlers, baseURL string) Handler { 22 | return &HelpHandler{ 23 | handlers: handlers, 24 | baseURL: baseURL, 25 | } 26 | } 27 | 28 | func (h HelpHandler) UsageHint() string { 29 | return "help" 30 | } 31 | 32 | func (h HelpHandler) ShortDescription() string { 33 | return "show available inline commands" 34 | } 35 | 36 | func (h HelpHandler) Handle(cmd slack.SlashCommand) (bool, error) { 37 | if cmd.Text != "" && cmd.Text != "help" { 38 | return false, nil 39 | } 40 | 41 | helps := []string{} 42 | 43 | for _, handler := range *h.handlers { 44 | usage := handler.UsageHint() 45 | if usage == "" { 46 | continue 47 | } 48 | 49 | shortDescription := handler.ShortDescription() 50 | if shortDescription != "" { 51 | shortDescription = fmt.Sprintf(" – %s", shortDescription) 52 | } 53 | 54 | helps = append(helps, fmt.Sprintf("_%s_%s", usage, shortDescription)) 55 | } 56 | 57 | managementURL := fmt.Sprintf("%s/team/%s/channel/%s/", h.baseURL, cmd.TeamID, cmd.ChannelID) 58 | 59 | responseMessage := fmt.Sprintf( 60 | "Hello! I understand the following commands, and you can find more details at <%s|%s>.\n\n%s", 61 | managementURL, 62 | strings.SplitN(managementURL, "/", 4)[2], 63 | strings.Join(helps, "\n"), 64 | ) 65 | 66 | bodyBuf, err := json.Marshal(map[string]string{ 67 | "text": responseMessage, 68 | "response_type": "ephemeral", 69 | }) 70 | if err != nil { 71 | return false, errors.Wrap(err, "marshalling response") 72 | } 73 | 74 | res, err := http.DefaultClient.Post( 75 | cmd.ResponseURL, 76 | "application/json", 77 | bytes.NewReader(bodyBuf), 78 | ) 79 | if err != nil { 80 | return false, errors.Wrap(err, "posting response") 81 | } else if res.StatusCode != 200 { 82 | // TODO valid 83 | return false, errors.Wrap(err, "status code") 84 | } 85 | 86 | return true, nil 87 | } 88 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/topiclookup/delegator.go: -------------------------------------------------------------------------------- 1 | package topiclookup 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 8 | "github.com/dpb587/slack-delegate-bot/pkg/message" 9 | "github.com/pkg/errors" 10 | "github.com/slack-go/slack" 11 | ) 12 | 13 | //go:generate counterfeiter . SlackAPI 14 | type SlackAPI interface { 15 | GetConversationInfo(string, bool) (*slack.Channel, error) 16 | } 17 | 18 | type Delegator struct { 19 | Channel string 20 | } 21 | 22 | var _ delegate.Delegator = &Delegator{} 23 | 24 | var slackRefRE = regexp.MustCompile(`<[^>]+>`) 25 | var topicInterruptREs = []*regexp.Regexp{ 26 | regexp.MustCompile("(?i)[`*_]*interrupt[`*_:]*\\s+(<[^>]+>(,?\\s+and\\s+|,?\\s*)?)+"), 27 | } 28 | 29 | func (i Delegator) Delegate(m message.Message) ([]message.Delegate, error) { 30 | channel := m.TargetChannelID 31 | 32 | if v := i.Channel; v != "" { 33 | // explicit channel reference takes precedence 34 | channel = v 35 | } 36 | 37 | api, ok := m.ServiceAPI.(SlackAPI) 38 | if !ok { 39 | return nil, nil 40 | } 41 | 42 | info, err := api.GetConversationInfo(channel, false) 43 | if err != nil { 44 | return nil, errors.Wrapf(err, "getting info of channel %s", channel) 45 | } 46 | 47 | for _, topicInterruptRE := range topicInterruptREs { 48 | matches := topicInterruptRE.FindStringSubmatch(info.Topic.Value) 49 | if len(matches) == 0 { 50 | continue 51 | } 52 | 53 | slackRefMatches := slackRefRE.FindAllStringSubmatch(matches[0], -1) 54 | 55 | var results []message.Delegate 56 | 57 | for _, slackRefMatch := range slackRefMatches { 58 | match := slackRefMatch[0] 59 | 60 | if strings.HasPrefix(match, ""), ""), "<@")}) 69 | } 70 | } 71 | 72 | return results, nil 73 | } 74 | 75 | return nil, nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/condition/conditions/boolor/condition_test.go: -------------------------------------------------------------------------------- 1 | package boolor_test 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 7 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditionfakes" 8 | . "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/boolor" 9 | "github.com/dpb587/slack-delegate-bot/pkg/message" 10 | 11 | . "github.com/onsi/ginkgo" 12 | . "github.com/onsi/gomega" 13 | ) 14 | 15 | var _ = Describe("Condition", func() { 16 | var subject Condition 17 | var msg message.Message 18 | 19 | Describe("Evaluate", func() { 20 | var errorCondition, falseCondition, trueCondition *conditionfakes.FakeCondition 21 | 22 | BeforeEach(func() { 23 | subject = Condition{} 24 | msg = message.Message{} 25 | 26 | errorCondition = &conditionfakes.FakeCondition{} 27 | errorCondition.EvaluateReturns(false, errors.New("fake-evaluate-err")) 28 | 29 | falseCondition = &conditionfakes.FakeCondition{} 30 | 31 | trueCondition = &conditionfakes.FakeCondition{} 32 | trueCondition.EvaluateReturns(true, nil) 33 | }) 34 | 35 | It("evaluates multiple conditions", func() { 36 | subject.Conditions = []condition.Condition{falseCondition, trueCondition} 37 | 38 | res, err := subject.Evaluate(msg) 39 | Expect(err).NotTo(HaveOccurred()) 40 | Expect(res).To(BeTrue()) 41 | 42 | Expect(falseCondition.EvaluateCallCount()).To(Equal(1)) 43 | Expect(trueCondition.EvaluateCallCount()).To(Equal(1)) 44 | }) 45 | 46 | It("stops on error", func() { 47 | subject.Conditions = []condition.Condition{falseCondition, errorCondition, trueCondition} 48 | 49 | _, err := subject.Evaluate(msg) 50 | Expect(err).To(HaveOccurred()) 51 | Expect(err.Error()).To(ContainSubstring("fake-evaluate-err")) 52 | 53 | Expect(falseCondition.EvaluateCallCount()).To(Equal(1)) 54 | Expect(errorCondition.EvaluateCallCount()).To(Equal(1)) 55 | Expect(trueCondition.EvaluateCallCount()).To(Equal(0)) 56 | }) 57 | 58 | It("evalutes to false if nothing matches", func() { 59 | subject.Conditions = []condition.Condition{falseCondition} 60 | 61 | res, err := subject.Evaluate(msg) 62 | Expect(err).NotTo(HaveOccurred()) 63 | Expect(res).To(BeFalse()) 64 | 65 | Expect(falseCondition.EvaluateCallCount()).To(Equal(1)) 66 | }) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/coalesce/delegator_test.go: -------------------------------------------------------------------------------- 1 | package coalesce_test 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegatefakes" 8 | . "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/coalesce" 9 | "github.com/dpb587/slack-delegate-bot/pkg/message" 10 | 11 | . "github.com/onsi/ginkgo" 12 | . "github.com/onsi/gomega" 13 | ) 14 | 15 | var _ = Describe("Delegator", func() { 16 | var delegateErr, delegateNone, delegateOne, delegateMany *delegatefakes.FakeDelegator 17 | 18 | BeforeEach(func() { 19 | delegateErr = &delegatefakes.FakeDelegator{} 20 | delegateErr.DelegateReturns(nil, errors.New("fake-err1")) 21 | 22 | delegateNone = &delegatefakes.FakeDelegator{} 23 | delegateNone.DelegateReturns(nil, nil) 24 | 25 | delegateOne = &delegatefakes.FakeDelegator{} 26 | delegateOne.DelegateReturns([]message.Delegate{delegate.Literal{Text: "one"}}, nil) 27 | 28 | delegateMany = &delegatefakes.FakeDelegator{} 29 | delegateMany.DelegateReturns([]message.Delegate{delegate.Literal{Text: "many1"}, delegate.Literal{Text: "many2"}}, nil) 30 | }) 31 | 32 | It("errors early", func() { 33 | subject := Delegator{ 34 | Delegators: []delegate.Delegator{delegateErr, delegateOne}, 35 | } 36 | 37 | _, err := subject.Delegate(message.Message{}) 38 | Expect(err).To(HaveOccurred()) 39 | Expect(err.Error()).To(ContainSubstring("fake-err1")) 40 | 41 | Expect(delegateOne.DelegateCallCount()).To(Equal(0)) 42 | }) 43 | 44 | It("can return empty", func() { 45 | subject := Delegator{ 46 | Delegators: []delegate.Delegator{delegateNone}, 47 | } 48 | 49 | found, err := subject.Delegate(message.Message{}) 50 | Expect(err).NotTo(HaveOccurred()) 51 | Expect(found).To(HaveLen(0)) 52 | }) 53 | 54 | It("stops with second delegate", func() { 55 | subject := Delegator{ 56 | Delegators: []delegate.Delegator{delegateNone, delegateOne, delegateMany}, 57 | } 58 | 59 | found, err := subject.Delegate(message.Message{}) 60 | Expect(err).NotTo(HaveOccurred()) 61 | Expect(found).To(ConsistOf(delegate.Literal{Text: "one"})) 62 | 63 | Expect(delegateNone.DelegateCallCount()).To(Equal(1)) 64 | Expect(delegateOne.DelegateCallCount()).To(Equal(1)) 65 | Expect(delegateMany.DelegateCallCount()).To(Equal(0)) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/conditional/factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions" 7 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 8 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 9 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 10 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/conditional" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | type factory struct { 15 | delegatesFactory delegates.Factory 16 | conditionsFactory conditions.Factory 17 | } 18 | 19 | type Options struct { 20 | When []interface{} `yaml:"when"` 21 | Then map[interface{}]interface{} `yaml:"then"` 22 | Else map[interface{}]interface{} `yaml:"else"` 23 | } 24 | 25 | func New(delegatesFactory delegates.Factory, conditionsFactory conditions.Factory) delegates.Factory { 26 | return &factory{ 27 | delegatesFactory: delegatesFactory, 28 | conditionsFactory: conditionsFactory, 29 | } 30 | } 31 | 32 | func (f factory) Create(name string, options interface{}) (delegate.Delegator, error) { 33 | if name != "if" { 34 | return nil, fmt.Errorf("unsupported delegate: %s", name) 35 | } 36 | 37 | parsed := Options{} 38 | 39 | err := configutil.RemarshalYAML(options, &parsed) 40 | if err != nil { 41 | return nil, errors.Wrap(err, "remarshalling") 42 | } 43 | 44 | when, err := f.conditionsFactory.Create("and", parsed.When) 45 | if err != nil { 46 | return nil, errors.Wrap(err, "creating conditional when") 47 | } 48 | 49 | thenName, thenOptions, err := configutil.KeyValueTuple(parsed.Then) 50 | if err != nil { 51 | return nil, errors.Wrap(err, "parsing conditional then") 52 | } 53 | 54 | then, err := f.delegatesFactory.Create(thenName, thenOptions) 55 | if err != nil { 56 | return nil, errors.Wrap(err, "creating conditional then") 57 | } 58 | 59 | var else_ delegate.Delegator 60 | 61 | if parsed.Else != nil { 62 | elseName, elseOptions, err := configutil.KeyValueTuple(parsed.Else) 63 | if err != nil { 64 | return nil, errors.Wrap(err, "parsing conditional else") 65 | } 66 | 67 | else_, err = f.delegatesFactory.Create(elseName, elseOptions) 68 | if err != nil { 69 | return nil, errors.Wrap(err, "creating conditional else") 70 | } 71 | } 72 | 73 | return &conditional.Delegator{ 74 | When: when, 75 | Then: then, 76 | Else: else_, 77 | }, nil 78 | } 79 | -------------------------------------------------------------------------------- /examples/cf-app/concourse.yml: -------------------------------------------------------------------------------- 1 | --- 2 | jobs: 3 | - name: deploy 4 | serial: true 5 | plan: 6 | - in_parallel: 7 | - get: slack-delegate-bot 8 | trigger: true 9 | - get: config 10 | trigger: true 11 | - task: build 12 | config: 13 | platform: linux 14 | image_resource: 15 | type: docker-image 16 | source: 17 | repository: alpine 18 | inputs: 19 | - name: slack-delegate-bot 20 | - name: config 21 | outputs: 22 | - name: app 23 | run: 24 | path: sh 25 | args: 26 | - -c 27 | - | 28 | set -eu 29 | 30 | # required when running the bot for user timezone configuration 31 | apk add --no-progress --no-cache tzdata 32 | 33 | # avoids google default credentials error during validation 34 | export PAIRIST_API_KEY=fake-token 35 | 36 | cp slack-delegate-bot/* app/slack-delegate-bot 37 | echo 'exec ./slack-delegate-bot --config=config/*.yml --config=config/default.delegatebot ${COMMAND:-run}' > app/exec 38 | chmod +x app/* 39 | 40 | cat > app/cf.yml <", 18 | }) 19 | 20 | Expect(msg.TargetChannelID).To(Equal("C9876543")) 21 | }) 22 | 23 | It("parses unnamed channels", func() { 24 | msg := ParseMessageForAnyChannelReference( 25 | message.Message{ 26 | RawText: "tell me about <#C9876543>", 27 | }, 28 | ) 29 | 30 | Expect(msg.TargetChannelID).To(Equal("C9876543")) 31 | }) 32 | }) 33 | 34 | Describe("ParseMessageForChannelReference", func() { 35 | DescribeTable( 36 | "in-context channels", 37 | func(appUserID, expectedTarget string) { 38 | msg := ParseMessageForChannelReference( 39 | message.Message{ 40 | TargetChannelID: "C1234567", 41 | RawText: "hey <#C9876543|star-wars> <@U1234567>, help!", 42 | }, 43 | func(in string) bool { 44 | return appUserID == in 45 | }, 46 | ) 47 | 48 | Expect(msg.TargetChannelID).To(Equal(expectedTarget)) 49 | }, 50 | Entry("next to app user", "U1234567", "C9876543"), 51 | Entry("next to random user", "U9876543", "C1234567"), 52 | ) 53 | 54 | It("parses unnamed channels", func() { 55 | msg := ParseMessageForChannelReference( 56 | message.Message{ 57 | RawText: "hey <#C9876543> <@U1234567>, help!", 58 | }, 59 | func(in string) bool { 60 | return true 61 | }, 62 | ) 63 | 64 | Expect(msg.TargetChannelID).To(Equal("C9876543")) 65 | }) 66 | }) 67 | 68 | Describe("CheckMessageForMention", func() { 69 | DescribeTable( 70 | "mentioned user", 71 | func(appUserID string, expected bool) { 72 | actual := CheckMessageForMention( 73 | message.Message{ 74 | TargetChannelID: "C1234567", 75 | RawText: "hey <@U1234567>, help!", 76 | }, 77 | func(in string) bool { 78 | return appUserID == in 79 | }, 80 | ) 81 | 82 | Expect(actual).To(Equal(expected)) 83 | }, 84 | Entry("with app user", "U1234567", true), 85 | Entry("with random user", "U9876543", false), 86 | ) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /pkg/slack/http/event_handler.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/dpb587/slack-delegate-bot/pkg/slack/event" 12 | "github.com/labstack/echo/v4" 13 | "github.com/pkg/errors" 14 | "github.com/slack-go/slack" 15 | "github.com/slack-go/slack/slackevents" 16 | "go.uber.org/zap" 17 | ) 18 | 19 | type EventHandler struct { 20 | processor event.Processor 21 | signingSecret string 22 | logger *zap.Logger 23 | } 24 | 25 | func NewEventHandler(processor event.Processor, signingSecret string, logger *zap.Logger) *EventHandler { 26 | return &EventHandler{ 27 | processor: processor, 28 | signingSecret: signingSecret, 29 | logger: logger, 30 | } 31 | } 32 | 33 | func (h EventHandler) Accept(c echo.Context) error { 34 | at := time.Now() 35 | 36 | if c.Request().Header.Get("content-type") != "application/json" { 37 | return c.String(http.StatusUnsupportedMediaType, http.StatusText(http.StatusUnsupportedMediaType)) 38 | } 39 | 40 | verifier, err := slack.NewSecretsVerifier(c.Request().Header, h.signingSecret) 41 | if err != nil { 42 | return errors.Wrap(err, "building secrets verifier") 43 | } 44 | 45 | body, err := ioutil.ReadAll(io.TeeReader(c.Request().Body, &verifier)) 46 | if err != nil { 47 | return errors.Wrap(err, "reading body") 48 | } 49 | 50 | if err = verifier.Ensure(); err != nil { 51 | h.logger.Debug("received unverified slack event", zap.ByteString("payload", body)) 52 | h.logger.Warn("unable to verify incoming slack event", zap.Error(err)) 53 | 54 | return c.String(http.StatusBadRequest, http.StatusText(http.StatusBadRequest)) 55 | } 56 | 57 | h.logger.Debug("received slack event", zap.ByteString("payload", body)) 58 | 59 | event, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken()) 60 | if err != nil { 61 | return errors.Wrap(err, "parsing incoming event") 62 | } 63 | 64 | switch event.Type { 65 | case slackevents.URLVerification: 66 | var r *slackevents.ChallengeResponse 67 | 68 | err := json.Unmarshal([]byte(body), &r) 69 | if err != nil { 70 | return errors.Wrap(err, "unmarshalling verification payload") 71 | } 72 | 73 | return c.String(http.StatusOK, r.Challenge) 74 | case slackevents.CallbackEvent: 75 | err := h.processor.Process(at, "callback_event", body) 76 | if err != nil { 77 | return errors.Wrap(err, "processing event") 78 | } 79 | 80 | return c.NoContent(http.StatusAccepted) 81 | } 82 | 83 | h.logger.Warn(fmt.Sprintf("unexpected slack event type: %s", event.Type)) 84 | 85 | return c.NoContent(http.StatusOK) 86 | } 87 | -------------------------------------------------------------------------------- /pkg/slack/slash/show_handler.go: -------------------------------------------------------------------------------- 1 | package slash 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 11 | "github.com/dpb587/slack-delegate-bot/pkg/message" 12 | "github.com/pkg/errors" 13 | "github.com/slack-go/slack" 14 | ) 15 | 16 | type ShowHandler struct { 17 | delegator delegate.Delegator 18 | serviceAPI interface{} 19 | } 20 | 21 | var _ Handler = HelpHandler{} 22 | 23 | func NewShowHandler(h delegate.Delegator, serviceAPI interface{}) Handler { 24 | return &ShowHandler{ 25 | delegator: h, 26 | serviceAPI: serviceAPI, 27 | } 28 | } 29 | 30 | func (h ShowHandler) UsageHint() string { 31 | return "show" 32 | } 33 | 34 | func (h ShowHandler) ShortDescription() string { 35 | return "show the current delegates for this channel" 36 | } 37 | 38 | func (h ShowHandler) Handle(cmd slack.SlashCommand) (bool, error) { 39 | if cmd.Text != "show" { 40 | return false, nil 41 | } 42 | 43 | now := time.Now() 44 | msg := message.Message{ 45 | ServiceAPI: h.serviceAPI, 46 | Delegator: h.delegator, 47 | ChannelTeamID: cmd.TeamID, 48 | ChannelID: cmd.ChannelID, 49 | UserTeamID: cmd.TeamID, 50 | UserID: cmd.UserID, 51 | RawTimestamp: fmt.Sprintf("%d.0", now.UTC().Unix()), 52 | TargetChannelTeamID: cmd.TeamID, 53 | TargetChannelID: cmd.ChannelID, 54 | RawText: "slash-command", 55 | Type: message.DirectMessageMessageType, 56 | Time: now, 57 | } 58 | 59 | dd, err := h.delegator.Delegate(msg) 60 | if err != nil { 61 | return false, errors.Wrap(err, "finding delegates") 62 | } 63 | 64 | var responseMessage string 65 | 66 | if len(dd) == 0 { 67 | responseMessage = "Looks like there is no interrupt available for this channel." 68 | } else { 69 | responseMessage = "Here are the current interrupt details for this channel:" 70 | 71 | for _, d := range dd { 72 | responseMessage = fmt.Sprintf("%s\n- %s", responseMessage, d) 73 | } 74 | } 75 | 76 | bodyBuf, err := json.Marshal(map[string]string{ 77 | "text": responseMessage, 78 | "response_type": "ephemeral", 79 | }) 80 | if err != nil { 81 | return false, errors.Wrap(err, "marshalling response") 82 | } 83 | 84 | res, err := http.DefaultClient.Post( 85 | cmd.ResponseURL, 86 | "application/json", 87 | bytes.NewReader(bodyBuf), 88 | ) 89 | if err != nil { 90 | return false, errors.Wrap(err, "posting response") 91 | } else if res.StatusCode != 200 { 92 | // TODO valid 93 | return false, errors.Wrap(err, "status code") 94 | } 95 | 96 | return true, nil 97 | } 98 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/defaultfactory/factory.go: -------------------------------------------------------------------------------- 1 | package defaultfactory 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions" 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 8 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates" 9 | coalescefactory "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/coalesce/factory" 10 | conditionalfactory "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/conditional/factory" 11 | emaillookupmapfactory "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/emaillookupmap/factory" 12 | literalfactory "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/literal/factory" 13 | literalmapfactory "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/literalmap/factory" 14 | lookupfactory "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/lookup/factory" 15 | pagerdutyfactory "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/pagerduty/factory" 16 | pairistfactory "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/pairist/factory" 17 | topiclookupfactory "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/topiclookup/factory" 18 | unionfactory "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/union/factory" 19 | userfactory "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/user/factory" 20 | usergroupfactory "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/usergroup/factory" 21 | ) 22 | 23 | type factory struct { 24 | factory map[string]delegates.Factory 25 | } 26 | 27 | var _ delegates.Factory = &factory{} 28 | 29 | func New(conditionsFactory conditions.Factory) delegates.Factory { 30 | f := &factory{ 31 | factory: map[string]delegates.Factory{}, 32 | } 33 | 34 | f.factory["coalesce"] = coalescefactory.New(f) 35 | f.factory["emaillookupmap"] = emaillookupmapfactory.New(f) 36 | f.factory["if"] = conditionalfactory.New(f, conditionsFactory) 37 | f.factory["literal"] = literalfactory.New() 38 | f.factory["literalmap"] = literalmapfactory.New(f) 39 | f.factory["lookup"] = lookupfactory.New() 40 | f.factory["pagerduty"] = pagerdutyfactory.New() 41 | f.factory["pairist"] = pairistfactory.New() 42 | f.factory["topiclookup"] = topiclookupfactory.New() 43 | f.factory["union"] = unionfactory.New(f) 44 | f.factory["user"] = userfactory.New() 45 | f.factory["usergroup"] = usergroupfactory.New() 46 | 47 | return f 48 | } 49 | 50 | func (f *factory) Create(name string, options interface{}) (delegate.Delegator, error) { 51 | ff, known := f.factory[name] 52 | if !known { 53 | return nil, fmt.Errorf("unsupported delegate: %s", name) 54 | } 55 | 56 | return ff.Create(name, options) 57 | } 58 | -------------------------------------------------------------------------------- /pkg/delegate/provider/db/delegator.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/configutil" 5 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 6 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/provider/db/model" 7 | ouryaml "github.com/dpb587/slack-delegate-bot/pkg/delegate/provider/yaml" 8 | "github.com/dpb587/slack-delegate-bot/pkg/message" 9 | "github.com/jinzhu/gorm" 10 | "github.com/pkg/errors" 11 | "gopkg.in/yaml.v2" 12 | ) 13 | 14 | type Delegator struct { 15 | db *gorm.DB 16 | parser *ouryaml.Parser 17 | } 18 | 19 | var _ delegate.Delegator = &Delegator{} 20 | 21 | func NewDelegator(db *gorm.DB, parser *ouryaml.Parser) delegate.Delegator { 22 | return &Delegator{ 23 | db: db, 24 | parser: parser, 25 | } 26 | } 27 | 28 | func (h *Delegator) Delegate(msg message.Message) ([]message.Delegate, error) { 29 | var config model.ChannelConfig 30 | 31 | err := h.db.Model(config). 32 | Where("team_id = ? AND channel_id = ?", msg.TargetChannelTeamID, msg.TargetChannelID). 33 | Where("revision_latest = ?", true). 34 | First(&config). 35 | Error 36 | if err != nil && err != gorm.ErrRecordNotFound { 37 | return nil, errors.Wrap(err, "loading channel config") 38 | } 39 | 40 | if config.Config == "" { 41 | return h.delegateTeam(msg) 42 | } 43 | 44 | return h.delegateWithConfig(msg, config.Config, config.ConfigSecrets) 45 | } 46 | 47 | func (h *Delegator) delegateTeam(msg message.Message) ([]message.Delegate, error) { 48 | var config model.TeamConfig 49 | 50 | err := h.db.Model(config). 51 | Where("team_id = ?", msg.TargetChannelTeamID). 52 | Where("revision_latest = ?", true). 53 | First(&config). 54 | Error 55 | if err != nil && err != gorm.ErrRecordNotFound { 56 | return nil, errors.Wrap(err, "loading channel config") 57 | } 58 | 59 | if config.DefaultConfig == "" { 60 | return nil, nil 61 | } 62 | 63 | return h.delegateWithConfig(msg, config.DefaultConfig, config.DefaultConfigSecrets) 64 | } 65 | 66 | func (h *Delegator) delegateWithConfig(msg message.Message, rawConfig, rawConfigSecrets string) ([]message.Delegate, error) { 67 | var secrets map[string]interface{} 68 | 69 | if rawConfigSecrets != "" { 70 | err := yaml.Unmarshal([]byte(rawConfigSecrets), &secrets) 71 | if err != nil { 72 | return nil, errors.Wrap(err, "parsing secrets") 73 | } 74 | } 75 | 76 | desanitizedConfig, _, err := configutil.DesanitizeConfig(rawConfig, secrets) 77 | if err != nil { 78 | return nil, errors.Wrap(err, "reinjecting secrets") 79 | } 80 | 81 | d, err := h.parser.Parse([]byte(desanitizedConfig)) 82 | if err != nil { 83 | return nil, errors.Wrap(err, "parsing config") 84 | } 85 | 86 | return d.Delegate(msg) 87 | } 88 | -------------------------------------------------------------------------------- /cmd/delegatebot/cmd/api_cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | nethttp "net/http" 6 | "time" 7 | 8 | "github.com/dpb587/slack-delegate-bot/cmd/delegatebot/opts" 9 | zlhttp "github.com/dpb587/slack-delegate-bot/pkg/http" 10 | "github.com/dpb587/slack-delegate-bot/pkg/slack" 11 | slackevent "github.com/dpb587/slack-delegate-bot/pkg/slack/event" 12 | slackhttp "github.com/dpb587/slack-delegate-bot/pkg/slack/http" 13 | "github.com/dpb587/slack-delegate-bot/pkg/slack/slash" 14 | _ "github.com/jinzhu/gorm/dialects/sqlite" 15 | slackapi "github.com/slack-go/slack" 16 | "go.uber.org/zap" 17 | "go.uber.org/zap/zapcore" 18 | ) 19 | 20 | type APICmd struct { 21 | *opts.Root `no-flags:"true"` 22 | 23 | BindHost string `long:"bind-host" description:"Bind host/IP" env:"BINDING" default:"0.0.0.0"` 24 | BindPort int `long:"bind-port" description:"Bind port" env:"PORT" default:"8080"` 25 | ExternalURL string `long:"external-url" description:"Public URL" env:"HTTP_EXTERNAL_URL"` 26 | 27 | SlackToken string `long:"slack-token" description:"Slack Bot OAuth API token" env:"SLACK_TOKEN"` 28 | SlackSigningSecret string `long:"slack-signing-secret" description:"Slack App Signing Secret" env:"SLACK_SIGNING_SECRET"` 29 | } 30 | 31 | func (c *APICmd) Execute(_ []string) error { 32 | http := &nethttp.Server{ 33 | Addr: fmt.Sprintf("%s:%d", c.BindHost, c.BindPort), 34 | ReadTimeout: 30 * time.Second, 35 | WriteTimeout: 30 * time.Second, 36 | } 37 | 38 | s := zlhttp.NewServer(http, c.GetLogger()) 39 | 40 | services := []zlhttp.Service{ 41 | zlhttp.MetaRuntimeHandler{}, 42 | c.slackService(), 43 | } 44 | 45 | return s.Run(services...) 46 | } 47 | 48 | func (c *APICmd) slackService() zlhttp.Service { 49 | var apiOpts []slackapi.Option 50 | 51 | if c.Root.LogLevel == zapcore.DebugLevel { 52 | ll, _ := zap.NewStdLogAt(c.Root.GetLogger(), zapcore.DebugLevel) 53 | 54 | apiOpts = append( 55 | apiOpts, 56 | slackapi.OptionDebug(true), 57 | slackapi.OptionLog(ll), 58 | ) 59 | } 60 | 61 | api := slackapi.New(c.SlackToken, apiOpts...) 62 | 63 | h, err := c.GetDelegator() 64 | if err != nil { 65 | // TODO 66 | panic(err) 67 | } 68 | 69 | processor := slackevent.NewSyncProcessor( 70 | slackevent.NewParser(slack.NewUserLookup(api)), 71 | slack.NewResponder(api, h), 72 | ) 73 | 74 | slashHandler := slash.Handlers{ 75 | slash.NewShowHandler(h, api), 76 | } 77 | 78 | slashHandler = append(slashHandler, slash.NewHelpHandler( 79 | &slashHandler, 80 | c.ExternalURL, 81 | )) 82 | 83 | slashProcessor := slash.NewSyncProcessor(slashHandler) 84 | 85 | return &slackhttp.Service{ 86 | EventProcessor: processor, 87 | SlashProcessor: slashProcessor, 88 | SigningSecret: c.SlackSigningSecret, 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /pkg/slack/rtm/parser.go: -------------------------------------------------------------------------------- 1 | package rtm 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/dpb587/slack-delegate-bot/pkg/message" 8 | "github.com/dpb587/slack-delegate-bot/pkg/slack/slackutil" 9 | "github.com/slack-go/slack" 10 | ) 11 | 12 | type Parser struct { 13 | teamID string 14 | userID string 15 | } 16 | 17 | func NewParser(teamID, userID string) *Parser { 18 | return &Parser{ 19 | teamID: teamID, 20 | userID: userID, 21 | } 22 | } 23 | 24 | func (p *Parser) ParseMessage(msg slack.Msg) (message.Message, bool, error) { 25 | if msg.Type != "message" { 26 | return message.Message{}, false, nil 27 | } else if msg.SubType == "message_deleted" { 28 | // no sense responding to deleted message notifications 29 | return message.Message{}, false, nil 30 | } else if msg.SubType == "group_topic" || strings.Contains(msg.Text, "set the channel topic: ") { 31 | // no sense responding to a reference in the topic 32 | // trivia: slack doesn't support topic threads, but still allows bots to 33 | // respond which means you get mentioned, but the browser app doesn't 34 | // render the thread in New Threads so you can't mark it as read unless you 35 | // use the mobile app (which happens to show it as -1 replies). 36 | return message.Message{}, false, nil 37 | } else if p.isSelf(msg.User) { 38 | // avoid accidentally talking to ourselves into a recursive DoS 39 | return message.Message{}, false, nil 40 | } 41 | 42 | incoming := message.Message{ 43 | UserTeamID: p.teamID, // TODO incorrect? i.e. shared channels 44 | UserID: msg.User, 45 | 46 | ChannelTeamID: p.teamID, 47 | ChannelID: msg.Channel, 48 | 49 | TargetChannelTeamID: p.teamID, 50 | TargetChannelID: msg.Channel, 51 | 52 | RawText: msg.Text, 53 | RawTimestamp: msg.Timestamp, 54 | RawThreadTimestamp: msg.ThreadTimestamp, 55 | 56 | Type: message.ChannelMessageType, 57 | Time: slackutil.MustConvertTimestamp(msg.Timestamp), 58 | } 59 | 60 | // include attachments 61 | for _, attachment := range msg.Attachments { 62 | if attachment.Fallback == "" { 63 | continue 64 | } 65 | 66 | incoming.RawText = fmt.Sprintf("%s\n\n---\n\n%s", incoming.RawText, attachment.Fallback) 67 | } 68 | 69 | if msg.Channel[0] == 'D' { // TODO better way to detect if this is our bot DM? 70 | incoming.Type = message.DirectMessageMessageType 71 | 72 | incoming = slackutil.ParseMessageForAnyChannelReference(incoming) 73 | 74 | return incoming, true, nil 75 | } else if !slackutil.CheckMessageForMention(incoming, p.isSelf) { 76 | return message.Message{}, false, nil 77 | } 78 | 79 | incoming = slackutil.ParseMessageForChannelReference(incoming, p.isSelf) 80 | 81 | return incoming, true, nil 82 | } 83 | 84 | func (p *Parser) isSelf(userID string) bool { 85 | return p.userID == userID 86 | } 87 | -------------------------------------------------------------------------------- /cmd/delegatebot/opts/root.go: -------------------------------------------------------------------------------- 1 | package opts 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | conditionsfactory "github.com/dpb587/slack-delegate-bot/pkg/condition/conditions/defaultfactory" 8 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 9 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/coalesce" 10 | interruptsfactory "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/defaultfactory" 11 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/provider/db" 12 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/provider/fs" 13 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/provider/yaml" 14 | "github.com/pkg/errors" 15 | "go.uber.org/zap" 16 | "go.uber.org/zap/zapcore" 17 | 18 | // include potential database adapters 19 | _ "github.com/jinzhu/gorm/dialects/mysql" 20 | _ "github.com/jinzhu/gorm/dialects/sqlite" 21 | ) 22 | 23 | type Root struct { 24 | Configs []string `long:"config" description:"Path to configuration files" env:"CONFIG"` 25 | delegator delegate.Delegator 26 | 27 | LogLevel zapcore.Level `long:"log-level" description:"Show additional levels of log messages" env:"LOG_LEVEL" default:"INFO"` 28 | logger *zap.Logger 29 | 30 | parser *yaml.Parser 31 | } 32 | 33 | func (r *Root) GetLogger() *zap.Logger { 34 | if r.logger == nil { 35 | cfg := zap.NewProductionConfig() 36 | cfg.Level.SetLevel(r.LogLevel) 37 | cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder 38 | cfg.OutputPaths = []string{"stdout"} 39 | 40 | logger, err := cfg.Build() 41 | if err != nil { 42 | panic(err) 43 | } 44 | 45 | r.logger = logger 46 | } 47 | 48 | return r.logger 49 | } 50 | 51 | func (r *Root) GetParser() *yaml.Parser { 52 | if r.parser == nil { 53 | conditions := conditionsfactory.New() 54 | interrupts := interruptsfactory.New(conditions) 55 | 56 | r.parser = yaml.NewParser(interrupts, conditions) 57 | } 58 | 59 | return r.parser 60 | } 61 | 62 | func (r *Root) GetDelegator() (delegate.Delegator, error) { 63 | var delegators []delegate.Delegator 64 | var filePaths []string 65 | 66 | for _, uri := range r.Configs { 67 | uriSplit := strings.SplitN(uri, "://", 2) 68 | 69 | if len(uriSplit) == 1 { 70 | filePaths = append(filePaths, uriSplit[0]) 71 | 72 | continue 73 | } 74 | 75 | switch uriSplit[0] { 76 | case "mysql", "sqlite3": 77 | dbh, err := db.OpenDB(uriSplit[0], uriSplit[1]) 78 | if err != nil { 79 | return nil, errors.Wrapf(err, "opening db") 80 | } 81 | 82 | delegators = append(delegators, db.NewDelegator(dbh, r.GetParser())) 83 | default: 84 | return nil, fmt.Errorf("unsupported handler uri: %s", uri) 85 | } 86 | } 87 | 88 | if len(filePaths) > 0 { 89 | // collected for later to be able to squash paths 90 | h, err := fs.BuildDelegator(r.GetParser(), r.Configs...) 91 | if err != nil { 92 | return nil, errors.Wrap(err, "loading configs") 93 | } 94 | 95 | delegators = append(delegators, h) 96 | } 97 | 98 | if len(delegators) == 1 { 99 | return delegators[0], nil 100 | } 101 | 102 | res := coalesce.Delegator{ 103 | Delegators: delegators, 104 | } 105 | 106 | return res, nil 107 | } 108 | -------------------------------------------------------------------------------- /pkg/condition/conditionfakes/fake_condition.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package conditionfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/dpb587/slack-delegate-bot/pkg/condition" 8 | "github.com/dpb587/slack-delegate-bot/pkg/message" 9 | ) 10 | 11 | type FakeCondition struct { 12 | EvaluateStub func(message.Message) (bool, error) 13 | evaluateMutex sync.RWMutex 14 | evaluateArgsForCall []struct { 15 | arg1 message.Message 16 | } 17 | evaluateReturns struct { 18 | result1 bool 19 | result2 error 20 | } 21 | evaluateReturnsOnCall map[int]struct { 22 | result1 bool 23 | result2 error 24 | } 25 | invocations map[string][][]interface{} 26 | invocationsMutex sync.RWMutex 27 | } 28 | 29 | func (fake *FakeCondition) Evaluate(arg1 message.Message) (bool, error) { 30 | fake.evaluateMutex.Lock() 31 | ret, specificReturn := fake.evaluateReturnsOnCall[len(fake.evaluateArgsForCall)] 32 | fake.evaluateArgsForCall = append(fake.evaluateArgsForCall, struct { 33 | arg1 message.Message 34 | }{arg1}) 35 | fake.recordInvocation("Evaluate", []interface{}{arg1}) 36 | fake.evaluateMutex.Unlock() 37 | if fake.EvaluateStub != nil { 38 | return fake.EvaluateStub(arg1) 39 | } 40 | if specificReturn { 41 | return ret.result1, ret.result2 42 | } 43 | return fake.evaluateReturns.result1, fake.evaluateReturns.result2 44 | } 45 | 46 | func (fake *FakeCondition) EvaluateCallCount() int { 47 | fake.evaluateMutex.RLock() 48 | defer fake.evaluateMutex.RUnlock() 49 | return len(fake.evaluateArgsForCall) 50 | } 51 | 52 | func (fake *FakeCondition) EvaluateArgsForCall(i int) message.Message { 53 | fake.evaluateMutex.RLock() 54 | defer fake.evaluateMutex.RUnlock() 55 | return fake.evaluateArgsForCall[i].arg1 56 | } 57 | 58 | func (fake *FakeCondition) EvaluateReturns(result1 bool, result2 error) { 59 | fake.EvaluateStub = nil 60 | fake.evaluateReturns = struct { 61 | result1 bool 62 | result2 error 63 | }{result1, result2} 64 | } 65 | 66 | func (fake *FakeCondition) EvaluateReturnsOnCall(i int, result1 bool, result2 error) { 67 | fake.EvaluateStub = nil 68 | if fake.evaluateReturnsOnCall == nil { 69 | fake.evaluateReturnsOnCall = make(map[int]struct { 70 | result1 bool 71 | result2 error 72 | }) 73 | } 74 | fake.evaluateReturnsOnCall[i] = struct { 75 | result1 bool 76 | result2 error 77 | }{result1, result2} 78 | } 79 | 80 | func (fake *FakeCondition) Invocations() map[string][][]interface{} { 81 | fake.invocationsMutex.RLock() 82 | defer fake.invocationsMutex.RUnlock() 83 | fake.evaluateMutex.RLock() 84 | defer fake.evaluateMutex.RUnlock() 85 | return fake.invocations 86 | } 87 | 88 | func (fake *FakeCondition) recordInvocation(key string, args []interface{}) { 89 | fake.invocationsMutex.Lock() 90 | defer fake.invocationsMutex.Unlock() 91 | if fake.invocations == nil { 92 | fake.invocations = map[string][][]interface{}{} 93 | } 94 | if fake.invocations[key] == nil { 95 | fake.invocations[key] = [][]interface{}{} 96 | } 97 | fake.invocations[key] = append(fake.invocations[key], args) 98 | } 99 | 100 | var _ condition.Condition = new(FakeCondition) 101 | -------------------------------------------------------------------------------- /docs/handlers/delegators.md: -------------------------------------------------------------------------------- 1 | # Delegators 2 | 3 | Delegators are configured to lookup someone who can be contacted. 4 | 5 | 6 | ## `topiclookup` 7 | 8 | Refer to a channel's topic to try and identify the interrupts. If a channel is not configured, the channel from the message will be used. The following conventions are matched: 9 | 10 | * `interrupt: <@1>...` 11 | * `interrupt <@n>...` 12 | 13 | ```yaml 14 | topiclookup: 15 | channel: C12345678 16 | ``` 17 | 18 | 19 | ## `coalesce` 20 | 21 | List multiple interrupts and the first one which finds an interrupt will be used. 22 | 23 | ```yaml 24 | coalesce: 25 | - if: 26 | when: 27 | - day: { days: Mon, Wed, Fri } 28 | then: 29 | user: { id: U12345678 } 30 | - if: 31 | when: 32 | - day: { days: Tue, Thu } 33 | then: 34 | user: { id: U98765432 } 35 | ``` 36 | 37 | 38 | ## `conditional` 39 | 40 | Wrap an interrupt in conditional behavior. When multiple conditionals are configured, all must evaluate to true. The `else` behavior is optional. 41 | 42 | ```yaml 43 | if: 44 | when: 45 | - hours: { start: 08:00, end: 18:00 } 46 | then: 47 | user: { id: U12345678 } 48 | else: 49 | literal: { text: "Try pinging us during work hours" } 50 | ``` 51 | 52 | 53 | ## `emaillookupmap` 54 | 55 | Attempt to map email addresses to Slack users. 56 | 57 | ```yaml 58 | emaillookupmap: 59 | from: { pagerduty: { api_key: $PAGERDUTY_API_KEY, escalation_policy: PZI9P8E } } 60 | ``` 61 | 62 | 63 | ## `literal` 64 | 65 | Instead of a user or user group, mention an interrupt with literal text. 66 | 67 | ```yaml 68 | literal: { text: "find the person with the *ninja* hat" } 69 | ``` 70 | 71 | 72 | ## `literalmap` 73 | 74 | Convert literal interrupts generated from another interrupt source into Slack users or usergroups. 75 | 76 | ```yaml 77 | literalmap: 78 | from: { pairist: { team: bosh-director, role: Interrupt } } 79 | users: 80 | Danny: U0FUK0EBH 81 | ``` 82 | 83 | 84 | ## `pagerduty` 85 | 86 | Refer to a PagerDuty escalation policy to find current on-call users. By default, only the first escalation level is used. 87 | 88 | ```yaml 89 | pagerduty: 90 | api_key: # literal or $PAGERDUTY_TEAM_x 91 | escalation_policy: PZI9P8E 92 | # escalation_level: 0 # to show all users, or a specific level number 93 | ``` 94 | 95 | 96 | ## `pairist` 97 | 98 | Refer to a team's [pairist](https://pair.ist/) to find people with a particular role. 99 | 100 | ```yaml 101 | pairist: 102 | team: bosh-director 103 | # password: literal # OR $PAIRIST_TEAM_x 104 | role: Interrupt 105 | # track: Community 106 | ``` 107 | 108 | 109 | ## `union` 110 | 111 | List multiple interrupts and all discovered interrupts will be suggested. 112 | 113 | ```yaml 114 | union: 115 | - user: { id: U12345678 } 116 | - usergroup: { id: S23456789, alias: "slackgroupname" } 117 | ``` 118 | 119 | 120 | ## `user` 121 | 122 | Interrupt a specific user. 123 | 124 | ```yaml 125 | user: { id: U12345678 } 126 | ``` 127 | 128 | 129 | ## `usergroup` 130 | 131 | Interrupt a specific user group. 132 | 133 | ```yaml 134 | usergroup: { id: S12345678, alias: "slackgroupname" } 135 | ``` 136 | -------------------------------------------------------------------------------- /pkg/delegate/delegatefakes/fake_delegator.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package delegatefakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 8 | "github.com/dpb587/slack-delegate-bot/pkg/message" 9 | ) 10 | 11 | type FakeDelegator struct { 12 | DelegateStub func(message.Message) ([]message.Delegate, error) 13 | delegateMutex sync.RWMutex 14 | delegateArgsForCall []struct { 15 | arg1 message.Message 16 | } 17 | delegateReturns struct { 18 | result1 []message.Delegate 19 | result2 error 20 | } 21 | delegateReturnsOnCall map[int]struct { 22 | result1 []message.Delegate 23 | result2 error 24 | } 25 | invocations map[string][][]interface{} 26 | invocationsMutex sync.RWMutex 27 | } 28 | 29 | func (fake *FakeDelegator) Delegate(arg1 message.Message) ([]message.Delegate, error) { 30 | fake.delegateMutex.Lock() 31 | ret, specificReturn := fake.delegateReturnsOnCall[len(fake.delegateArgsForCall)] 32 | fake.delegateArgsForCall = append(fake.delegateArgsForCall, struct { 33 | arg1 message.Message 34 | }{arg1}) 35 | fake.recordInvocation("Delegate", []interface{}{arg1}) 36 | fake.delegateMutex.Unlock() 37 | if fake.DelegateStub != nil { 38 | return fake.DelegateStub(arg1) 39 | } 40 | if specificReturn { 41 | return ret.result1, ret.result2 42 | } 43 | return fake.delegateReturns.result1, fake.delegateReturns.result2 44 | } 45 | 46 | func (fake *FakeDelegator) DelegateCallCount() int { 47 | fake.delegateMutex.RLock() 48 | defer fake.delegateMutex.RUnlock() 49 | return len(fake.delegateArgsForCall) 50 | } 51 | 52 | func (fake *FakeDelegator) DelegateArgsForCall(i int) message.Message { 53 | fake.delegateMutex.RLock() 54 | defer fake.delegateMutex.RUnlock() 55 | return fake.delegateArgsForCall[i].arg1 56 | } 57 | 58 | func (fake *FakeDelegator) DelegateReturns(result1 []message.Delegate, result2 error) { 59 | fake.DelegateStub = nil 60 | fake.delegateReturns = struct { 61 | result1 []message.Delegate 62 | result2 error 63 | }{result1, result2} 64 | } 65 | 66 | func (fake *FakeDelegator) DelegateReturnsOnCall(i int, result1 []message.Delegate, result2 error) { 67 | fake.DelegateStub = nil 68 | if fake.delegateReturnsOnCall == nil { 69 | fake.delegateReturnsOnCall = make(map[int]struct { 70 | result1 []message.Delegate 71 | result2 error 72 | }) 73 | } 74 | fake.delegateReturnsOnCall[i] = struct { 75 | result1 []message.Delegate 76 | result2 error 77 | }{result1, result2} 78 | } 79 | 80 | func (fake *FakeDelegator) Invocations() map[string][][]interface{} { 81 | fake.invocationsMutex.RLock() 82 | defer fake.invocationsMutex.RUnlock() 83 | fake.delegateMutex.RLock() 84 | defer fake.delegateMutex.RUnlock() 85 | return fake.invocations 86 | } 87 | 88 | func (fake *FakeDelegator) recordInvocation(key string, args []interface{}) { 89 | fake.invocationsMutex.Lock() 90 | defer fake.invocationsMutex.Unlock() 91 | if fake.invocations == nil { 92 | fake.invocations = map[string][][]interface{}{} 93 | } 94 | if fake.invocations[key] == nil { 95 | fake.invocations[key] = [][]interface{}{} 96 | } 97 | fake.invocations[key] = append(fake.invocations[key], args) 98 | } 99 | 100 | var _ delegate.Delegator = new(FakeDelegator) 101 | -------------------------------------------------------------------------------- /pkg/slack/event/parser.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "github.com/dpb587/slack-delegate-bot/pkg/message" 5 | "github.com/dpb587/slack-delegate-bot/pkg/slack" 6 | "github.com/dpb587/slack-delegate-bot/pkg/slack/slackutil" 7 | "github.com/slack-go/slack/slackevents" 8 | ) 9 | 10 | type Parser struct { 11 | userLookup *slack.UserLookup 12 | } 13 | 14 | func NewParser(userLookup *slack.UserLookup) *Parser { 15 | return &Parser{ 16 | userLookup: userLookup, 17 | } 18 | } 19 | 20 | func (m *Parser) ParseAppMention(raw slackevents.EventsAPIEvent, e slackevents.AppMentionEvent) (message.Message, bool, error) { 21 | isSelf := m.selfID(raw.APIAppID) 22 | 23 | if isSelf(e.User) { 24 | return message.Message{}, false, nil 25 | } 26 | 27 | msg := message.Message{ 28 | ChannelTeamID: raw.TeamID, 29 | ChannelID: e.Channel, 30 | UserID: e.User, 31 | UserTeamID: e.UserTeam, 32 | RawTimestamp: e.TimeStamp, 33 | RawThreadTimestamp: e.ThreadTimeStamp, 34 | RawText: e.Text, 35 | TargetChannelTeamID: raw.TeamID, 36 | TargetChannelID: e.Channel, 37 | Type: message.ChannelMessageType, 38 | Time: slackutil.MustConvertTimestamp(e.TimeStamp), 39 | } 40 | 41 | // TODO attachments? 42 | 43 | msg = slackutil.ParseMessageForChannelReference(msg, isSelf) 44 | 45 | return msg, true, nil 46 | } 47 | 48 | func (m *Parser) ParseMessage(raw slackevents.EventsAPIEvent, e slackevents.MessageEvent) (message.Message, bool, error) { 49 | isSelf := m.selfID(raw.APIAppID) 50 | 51 | if isSelf(e.User) { 52 | return message.Message{}, false, nil 53 | } 54 | 55 | msg := message.Message{ 56 | ChannelTeamID: raw.TeamID, 57 | ChannelID: e.Channel, 58 | UserID: e.User, 59 | UserTeamID: e.UserTeam, 60 | TargetChannelTeamID: raw.TeamID, 61 | TargetChannelID: e.Channel, 62 | RawTimestamp: e.TimeStamp, 63 | RawThreadTimestamp: e.ThreadTimeStamp, 64 | RawText: e.Text, 65 | Type: message.ChannelMessageType, 66 | Time: slackutil.MustConvertTimestamp(e.TimeStamp), 67 | } 68 | 69 | if e.ChannelType == "im" { 70 | msg.Type = message.DirectMessageMessageType 71 | 72 | // assume they mention a channel for the interrupt 73 | msg = slackutil.ParseMessageForAnyChannelReference(msg) 74 | 75 | if msg.TargetChannelTeamID == msg.ChannelTeamID && msg.TargetChannelID == msg.ChannelID { 76 | // but if no channel mentioned in the dm, ignore them 77 | // TODO give a help link; move to responder? 78 | return message.Message{}, false, nil 79 | } 80 | } else if !slackutil.CheckMessageForMention(msg, isSelf) { 81 | // assume channel-style needing a reference; guess not 82 | // TODO give a help link; move to responder? 83 | return message.Message{}, false, nil 84 | } else { 85 | // check for contextual channel 86 | msg = slackutil.ParseMessageForChannelReference(msg, isSelf) 87 | 88 | if msg.TargetChannelTeamID == msg.ChannelTeamID && msg.TargetChannelID == msg.ChannelID { 89 | // cannot detect channel from assumed-mpim/non-channel messages 90 | // TODO give a help link; move to responder? 91 | return message.Message{}, false, nil 92 | } 93 | } 94 | 95 | // TODO attachments? how? 96 | 97 | return msg, true, nil 98 | } 99 | 100 | func (m *Parser) selfID(appID string) func(string) bool { 101 | return func(userID string) bool { 102 | isSelf, err := m.userLookup.IsAppBot(appID, userID) 103 | if err != nil { 104 | // TODO log 105 | return false 106 | } 107 | 108 | return isSelf 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /pkg/slack/slackfakes/fake_user_lookup_slack_api.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package slackfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/dpb587/slack-delegate-bot/pkg/slack" 8 | slacka "github.com/slack-go/slack" 9 | ) 10 | 11 | type FakeUserLookupSlackAPI struct { 12 | GetUserInfoStub func(string) (*slacka.User, error) 13 | getUserInfoMutex sync.RWMutex 14 | getUserInfoArgsForCall []struct { 15 | arg1 string 16 | } 17 | getUserInfoReturns struct { 18 | result1 *slacka.User 19 | result2 error 20 | } 21 | getUserInfoReturnsOnCall map[int]struct { 22 | result1 *slacka.User 23 | result2 error 24 | } 25 | invocations map[string][][]interface{} 26 | invocationsMutex sync.RWMutex 27 | } 28 | 29 | func (fake *FakeUserLookupSlackAPI) GetUserInfo(arg1 string) (*slacka.User, error) { 30 | fake.getUserInfoMutex.Lock() 31 | ret, specificReturn := fake.getUserInfoReturnsOnCall[len(fake.getUserInfoArgsForCall)] 32 | fake.getUserInfoArgsForCall = append(fake.getUserInfoArgsForCall, struct { 33 | arg1 string 34 | }{arg1}) 35 | fake.recordInvocation("GetUserInfo", []interface{}{arg1}) 36 | fake.getUserInfoMutex.Unlock() 37 | if fake.GetUserInfoStub != nil { 38 | return fake.GetUserInfoStub(arg1) 39 | } 40 | if specificReturn { 41 | return ret.result1, ret.result2 42 | } 43 | fakeReturns := fake.getUserInfoReturns 44 | return fakeReturns.result1, fakeReturns.result2 45 | } 46 | 47 | func (fake *FakeUserLookupSlackAPI) GetUserInfoCallCount() int { 48 | fake.getUserInfoMutex.RLock() 49 | defer fake.getUserInfoMutex.RUnlock() 50 | return len(fake.getUserInfoArgsForCall) 51 | } 52 | 53 | func (fake *FakeUserLookupSlackAPI) GetUserInfoCalls(stub func(string) (*slacka.User, error)) { 54 | fake.getUserInfoMutex.Lock() 55 | defer fake.getUserInfoMutex.Unlock() 56 | fake.GetUserInfoStub = stub 57 | } 58 | 59 | func (fake *FakeUserLookupSlackAPI) GetUserInfoArgsForCall(i int) string { 60 | fake.getUserInfoMutex.RLock() 61 | defer fake.getUserInfoMutex.RUnlock() 62 | argsForCall := fake.getUserInfoArgsForCall[i] 63 | return argsForCall.arg1 64 | } 65 | 66 | func (fake *FakeUserLookupSlackAPI) GetUserInfoReturns(result1 *slacka.User, result2 error) { 67 | fake.getUserInfoMutex.Lock() 68 | defer fake.getUserInfoMutex.Unlock() 69 | fake.GetUserInfoStub = nil 70 | fake.getUserInfoReturns = struct { 71 | result1 *slacka.User 72 | result2 error 73 | }{result1, result2} 74 | } 75 | 76 | func (fake *FakeUserLookupSlackAPI) GetUserInfoReturnsOnCall(i int, result1 *slacka.User, result2 error) { 77 | fake.getUserInfoMutex.Lock() 78 | defer fake.getUserInfoMutex.Unlock() 79 | fake.GetUserInfoStub = nil 80 | if fake.getUserInfoReturnsOnCall == nil { 81 | fake.getUserInfoReturnsOnCall = make(map[int]struct { 82 | result1 *slacka.User 83 | result2 error 84 | }) 85 | } 86 | fake.getUserInfoReturnsOnCall[i] = struct { 87 | result1 *slacka.User 88 | result2 error 89 | }{result1, result2} 90 | } 91 | 92 | func (fake *FakeUserLookupSlackAPI) Invocations() map[string][][]interface{} { 93 | fake.invocationsMutex.RLock() 94 | defer fake.invocationsMutex.RUnlock() 95 | fake.getUserInfoMutex.RLock() 96 | defer fake.getUserInfoMutex.RUnlock() 97 | copiedInvocations := map[string][][]interface{}{} 98 | for key, value := range fake.invocations { 99 | copiedInvocations[key] = value 100 | } 101 | return copiedInvocations 102 | } 103 | 104 | func (fake *FakeUserLookupSlackAPI) recordInvocation(key string, args []interface{}) { 105 | fake.invocationsMutex.Lock() 106 | defer fake.invocationsMutex.Unlock() 107 | if fake.invocations == nil { 108 | fake.invocations = map[string][][]interface{}{} 109 | } 110 | if fake.invocations[key] == nil { 111 | fake.invocations[key] = [][]interface{}{} 112 | } 113 | fake.invocations[key] = append(fake.invocations[key], args) 114 | } 115 | 116 | var _ slack.UserLookupSlackAPI = new(FakeUserLookupSlackAPI) 117 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/topiclookup/delegator_test.go: -------------------------------------------------------------------------------- 1 | package topiclookup_test 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 7 | . "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/topiclookup" 8 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/topiclookup/topiclookupfakes" 9 | "github.com/dpb587/slack-delegate-bot/pkg/message" 10 | "github.com/slack-go/slack" 11 | 12 | . "github.com/onsi/ginkgo" 13 | . "github.com/onsi/ginkgo/extensions/table" 14 | . "github.com/onsi/gomega" 15 | ) 16 | 17 | var _ = Describe("Delegator", func() { 18 | var fakeSlackAPI *topiclookupfakes.FakeSlackAPI 19 | var subject Delegator 20 | var msg message.Message 21 | 22 | BeforeEach(func() { 23 | fakeSlackAPI = &topiclookupfakes.FakeSlackAPI{} 24 | msg.ServiceAPI = fakeSlackAPI 25 | 26 | subject = Delegator{ 27 | Channel: "C12345678", 28 | } 29 | }) 30 | 31 | DescribeTable( 32 | "parsing the real topics", 33 | func(topic string, expected ...message.Delegate) { 34 | channelInfo := &slack.Channel{} 35 | channelInfo.Topic = slack.Topic{ 36 | Value: topic, 37 | } 38 | 39 | fakeSlackAPI.GetConversationInfoReturns(channelInfo, nil) 40 | 41 | actual, err := subject.Delegate(msg) 42 | Expect(err).NotTo(HaveOccurred()) 43 | Expect(actual).To(ConsistOf(expected)) 44 | 45 | Expect(fakeSlackAPI.GetConversationInfoArgsForCall(0)).To(Equal("C12345678")) 46 | }, 47 | Entry("bbl-users", ":bbl: *interrupt:* note:* bbl _always_ works", delegate.UserGroup{ID: "S7E4C41HS", Alias: "infrastructureteam"}), 48 | Entry("bbr", "BOSH Backup & Restore | interrupt: <@U08J13EG0> <@UCKK7PZKK> :party_gopher: For PCF/customer specific questions, please ask in the #pcf-backup-restore channel in Pivotal Slack.", delegate.User{ID: "U08J13EG0"}, delegate.User{ID: "UCKK7PZKK"}), 49 | // Entry("buildpacks", "Interrupt: `@guillermo` `@ty` `@buildpacks-team` | Lead: `@slevine` | CI: | Java BP: <#C03F5ELTK|java-buildpack> | Hours: 9-6pm EST"), 50 | Entry("cf-docs", "Questions? Interrupt <@U0JAEKNBH>. Contribute to the Docs! ", delegate.User{ID: "U0JAEKNBH"}), 51 | Entry("cli", "Question about Apps or the CC API? Try <#C07C04W4Q|capi> first! Interrupt: PM: <@U0CPY3BL2> For contributor discussion, please visit <#CDVP0651P|cli-dev-internal>", delegate.UserGroup{ID: "S1ZAS8DNY", Alias: "cli-team"}), 52 | Entry("credhub", "Please include your CredHub logs in case of Errors | interrupt: <@U6W2F82B1> <@U8TDZ8VU3> | break glass: `@credhub-team` | PM: <@UDFK4K0KT>, <@UHPMJCXGC>", delegate.User{ID: "U6W2F82B1"}, delegate.User{ID: "U8TDZ8VU3"}), 53 | Entry("networking", "@Eng Interrupt: <@U12345678> and <@U23456789> | the Networking Program (formerly Container Networking and Routing).  All your packets belong to us.", delegate.User{ID: "U12345678"}, delegate.User{ID: "U23456789"}), 54 | Entry("test-commas", "Interrupt: <@U12345678>, <@U23456789>", delegate.User{ID: "U12345678"}, delegate.User{ID: "U23456789"}), 55 | Entry("test-commas-and", "Interrupt: <@U12345678>, and <@U23456789>", delegate.User{ID: "U12345678"}, delegate.User{ID: "U23456789"}), 56 | Entry("multiple spaces", "Interrupt: <@U12345678>", delegate.User{ID: "U12345678"}), 57 | Entry("test-backtick", "`something` else\n`interrupt` <@U12345678> <@U23456789>\n`third` thing)", delegate.User{ID: "U12345678"}, delegate.User{ID: "U23456789"}), 58 | // extra, surrounding emojis do not match 59 | Entry("capi", "Can I push: Interrupt: :whale: <@U0GQNFF8R> <@U056V1DDK> :boom-avocado: | PM: <@U91NR3Q3T> :spacewhale2: : | Operators are standing by to take your call 9-6 Pacific"), 60 | ) 61 | 62 | Context("slack errors", func() { 63 | BeforeEach(func() { 64 | fakeSlackAPI.GetConversationInfoReturns(nil, errors.New("fake-err1")) 65 | }) 66 | 67 | It("errors", func() { 68 | _, err := subject.Delegate(msg) 69 | Expect(err).To(HaveOccurred()) 70 | Expect(err.Error()).To(ContainSubstring("fake-err1")) 71 | }) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /pkg/slack/slackfakes/fake_responder_slack_api.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package slackfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/dpb587/slack-delegate-bot/pkg/slack" 8 | slacka "github.com/slack-go/slack" 9 | ) 10 | 11 | type FakeResponderSlackAPI struct { 12 | PostMessageStub func(string, ...slacka.MsgOption) (string, string, error) 13 | postMessageMutex sync.RWMutex 14 | postMessageArgsForCall []struct { 15 | arg1 string 16 | arg2 []slacka.MsgOption 17 | } 18 | postMessageReturns struct { 19 | result1 string 20 | result2 string 21 | result3 error 22 | } 23 | postMessageReturnsOnCall map[int]struct { 24 | result1 string 25 | result2 string 26 | result3 error 27 | } 28 | invocations map[string][][]interface{} 29 | invocationsMutex sync.RWMutex 30 | } 31 | 32 | func (fake *FakeResponderSlackAPI) PostMessage(arg1 string, arg2 ...slacka.MsgOption) (string, string, error) { 33 | fake.postMessageMutex.Lock() 34 | ret, specificReturn := fake.postMessageReturnsOnCall[len(fake.postMessageArgsForCall)] 35 | fake.postMessageArgsForCall = append(fake.postMessageArgsForCall, struct { 36 | arg1 string 37 | arg2 []slacka.MsgOption 38 | }{arg1, arg2}) 39 | fake.recordInvocation("PostMessage", []interface{}{arg1, arg2}) 40 | fake.postMessageMutex.Unlock() 41 | if fake.PostMessageStub != nil { 42 | return fake.PostMessageStub(arg1, arg2...) 43 | } 44 | if specificReturn { 45 | return ret.result1, ret.result2, ret.result3 46 | } 47 | fakeReturns := fake.postMessageReturns 48 | return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3 49 | } 50 | 51 | func (fake *FakeResponderSlackAPI) PostMessageCallCount() int { 52 | fake.postMessageMutex.RLock() 53 | defer fake.postMessageMutex.RUnlock() 54 | return len(fake.postMessageArgsForCall) 55 | } 56 | 57 | func (fake *FakeResponderSlackAPI) PostMessageCalls(stub func(string, ...slacka.MsgOption) (string, string, error)) { 58 | fake.postMessageMutex.Lock() 59 | defer fake.postMessageMutex.Unlock() 60 | fake.PostMessageStub = stub 61 | } 62 | 63 | func (fake *FakeResponderSlackAPI) PostMessageArgsForCall(i int) (string, []slacka.MsgOption) { 64 | fake.postMessageMutex.RLock() 65 | defer fake.postMessageMutex.RUnlock() 66 | argsForCall := fake.postMessageArgsForCall[i] 67 | return argsForCall.arg1, argsForCall.arg2 68 | } 69 | 70 | func (fake *FakeResponderSlackAPI) PostMessageReturns(result1 string, result2 string, result3 error) { 71 | fake.postMessageMutex.Lock() 72 | defer fake.postMessageMutex.Unlock() 73 | fake.PostMessageStub = nil 74 | fake.postMessageReturns = struct { 75 | result1 string 76 | result2 string 77 | result3 error 78 | }{result1, result2, result3} 79 | } 80 | 81 | func (fake *FakeResponderSlackAPI) PostMessageReturnsOnCall(i int, result1 string, result2 string, result3 error) { 82 | fake.postMessageMutex.Lock() 83 | defer fake.postMessageMutex.Unlock() 84 | fake.PostMessageStub = nil 85 | if fake.postMessageReturnsOnCall == nil { 86 | fake.postMessageReturnsOnCall = make(map[int]struct { 87 | result1 string 88 | result2 string 89 | result3 error 90 | }) 91 | } 92 | fake.postMessageReturnsOnCall[i] = struct { 93 | result1 string 94 | result2 string 95 | result3 error 96 | }{result1, result2, result3} 97 | } 98 | 99 | func (fake *FakeResponderSlackAPI) Invocations() map[string][][]interface{} { 100 | fake.invocationsMutex.RLock() 101 | defer fake.invocationsMutex.RUnlock() 102 | fake.postMessageMutex.RLock() 103 | defer fake.postMessageMutex.RUnlock() 104 | copiedInvocations := map[string][][]interface{}{} 105 | for key, value := range fake.invocations { 106 | copiedInvocations[key] = value 107 | } 108 | return copiedInvocations 109 | } 110 | 111 | func (fake *FakeResponderSlackAPI) recordInvocation(key string, args []interface{}) { 112 | fake.invocationsMutex.Lock() 113 | defer fake.invocationsMutex.Unlock() 114 | if fake.invocations == nil { 115 | fake.invocations = map[string][][]interface{}{} 116 | } 117 | if fake.invocations[key] == nil { 118 | fake.invocations[key] = [][]interface{}{} 119 | } 120 | fake.invocations[key] = append(fake.invocations[key], args) 121 | } 122 | 123 | var _ slack.ResponderSlackAPI = new(FakeResponderSlackAPI) 124 | -------------------------------------------------------------------------------- /pkg/delegate/delegates/topiclookup/topiclookupfakes/fake_slack_api.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package topiclookupfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegates/topiclookup" 8 | "github.com/slack-go/slack" 9 | ) 10 | 11 | type FakeSlackAPI struct { 12 | GetConversationInfoStub func(string, bool) (*slack.Channel, error) 13 | getConversationInfoMutex sync.RWMutex 14 | getConversationInfoArgsForCall []struct { 15 | arg1 string 16 | arg2 bool 17 | } 18 | getConversationInfoReturns struct { 19 | result1 *slack.Channel 20 | result2 error 21 | } 22 | getConversationInfoReturnsOnCall map[int]struct { 23 | result1 *slack.Channel 24 | result2 error 25 | } 26 | invocations map[string][][]interface{} 27 | invocationsMutex sync.RWMutex 28 | } 29 | 30 | func (fake *FakeSlackAPI) GetConversationInfo(arg1 string, arg2 bool) (*slack.Channel, error) { 31 | fake.getConversationInfoMutex.Lock() 32 | ret, specificReturn := fake.getConversationInfoReturnsOnCall[len(fake.getConversationInfoArgsForCall)] 33 | fake.getConversationInfoArgsForCall = append(fake.getConversationInfoArgsForCall, struct { 34 | arg1 string 35 | arg2 bool 36 | }{arg1, arg2}) 37 | fake.recordInvocation("GetConversationInfo", []interface{}{arg1, arg2}) 38 | fake.getConversationInfoMutex.Unlock() 39 | if fake.GetConversationInfoStub != nil { 40 | return fake.GetConversationInfoStub(arg1, arg2) 41 | } 42 | if specificReturn { 43 | return ret.result1, ret.result2 44 | } 45 | fakeReturns := fake.getConversationInfoReturns 46 | return fakeReturns.result1, fakeReturns.result2 47 | } 48 | 49 | func (fake *FakeSlackAPI) GetConversationInfoCallCount() int { 50 | fake.getConversationInfoMutex.RLock() 51 | defer fake.getConversationInfoMutex.RUnlock() 52 | return len(fake.getConversationInfoArgsForCall) 53 | } 54 | 55 | func (fake *FakeSlackAPI) GetConversationInfoCalls(stub func(string, bool) (*slack.Channel, error)) { 56 | fake.getConversationInfoMutex.Lock() 57 | defer fake.getConversationInfoMutex.Unlock() 58 | fake.GetConversationInfoStub = stub 59 | } 60 | 61 | func (fake *FakeSlackAPI) GetConversationInfoArgsForCall(i int) (string, bool) { 62 | fake.getConversationInfoMutex.RLock() 63 | defer fake.getConversationInfoMutex.RUnlock() 64 | argsForCall := fake.getConversationInfoArgsForCall[i] 65 | return argsForCall.arg1, argsForCall.arg2 66 | } 67 | 68 | func (fake *FakeSlackAPI) GetConversationInfoReturns(result1 *slack.Channel, result2 error) { 69 | fake.getConversationInfoMutex.Lock() 70 | defer fake.getConversationInfoMutex.Unlock() 71 | fake.GetConversationInfoStub = nil 72 | fake.getConversationInfoReturns = struct { 73 | result1 *slack.Channel 74 | result2 error 75 | }{result1, result2} 76 | } 77 | 78 | func (fake *FakeSlackAPI) GetConversationInfoReturnsOnCall(i int, result1 *slack.Channel, result2 error) { 79 | fake.getConversationInfoMutex.Lock() 80 | defer fake.getConversationInfoMutex.Unlock() 81 | fake.GetConversationInfoStub = nil 82 | if fake.getConversationInfoReturnsOnCall == nil { 83 | fake.getConversationInfoReturnsOnCall = make(map[int]struct { 84 | result1 *slack.Channel 85 | result2 error 86 | }) 87 | } 88 | fake.getConversationInfoReturnsOnCall[i] = struct { 89 | result1 *slack.Channel 90 | result2 error 91 | }{result1, result2} 92 | } 93 | 94 | func (fake *FakeSlackAPI) Invocations() map[string][][]interface{} { 95 | fake.invocationsMutex.RLock() 96 | defer fake.invocationsMutex.RUnlock() 97 | fake.getConversationInfoMutex.RLock() 98 | defer fake.getConversationInfoMutex.RUnlock() 99 | copiedInvocations := map[string][][]interface{}{} 100 | for key, value := range fake.invocations { 101 | copiedInvocations[key] = value 102 | } 103 | return copiedInvocations 104 | } 105 | 106 | func (fake *FakeSlackAPI) recordInvocation(key string, args []interface{}) { 107 | fake.invocationsMutex.Lock() 108 | defer fake.invocationsMutex.Unlock() 109 | if fake.invocations == nil { 110 | fake.invocations = map[string][][]interface{}{} 111 | } 112 | if fake.invocations[key] == nil { 113 | fake.invocations[key] = [][]interface{}{} 114 | } 115 | fake.invocations[key] = append(fake.invocations[key], args) 116 | } 117 | 118 | var _ topiclookup.SlackAPI = new(FakeSlackAPI) 119 | -------------------------------------------------------------------------------- /pkg/slack/rtm/parser_test.go: -------------------------------------------------------------------------------- 1 | package rtm_test 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/dpb587/slack-delegate-bot/pkg/message" 8 | . "github.com/dpb587/slack-delegate-bot/pkg/slack/rtm" 9 | "github.com/slack-go/slack" 10 | 11 | . "github.com/onsi/ginkgo" 12 | . "github.com/onsi/gomega" 13 | ) 14 | 15 | var _ = Describe("Parser", func() { 16 | var subject *Parser 17 | var msg slack.Msg 18 | 19 | BeforeEach(func() { 20 | subject = NewParser(&slack.UserDetails{ 21 | ID: "U1234567", 22 | }) 23 | msg = slack.Msg{ 24 | Channel: "C1234567", 25 | Type: "message", 26 | Text: "help me, <@U1234567> you're my only hope.", 27 | Timestamp: fmt.Sprintf("%d.0", time.Now().Unix()), 28 | } 29 | }) 30 | 31 | Describe("ParseMessage", func() { 32 | It("ignores messages from ourselves", func() { 33 | msg.User = "U1234567" 34 | 35 | _, reply, err := subject.ParseMessage(msg) 36 | Expect(err).NotTo(HaveOccurred()) 37 | Expect(reply).To(BeFalse()) 38 | }) 39 | 40 | It("ignores non-message messages", func() { 41 | msg.Type = "non-message" 42 | 43 | _, reply, err := subject.ParseMessage(msg) 44 | Expect(err).NotTo(HaveOccurred()) 45 | Expect(reply).To(BeFalse()) 46 | }) 47 | 48 | It("ignores message deletions", func() { 49 | msg.SubType = "message_deleted" 50 | 51 | _, reply, err := subject.ParseMessage(msg) 52 | Expect(err).NotTo(HaveOccurred()) 53 | Expect(reply).To(BeFalse()) 54 | }) 55 | 56 | It("ignores topic changes", func() { 57 | msg.SubType = "group_topic" 58 | 59 | _, reply, err := subject.ParseMessage(msg) 60 | Expect(err).NotTo(HaveOccurred()) 61 | Expect(reply).To(BeFalse()) 62 | }) 63 | 64 | It("ignores topic change-looking messages", func() { 65 | msg.Text = "<@U1234567> set the channel topic: something else" 66 | 67 | _, reply, err := subject.ParseMessage(msg) 68 | Expect(err).NotTo(HaveOccurred()) 69 | Expect(reply).To(BeFalse()) 70 | }) 71 | 72 | It("parses attachment fallback", func() { 73 | msg.Attachments = []slack.Attachment{ 74 | { 75 | Fallback: msg.Text, 76 | }, 77 | } 78 | 79 | msg.Text = "something else entirely" 80 | 81 | res, reply, err := subject.ParseMessage(msg) 82 | Expect(err).NotTo(HaveOccurred()) 83 | Expect(reply).To(BeTrue()) 84 | Expect(res.RawText).To(Equal("something else entirely\n\n---\n\nhelp me, <@U1234567> you're my only hope.")) 85 | }) 86 | 87 | Context("direct messages", func() { 88 | BeforeEach(func() { 89 | msg.Channel = "D1234567" 90 | }) 91 | 92 | Context("channel mentions", func() { 93 | It("parses it as the interrupt target", func() { 94 | msg.Text = "<#C3EN0BFC0|credhub>" 95 | 96 | res, reply, err := subject.ParseMessage(msg) 97 | Expect(err).NotTo(HaveOccurred()) 98 | Expect(reply).To(BeTrue()) 99 | Expect(res.ChannelID).To(Equal("D1234567")) 100 | Expect(res.TargetChannelID).To(Equal("C3EN0BFC0")) 101 | Expect(res.Type).To(Equal(message.DirectMessageMessageType)) 102 | }) 103 | }) 104 | }) 105 | 106 | It("ignores messages without a self-mention", func() { 107 | msg.Text = "chit chat" 108 | 109 | _, reply, err := subject.ParseMessage(msg) 110 | Expect(err).ToNot(HaveOccurred()) 111 | Expect(reply).To(BeFalse()) 112 | }) 113 | 114 | It("ignores messages mentioning others", func() { 115 | msg.Text = "<@U9876543> knows stuff" 116 | 117 | _, reply, err := subject.ParseMessage(msg) 118 | Expect(err).ToNot(HaveOccurred()) 119 | Expect(reply).To(BeFalse()) 120 | }) 121 | 122 | It("hears itself mentioned", func() { 123 | res, reply, err := subject.ParseMessage(msg) 124 | Expect(err).ToNot(HaveOccurred()) 125 | Expect(reply).To(BeTrue()) 126 | Expect(res.ChannelID).To(Equal("C1234567")) 127 | Expect(res.TargetChannelID).To(Equal("C1234567")) 128 | Expect(res.Type).To(Equal(message.ChannelMessageType)) 129 | }) 130 | 131 | Context("external channel reference", func() { 132 | BeforeEach(func() { 133 | msg.Text = "hey <#C9876543|star-wars> <@U1234567>, help!" 134 | }) 135 | 136 | It("supports prefixes", func() { 137 | res, reply, err := subject.ParseMessage(msg) 138 | Expect(err).ToNot(HaveOccurred()) 139 | Expect(reply).To(BeTrue()) 140 | Expect(res.ChannelID).To(Equal("C1234567")) 141 | Expect(res.TargetChannelID).To(Equal("C9876543")) 142 | Expect(res.Type).To(Equal(message.ChannelMessageType)) 143 | }) 144 | }) 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /pkg/configutil/segmented_secrets.go: -------------------------------------------------------------------------------- 1 | package configutil 2 | 3 | import ( 4 | "crypto/sha1" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/pkg/errors" 10 | "gopkg.in/yaml.v2" 11 | ) 12 | 13 | var segmentedSecretPaths = []string{ 14 | "/pairist/password", 15 | "/pagerduty/api_key", 16 | } 17 | 18 | func DesanitizeConfig(in string, secrets map[string]interface{}) (string, map[string]interface{}, error) { 19 | var intermediate interface{} 20 | 21 | err := yaml.Unmarshal([]byte(in), &intermediate) 22 | if err != nil { 23 | return "", nil, errors.Wrap(err, "unmarshalling") 24 | } 25 | 26 | intermediate, usedSecrets, err := desanitizeConfig(intermediate, secrets, nil) 27 | if err != nil { 28 | return "", nil, err 29 | } 30 | 31 | out, err := yaml.Marshal(intermediate) 32 | if err != nil { 33 | return "", nil, errors.Wrap(err, "marshalling") 34 | } 35 | 36 | return string(out), usedSecrets, nil 37 | } 38 | 39 | func desanitizeConfig(inT interface{}, secrets map[string]interface{}, chain []string) (interface{}, map[string]interface{}, error) { 40 | var err error 41 | usedSecrets := map[string]interface{}{} 42 | var localUsedSecrets map[string]interface{} 43 | 44 | switch in := inT.(type) { 45 | case []interface{}: 46 | for key, val := range in { 47 | in[key], localUsedSecrets, err = desanitizeConfig(val, secrets, append(chain, fmt.Sprintf("%v", key))) 48 | if err != nil { 49 | return nil, nil, errors.Wrapf(err, "sanitizing node %s/%d", strings.Join(chain, "/"), key) 50 | } 51 | 52 | for k, v := range localUsedSecrets { 53 | usedSecrets[k] = v 54 | } 55 | } 56 | case map[interface{}]interface{}: 57 | for key, val := range in { 58 | in[key], localUsedSecrets, err = desanitizeConfig(val, secrets, append(chain, fmt.Sprintf("%v", key))) 59 | if err != nil { 60 | return nil, nil, errors.Wrapf(err, "sanitizing node %s/%v", strings.Join(chain, "/"), key) 61 | } 62 | 63 | for k, v := range localUsedSecrets { 64 | usedSecrets[k] = v 65 | } 66 | } 67 | case string: 68 | if !strings.HasPrefix(in, "@secret:") { 69 | break 70 | } 71 | 72 | secretID := strings.TrimPrefix(in, "@secret:") 73 | 74 | secret, found := secrets[secretID] 75 | if !found { 76 | // fail (un?)safe 77 | break 78 | } 79 | 80 | inT = secret 81 | usedSecrets[secretID] = secret 82 | } 83 | 84 | return inT, usedSecrets, nil 85 | } 86 | 87 | func SanitizeConfig(in string) (string, map[string]interface{}, error) { 88 | var intermediate interface{} 89 | 90 | err := yaml.Unmarshal([]byte(in), &intermediate) 91 | if err != nil { 92 | return "", nil, errors.Wrap(err, "unmarshalling") 93 | } 94 | 95 | intermediate, secrets, err := sanitizeConfig(intermediate, map[string]interface{}{}, nil) 96 | if err != nil { 97 | return "", nil, err 98 | } 99 | 100 | out, err := yaml.Marshal(intermediate) 101 | if err != nil { 102 | return "", nil, errors.Wrap(err, "marshalling") 103 | } 104 | 105 | return string(out), secrets, nil 106 | } 107 | 108 | func sanitizeConfig(inT interface{}, secrets map[string]interface{}, chain []string) (interface{}, map[string]interface{}, error) { 109 | var err error 110 | 111 | switch in := inT.(type) { 112 | case []interface{}: 113 | for key, val := range in { 114 | in[key], secrets, err = sanitizeConfig(val, secrets, append(chain, fmt.Sprintf("%v", key))) 115 | if err != nil { 116 | return nil, nil, errors.Wrapf(err, "sanitizing node %s/%d", strings.Join(chain, "/"), key) 117 | } 118 | } 119 | case map[interface{}]interface{}: 120 | for key, val := range in { 121 | in[key], secrets, err = sanitizeConfig(val, secrets, append(chain, fmt.Sprintf("%v", key))) 122 | if err != nil { 123 | return nil, nil, errors.Wrapf(err, "sanitizing node %s/%v", strings.Join(chain, "/"), key) 124 | } 125 | } 126 | default: 127 | secretID, secret := checkSecretPath(chain) 128 | if !secret { 129 | break 130 | } 131 | 132 | secrets[secretID] = inT 133 | inT = fmt.Sprintf("@secret:%s", secretID) 134 | } 135 | 136 | return inT, secrets, nil 137 | } 138 | 139 | func checkSecretPath(chain []string) (string, bool) { 140 | chainJoin := strings.Join(chain, "/") 141 | 142 | for _, pathset := range segmentedSecretPaths { 143 | if strings.HasSuffix(chainJoin, pathset) { 144 | h := sha1.New() 145 | h.Write([]byte(chainJoin)) 146 | h.Write([]byte(fmt.Sprintf("%d", time.Now().Unix()))) 147 | 148 | return fmt.Sprintf("%x", h.Sum(nil))[0:12], true 149 | } 150 | } 151 | 152 | return "", false 153 | } 154 | -------------------------------------------------------------------------------- /pkg/slack/responder_test.go: -------------------------------------------------------------------------------- 1 | package slack_test 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/dpb587/slack-delegate-bot/pkg/delegate" 7 | "github.com/dpb587/slack-delegate-bot/pkg/delegate/delegatefakes" 8 | "github.com/dpb587/slack-delegate-bot/pkg/message" 9 | . "github.com/dpb587/slack-delegate-bot/pkg/slack" 10 | "github.com/dpb587/slack-delegate-bot/pkg/slack/slackfakes" 11 | "github.com/slack-go/slack" 12 | 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | ) 16 | 17 | var _ = Describe("MessageHandler", func() { 18 | var subject *Responder 19 | var msg message.Message 20 | var delegator *delegatefakes.FakeDelegator 21 | var slackAPI *slackfakes.FakeResponderSlackAPI 22 | 23 | BeforeEach(func() { 24 | slackAPI = &slackfakes.FakeResponderSlackAPI{} 25 | delegator = &delegatefakes.FakeDelegator{} 26 | 27 | subject = NewResponder(slackAPI, delegator) 28 | 29 | msg = message.Message{ 30 | ChannelID: "C1234567", 31 | RawTimestamp: "fake-timestamp", 32 | Type: message.ChannelMessageType, 33 | } 34 | }) 35 | 36 | Context("delegate handling", func() { 37 | It("propagates errors", func() { 38 | delegator.DelegateReturns(nil, errors.New("fake-err1")) 39 | 40 | err := subject.ProcessMessage(msg) 41 | Expect(err).To(HaveOccurred()) 42 | Expect(err.Error()).To(ContainSubstring("fake-err1")) 43 | Expect(slackAPI.PostMessageCallCount()).To(Equal(0)) 44 | }) 45 | 46 | Context("delegates provided", func() { 47 | BeforeEach(func() { 48 | delegator.DelegateReturns( 49 | []message.Delegate{ 50 | delegate.Literal{Text: "something"}, 51 | delegate.Literal{Text: "completely"}, 52 | delegate.Literal{Text: "different"}, 53 | }, 54 | nil, 55 | ) 56 | }) 57 | 58 | It("responds to direct messages", func() { 59 | msg.ChannelID = "D1234567" 60 | msg.Type = message.DirectMessageMessageType 61 | 62 | err := subject.ProcessMessage(msg) 63 | Expect(err).NotTo(HaveOccurred()) 64 | Expect(slackAPI.PostMessageCallCount()).To(Equal(1)) 65 | 66 | channel, opts := slackAPI.PostMessageArgsForCall(0) 67 | endpoint, values, err := slack.UnsafeApplyMsgOptions("fake-token", channel, "fake-url/", opts...) 68 | Expect(err).ToNot(HaveOccurred()) 69 | Expect(endpoint).To(Equal("fake-url/chat.postMessage")) 70 | Expect(values.Get("channel")).To(Equal("D1234567")) 71 | Expect(values.Get("text")).To(Equal("something completely different")) 72 | 73 | // Expect(res.Text).To(Equal("something completely different")) 74 | }) 75 | 76 | It("responds to channels in threads", func() { 77 | err := subject.ProcessMessage(msg) 78 | Expect(err).NotTo(HaveOccurred()) 79 | Expect(slackAPI.PostMessageCallCount()).To(Equal(1)) 80 | 81 | channel, opts := slackAPI.PostMessageArgsForCall(0) 82 | endpoint, values, err := slack.UnsafeApplyMsgOptions("fake-token", channel, "fake-url/", opts...) 83 | Expect(err).ToNot(HaveOccurred()) 84 | Expect(endpoint).To(Equal("fake-url/chat.postMessage")) 85 | Expect(values.Get("channel")).To(Equal("C1234567")) 86 | Expect(values.Get("thread_ts")).To(Equal("fake-timestamp")) 87 | Expect(values.Get("text")).To(Equal("^ something completely different")) 88 | 89 | // Expect(res).ToNot(BeNil()) 90 | // Expect(res.Channel).To(Equal("C1234567")) 91 | // Expect(res.ThreadTimestamp).To(Equal("fake-timestamp")) 92 | // Expect(res.Text).To(Equal("^ something completely different")) 93 | }) 94 | 95 | It("responds to existing threads", func() { 96 | msg.RawThreadTimestamp = "fake-earlier-timestamp" 97 | 98 | err := subject.ProcessMessage(msg) 99 | Expect(err).NotTo(HaveOccurred()) 100 | Expect(slackAPI.PostMessageCallCount()).To(Equal(1)) 101 | 102 | channel, opts := slackAPI.PostMessageArgsForCall(0) 103 | endpoint, values, err := slack.UnsafeApplyMsgOptions("fake-token", channel, "fake-url/", opts...) 104 | Expect(err).ToNot(HaveOccurred()) 105 | Expect(endpoint).To(Equal("fake-url/chat.postMessage")) 106 | Expect(values.Get("channel")).To(Equal("C1234567")) 107 | Expect(values.Get("thread_ts")).To(Equal("fake-earlier-timestamp")) 108 | Expect(values.Get("text")).To(Equal("^ something completely different")) 109 | 110 | // Expect(res).ToNot(BeNil()) 111 | // Expect(res.Channel).To(Equal("C1234567")) 112 | // Expect(res.ThreadTimestamp).To(Equal("fake-earlier-timestamp")) 113 | // Expect(res.Text).To(Equal("^ something completely different")) 114 | }) 115 | }) 116 | 117 | Context("no delegates", func() { 118 | It("stays quiet", func() { 119 | err := subject.ProcessMessage(msg) 120 | Expect(err).NotTo(HaveOccurred()) 121 | Expect(slackAPI.PostMessageCallCount()).To(Equal(0)) 122 | }) 123 | }) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /pkg/slack/event/parser_test.go: -------------------------------------------------------------------------------- 1 | package event_test 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | "github.com/slack-go/slack" 10 | "github.com/slack-go/slack/slackevents" 11 | 12 | "github.com/dpb587/slack-delegate-bot/pkg/message" 13 | ourslack "github.com/dpb587/slack-delegate-bot/pkg/slack" 14 | . "github.com/dpb587/slack-delegate-bot/pkg/slack/event" 15 | "github.com/dpb587/slack-delegate-bot/pkg/slack/slackfakes" 16 | ) 17 | 18 | var _ = Describe("Parser", func() { 19 | const appID = "A1234567" 20 | const teamID = "T1234567" 21 | const botUserID = "U1234567" 22 | const realUserID = "U9876543" 23 | 24 | const localChannelID = "C1234567" 25 | const remoteChannelID = "C9876543" 26 | 27 | var subject *Parser 28 | var fakeUserLookupSlackAPI *slackfakes.FakeUserLookupSlackAPI 29 | var eventRaw slackevents.EventsAPIEvent 30 | 31 | BeforeEach(func() { 32 | fakeUserLookupSlackAPI = &slackfakes.FakeUserLookupSlackAPI{} 33 | fakeUserLookupSlackAPI.GetUserInfoStub = func(in string) (*slack.User, error) { 34 | if in == botUserID { 35 | return &slack.User{ 36 | Profile: slack.UserProfile{ 37 | ApiAppID: appID, 38 | }, 39 | }, nil 40 | } 41 | 42 | return &slack.User{}, nil 43 | } 44 | 45 | subject = NewParser(ourslack.NewUserLookup(fakeUserLookupSlackAPI)) 46 | eventRaw = slackevents.EventsAPIEvent{ 47 | APIAppID: appID, 48 | TeamID: teamID, 49 | } 50 | }) 51 | 52 | Describe("ParseAppMention", func() { 53 | var event slackevents.AppMentionEvent 54 | 55 | BeforeEach(func() { 56 | event = slackevents.AppMentionEvent{ 57 | User: realUserID, 58 | UserTeam: teamID, 59 | Channel: localChannelID, 60 | Text: fmt.Sprintf("hi <@%s> i haz questions", realUserID), 61 | TimeStamp: "1588524033.0", 62 | ThreadTimeStamp: "1588524033.1", 63 | } 64 | }) 65 | 66 | It("parses a default test message", func() { 67 | msg, reply, err := subject.ParseAppMention(eventRaw, event) 68 | Expect(err).ToNot(HaveOccurred()) 69 | Expect(reply).To(BeTrue()) 70 | Expect(msg.ChannelTeamID).To(Equal(teamID)) 71 | Expect(msg.ChannelID).To(Equal(localChannelID)) 72 | Expect(msg.UserTeamID).To(Equal(teamID)) 73 | Expect(msg.UserID).To(Equal(realUserID)) 74 | Expect(msg.TargetChannelTeamID).To(Equal(teamID)) 75 | Expect(msg.TargetChannelID).To(Equal(localChannelID)) 76 | Expect(msg.RawTimestamp).To(Equal("1588524033.0")) 77 | Expect(msg.RawThreadTimestamp).To(Equal("1588524033.1")) 78 | Expect(msg.RawText).To(Equal(fmt.Sprintf("hi <@%s> i haz questions", realUserID))) 79 | Expect(msg.Type).To(Equal(message.ChannelMessageType)) 80 | Expect(msg.Time.Format(time.RFC3339)).To(Equal("2020-05-03T16:40:33Z")) 81 | }) 82 | 83 | It("ignores messages from self", func() { 84 | event.User = botUserID 85 | 86 | _, reply, err := subject.ParseAppMention(eventRaw, event) 87 | Expect(err).ToNot(HaveOccurred()) 88 | Expect(reply).To(BeFalse()) 89 | }) 90 | 91 | It("captures contextual channels", func() { 92 | event.Text = fmt.Sprintf("hi <#%s> <@%s> i haz questions", remoteChannelID, botUserID) 93 | 94 | msg, reply, err := subject.ParseAppMention(eventRaw, event) 95 | Expect(err).ToNot(HaveOccurred()) 96 | Expect(reply).To(BeTrue()) 97 | Expect(msg.ChannelID).To(Equal(localChannelID)) 98 | Expect(msg.TargetChannelID).To(Equal(remoteChannelID)) 99 | }) 100 | 101 | PIt("parses attachment fallback", func() { 102 | // msg.Attachments = []slack.Attachment{ 103 | // { 104 | // Fallback: msg.Text, 105 | // }, 106 | // } 107 | 108 | // msg.Text = "something else entirely" 109 | 110 | // res, err := subject.ParseMessage(msg) 111 | // Expect(err).NotTo(HaveOccurred()) 112 | // Expect(res).ToNot(BeNil()) 113 | // Expect(res.Text).To(Equal("something else entirely\n\n---\n\nhelp me, <@U1234567> you're my only hope.")) 114 | }) 115 | }) 116 | 117 | Describe("ParseMessage", func() { 118 | const directID = "D1234567" 119 | 120 | var event slackevents.MessageEvent 121 | 122 | BeforeEach(func() { 123 | event = slackevents.MessageEvent{ 124 | User: realUserID, 125 | UserTeam: teamID, 126 | Channel: directID, 127 | Text: fmt.Sprintf("hi <#%s> <@%s> i haz questions", remoteChannelID, botUserID), 128 | TimeStamp: "1588524033.0", 129 | ThreadTimeStamp: "1588524033.1", 130 | ChannelType: "mpim", 131 | } 132 | }) 133 | 134 | It("ignores messages from self", func() { 135 | event.User = botUserID 136 | 137 | _, reply, err := subject.ParseMessage(eventRaw, event) 138 | Expect(err).ToNot(HaveOccurred()) 139 | Expect(reply).To(BeFalse()) 140 | }) 141 | 142 | It("ignore mentions without a channel", func() { 143 | event.Text = fmt.Sprintf("hi <@%s> i haz questions", botUserID) 144 | 145 | _, reply, err := subject.ParseMessage(eventRaw, event) 146 | Expect(err).ToNot(HaveOccurred()) 147 | Expect(reply).To(BeFalse()) 148 | }) 149 | 150 | It("ignore channels without a mention", func() { 151 | event.Text = fmt.Sprintf("tell me about <#%s> please", remoteChannelID) 152 | 153 | _, reply, err := subject.ParseMessage(eventRaw, event) 154 | Expect(err).ToNot(HaveOccurred()) 155 | Expect(reply).To(BeFalse()) 156 | }) 157 | 158 | It("ignore channel mention syntax with non-bot user", func() { 159 | event.Text = fmt.Sprintf("hi <#%s> <@%s> i haz questions", remoteChannelID, realUserID) 160 | 161 | _, reply, err := subject.ParseMessage(eventRaw, event) 162 | Expect(err).ToNot(HaveOccurred()) 163 | Expect(reply).To(BeFalse()) 164 | }) 165 | 166 | PIt("parses attachment fallback", func() { 167 | // msg.Attachments = []slack.Attachment{ 168 | // { 169 | // Fallback: msg.Text, 170 | // }, 171 | // } 172 | 173 | // msg.Text = "something else entirely" 174 | 175 | // res, err := subject.ParseMessage(msg) 176 | // Expect(err).NotTo(HaveOccurred()) 177 | // Expect(res).ToNot(BeNil()) 178 | // Expect(res.Text).To(Equal("something else entirely\n\n---\n\nhelp me, <@U1234567> you're my only hope.")) 179 | }) 180 | 181 | Context("overhearing mentions", func() { 182 | It("parses a default test message", func() { 183 | msg, reply, err := subject.ParseMessage(eventRaw, event) 184 | Expect(err).ToNot(HaveOccurred()) 185 | Expect(reply).To(BeTrue()) 186 | Expect(msg.ChannelTeamID).To(Equal(teamID)) 187 | Expect(msg.ChannelID).To(Equal(directID)) 188 | Expect(msg.UserTeamID).To(Equal(teamID)) 189 | Expect(msg.UserID).To(Equal(realUserID)) 190 | Expect(msg.TargetChannelTeamID).To(Equal(teamID)) 191 | Expect(msg.TargetChannelID).To(Equal(remoteChannelID)) 192 | Expect(msg.RawTimestamp).To(Equal("1588524033.0")) 193 | Expect(msg.RawThreadTimestamp).To(Equal("1588524033.1")) 194 | Expect(msg.RawText).To(Equal(fmt.Sprintf("hi <#%s> <@%s> i haz questions", remoteChannelID, botUserID))) 195 | Expect(msg.Type).To(Equal(message.ChannelMessageType)) 196 | Expect(msg.Time.Format(time.RFC3339)).To(Equal("2020-05-03T16:40:33Z")) 197 | }) 198 | 199 | It("captures contextual channels", func() { 200 | event.Text = fmt.Sprintf("hi <#%s> <@%s> i haz questions", remoteChannelID, botUserID) 201 | 202 | msg, reply, err := subject.ParseMessage(eventRaw, event) 203 | Expect(err).ToNot(HaveOccurred()) 204 | Expect(reply).To(BeTrue()) 205 | Expect(msg.ChannelID).To(Equal(directID)) 206 | Expect(msg.TargetChannelID).To(Equal(remoteChannelID)) 207 | }) 208 | }) 209 | 210 | Context("direct", func() { 211 | BeforeEach(func() { 212 | event.ChannelType = "im" 213 | event.Text = fmt.Sprintf("tell me about <#%s> please", remoteChannelID) 214 | }) 215 | 216 | It("parses a default test message", func() { 217 | msg, reply, err := subject.ParseMessage(eventRaw, event) 218 | Expect(err).ToNot(HaveOccurred()) 219 | Expect(reply).To(BeTrue()) 220 | Expect(msg.ChannelTeamID).To(Equal(teamID)) 221 | Expect(msg.ChannelID).To(Equal(directID)) 222 | Expect(msg.UserTeamID).To(Equal(teamID)) 223 | Expect(msg.UserID).To(Equal(realUserID)) 224 | Expect(msg.TargetChannelTeamID).To(Equal(teamID)) 225 | Expect(msg.TargetChannelID).To(Equal(remoteChannelID)) 226 | Expect(msg.RawTimestamp).To(Equal("1588524033.0")) 227 | Expect(msg.RawThreadTimestamp).To(Equal("1588524033.1")) 228 | Expect(msg.RawText).To(Equal(fmt.Sprintf("tell me about <#%s> please", remoteChannelID))) 229 | Expect(msg.Type).To(Equal(message.DirectMessageMessageType)) 230 | Expect(msg.Time.Format(time.RFC3339)).To(Equal("2020-05-03T16:40:33Z")) 231 | }) 232 | 233 | It("ignores messages without a channel", func() { 234 | event.Text = fmt.Sprintf("hi <@%s> i haz questions", realUserID) 235 | 236 | _, reply, err := subject.ParseMessage(eventRaw, event) 237 | Expect(err).ToNot(HaveOccurred()) 238 | Expect(reply).To(BeFalse()) 239 | }) 240 | }) 241 | }) 242 | }) 243 | --------------------------------------------------------------------------------