mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-11 08:06:13 +00:00
250 lines
7.3 KiB
Go
250 lines
7.3 KiB
Go
package integration
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
// actionHTTP is the shared client for outbound provider action calls. Short
|
|
// timeout — a slow third party shouldn't pin a dispatch goroutine for long.
|
|
var actionHTTP = &http.Client{Timeout: 15 * time.Second}
|
|
|
|
// slackPostMessage posts to a channel using a bot token from the OAuth connect.
|
|
// Slack returns HTTP 200 with {ok:false,error:...} on failure, so we inspect
|
|
// the body rather than the status code.
|
|
func slackPostMessage(ctx context.Context, token, channel string, msg eventMessage) error {
|
|
if channel == "" {
|
|
return fmt.Errorf("no slack channel configured")
|
|
}
|
|
body, _ := json.Marshal(map[string]any{
|
|
"channel": channel,
|
|
"text": msg.plainText(),
|
|
})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://slack.com/api/chat.postMessage", bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
resp, err := actionHTTP.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
var out struct {
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error"`
|
|
}
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
_ = json.Unmarshal(raw, &out)
|
|
if !out.OK {
|
|
if out.Error == "" {
|
|
out.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
|
}
|
|
return fmt.Errorf("slack chat.postMessage: %s", out.Error)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// webhookPost delivers a Discord-compatible payload (and works for any generic
|
|
// JSON webhook): Discord requires a top-level "content" string.
|
|
func webhookPost(ctx context.Context, url string, msg eventMessage) error {
|
|
body, _ := json.Marshal(map[string]any{
|
|
"content": msg.plainText(),
|
|
"email": msg.Email,
|
|
"subject": msg.Subject,
|
|
"title": msg.Title,
|
|
})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := actionHTTP.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("webhook POST: HTTP %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// hubspotUpsertContact creates or updates a HubSpot contact keyed by email and
|
|
// logs the event as a note on the contact timeline (best-effort).
|
|
func hubspotUpsertContact(ctx context.Context, token string, data map[string]any, msg eventMessage) error {
|
|
email := msg.Email
|
|
if email == "" {
|
|
// Nothing to key on; treat as a no-op success.
|
|
return nil
|
|
}
|
|
firstName := stringFromMap(data, "first_name", "contact_first_name")
|
|
lastName := stringFromMap(data, "last_name", "contact_last_name")
|
|
|
|
props := map[string]any{"email": email}
|
|
if firstName != "" {
|
|
props["firstname"] = firstName
|
|
}
|
|
if lastName != "" {
|
|
props["lastname"] = lastName
|
|
}
|
|
|
|
// Find existing contact by email.
|
|
searchBody, _ := json.Marshal(map[string]any{
|
|
"filterGroups": []map[string]any{{
|
|
"filters": []map[string]any{{
|
|
"propertyName": "email", "operator": "EQ", "value": email,
|
|
}},
|
|
}},
|
|
"properties": []string{"email"},
|
|
"limit": 1,
|
|
})
|
|
var search struct {
|
|
Results []struct {
|
|
ID string `json:"id"`
|
|
} `json:"results"`
|
|
}
|
|
if err := hubspotJSON(ctx, http.MethodPost, "https://api.hubapi.com/crm/v3/objects/contacts/search", token, searchBody, &search); err != nil {
|
|
return err
|
|
}
|
|
|
|
contactID := ""
|
|
if len(search.Results) > 0 {
|
|
contactID = search.Results[0].ID
|
|
upd, _ := json.Marshal(map[string]any{"properties": props})
|
|
if err := hubspotJSON(ctx, http.MethodPatch, "https://api.hubapi.com/crm/v3/objects/contacts/"+contactID, token, upd, nil); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
create, _ := json.Marshal(map[string]any{"properties": props})
|
|
var created struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := hubspotJSON(ctx, http.MethodPost, "https://api.hubapi.com/crm/v3/objects/contacts", token, create, &created); err != nil {
|
|
return err
|
|
}
|
|
contactID = created.ID
|
|
}
|
|
|
|
// Best-effort note on the timeline.
|
|
if contactID != "" {
|
|
note, _ := json.Marshal(map[string]any{
|
|
"properties": map[string]any{
|
|
"hs_note_body": msg.plainText(),
|
|
"hs_timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
},
|
|
"associations": []map[string]any{{
|
|
"to": map[string]any{"id": contactID},
|
|
"types": []map[string]any{{
|
|
"associationCategory": "HUBSPOT_DEFINED",
|
|
"associationTypeId": 202,
|
|
}},
|
|
}},
|
|
})
|
|
_ = hubspotJSON(ctx, http.MethodPost, "https://api.hubapi.com/crm/v3/objects/notes", token, note, nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hubspotJSON(ctx context.Context, method, url, token string, body []byte, dst any) error {
|
|
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := actionHTTP.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("hubspot %s %s: HTTP %d", method, shortURL(url), resp.StatusCode)
|
|
}
|
|
if dst != nil && len(raw) > 0 {
|
|
return json.Unmarshal(raw, dst)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pipedriveUpsertPerson creates a Pipedrive person keyed by email. Pipedrive's
|
|
// REST API does not offer a true upsert, so we search first.
|
|
func pipedriveUpsertPerson(ctx context.Context, token string, data map[string]any) error {
|
|
email := stringFromMap(data, "contact_email", "invitee_email", "email")
|
|
if email == "" {
|
|
return nil
|
|
}
|
|
name := stringFromMap(data, "contact_name", "invitee_name", "name")
|
|
if name == "" {
|
|
name = email
|
|
}
|
|
|
|
// Search for an existing person by email.
|
|
searchURL := "https://api.pipedrive.com/v1/persons/search?term=" + url.QueryEscape(email) + "&fields=email&exact_match=true"
|
|
var search struct {
|
|
Data struct {
|
|
Items []struct {
|
|
Item struct {
|
|
ID int64 `json:"id"`
|
|
} `json:"item"`
|
|
} `json:"items"`
|
|
} `json:"data"`
|
|
}
|
|
if err := pipedriveJSON(ctx, http.MethodGet, searchURL, token, nil, &search); err != nil {
|
|
return err
|
|
}
|
|
if len(search.Data.Items) > 0 {
|
|
return nil // already present; nothing to do
|
|
}
|
|
|
|
create, _ := json.Marshal(map[string]any{
|
|
"name": name,
|
|
"email": []string{email},
|
|
})
|
|
return pipedriveJSON(ctx, http.MethodPost, "https://api.pipedrive.com/v1/persons", token, create, nil)
|
|
}
|
|
|
|
func pipedriveJSON(ctx context.Context, method, url, token string, body []byte, dst any) error {
|
|
var reader io.Reader
|
|
if body != nil {
|
|
reader = bytes.NewReader(body)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, url, reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := actionHTTP.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("pipedrive %s: HTTP %d", method, resp.StatusCode)
|
|
}
|
|
if dst != nil && len(raw) > 0 {
|
|
return json.Unmarshal(raw, dst)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func shortURL(u string) string {
|
|
if i := len("https://api.hubapi.com"); len(u) > i {
|
|
return u[i:]
|
|
}
|
|
return u
|
|
}
|