package securechat import ( "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) // APIError es la respuesta de error de /v1: { "error": { "code", "message", "details" } }. type APIError struct { Status int Code string Message string Details json.RawMessage } func (e *APIError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) } // Client es un cliente mínimo de la API de empresa (/v1) con la firma HMAC. type Client struct { BaseURL string APIKey string WebhookSecret string HTTP *http.Client } func NewClient(baseURL, apiKey, webhookSecret string) *Client { return &Client{ BaseURL: strings.TrimRight(baseURL, "/"), APIKey: apiKey, WebhookSecret: webhookSecret, HTTP: &http.Client{Timeout: 15 * time.Second}, } } // Do firma y envía. Se firma EXACTAMENTE la ruta+query que va en la URL. body == nil → sin cuerpo. // out puede ser nil. idempotencyKey vacío en POST → uno aleatorio. func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body any, idempotencyKey string, out any) error { method = strings.ToUpper(method) pathAndQuery := path if len(query) > 0 { pathAndQuery += "?" + query.Encode() } var raw []byte if body != nil { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(body); err != nil { return err } raw = bytes.TrimSuffix(buf.Bytes(), []byte("\n")) } req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+pathAndQuery, bytes.NewReader(raw)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.APIKey) req.Header.Set("X-SecureChat-Signature", SignRequest(method, pathAndQuery, raw, c.WebhookSecret, 0)) if len(raw) > 0 { req.Header.Set("Content-Type", "application/json") } if method != http.MethodGet { if idempotencyKey == "" { b := make([]byte, 16) _, _ = rand.Read(b) idempotencyKey = hex.EncodeToString(b) } req.Header.Set("Idempotency-Key", idempotencyKey) } res, err := c.HTTP.Do(req) if err != nil { return err } defer res.Body.Close() data, err := io.ReadAll(io.LimitReader(res.Body, 8<<20)) if err != nil { return err } if res.StatusCode < 200 || res.StatusCode > 299 { var e struct { Error struct { Code string `json:"code"` Message string `json:"message"` Details json.RawMessage `json:"details"` } `json:"error"` } _ = json.Unmarshal(data, &e) if e.Error.Code == "" { e.Error.Code = "HTTP_ERROR" } return &APIError{Status: res.StatusCode, Code: e.Error.Code, Message: e.Error.Message, Details: e.Error.Details} } if out != nil && len(data) > 0 { return json.Unmarshal(data, out) } return nil } // Conversation es lo que usa la empresa de GET /v1/conversations/{id}. type Conversation struct { ConversationID string `json:"conversationId"` Status string `json:"status"` KeyVersion int `json:"keyVersion"` UserPublicKey string `json:"userPublicKey"` WrapForCompany *Wrap `json:"wrapForCompany"` } func (c *Client) GetConversation(ctx context.Context, conversationID string) (Conversation, error) { var r struct { Conversation Conversation `json:"conversation"` } err := c.Do(ctx, "GET", "/v1/conversations/"+url.PathEscape(conversationID), nil, nil, "", &r) return r.Conversation, err } // SendMessage: POST /v1/messages con contentType "text". func (c *Client) SendMessage(ctx context.Context, conversationID string, enc Envelope, idempotencyKey string) error { body := map[string]any{"conversationId": conversationID, "contentType": "text", "encryption": enc} return c.Do(ctx, "POST", "/v1/messages", nil, body, idempotencyKey, nil) } // Rewrap: POST /v1/conversations/{id}/rewrap (reinvitación verificada). func (c *Client) Rewrap(ctx context.Context, conversationID string, wrapForUser Wrap) error { return c.Do(ctx, "POST", "/v1/conversations/"+url.PathEscape(conversationID)+"/rewrap", nil, map[string]any{"wrapForUser": wrapForUser}, "", nil) }