Skip to content

Commit

Permalink
Add broadcast channel as input option (#24)
Browse files Browse the repository at this point in the history
* Add broadcast channel as input option

* debug

* update

* update

* update

* update

* update

* toml update

* string updates

* update

* update

Co-authored-by: Karl-Johan Grahn <[email protected]>
  • Loading branch information
karl-johan-grahn and Karl-Johan Grahn authored Dec 6, 2021
1 parent 01b8a3f commit 2b35036
Show file tree
Hide file tree
Showing 16 changed files with 190 additions and 67 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/docker-publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
# Extract metadata for Docker for subsequent registry push
- name: Extract Docker metadata
id: meta
uses: docker/[email protected].0
uses: docker/[email protected].1
with:
images: ${{env.REGISTRY}}/${{env.IMAGE_NAME}}
tags: |
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.12.0] - 2021-11-29
### Adds
- Add broadcast channel as input option, add dispatch action when characters are entered, update to Slack Go API v0.10.0

## [0.11.0] - 2021-11-16
### Updates
- Update to Go 1.17.3
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
FROM golang:1.17.3 AS builder
FROM docker.io/library/golang:1.17.3 AS builder

WORKDIR /go/src/github.com/karl-johan-grahn/devopsbot

COPY . ./

RUN make build

FROM alpine:3.14.2
FROM docker.io/library/alpine:3.14.2

RUN apk add --no-cache ca-certificates=20191127-r5

Expand Down
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ SHELL := /bin/bash

export GOPRIVATE=github.com/karl-johan-grahn/devopsbot

PODMAN?=0

REVISION = $(shell git rev-parse --short HEAD)
VERSION = $(shell cat version.txt)

Expand All @@ -15,11 +17,17 @@ VERSION_FLAG = -X $(shell go list ./version).Version=$(VERSION)
GOOS ?= $(shell go version | sed 's/^.*\ \([a-z0-9]*\)\/\([a-z0-9]*\)/\1/')
GOARCH ?= $(shell go version | sed 's/^.*\ \([a-z0-9]*\)\/\([a-z0-9]*\)/\2/')

ifeq ($(PODMAN), 1)
docker=podman
else
docker=docker
endif

version.txt:
@./gen_version.sh > $@

image.iid: version.txt Dockerfile
@docker build \
@$(docker) build \
--build-arg REVISION=$(REVISION) \
--build-arg VERSION=$(VERSION) \
--iidfile $@ \
Expand Down
6 changes: 4 additions & 2 deletions bot/active.en.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{
"ArchiveIncidentChannel": "Archive incident channel",
"BroadcastChannel": "Broadcast channel",
"BroadcastChannelHint": "The channels listed are the ones that the bot has been added to as a user",
"Cancel": "Cancel",
"Commander": "Commander",
"CommanderHint": "The incident commander coordinates, communicates, and controls the response",
Expand All @@ -9,7 +11,7 @@
"HelpMessage": "These are the available commands:\n> `/devopsbot help` - Get this help\n> `/devopsbot incident` - Declare an incident\n> `/devopsbot resolve` - Resolve an incident",
"Incident": "Incident",
"IncidentChannelNamePattern": "Choose a channel that starts with 'inc_'",
"IncidentCreationDescription": "This will create a new incident Slack channel, and notify {{.broadcastChannel}} about the incident. This incident response system is based on the Incident Command System (ICS).",
"IncidentCreationDescription": "This will create a new incident Slack channel, and notify about the incident in a broadcast channel. This incident response system is based on the Incident Command System.",
"IncidentName": "Incident name",
"IncidentNameHint": "Incident names may only contain lowercase letters, numbers, hyphens, and underscores, and must be 60 characters or less",
"IncidentSummary": "Incident summary",
Expand All @@ -19,7 +21,7 @@
"Resolution": "Resolution",
"ResolveAnIncident": "Resolve an incident",
"ResolveIncident": "Resolve incident",
"ResolveIncidentDescription": "This will resolve the chosen incident and notify {{.broadcastChannel}} about the resolution",
"ResolveIncidentDescription": "This will resolve an incident and notify about the resolution in a broadcast channel",
"Responder": "Responder",
"ResponderHint": "The responder leads the work of resolving the incident",
"SecurityIncident": "Security Incident",
Expand Down
115 changes: 100 additions & 15 deletions bot/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ type ugMembers struct {
}

type Opts struct {
// UserAccessToken - the Slack user access token
UserAccessToken string
// SigningSecret - the signing secret from the Slack app config
SigningSecret string
// BroadcastChannelID - the ID of the Slack channel the bot will broadcast in
Expand Down Expand Up @@ -168,11 +170,53 @@ func (h *botHandler) cmdIncident(ctx context.Context, w http.ResponseWriter, cmd
h.opts.Localizer.MustLocalize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "IncidentCreationDescription",
Other: "This will create a new incident Slack channel, and notify {{.broadcastChannel}} about the incident. This incident response system is based on the Incident Command System (ICS)."},
Other: "This will create a new incident Slack channel, and notify about the incident in a broadcast channel. This incident response system is based on the Incident Command System."},
TemplateData: map[string]string{"broadcastChannel": fmt.Sprintf("<#%s>", h.opts.BroadcastChannelID)},
}), false, false)
contextBlock := slack.NewContextBlock("context", contextText)

