chore: import clean Agent Desk snapshot (3/7)
This commit is contained in:
109
internal/slack/client.go
Normal file
109
internal/slack/client.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultBaseURL = "https://slack.com/api"
|
||||
|
||||
type Client struct {
|
||||
botToken string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(botToken string) *Client {
|
||||
return &Client{
|
||||
botToken: strings.TrimSpace(botToken),
|
||||
baseURL: defaultBaseURL,
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetBaseURL(url string) {
|
||||
if strings.TrimSpace(url) != "" {
|
||||
c.baseURL = strings.TrimRight(strings.TrimSpace(url), "/")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) PostMessage(ctx context.Context, channel string, text string, threadTS string) (*SendMessageResponse, error) {
|
||||
channel = strings.TrimSpace(channel)
|
||||
if channel == "" {
|
||||
return nil, fmt.Errorf("slack channel is required")
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("message text is required")
|
||||
}
|
||||
|
||||
payload := SendMessageRequest{
|
||||
Channel: channel,
|
||||
Text: text,
|
||||
ThreadTS: threadTS,
|
||||
}
|
||||
|
||||
var resp SendMessageResponse
|
||||
if err := c.doRequest(ctx, "/chat.postMessage", payload, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !resp.OK {
|
||||
return nil, fmt.Errorf("slack api error: %s", resp.Error)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(ctx context.Context, path string, payload any, result any) error {
|
||||
if c.botToken == "" {
|
||||
return fmt.Errorf("slack bot token is required")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s%s", c.baseURL, path)
|
||||
|
||||
var bodyReader io.Reader
|
||||
if payload != nil {
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal slack request failed: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewBuffer(bodyBytes)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create slack request failed: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.botToken)
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
|
||||
res, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack http request failed: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read slack response failed: %w", err)
|
||||
}
|
||||
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return fmt.Errorf("slack api error (%d): %s", res.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
if err := json.Unmarshal(bodyBytes, result); err != nil {
|
||||
return fmt.Errorf("unmarshal slack response failed: %w (body: %s)", err, string(bodyBytes))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
120
internal/slack/client_test.go
Normal file
120
internal/slack/client_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPostMessageSendsThreadedReply(t *testing.T) {
|
||||
var (
|
||||
gotPath string
|
||||
gotAuth string
|
||||
gotBody map[string]any
|
||||
)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &gotBody)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"channel":"C0123","ts":"1725260000.000100"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("xoxb-test-token")
|
||||
client.SetBaseURL(server.URL)
|
||||
|
||||
resp, err := client.PostMessage(context.Background(), "C0123", "hello there", "1725260000.000200")
|
||||
if err != nil {
|
||||
t.Fatalf("PostMessage failed: %v", err)
|
||||
}
|
||||
if resp == nil || resp.TS != "1725260000.000100" {
|
||||
t.Fatalf("response = %+v, want the posted message ts", resp)
|
||||
}
|
||||
|
||||
if gotPath != "/chat.postMessage" {
|
||||
t.Errorf("path = %q, want /chat.postMessage", gotPath)
|
||||
}
|
||||
if gotAuth != "Bearer xoxb-test-token" {
|
||||
t.Errorf("authorization = %q, want the bot token as a bearer", gotAuth)
|
||||
}
|
||||
if gotBody["channel"] != "C0123" {
|
||||
t.Errorf("channel = %v, want C0123", gotBody["channel"])
|
||||
}
|
||||
if gotBody["text"] != "hello there" {
|
||||
t.Errorf("text = %v, want 'hello there'", gotBody["text"])
|
||||
}
|
||||
// A reply has to stay in the customer's thread, otherwise the workspace
|
||||
// timeline fills up with support answers.
|
||||
if gotBody["thread_ts"] != "1725260000.000200" {
|
||||
t.Errorf("thread_ts = %v, want the parent ts", gotBody["thread_ts"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostMessageOmitsThreadTSForTopLevel(t *testing.T) {
|
||||
var gotRaw string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
gotRaw = string(raw)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("xoxb-test-token")
|
||||
client.SetBaseURL(server.URL)
|
||||
|
||||
if _, err := client.PostMessage(context.Background(), "C0123", "top level", ""); err != nil {
|
||||
t.Fatalf("PostMessage failed: %v", err)
|
||||
}
|
||||
// thread_ts is omitempty on the request struct, so a top-level post must not
|
||||
// carry an empty one.
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal([]byte(gotRaw), &body); err != nil {
|
||||
t.Fatalf("unmarshal request body: %v", err)
|
||||
}
|
||||
if _, present := body["thread_ts"]; present {
|
||||
t.Errorf("request body carried thread_ts for a top-level message: %s", gotRaw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostMessageSurfacesSlackAPIError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Slack returns HTTP 200 with ok:false for application-level failures, so
|
||||
// the client has to read the envelope rather than the status code.
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error":"channel_not_found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("xoxb-test-token")
|
||||
client.SetBaseURL(server.URL)
|
||||
|
||||
_, err := client.PostMessage(context.Background(), "C_MISSING", "hello", "")
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error when Slack answers ok:false")
|
||||
}
|
||||
if got := err.Error(); got != "slack api error: channel_not_found" {
|
||||
t.Errorf("error = %q, want the slack error code to be carried through", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostMessageValidatesInputs(t *testing.T) {
|
||||
client := NewClient("xoxb-test-token")
|
||||
|
||||
if _, err := client.PostMessage(context.Background(), "", "hello", ""); err == nil {
|
||||
t.Errorf("expected an error for an empty channel")
|
||||
}
|
||||
if _, err := client.PostMessage(context.Background(), "C0123", " ", ""); err == nil {
|
||||
t.Errorf("expected an error for blank text")
|
||||
}
|
||||
|
||||
noToken := NewClient("")
|
||||
if _, err := noToken.PostMessage(context.Background(), "C0123", "hello", ""); err == nil {
|
||||
t.Errorf("expected an error when no bot token is configured")
|
||||
}
|
||||
}
|
||||
37
internal/slack/types.go
Normal file
37
internal/slack/types.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package slack
|
||||
|
||||
// SendMessageRequest represents payload for Slack chat.postMessage API.
|
||||
type SendMessageRequest struct {
|
||||
Channel string `json:"channel"`
|
||||
Text string `json:"text"`
|
||||
ThreadTS string `json:"thread_ts,omitempty"`
|
||||
ParseMode string `json:"parse,omitempty"`
|
||||
}
|
||||
|
||||
// SendMessageResponse represents response from Slack Web API.
|
||||
type SendMessageResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
TS string `json:"ts,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// EventCallback represents incoming Slack Events API payload.
|
||||
type EventCallback struct {
|
||||
Token string `json:"token"`
|
||||
TeamID string `json:"team_id"`
|
||||
APIAppID string `json:"api_app_id"`
|
||||
Type string `json:"type"` // url_verification | event_callback
|
||||
Challenge string `json:"challenge"` // for url_verification
|
||||
Event *struct {
|
||||
Type string `json:"type"` // message | app_mention
|
||||
User string `json:"user"`
|
||||
Text string `json:"text"`
|
||||
TS string `json:"ts"`
|
||||
ThreadTS string `json:"thread_ts,omitempty"`
|
||||
Channel string `json:"channel"`
|
||||
ChannelType string `json:"channel_type"` // im | channel | group
|
||||
BotID string `json:"bot_id,omitempty"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
} `json:"event,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user