slack-go / slack

Slack API in Go, originally by @nlopes; Maintainers needed, contact @parsley42
https://pkg.go.dev/github.com/slack-go/slack
BSD 2-Clause "Simplified" License
4.6k stars 1.11k forks source link

chat.update: add support for sending empty blocks #1214

Open batazor opened 1 year ago

batazor commented 1 year ago

What happened

I'm developing a bot for an employee survey. When the employee selects the answer option - yes/no - I want to replace the block with a text field answer, however, slack-go does a check on the length of the blocks field and does not add ?blocks=[] to the request to the Slack API. Without that field, the API says the update was successful, but really doesn't change the message

Expected behavior

The ?blocks=[] field is added to the query

Steps to reproduce

  1. Create message with blocks
[
    {
      "type":"actions",
      "elements":[
        {"type":"button","text":{"type":"plain_text","text":"Approve"},"value":"click_me_123"},
        {"type":"button","text":{"type":"plain_text","text":"Deny"},"value":"click_me_123"}
      ]
    }
]
  1. Update message without blocks

reproducible code

manifest.yaml

Versions

TopherGopher commented 10 months ago

Can confirm this issue is still present. If you add a block, then the update works great, as intended, but you can't update using text.

I've done my best to recreate some reproducible code - but you may need to tweak it a little:

package chatbot

import (
    "fmt"
    "github.com/slack-go/slack"
    "github.com/stretchr/testify/assert"
    "testing"
)

var slackClient *slack.Client
// ErrChannelNotFound can be used with errors.Is to determine if the channel
// doesn't exist
var ErrChannelNotFound = errors.New("channel_not_found")

func setup(t *testing.T) {
    t.Helper()
    slackClient = slack.New(slackBotToken,
        slack.OptionAppLevelToken(slackAppToken))
}

// ChatMessage is an abstraction, designed to make chat input/output more accessible from other components
// You should instantiate this by using NewChatMessage()
type ChatMessage struct {
    ShortMessage    string             `bson:"shortMessage" json:"shortMessage"`
    ChannelName     string             `bson:"channelName" json:"channelName"`
    SuccessResponse string             `bson:"successResponse" json:"successResponse"`
    FailureResponse string             `bson:"failureResponse" json:"failureResponse"`
    // AtMentionUser is the user to mention in the message, e.g. topher.sterling
    //     - there should not be an @prefix
    AtMentionUser string                 `bson:"atMentionUser" json:"atMentionUser"`
    Metadata      map[string]interface{} `bson:"metadata" json:"metadata"`
    Blocks        []CFChatbotBlock       `bson:"-" json:"-"`
}

func NewChatMessage(shortMessage, channelName string) *ChatMessage {
    c := &ChatMessage{
        ChannelName:  channelName,
        ShortMessage: shortMessage,
    }
    if len(shortMessage) > 0 {
        c.AddTitle(shortMessage)
    }
    return c
}

type CFChatbotBlock struct {
    slack.Block
}

// slackBlocks converts the ChatMessage's blocks to a slice of slack.Blocks
func (c *ChatMessage) slackBlocks() []slack.Block {
    blocks := make([]slack.Block, len(c.Blocks))
    for i := range c.Blocks {
        blocks[i] = c.Blocks[i].Block
    }
    if len(c.AtMentionUser) > 0 {
        // We're supposed to be able to reply with a user ID - but we can't. So... we'll have to get the user info
        // userInfo, err := bot.SlackClient.GetUserInfo(c.AtMentionUser)
        // if err != nil {
        //  bot.log.WithError(err).Error("Could not get user info")
        // } else {
        //  bot.log.WithField("user", userInfo).Info("Found user info")
        // }
        // Prepend the @mention to the message
        c.Blocks = append([]CFChatbotBlock{
            {Block: slack.NewSectionBlock(
                slack.NewTextBlockObject(
                    slack.MarkdownType,
                    fmt.Sprintf("FYI <@%s>?", c.AtMentionUser),
                    false, false,
                ),
                nil,
                nil,
            ),
            },
        }, c.Blocks...)
    }
    return blocks
}