broadcastChLabel := slack.NewTextBlockObject(slack.PlainTextType,
h.opts.Localizer.MustLocalize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "BroadcastChannel",
Other: "Broadcast channel"},
}), false, false)
// TODO: Only get one page of results for now, implement pagination later if needed
authTestResp, _ := h.slackClient.AuthTestContext(ctx)
channels, _, err := h.slackClient.GetConversationsForUserContext(ctx, &slack.GetConversationsForUserParameters{
UserID: authTestResp.UserID,
Limit: 100,
ExcludeArchived: true,
})
if err != nil {
log.Error().Err(err).Msg("Failed to get conversations for bot")
w.WriteHeader(http.StatusInternalServerError)
return err
}
if channels == nil {
// If the bot has not been added as a member to any channel at all, it is not possible to post error in the UI
log.Error().Err(err).Msg("Bot must be added to a channel for broadcasting messages")
w.WriteHeader(http.StatusInternalServerError)
return err
}
channelIDs := []string{}
for i := range channels {
channelIDs = append(channelIDs, fmt.Sprintf("<#%s>", channels[i].ID))
}
botChannels := createOptionBlockObjects(channelIDs)
broadcastChOption := slack.NewOptionsSelectBlockElement(slack.OptTypeStatic, nil, "broadcast_channel", botChannels...)
initialChannel := fmt.Sprintf("<#%s>", h.opts.BroadcastChannelID)
initialChannelLabel := slack.NewTextBlockObject(slack.PlainTextType, initialChannel, false, false)
initialChannelOptionBlockObject := slack.NewOptionBlockObject(initialChannel, initialChannelLabel, nil)
broadcastChOption.InitialOption = initialChannelOptionBlockObject
broadcastChBlock := slack.NewInputBlock("broadcast_channel", broadcastChLabel, broadcastChOption)
broadcastChBlock.Hint = slack.NewTextBlockObject(slack.PlainTextType,
h.opts.Localizer.MustLocalize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "BroadcastChannelHint",
Other: "The channels listed are the ones that the bot has been added to as a user"},
}), false, false)

