Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions adapters/slack/slackapi/apps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package slackapi

import (
"context"
"encoding/json"
"fmt"
"net/url"
)

// CreateAppRequest is the request of apps.manifest.create.
type CreateAppRequest struct {
// Manifest is the manifest of the new app.
Manifest Manifest
}

// CreateAppResponse is the response of apps.manifest.create.
type CreateAppResponse struct {
// AppID is the ID of the new app.
AppID string `json:"app_id"`
// Credentials are the credentials of the new app.
Credentials AppCredentials `json:"credentials"`
}

// AppCredentials are the credentials of an app that apps.manifest.create
// returns.
type AppCredentials struct {
// ClientID is the OAuth client ID.
ClientID string `json:"client_id"`
// ClientSecret is the OAuth client secret.
ClientSecret string `json:"client_secret"`
// SigningSecret is the secret that VerifyRequest checks the requests from
// Slack with.
SigningSecret string `json:"signing_secret"`
}

// DeleteAppRequest is the request of apps.manifest.delete.
type DeleteAppRequest struct {
// AppID is the ID of the app.
AppID string
}

// CreateApp calls apps.manifest.create to create an app from req.Manifest.
// Call it on a Client with an app configuration token (xoxe.xoxp-):
// api.WithToken(configToken).CreateApp(ctx, req). The token belongs to one
// user and one workspace, and expires after 12 hours. CreateApp sends the
// manifest as a JSON string in a form-encoded body. For an invalid manifest,
// Slack returns the error code "invalid_manifest", and APIError.Raw holds the
// "errors" list of the response.
func (c *Client) CreateApp(ctx context.Context, req CreateAppRequest) (*CreateAppResponse, error) {
const method = "apps.manifest.create"
manifest, err := json.Marshal(req.Manifest)
if err != nil {
return nil, fmt.Errorf("slack: encode %s request: %w", method, err)
}
var resp CreateAppResponse
if err := c.Call(ctx, method, url.Values{"manifest": {string(manifest)}}, &resp); err != nil {
return nil, err
}
return &resp, nil
}

// DeleteApp calls apps.manifest.delete to delete an app that was created from
// a manifest. Call it on a Client with an app configuration token, as for
// CreateApp. It sends a form-encoded body.
func (c *Client) DeleteApp(ctx context.Context, req DeleteAppRequest) error {
values := url.Values{}
setFormString(values, "app_id", req.AppID)
return c.Call(ctx, "apps.manifest.delete", values, nil)
}
81 changes: 81 additions & 0 deletions adapters/slack/slackapi/apps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package slackapi_test

import (
"encoding/json"
"net/url"
"testing"

"github.com/coder/chat/adapters/slack/slackapi"
"github.com/coder/chat/adapters/slack/slackapi/slackapitest"
)

const appsConfigToken = "xoxe.xoxp-config"

// appsOnlyCall returns the only request to method on srv and checks that it
// is form encoded with the app configuration token.
func appsOnlyCall(t *testing.T, srv *slackapitest.Server, method string) slackapitest.Call {
t.Helper()
calls := srv.Calls(method)
if len(calls) != 1 {
t.Fatalf("%s calls = %d, want 1", method, len(calls))
}
call := calls[0]
if got, want := call.Header.Get("Authorization"), "Bearer "+appsConfigToken; got != want {
t.Errorf("%s Authorization = %q, want %q", method, got, want)
}
if got := call.Header.Get("Content-Type"); got != methodsFormType {
t.Errorf("%s Content-Type = %q, want %q", method, got, methodsFormType)
}
return call
}