// Send Posts the message to Slack and saves it to the DB.
// It returns an error if the message could not be sent or saved.
func (c *ChatMessage) Send() (msgID string, err error) {
    _, msgID, err = slackClient.PostMessage(c.ChannelName,
        slack.MsgOptionEnableLinkUnfurl(),
        slack.MsgOptionBlocks(
            c.slackBlocks()...,
        ),
        slack.MsgOptionMetadata(slack.SlackMetadata{
            EventType:    "cf-emitting-chat-message",
            EventPayload: c.Metadata,
        }),
        slack.MsgOptionUsername(fmt.Sprintf("CF %s Chat Bot", "DEV")),
    )
    if err != nil {
        if IsChannelNotFound(err) {
            err = ErrChannelNotFound
        }
        return msgID, fmt.Errorf("could not send message to Slack channel '%s': %w", c.ChannelName, err)
    }
    return msgID, nil
}

// CompleteInteraction allows you to mark a slack interaction as "completed"
func CompleteInteraction(channelName, msgId, result, completedByUser string) (err error) {
    channelID, _, err := ChannelNameToID(channelName)
    if err != nil {
        return fmt.Errorf("unable to complete slack interaction out of band: %w", err)
    }
    text := fmt.Sprintf("Thank you for submitting your answer of '%s' %s! (We realize this is a thread - slack does *not* like updating the original message without a user initiated action).", result, completedByUser)
    // Note -  will post a thread reply
    _, _, _, err = slackClient.UpdateMessage(channelID,
        msgId,
        slack.MsgOptionText(text, false),
        slack.MsgOptionAsUser(false),
        slack.MsgOptionUsername(fmt.Sprintf("CF %s Chat Bot", consts.ActualCFEnv)))
    if err != nil {
        return fmt.Errorf("unable to complete slack interaction out of band: %w", err)
    }
    return nil
}

// ChannelNameToID converts a channel name to a channel ID - a note that this
// will call the Slack API to get the list of channels. Caching has been removed for this example.
func ChannelNameToID(channelName string) (channelID string, isChannelMember bool, err error) {
    // Remove the # if it's there
    channelName = strings.TrimPrefix(channelName, "#")

        // There was a cache here - removing to make it easier

    channelNamesToIDs, err := ListChannels()
    if err != nil {
        return "", false, err
    }
    channelID, ok := channelNamesToIDs[channelName]
    if !ok {
        return "", false, ErrChannelNotFound
    }
    return channelID, false, nil
}

// ListChannels returns a map of channel names to channel IDs for all public and
// private channels. These channels are not auto-joined. Caching has been removed for this example.
func ListChannels() (channelNamesToIDs map[string]string, err error) {
    channels, _, err := slackClient.GetConversations(&slack.GetConversationsParameters{
        ExcludeArchived: true,
        Limit:           1000,
        Types:           []string{"public_channel", "private_channel"},
    })
    if err != nil {
        return nil, err
    }
    channelNamesToIDs = make(map[string]string)
    for i := range channels {
        channelNamesToIDs[channels[i].Name] = channels[i].ID
    }
    return channelNamesToIDs, nil
}

// TestCompleteInteraction is a test which should first send a message, then
// subsequently update that message. We are able to get this test to sort of work
// by updating the slack.MsgOptionTS(msgId) - but that's not ideal as it doesn't allow us to
// mark the interaction as complete.
// We also can send using ephemeral messages, but that's not ideal either, as we want the message
// to persist until the interaction is either completed from with-in Slack or the
// external system completes the interaction.
func TestCompleteInteraction(t *testing.T) {
    is := assert.New(t)
    setup(t)
    msg := NewChatMessage("My initial message", "cf-chatbot-test-")
    // msg.AddControl("How are you?", formtypes.ControlTypeButton, "Good", "Bad")
    msgId, err := msg.Send()
    is.NoError(err, "Could not send chat message")
    is.NoError(CompleteInteraction(msg.ChannelName, msgId, "Good", "Topher Sterling"))
}

Note that changing the UpdateMessage code to use blocks rather than slack.MsgOptionText(text, false) fixes the issue and the update succeeds. If you swap the CompleteInteration/UpdateMessage call for this, then you'll see it work:

_, _, _, err = slackClient.UpdateMessage(channelID,
        msgId,
        slack.MsgOptionBlocks(slack.NewSectionBlock(
            slack.NewTextBlockObject(
                "mrkdwn", text, false, false),
            nil, nil)),
        slack.MsgOptionAsUser(false),
        slack.MsgOptionUsername(fmt.Sprintf("CF %s Chat Bot", consts.ActualCFEnv)))

So perhaps the SDK could convert the MsgOptionText to a single MsgOptionBlocks?