// Only the inputs in input blocks will be included in view_submission’s view.state.values: https://slack.dev/java-slack-sdk/guides/modals
incidentNameText := slack.NewTextBlockObject(slack.PlainTextType,
h.opts.Localizer.MustLocalize(&i18n.LocalizeConfig{
Expand All @@ -183,6 +227,9 @@ func (h *botHandler) cmdIncident(ctx context.Context, w http.ResponseWriter, cmd
incidentNameElement := slack.NewPlainTextInputBlockElement(incidentNameText, "incident_name")
// Name will be prefixed with "inc_" and postfixed with "_<date>" so keep it shorter than the maximum 80 characters
incidentNameElement.MaxLength = 60
incidentNameElement.DispatchActionConfig = &slack.DispatchActionConfig{
TriggerActionsOn: []string{"on_character_entered"},
}
incidentNameBlock := slack.NewInputBlock("incident_name", incidentNameText, incidentNameElement)
incidentNameBlock.DispatchAction = true
incidentNameBlock.Hint = slack.NewTextBlockObject(slack.PlainTextType,
Expand Down Expand Up @@ -251,7 +298,7 @@ func (h *botHandler) cmdIncident(ctx context.Context, w http.ResponseWriter, cmd
w.WriteHeader(http.StatusInternalServerError)
return err
}
envOptions := createOptionBlockObjects(envs, false)
envOptions := createOptionBlockObjects(envs)
envOptionsBlock := slack.NewCheckboxGroupsBlockElement("incident_environment_affected", envOptions...)
environmentBlock := slack.NewInputBlock("incident_environment_affected", envTxt, envOptionsBlock)

Expand All @@ -267,7 +314,7 @@ func (h *botHandler) cmdIncident(ctx context.Context, w http.ResponseWriter, cmd
w.WriteHeader(http.StatusInternalServerError)
return err
}
regionOptions := createOptionBlockObjects(regions, false)
regionOptions := createOptionBlockObjects(regions)
regionOptionsBlock := slack.NewCheckboxGroupsBlockElement("incident_region_affected", regionOptions...)
regionBlock := slack.NewInputBlock("incident_region_affected", regionTxt, regionOptionsBlock)

Expand Down Expand Up @@ -296,6 +343,7 @@ func (h *botHandler) cmdIncident(ctx context.Context, w http.ResponseWriter, cmd
blocks := slack.Blocks{
BlockSet: []slack.Block{
contextBlock,
broadcastChBlock,
incidentNameBlock,
securityBlock,
responderBlock,
Expand All @@ -316,7 +364,7 @@ func (h *botHandler) cmdIncident(ctx context.Context, w http.ResponseWriter, cmd
modalVReq.ClearOnClose = true
modalVReq.CallbackID = "declare_incident"

_, err := h.slackClient.OpenViewContext(ctx, cmd.TriggerID, modalVReq)
_, err = h.slackClient.OpenViewContext(ctx, cmd.TriggerID, modalVReq)
if err != nil {
log.Error().Err(err).Msg("Error opening view")
w.WriteHeader(http.StatusInternalServerError)
Expand Down Expand Up @@ -354,11 +402,53 @@ func (h *botHandler) cmdResolveIncident(ctx context.Context, w http.ResponseWrit
h.opts.Localizer.MustLocalize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "ResolveIncidentDescription",
Other: "This will resolve the chosen incident and notify {{.broadcastChannel}} about the resolution"},
Other: "This will resolve an incident and notify about the resolution in a broadcast channel"},
TemplateData: map[string]string{"broadcastChannel": fmt.Sprintf("<#%s>", h.opts.BroadcastChannelID)},
}), false, false)
contextBlock := slack.NewContextBlock("context", contextText)

broadcastChLabel := slack.NewTextBlockObject(slack.PlainTextType,
h.opts.Localizer.MustLocalize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "BroadcastChannel",
Other: "Broadcast channel"},
}), false, false)
// TODO: Only get one page of results for now, implement pagination later if needed
authTestResp, _ := h.slackClient.AuthTestContext(ctx)
channels, _, err := h.slackClient.GetConversationsForUserContext(ctx, &slack.GetConversationsForUserParameters{
UserID: authTestResp.UserID,
Limit: 100,
ExcludeArchived: true,
})
if err != nil {
log.Error().Err(err).Msg("Failed to get conversations for bot")
w.WriteHeader(http.StatusInternalServerError)
return err
}
if channels == nil {
// If the bot has not been added as a member to any channel at all, it is not possible to post error in the UI
log.Error().Err(err).Msg("Bot must be added to a channel for broadcasting messages")
w.WriteHeader(http.StatusInternalServerError)
return err
}
channelIDs := []string{}
for i := range channels {
channelIDs = append(channelIDs, fmt.Sprintf("<#%s>", channels[i].ID))
}
botChannels := createOptionBlockObjects(channelIDs)
broadcastChOption := slack.NewOptionsSelectBlockElement(slack.OptTypeStatic, nil, "broadcast_channel", botChannels...)
initialChannel := fmt.Sprintf("<#%s>", h.opts.BroadcastChannelID)
initialChannelLabel := slack.NewTextBlockObject(slack.PlainTextType, initialChannel, false, false)
initialChannelOptionBlockObject := slack.NewOptionBlockObject(initialChannel, initialChannelLabel, nil)
broadcastChOption.InitialOption = initialChannelOptionBlockObject
broadcastChBlock := slack.NewInputBlock("broadcast_channel", broadcastChLabel, broadcastChOption)
broadcastChBlock.Hint = slack.NewTextBlockObject(slack.PlainTextType,
h.opts.Localizer.MustLocalize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "BroadcastChannelHint",
Other: "The channels listed are the ones that the bot has been added to as a user"},
}), false, false)