func TestCreateApp(t *testing.T) {
t.Parallel()

srv := slackapitest.NewServer(t)
srv.Respond("apps.manifest.create", json.RawMessage(`{
"ok": true,
"app_id": "A0APP",
"credentials": {
"client_id": "123.456",
"client_secret": "client-secret",
"verification_token": "verification-token",
"signing_secret": "signing-secret"
},
"oauth_authorize_url": "https://slack.com/oauth/v2/authorize?client_id=123.456&scope=chat:write"
}`))

resp, err := methodsNewClient(srv).WithToken(appsConfigToken).CreateApp(t.Context(), slackapi.CreateAppRequest{
Manifest: formatsManifest(),
})
if err != nil {
t.Fatalf("CreateApp: %v", err)
}
call := appsOnlyCall(t, srv, "apps.manifest.create")
if len(call.Form) != 1 || len(call.Form["manifest"]) != 1 {
t.Fatalf("form = %v, want only manifest", call.Form)
}
formatsAssertSameJSON(t, []byte(call.Form.Get("manifest")), []byte(formatsManifestJSON))
methodsCheckEqual(t, "response", resp, &slackapi.CreateAppResponse{
AppID: "A0APP",
Credentials: slackapi.AppCredentials{
ClientID: "123.456",
ClientSecret: "client-secret",
SigningSecret: "signing-secret",
},
})
}

func TestDeleteApp(t *testing.T) {
t.Parallel()

srv := slackapitest.NewServer(t)
srv.Respond("apps.manifest.delete", json.RawMessage(`{"ok":true}`))

err := methodsNewClient(srv).WithToken(appsConfigToken).DeleteApp(t.Context(), slackapi.DeleteAppRequest{AppID: "A0APP"})
if err != nil {
t.Fatalf("DeleteApp: %v", err)
}
call := appsOnlyCall(t, srv, "apps.manifest.delete")
methodsCheckEqual(t, "form", call.Form, url.Values{"app_id": {"A0APP"}})
}
39 changes: 39 additions & 0 deletions adapters/slack/slackapi/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,45 @@ func (c *Client) PostMessage(ctx context.Context, req PostMessageRequest) (*Post
return &resp, nil
}

// PostEphemeralRequest is the request of chat.postEphemeral.
type PostEphemeralRequest struct {
// Channel is the ID of the channel, DM, or group DM.
Channel string `json:"channel"`
// User is the ID of the only user who sees the message. The user must be
// a member of Channel.
User string `json:"user"`
// ThreadTS posts the message in a thread. It must be the ts of the parent
// message, not the ts of a reply.
ThreadTS string `json:"thread_ts,omitempty"`
// Text is the message text in Slack mrkdwn. With Blocks, Slack uses it as
// the fallback text for notifications.
Text string `json:"text,omitempty"`
// Blocks are the Block Kit blocks of the message.
Blocks []Block `json:"blocks,omitempty"`
// MarkdownText is the message text in standard Markdown. It cannot be
// combined with Text or Blocks and holds at most 12,000 characters.
MarkdownText string `json:"markdown_text,omitempty"`
}

// PostEphemeralResponse is the response of chat.postEphemeral.
type PostEphemeralResponse struct {
// MessageTS is the ts of the message. Slack does not store an ephemeral
// message, so the ts cannot update or delete it.
MessageTS string `json:"message_ts"`
}

// PostEphemeral calls chat.postEphemeral to post a message that only one user
// sees. Slack returns the error code "user_not_in_channel" when the user is
// not a member of the channel. Set Text, Blocks, or MarkdownText, as for
// PostMessage.
func (c *Client) PostEphemeral(ctx context.Context, req PostEphemeralRequest) (*PostEphemeralResponse, error) {
var resp PostEphemeralResponse
if err := c.Call(ctx, "chat.postEphemeral", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}

// UpdateMessageRequest is the request of chat.update.
type UpdateMessageRequest struct {
// Channel is the ID of the channel of the message.
Expand Down
24 changes: 24 additions & 0 deletions adapters/slack/slackapi/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,27 @@ func TestDeleteMessage(t *testing.T) {
methodsCheckJSONBody(t, srv, "chat.delete", `{"channel":"C1","ts":"1700000000.000100"}`)
})
}

