nomadcode/services/core/internal/adapters/mattermost/client.go
toki 84408ab245 feat: external integration adapters and test improvements
- Add JIRA, Mattermost, Plane adapter implementations
- Add Mattermost client tests
- Update core server setup and main.go
- Improve config and validation tests
- Enhance HTTP handlers and middleware tests
- Update work item provider and notification tests
- Add protoSocket tasks tests
- Update agent-task and agent-roadmap documentation
2026-06-03 18:41:12 +09:00

168 lines
4 KiB
Go

package mattermost
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
)
var (
ErrMissingConfig = errors.New("mattermost adapter missing required configuration")
ErrInvalidInput = errors.New("invalid mattermost adapter input")
)
type Config struct {
BaseURL string
Token string
ChannelID string
}
type Client struct {
cfg Config
http *http.Client
logger *slog.Logger
}
type MessageInput struct {
ChannelID string
Text string
}
type TaskNotificationInput struct {
TaskID string
Title string
Status string
Message string
}
func NewClient(cfg Config, logger *slog.Logger) *Client {
return NewClientWithHTTPClient(cfg, http.DefaultClient, logger)
}
func NewClientWithHTTPClient(cfg Config, httpClient *http.Client, logger *slog.Logger) *Client {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &Client{cfg: cfg, http: httpClient, logger: logger}
}
func (c *Client) SendMessage(ctx context.Context, input MessageInput) error {
baseURL, token, err := c.config()
if err != nil {
return err
}
channelID := strings.TrimSpace(input.ChannelID)
text := strings.TrimSpace(input.Text)
if channelID == "" || text == "" {
return ErrInvalidInput
}
payload, err := json.Marshal(postRequest{
ChannelID: channelID,
Message: text,
})
if err != nil {
return err
}
endpoint := baseURL + "/api/v4/posts"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.logger != nil {
c.logger.Info("mattermost message post requested", "channel_id", channelID)
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("mattermost API request failed (POST /api/v4/posts): status %d: %s", resp.StatusCode, redactedBody(raw, token))
}
return nil
}
func (c *Client) SendTaskNotification(ctx context.Context, input TaskNotificationInput) error {
channelID := strings.TrimSpace(c.cfg.ChannelID)
if channelID == "" {
return fmt.Errorf("%w: channel ID", ErrMissingConfig)
}
return c.SendMessage(ctx, MessageInput{
ChannelID: channelID,
Text: formatTaskNotification(input),
})
}
type postRequest struct {
ChannelID string `json:"channel_id"`
Message string `json:"message"`
}
func (c *Client) config() (string, string, error) {
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/")
token := strings.TrimSpace(c.cfg.Token)
if baseURL == "" || token == "" {
return "", "", fmt.Errorf("%w: base URL and token", ErrMissingConfig)
}
parsed, err := url.Parse(baseURL)
if err != nil {
return "", "", err
}
if parsed.Scheme == "" || parsed.Host == "" {
return "", "", fmt.Errorf("mattermost base URL must include scheme and host")
}
return baseURL, token, nil
}
func formatTaskNotification(input TaskNotificationInput) string {
title := strings.TrimSpace(input.Title)
if title == "" {
title = strings.TrimSpace(input.TaskID)
}
var lines []string
if title == "" {
lines = append(lines, "NomadCode task completed")
} else {
lines = append(lines, "NomadCode task completed: "+title)
}
if taskID := strings.TrimSpace(input.TaskID); taskID != "" {
lines = append(lines, "Task ID: "+taskID)
}
if status := strings.TrimSpace(input.Status); status != "" {
lines = append(lines, "Status: "+status)
}
if message := strings.TrimSpace(input.Message); message != "" {
lines = append(lines, "Message: "+message)
}
return strings.Join(lines, "\n")
}
func redactedBody(raw []byte, token string) string {
message := strings.TrimSpace(string(raw))
if token != "" {
message = strings.ReplaceAll(message, token, "<redacted>")
}
return message
}