From 00d0e9a995c975504c6db72b35c9da491e493ae2 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Wed, 6 Nov 2024 12:08:12 -0500 Subject: [PATCH] feat: Slack AI & Assistants Compatibility (#1331) Added new function for Slack AI & Assistants, information can be found here: https://api.slack.com/docs/apps/ai New Events: https://api.slack.com/events/assistant_thread_started https://api.slack.com/events/assistant_thread_context_changed New API Calls: https://api.slack.com/methods/assistant.threads.setStatus https://api.slack.com/methods/assistant.threads.setSuggestedPrompts https://api.slack.com/methods/assistant.threads.setTitle Tests have been added and pass all checks via `make pr-prep`. --- assistant.go | 153 +++++++++++++++++++++++++ assistant_test.go | 86 ++++++++++++++ block_section.go | 10 ++ slackevents/inner_events.go | 189 ++++++++++++++++++------------- slackevents/inner_events_test.go | 53 +++++++++ 5 files changed, 414 insertions(+), 77 deletions(-) create mode 100644 assistant.go create mode 100644 assistant_test.go diff --git a/assistant.go b/assistant.go new file mode 100644 index 000000000..ef1425ff3 --- /dev/null +++ b/assistant.go @@ -0,0 +1,153 @@ +package slack + +import ( + "context" + "encoding/json" + "net/url" +) + +// AssistantThreadSetStatusParameters are the parameters for AssistantThreadSetStatus +type AssistantThreadsSetStatusParameters struct { + ChannelID string `json:"channel_id"` + Status string `json:"status"` + ThreadTS string `json:"thread_ts"` +} + +// AssistantThreadSetTitleParameters are the parameters for AssistantThreadSetTitle +type AssistantThreadsSetTitleParameters struct { + ChannelID string `json:"channel_id"` + ThreadTS string `json:"thread_ts"` + Title string `json:"title"` +} + +// AssistantThreadSetSuggestedPromptsParameters are the parameters for AssistantThreadSetSuggestedPrompts +type AssistantThreadsSetSuggestedPromptsParameters struct { + Title string `json:"title"` + ChannelID string `json:"channel_id"` + ThreadTS string `json:"thread_ts"` + Prompts []AssistantThreadsPrompt `json:"prompts"` +} + +// AssistantThreadPrompt is a suggested prompt for a thread +type AssistantThreadsPrompt struct { + Title string `json:"title"` + Message string `json:"message"` +} + +// AssistantThreadSetSuggestedPrompts sets the suggested prompts for a thread +func (p *AssistantThreadsSetSuggestedPromptsParameters) AddPrompt(title, message string) { + p.Prompts = append(p.Prompts, AssistantThreadsPrompt{ + Title: title, + Message: message, + }) +} + +// SetAssistantThreadsSugesstedPrompts sets the suggested prompts for a thread +// @see https://api.slack.com/methods/assistant.threads.setSuggestedPrompts +func (api *Client) SetAssistantThreadsSuggestedPrompts(params AssistantThreadsSetSuggestedPromptsParameters) (err error) { + return api.SetAssistantThreadsSuggestedPromptsContext(context.Background(), params) +} + +// SetAssistantThreadSuggestedPromptsContext sets the suggested prompts for a thread with a custom context +// @see https://api.slack.com/methods/assistant.threads.setSuggestedPrompts +func (api *Client) SetAssistantThreadsSuggestedPromptsContext(ctx context.Context, params AssistantThreadsSetSuggestedPromptsParameters) (err error) { + + values := url.Values{ + "token": {api.token}, + } + + if params.ThreadTS != "" { + values.Add("thread_ts", params.ThreadTS) + } + + // Send Prompts as JSON + prompts, err := json.Marshal(params.Prompts) + if err != nil { + return err + } + + values.Add("prompts", string(prompts)) + + response := struct { + SlackResponse + }{} + + err = api.postMethod(ctx, "assistant.threads.setSuggestedPrompts", values, &response) + if err != nil { + return + } + + return response.Err() +} + +// SetAssistantThreadStatus sets the status of a thread +// @see https://api.slack.com/methods/assistant.threads.setStatus +func (api *Client) SetAssistantThreadsStatus(params AssistantThreadsSetStatusParameters) (err error) { + return api.SetAssistantThreadsStatusContext(context.Background(), params) +} + +// SetAssistantThreadStatusContext sets the status of a thread with a custom context +// @see https://api.slack.com/methods/assistant.threads.setStatus +func (api *Client) SetAssistantThreadsStatusContext(ctx context.Context, params AssistantThreadsSetStatusParameters) (err error) { + + values := url.Values{ + "token": {api.token}, + } + + if params.ThreadTS != "" { + values.Add("thread_ts", params.ThreadTS) + } + + // Always send the status parameter, if empty, it will clear any existing status + values.Add("status", params.Status) + + response := struct { + SlackResponse + }{} + + err = api.postMethod(ctx, "assistant.threads.setStatus", values, &response) + if err != nil { + return + } + + return response.Err() +} + +// SetAssistantThreadsTitle sets the title of a thread +// @see https://api.slack.com/methods/assistant.threads.setTitle +func (api *Client) SetAssistantThreadsTitle(params AssistantThreadsSetTitleParameters) (err error) { + return api.SetAssistantThreadsTitleContext(context.Background(), params) +} + +// SetAssistantThreadsTitleContext sets the title of a thread with a custom context +// @see https://api.slack.com/methods/assistant.threads.setTitle +func (api *Client) SetAssistantThreadsTitleContext(ctx context.Context, params AssistantThreadsSetTitleParameters) (err error) { + + values := url.Values{ + "token": {api.token}, + } + + if params.ChannelID != "" { + values.Add("channel_id", params.ChannelID) + } + + if params.ThreadTS != "" { + values.Add("thread_ts", params.ThreadTS) + } + + if params.Title != "" { + values.Add("title", params.Title) + } + + response := struct { + SlackResponse + }{} + + err = api.postMethod(ctx, "assistant.threads.setTitle", values, &response) + if err != nil { + return + } + + return response.Err() + +} diff --git a/assistant_test.go b/assistant_test.go new file mode 100644 index 000000000..51c271611 --- /dev/null +++ b/assistant_test.go @@ -0,0 +1,86 @@ +package slack + +import ( + "encoding/json" + "net/http" + "testing" +) + +func TestAssistantThreadsSuggestedPrompts(t *testing.T) { + + http.HandleFunc("/assistant.threads.setSuggestedPrompts", okJSONHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := AssistantThreadsSetSuggestedPromptsParameters{ + ChannelID: "CXXXXXXXX", + ThreadTS: "1234567890.123456", + } + + params.AddPrompt("title1", "message1") + params.AddPrompt("title2", "message2") + + err := api.SetAssistantThreadsSuggestedPrompts(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + +} + +func TestSetAssistantThreadsStatus(t *testing.T) { + + http.HandleFunc("/assistant.threads.setStatus", okJSONHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := AssistantThreadsSetStatusParameters{ + ChannelID: "CXXXXXXXX", + ThreadTS: "1234567890.123456", + Status: "updated status", + } + + err := api.SetAssistantThreadsStatus(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + +} + +func assistantThreadsTitleHandler(rw http.ResponseWriter, r *http.Request) { + + channelID := r.FormValue("channel_id") + threadTS := r.FormValue("thread_ts") + title := r.FormValue("title") + + rw.Header().Set("Content-Type", "application/json") + + if channelID != "" && threadTS != "" && title != "" { + + resp, _ := json.Marshal(&addBookmarkResponse{ + SlackResponse: SlackResponse{Ok: true}, + }) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } + +} + +func TestSetAssistantThreadsTitle(t *testing.T) { + + http.HandleFunc("/assistant.threads.setTitle", assistantThreadsTitleHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := AssistantThreadsSetTitleParameters{ + ChannelID: "CXXXXXXXX", + ThreadTS: "1234567890.123456", + Title: "updated title", + } + + err := api.SetAssistantThreadsTitle(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + +} diff --git a/block_section.go b/block_section.go index 01ffd5a1d..1ffc17e82 100644 --- a/block_section.go +++ b/block_section.go @@ -9,6 +9,7 @@ type SectionBlock struct { BlockID string `json:"block_id,omitempty"` Fields []*TextBlockObject `json:"fields,omitempty"` Accessory *Accessory `json:"accessory,omitempty"` + Expand bool `json:"expand,omitempty"` } // BlockType returns the type of the block @@ -25,6 +26,15 @@ func SectionBlockOptionBlockID(blockID string) SectionBlockOption { } } +// SectionBlockOptionExpand allows long text to be auto-expanded when displaying +// +// @see https://api.slack.com/reference/block-kit/blocks#section +func SectionBlockOptionExpand(shouldExpand bool) SectionBlockOption { + return func(block *SectionBlock) { + block.Expand = shouldExpand + } +} + // NewSectionBlock returns a new instance of a section block to be rendered func NewSectionBlock(textObj *TextBlockObject, fields []*TextBlockObject, accessory *Accessory, options ...SectionBlockOption) *SectionBlock { block := SectionBlock{ diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 8f8effaae..720640b63 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -12,6 +12,35 @@ type EventsAPIInnerEvent struct { Data interface{} } +// AssistantThreadMessageEvent is an (inner) EventsAPI subscribable event. +type AssistantThreadStartedEvent struct { + Type string `json:"type"` + AssistantThread AssistantThread `json:"assistant_thread"` + EventTimestamp string `json:"event_ts"` +} + +// AssistantThreadChangedEvent is an (inner) EventsAPI subscribable event. +type AssistantThreadContextChangedEvent struct { + Type string `json:"type"` + AssistantThread AssistantThread `json:"assistant_thread"` + EventTimestamp string `json:"event_ts"` +} + +// AssistantThread is an object that represents a thread of messages between a user and an assistant. +type AssistantThread struct { + UserID string `json:"user_id"` + Context AssistantThreadContext `json:"context"` + ChannelID string `json:"channel_id"` + ThreadTimeStamp string `json:"thread_ts"` +} + +// AssistantThreadContext is an object that represents the context of an assistant thread. +type AssistantThreadContext struct { + ChannelID string `json:"channel_id"` + TeamID string `json:"team_id"` + EnterpriseID string `json:"enterprise_id"` +} + // AppMentionEvent is an (inner) EventsAPI subscribable event. type AppMentionEvent struct { Type string `json:"type"` @@ -1117,6 +1146,10 @@ const ( AppHomeOpened = EventsAPIType("app_home_opened") // AppUninstalled Your Slack app was uninstalled. AppUninstalled = EventsAPIType("app_uninstalled") + // AssistantThreadStarted Your Slack AI Assistant has started a new thread + AssistantThreadStarted = EventsAPIType("assistant_thread_started") + // AssistantThreadContextChanged Your Slack AI Assistant has changed the context of a thread + AssistantThreadContextChanged = EventsAPIType("assistant_thread_context_changed") // ChannelCreated is sent when a new channel is created. ChannelCreated = EventsAPIType("channel_created") // ChannelDeleted is sent when a channel is deleted. @@ -1273,81 +1306,83 @@ const ( // implementations. The structs should be instances of the unmarshalling // target for the matching event type. var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ - AppMention: AppMentionEvent{}, - AppHomeOpened: AppHomeOpenedEvent{}, - AppUninstalled: AppUninstalledEvent{}, - ChannelCreated: ChannelCreatedEvent{}, - ChannelDeleted: ChannelDeletedEvent{}, - ChannelArchive: ChannelArchiveEvent{}, - ChannelUnarchive: ChannelUnarchiveEvent{}, - ChannelLeft: ChannelLeftEvent{}, - ChannelRename: ChannelRenameEvent{}, - ChannelIDChanged: ChannelIDChangedEvent{}, - FileChange: FileChangeEvent{}, - FileDeleted: FileDeletedEvent{}, - FileShared: FileSharedEvent{}, - FileUnshared: FileUnsharedEvent{}, - GroupDeleted: GroupDeletedEvent{}, - GroupArchive: GroupArchiveEvent{}, - GroupUnarchive: GroupUnarchiveEvent{}, - GroupLeft: GroupLeftEvent{}, - GroupRename: GroupRenameEvent{}, - GridMigrationFinished: GridMigrationFinishedEvent{}, - GridMigrationStarted: GridMigrationStartedEvent{}, - LinkShared: LinkSharedEvent{}, - Message: MessageEvent{}, - MemberJoinedChannel: MemberJoinedChannelEvent{}, - MemberLeftChannel: MemberLeftChannelEvent{}, - PinAdded: PinAddedEvent{}, - PinRemoved: PinRemovedEvent{}, - ReactionAdded: ReactionAddedEvent{}, - ReactionRemoved: ReactionRemovedEvent{}, - SharedChannelInviteApproved: SharedChannelInviteApprovedEvent{}, - SharedChannelInviteAccepted: SharedChannelInviteAcceptedEvent{}, - SharedChannelInviteDeclined: SharedChannelInviteDeclinedEvent{}, - SharedChannelInviteReceived: SharedChannelInviteReceivedEvent{}, - TeamJoin: TeamJoinEvent{}, - TokensRevoked: TokensRevokedEvent{}, - EmojiChanged: EmojiChangedEvent{}, - WorkflowStepExecute: WorkflowStepExecuteEvent{}, - MessageMetadataPosted: MessageMetadataPostedEvent{}, - MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, - MessageMetadataDeleted: MessageMetadataDeletedEvent{}, - TeamAccessGranted: TeamAccessGrantedEvent{}, - TeamAccessRevoked: TeamAccessRevokedEvent{}, - UserProfileChanged: UserProfileChangedEvent{}, - ChannelHistoryChanged: ChannelHistoryChangedEvent{}, - DndUpdated: DndUpdatedEvent{}, - DndUpdatedUser: DndUpdatedUserEvent{}, - EmailDomainChanged: EmailDomainChangedEvent{}, - GroupClose: GroupCloseEvent{}, - GroupHistoryChanged: GroupHistoryChangedEvent{}, - GroupOpen: GroupOpenEvent{}, - ImClose: ImCloseEvent{}, - ImCreated: ImCreatedEvent{}, - ImHistoryChanged: ImHistoryChangedEvent{}, - ImOpen: ImOpenEvent{}, - SubteamCreated: SubteamCreatedEvent{}, - SubteamMembersChanged: SubteamMembersChangedEvent{}, - SubteamSelfAdded: SubteamSelfAddedEvent{}, - SubteamSelfRemoved: SubteamSelfRemovedEvent{}, - SubteamUpdated: SubteamUpdatedEvent{}, - TeamDomainChange: TeamDomainChangeEvent{}, - TeamRename: TeamRenameEvent{}, - UserChange: UserChangeEvent{}, - AppDeleted: AppDeletedEvent{}, - AppInstalled: AppInstalledEvent{}, - AppRequested: AppRequestedEvent{}, - AppUninstalledTeam: AppUninstalledTeamEvent{}, - CallRejected: CallRejectedEvent{}, - ChannelShared: ChannelSharedEvent{}, - FileCreated: FileCreatedEvent{}, - FilePublic: FilePublicEvent{}, - FunctionExecuted: FunctionExecutedEvent{}, - InviteRequested: InviteRequestedEvent{}, - SharedChannelInviteRequested: SharedChannelInviteRequestedEvent{}, - StarAdded: StarAddedEvent{}, - StarRemoved: StarRemovedEvent{}, - UserHuddleChanged: UserHuddleChangedEvent{}, - UserStatusChanged: UserStatusChangedEvent{}, + AppMention: AppMentionEvent{}, + AppHomeOpened: AppHomeOpenedEvent{}, + AppUninstalled: AppUninstalledEvent{}, + AssistantThreadStarted: AssistantThreadStartedEvent{}, + AssistantThreadContextChanged: AssistantThreadContextChangedEvent{}, + ChannelCreated: ChannelCreatedEvent{}, + ChannelDeleted: ChannelDeletedEvent{}, + ChannelArchive: ChannelArchiveEvent{}, + ChannelUnarchive: ChannelUnarchiveEvent{}, + ChannelLeft: ChannelLeftEvent{}, + ChannelRename: ChannelRenameEvent{}, + ChannelIDChanged: ChannelIDChangedEvent{}, + FileChange: FileChangeEvent{}, + FileDeleted: FileDeletedEvent{}, + FileShared: FileSharedEvent{}, + FileUnshared: FileUnsharedEvent{}, + GroupDeleted: GroupDeletedEvent{}, + GroupArchive: GroupArchiveEvent{}, + GroupUnarchive: GroupUnarchiveEvent{}, + GroupLeft: GroupLeftEvent{}, + GroupRename: GroupRenameEvent{}, + GridMigrationFinished: GridMigrationFinishedEvent{}, + GridMigrationStarted: GridMigrationStartedEvent{}, + LinkShared: LinkSharedEvent{}, + Message: MessageEvent{}, + MemberJoinedChannel: MemberJoinedChannelEvent{}, + MemberLeftChannel: MemberLeftChannelEvent{}, + PinAdded: PinAddedEvent{}, + PinRemoved: PinRemovedEvent{}, + ReactionAdded: ReactionAddedEvent{}, + ReactionRemoved: ReactionRemovedEvent{}, + SharedChannelInviteApproved: SharedChannelInviteApprovedEvent{}, + SharedChannelInviteAccepted: SharedChannelInviteAcceptedEvent{}, + SharedChannelInviteDeclined: SharedChannelInviteDeclinedEvent{}, + SharedChannelInviteReceived: SharedChannelInviteReceivedEvent{}, + TeamJoin: TeamJoinEvent{}, + TokensRevoked: TokensRevokedEvent{}, + EmojiChanged: EmojiChangedEvent{}, + WorkflowStepExecute: WorkflowStepExecuteEvent{}, + MessageMetadataPosted: MessageMetadataPostedEvent{}, + MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, + MessageMetadataDeleted: MessageMetadataDeletedEvent{}, + TeamAccessGranted: TeamAccessGrantedEvent{}, + TeamAccessRevoked: TeamAccessRevokedEvent{}, + UserProfileChanged: UserProfileChangedEvent{}, + ChannelHistoryChanged: ChannelHistoryChangedEvent{}, + DndUpdated: DndUpdatedEvent{}, + DndUpdatedUser: DndUpdatedUserEvent{}, + EmailDomainChanged: EmailDomainChangedEvent{}, + GroupClose: GroupCloseEvent{}, + GroupHistoryChanged: GroupHistoryChangedEvent{}, + GroupOpen: GroupOpenEvent{}, + ImClose: ImCloseEvent{}, + ImCreated: ImCreatedEvent{}, + ImHistoryChanged: ImHistoryChangedEvent{}, + ImOpen: ImOpenEvent{}, + SubteamCreated: SubteamCreatedEvent{}, + SubteamMembersChanged: SubteamMembersChangedEvent{}, + SubteamSelfAdded: SubteamSelfAddedEvent{}, + SubteamSelfRemoved: SubteamSelfRemovedEvent{}, + SubteamUpdated: SubteamUpdatedEvent{}, + TeamDomainChange: TeamDomainChangeEvent{}, + TeamRename: TeamRenameEvent{}, + UserChange: UserChangeEvent{}, + AppDeleted: AppDeletedEvent{}, + AppInstalled: AppInstalledEvent{}, + AppRequested: AppRequestedEvent{}, + AppUninstalledTeam: AppUninstalledTeamEvent{}, + CallRejected: CallRejectedEvent{}, + ChannelShared: ChannelSharedEvent{}, + FileCreated: FileCreatedEvent{}, + FilePublic: FilePublicEvent{}, + FunctionExecuted: FunctionExecutedEvent{}, + InviteRequested: InviteRequestedEvent{}, + SharedChannelInviteRequested: SharedChannelInviteRequestedEvent{}, + StarAdded: StarAddedEvent{}, + StarRemoved: StarRemovedEvent{}, + UserHuddleChanged: UserHuddleChangedEvent{}, + UserStatusChanged: UserStatusChangedEvent{}, } diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 4307e8ad6..baa4e2c08 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -8,6 +8,59 @@ import ( "github.com/stretchr/testify/assert" ) +func TestAssistantThreadStartedEvent(t *testing.T) { + + rawE := []byte(` + { + "type": "assistant_thread_started", + "assistant_thread": { + "user_id": "U123ABC456", + "context": { + "channel_id": "C123ABC456", + "team_id": "T07XY8FPJ5C", + "enterprise_id": "E480293PS82" + }, + "channel_id": "D123ABC456", + "thread_ts": "1729999327.187299" + + }, + "event_ts": "1715873754.429808" + } + `) + + err := json.Unmarshal(rawE, &AssistantThreadStartedEvent{}) + if err != nil { + t.Error(err) + } + +} + +func TestAssistantThreadContextChangedEvent(t *testing.T) { + + rawE := []byte(` + { + "type": "assistant_thread_context_changed", + "assistant_thread": { + "user_id": "U123ABC456", + "context": { + "channel_id": "C123ABC456", + "team_id": "T07XY8FPJ5C", + "enterprise_id": "E480293PS82" + }, + "channel_id": "D123ABC456", + "thread_ts": "1729999327.187299" + }, + "event_ts": "17298244.022142" + } + `) + + err := json.Unmarshal(rawE, &AssistantThreadContextChangedEvent{}) + if err != nil { + t.Error(err) + } + +} + func TestAppMention(t *testing.T) { rawE := []byte(` {