func TestPostEphemeral(t *testing.T) {
t.Parallel()

srv := slackapitest.NewServer(t)
srv.Respond("chat.postEphemeral", json.RawMessage(`{"ok":true,"message_ts":"1700000000.000200"}`))

resp, err := methodsNewClient(srv).PostEphemeral(t.Context(), slackapi.PostEphemeralRequest{
Channel: "C1",
User: "U1",
ThreadTS: "1700000000.000001",
Text: "I only answer members of this workspace.",
})
if err != nil {
t.Fatalf("PostEphemeral: %v", err)
}
methodsCheckJSONBody(t, srv, "chat.postEphemeral", `{
"channel": "C1",
"user": "U1",
"thread_ts": "1700000000.000001",
"text": "I only answer members of this workspace."
}`)
methodsCheckEqual(t, "response", resp, &slackapi.PostEphemeralResponse{MessageTS: "1700000000.000200"})
}
3 changes: 2 additions & 1 deletion adapters/slack/slackapi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ var defaultFileOrigins = []string{"https://files.slack.com", "https://slack.com"
// Options configures a Client.
type Options struct {
// Token is the bot or user token. Call, the typed methods, and DownloadFile
// send it as a bearer token. UploadToURL and PostResponseURL never send it.
// send it as a bearer token. UploadToURL, PostResponseURL, and
// OAuthV2Access never send it.
Token string
// BaseURL is the Web API base URL. Empty uses DefaultBaseURL. New removes a
// trailing slash.
Expand Down
34 changes: 22 additions & 12 deletions adapters/slack/slackapi/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,27 @@
//
// New returns a Client for Options; the fields of Options document the
// defaults. The typed methods cover the calls that a bot needs: AuthTest,
// PostMessage, UpdateMessage, DeleteMessage, SetAssistantThreadStatus,
// AddReaction, RemoveReaction, UserInfo, ConversationInfo, ConversationHistory,
// and ConversationReplies. Call is the escape hatch for other methods.
// PostResponseURL posts JSON without a token to the response_url of a slash
// command or an interaction. WithToken returns a copy of a Client that sends
// another token.
// PostMessage, PostEphemeral, UpdateMessage, DeleteMessage,
// SetAssistantThreadStatus, SetAgentSessionStatus, AddReaction,
// RemoveReaction, UserInfo, ConversationInfo, ConversationHistory,
// ConversationReplies, and FileInfo. Call is the escape hatch for other
// methods. PostResponseURL posts JSON without a token to the response_url of a
// slash command or an interaction. WithToken returns a copy of a Client that
// sends another token.
//
// The setup of a bot needs three more methods. OAuthV2Access exchanges the code
// of an install for a bot token, with the client ID and the client secret of
// the app and no bearer token. CreateApp and DeleteApp create and delete an
// app from a Manifest. Call them on a Client with an app configuration token:
// api.WithToken(configToken).CreateApp(ctx, req).
//
// Web API calls are POSTs with the bearer token and a JSON body. Slack
// documents users.info, conversations.info, conversations.history,
// conversations.replies, and files.getUploadURLExternal as GET or form-only
// methods, so the client sends them with a form-encoded body. Call sends a
// url.Values payload form encoded and any other payload as JSON, so it reaches
// form-only methods too.
// conversations.replies, files.info, and files.getUploadURLExternal as GET or
// form-only methods, so the client sends them with a form-encoded body.
// OAuthV2Access, CreateApp, and DeleteApp send a form-encoded body too. Call
// sends a url.Values payload form encoded and any other payload as JSON, so it
// reaches form-only methods too.
//
// An ok:false response or a non-2xx status returns *APIError. Every method
// except DownloadFile retries Slack throttling (HTTP 429, or the ratelimited or
Expand Down Expand Up @@ -56,8 +64,10 @@
// url_verification request, and gives each other request to its Handler as a
// Delivery.
//
// ParseEnvelope decodes an Events API payload, and Envelope.MessageEvent
// decodes its app_mention or message event.
// ParseEnvelope decodes an Events API payload. Envelope.MessageEvent decodes
// its app_mention or message event, Envelope.AgentSessionStoppedEvent its
// agent_session_stopped event, and Envelope.ChannelSharedEvent its
// channel_shared event.
//
// # Socket Mode
//
Expand Down
63 changes: 60 additions & 3 deletions adapters/slack/slackapi/envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
)

// Envelope types of a Slack Events API request.
Expand All @@ -16,14 +17,20 @@ const (
EnvelopeEventCallback = "event_callback"
)

// Event types that MessageEvent decodes.
// Event types that the event methods of Envelope decode.
const (
// EventTypeAppMention is the type of an event for a message that
// mentions the app.
EventTypeAppMention = "app_mention"
// EventTypeMessage is the type of an event for a message in a
// conversation that the app is a member of.
EventTypeMessage = "message"
// EventTypeAgentSessionStopped is the type of an event for a click on the
// Stop button of an agent session.
EventTypeAgentSessionStopped = "agent_session_stopped"
// EventTypeChannelShared is the type of an event for a channel that is
// shared with another workspace or organization.
EventTypeChannelShared = "channel_shared"
)

// Message subtypes of Message.Subtype.
Expand Down Expand Up @@ -108,6 +115,56 @@ type MessageEvent struct {
// another type. It returns an error if the event is missing or is not valid
// JSON for its type.
func (e *Envelope) MessageEvent() (*MessageEvent, bool, error) {
return decodeEvent[MessageEvent](e, EventTypeAppMention, EventTypeMessage)
}

// AgentSessionStoppedEvent is an agent_session_stopped event. Slack sends it
// when a user clicks Stop on an agent session in the processing status. Slack
// does not change the status of the session.
type AgentSessionStoppedEvent struct {
// Channel is the ID of the channel of the session.
Channel string `json:"channel,omitempty"`
// ThreadTS is the ts of the parent message of the thread of the session.
ThreadTS string `json:"thread_ts,omitempty"`
// User is the ID of the user who clicked Stop.
User string `json:"user,omitempty"`
// EventTS is the timestamp of the event.
EventTS string `json:"event_ts,omitempty"`
}

// AgentSessionStoppedEvent decodes the inner event of an EnvelopeEventCallback
// envelope when its type is EventTypeAgentSessionStopped. It returns false and
// errors as MessageEvent does.
func (e *Envelope) AgentSessionStoppedEvent() (*AgentSessionStoppedEvent, bool, error) {
return decodeEvent[AgentSessionStoppedEvent](e, EventTypeAgentSessionStopped)
}

// ChannelSharedEvent is a channel_shared event. Slack sends it when a channel
// becomes shared, and when another workspace or organization joins a shared
// channel. Slack also sends it for a share between the workspaces of one
// Enterprise Grid organization.
type ChannelSharedEvent struct {
// Channel is the ID of the channel.
Channel string `json:"channel,omitempty"`
// ConnectedTeamID is the ID of the workspace (T) or the organization (E)
// that the channel is now shared with.
ConnectedTeamID string `json:"connected_team_id,omitempty"`
// EventTS is the timestamp of the event.
EventTS string `json:"event_ts,omitempty"`
}

// ChannelSharedEvent decodes the inner event of an EnvelopeEventCallback
// envelope when its type is EventTypeChannelShared. It returns false and
// errors as MessageEvent does.
func (e *Envelope) ChannelSharedEvent() (*ChannelSharedEvent, bool, error) {
return decodeEvent[ChannelSharedEvent](e, EventTypeChannelShared)
}

// decodeEvent decodes the inner event of an EnvelopeEventCallback envelope
// into T when the event has one of types. It returns false for an envelope of
// another type and for an event of another type, and an error if the event is
// missing or is not valid JSON for T.
func decodeEvent[T any](e *Envelope, types ...string) (*T, bool, error) {
if e.Type != EnvelopeEventCallback {
return nil, false, nil
}
Expand All @@ -120,12 +177,12 @@ func (e *Envelope) MessageEvent() (*MessageEvent, bool, error) {
if err := json.Unmarshal(e.Event, &head); err != nil {
return nil, false, fmt.Errorf("slack: invalid event: %w", err)
}
if head.Type != EventTypeAppMention && head.Type != EventTypeMessage {
if !slices.Contains(types, head.Type) {
// Other event types can reuse field names with other shapes, for
// example an object in "channel", so they are not decoded further.
return nil, false, nil
}
var ev MessageEvent
var ev T
if err := json.Unmarshal(e.Event, &ev); err != nil {
return nil, false, fmt.Errorf("slack: invalid %s event: %w", head.Type, err)
}
Expand Down
Loading
Loading