incChanText := slack.NewTextBlockObject(slack.PlainTextType,
h.opts.Localizer.MustLocalize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
Expand Down Expand Up @@ -397,7 +487,7 @@ func (h *botHandler) cmdResolveIncident(ctx context.Context, w http.ResponseWrit
DefaultMessage: &i18n.Message{
ID: "No",
Other: "No"},
})}, false)
})})
archiveOptionsBlock := slack.NewRadioButtonsBlockElement("archive_choice", archiveOptions...)
archiveBlock := slack.NewInputBlock("archive_choice", archiveTxt, archiveOptionsBlock)

Expand All @@ -415,6 +505,7 @@ func (h *botHandler) cmdResolveIncident(ctx context.Context, w http.ResponseWrit
blocks := slack.Blocks{
BlockSet: []slack.Block{
contextBlock,
broadcastChBlock,
incChanBlock,
archiveBlock,
resolutionBlock,
Expand All @@ -430,7 +521,7 @@ func (h *botHandler) cmdResolveIncident(ctx context.Context, w http.ResponseWrit
modalVReq.ClearOnClose = true
modalVReq.CallbackID = "resolve_incident"

_, err := h.slackClient.OpenViewContext(ctx, cmd.TriggerID, modalVReq)
_, err = h.slackClient.OpenViewContext(ctx, cmd.TriggerID, modalVReq)
if err != nil {
log.Error().Err(err).Msg("Error opening view")
w.WriteHeader(http.StatusInternalServerError)
Expand All @@ -442,16 +533,10 @@ func (h *botHandler) cmdResolveIncident(ctx context.Context, w http.ResponseWrit
}

// createOptionBlockObjects - utility function for generating option block objects
func createOptionBlockObjects(options []string, users bool) []*slack.OptionBlockObject {
func createOptionBlockObjects(options []string) []*slack.OptionBlockObject {
optionBlockObjects := make([]*slack.OptionBlockObject, 0, len(options))
var text string
for _, o := range options {
if users {
text = fmt.Sprintf("<@%s>", o)
} else {
text = o
}
optionText := slack.NewTextBlockObject(slack.PlainTextType, text, false, false)
optionText := slack.NewTextBlockObject(slack.PlainTextType, o, false, false)
optionBlockObjects = append(optionBlockObjects, slack.NewOptionBlockObject(o, optionText, nil))
}
return optionBlockObjects
Expand Down
14 changes: 12 additions & 2 deletions bot/bot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,11 @@ func TestHandleCommand(t *testing.T) {

func TestCreateOptionBlockObjects(t *testing.T) {
options := []string{}
optionBlockObjects := createOptionBlockObjects(options, false)
optionBlockObjects := createOptionBlockObjects(options)
assert.Empty(t, optionBlockObjects)

options = []string{"a", "b"}
optionBlockObjects = createOptionBlockObjects(options, false)
optionBlockObjects = createOptionBlockObjects(options)
assert.Equal(t, []*slack.OptionBlockObject{
(&slack.OptionBlockObject{
Text: &slack.TextBlockObject{Type: "plain_text", Text: "a", Emoji: false, Verbatim: false},
Expand All @@ -152,6 +152,8 @@ type dummyClient struct {
Reminder *slack.Reminder
AuthTestResponse *slack.AuthTestResponse
User *slack.User
Channels []slack.Channel
NextCursor string
}

var _ SlackClient = &dummyClient{}
Expand Down Expand Up @@ -204,3 +206,11 @@ func (c *dummyClient) AddChannelReminder(channelID string, text string, time str
func (c *dummyClient) GetUserInfoContext(ctx context.Context, user string) (*slack.User, error) {
return c.User, c.err
}

func (c *dummyClient) AuthTestContext(ctx context.Context) (*slack.AuthTestResponse, error) {
return c.AuthTestResponse, c.err
}

func (c *dummyClient) GetConversationsForUserContext(ctx context.Context, params *slack.GetConversationsForUserParameters) ([]slack.Channel, string, error) {
return c.Channels, c.NextCursor, c.err
}
Loading

0 comments on commit 2b35036

Please sign in to comment.