feat: per-location sender ID with admin API
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Allows each GHL sub-account to use a different Cast sender ID instead of
the global CAST_SENDER_ID default.
- store.TokenRecord gains a sender_id field (MongoDB)
- store.UpdateSenderID method to set it per location
- cast.Client.SendSMS accepts a senderID override param (empty = use
client-level default)
- webhook.processOutbound reads the location's sender_id from the token
record and passes it to Cast
- new admin handler: PUT /api/admin/locations/{locationId}/sender-id
protected by Authorization: Bearer <INBOUND_API_KEY>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
3ea663c8dc
commit
5312eb0ca2
@ -50,6 +50,8 @@ func run() error {
|
||||
return fmt.Errorf("webhook handler: %w", err)
|
||||
}
|
||||
|
||||
adminHandler := ghl.NewAdminHandler(cfg.InboundAPIKey, s)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
@ -61,6 +63,7 @@ func run() error {
|
||||
r.Get("/oauth-callback", oauthHandler.HandleCallback)
|
||||
r.Post("/api/ghl/v1/webhook/messages", webhookHandler.HandleWebhook)
|
||||
r.Post("/api/ghl/v1/webhook/uninstall", webhookHandler.HandleUninstall)
|
||||
r.Put("/api/admin/locations/{locationId}/sender-id", adminHandler.HandleSetSenderID)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
|
||||
@ -29,9 +29,13 @@ func NewClient(baseURL, apiKey, senderID string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SendSMS(ctx context.Context, to, message string) (*SendResponse, error) {
|
||||
// SendSMS sends an SMS via Cast API. senderID overrides the client-level default when non-empty.
|
||||
func (c *Client) SendSMS(ctx context.Context, to, message, senderID string) (*SendResponse, error) {
|
||||
req := SendRequest{To: to, Message: message}
|
||||
if c.senderID != "" {
|
||||
switch {
|
||||
case senderID != "":
|
||||
req.SenderID = senderID
|
||||
case c.senderID != "":
|
||||
req.SenderID = c.senderID
|
||||
}
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ func TestSendSMS_Success(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "cast_testkey", "")
|
||||
resp, err := client.SendSMS(context.Background(), "09171234567", "test message")
|
||||
resp, err := client.SendSMS(context.Background(), "09171234567", "test message", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@ -49,7 +49,7 @@ func TestSendSMS_APIError(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "cast_testkey", "")
|
||||
_, err := client.SendSMS(context.Background(), "09171234567", "test")
|
||||
_, err := client.SendSMS(context.Background(), "09171234567", "test", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@ -70,7 +70,7 @@ func TestSendSMS_SuccessFalseInBody(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "cast_testkey", "")
|
||||
_, err := client.SendSMS(context.Background(), "09171234567", "test")
|
||||
_, err := client.SendSMS(context.Background(), "09171234567", "test", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@ -96,7 +96,7 @@ func TestSendSMS_WithSenderID(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "cast_testkey", "CAST")
|
||||
_, err := client.SendSMS(context.Background(), "09171234567", "test")
|
||||
_, err := client.SendSMS(context.Background(), "09171234567", "test", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@ -115,7 +115,7 @@ func TestSendSMS_WithoutSenderID(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "cast_testkey", "")
|
||||
_, err := client.SendSMS(context.Background(), "09171234567", "test")
|
||||
_, err := client.SendSMS(context.Background(), "09171234567", "test", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@ -130,7 +130,7 @@ func TestSendSMS_Unauthorized(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "cast_badkey", "")
|
||||
_, err := client.SendSMS(context.Background(), "09171234567", "test")
|
||||
_, err := client.SendSMS(context.Background(), "09171234567", "test", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 401, got nil")
|
||||
}
|
||||
@ -158,7 +158,7 @@ func TestSendSMS_RetryOn429(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "cast_testkey", "")
|
||||
resp, err := client.SendSMS(context.Background(), "09171234567", "test")
|
||||
resp, err := client.SendSMS(context.Background(), "09171234567", "test", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
78
internal/ghl/admin.go
Normal file
78
internal/ghl/admin.go
Normal file
@ -0,0 +1,78 @@
|
||||
package ghl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// LocationConfigStore is the store interface used by AdminHandler.
|
||||
type LocationConfigStore interface {
|
||||
UpdateSenderID(ctx context.Context, locationID, senderID string) error
|
||||
}
|
||||
|
||||
// AdminHandler exposes internal management endpoints.
|
||||
// All routes require a valid Authorization: Bearer <adminKey> header.
|
||||
type AdminHandler struct {
|
||||
adminKey string
|
||||
store LocationConfigStore
|
||||
}
|
||||
|
||||
func NewAdminHandler(adminKey string, store LocationConfigStore) *AdminHandler {
|
||||
return &AdminHandler{adminKey: adminKey, store: store}
|
||||
}
|
||||
|
||||
// HandleSetSenderID sets or clears the per-location Cast sender ID.
|
||||
//
|
||||
// PUT /api/admin/locations/{locationId}/sender-id
|
||||
// Authorization: Bearer <INBOUND_API_KEY>
|
||||
// {"sender_id":"CAST"}
|
||||
func (h *AdminHandler) HandleSetSenderID(w http.ResponseWriter, r *http.Request) {
|
||||
if h.adminKey == "" || r.Header.Get("Authorization") != "Bearer "+h.adminKey {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
locationID := chi.URLParam(r, "locationId")
|
||||
if locationID == "" {
|
||||
http.Error(w, "missing locationId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1024))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
SenderID string `json:"sender_id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.store.UpdateSenderID(ctx, locationID, payload.SenderID); err != nil {
|
||||
slog.Error("admin: failed to update sender_id", "location_id", locationID, "err", err)
|
||||
if err.Error() == "location not found" {
|
||||
http.Error(w, "location not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("admin: sender_id updated", "location_id", locationID, "sender_id", payload.SenderID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
@ -28,6 +28,7 @@ type TokenStore interface {
|
||||
SaveToken(ctx context.Context, record *store.TokenRecord) error
|
||||
GetToken(ctx context.Context, locationID string) (*store.TokenRecord, error)
|
||||
UpdateToken(ctx context.Context, locationID, accessToken, refreshToken string, expiresAt time.Time) error
|
||||
UpdateSenderID(ctx context.Context, locationID, senderID string) error
|
||||
DeleteToken(ctx context.Context, locationID string) error
|
||||
}
|
||||
|
||||
|
||||
@ -201,6 +201,13 @@ func (m *inMemStore) UpdateToken(_ context.Context, locationID, accessToken, ref
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *inMemStore) UpdateSenderID(_ context.Context, locationID, senderID string) error {
|
||||
if m.token != nil && m.token.LocationID == locationID {
|
||||
m.token.SenderID = senderID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *inMemStore) DeleteToken(_ context.Context, locationID string) error {
|
||||
if m.token != nil && m.token.LocationID == locationID {
|
||||
m.token = nil
|
||||
|
||||
@ -138,8 +138,14 @@ func (h *WebhookHandler) processOutbound(webhook OutboundMessageWebhook) {
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("webhook: calling cast api", "message_id", webhook.MessageID, "to", localPhone)
|
||||
_, err = h.castClient.SendSMS(ctx, localPhone, webhook.Message)
|
||||
// Look up per-location sender ID; fall back to Cast client default if unset.
|
||||
var senderID string
|
||||
if rec, err := h.store.GetToken(ctx, webhook.LocationID); err == nil && rec != nil {
|
||||
senderID = rec.SenderID
|
||||
}
|
||||
|
||||
slog.Info("webhook: calling cast api", "message_id", webhook.MessageID, "to", localPhone, "sender_id", senderID)
|
||||
_, err = h.castClient.SendSMS(ctx, localPhone, webhook.Message, senderID)
|
||||
if err != nil {
|
||||
slog.Error("webhook: cast send failed", "message_id", webhook.MessageID, "to", localPhone, "err", err)
|
||||
h.updateStatus(ctx, webhook, "failed")
|
||||
|
||||
@ -18,6 +18,7 @@ type TokenRecord struct {
|
||||
ExpiresAt time.Time `bson:"expires_at"`
|
||||
InstalledAt time.Time `bson:"installed_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
SenderID string `bson:"sender_id,omitempty"` // per-location Cast sender ID; overrides global default
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
@ -83,6 +84,22 @@ func (s *Store) UpdateToken(ctx context.Context, locationID, accessToken, refres
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSenderID(ctx context.Context, locationID, senderID string) error {
|
||||
filter := bson.D{{Key: "location_id", Value: locationID}}
|
||||
update := bson.D{{Key: "$set", Value: bson.D{
|
||||
{Key: "sender_id", Value: senderID},
|
||||
{Key: "updated_at", Value: time.Now()},
|
||||
}}}
|
||||
res, err := s.collection.UpdateOne(ctx, filter, update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return errors.New("location not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteToken(ctx context.Context, locationID string) error {
|
||||
filter := bson.D{{Key: "location_id", Value: locationID}}
|
||||
_, err := s.collection.DeleteOne(ctx, filter)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user