Skip to content

Commit e350973

Browse files
committed
Use firebase admin package to send FCM messages
1 parent 85059e9 commit e350973

File tree

2 files changed

+122
-55
lines changed

2 files changed

+122
-55
lines changed

Diff for: handlers/firebase/handler.go

+52-39
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"encoding/json"
66
"fmt"
77
"net/http"
8+
"strings"
9+
"sync"
810
"time"
911

1012
firebase "firebase.google.com/go/v4"
@@ -16,14 +18,13 @@ import (
1618
)
1719

1820
const (
19-
configTitle = "FCM_TITLE"
20-
configNotification = "FCM_NOTIFICATION"
21-
configKey = "FCM_KEY"
22-
configAuthJSON = "FCM_AUTH_JSON"
21+
configTitle = "FCM_TITLE"
22+
configNotification = "FCM_NOTIFICATION"
23+
configKey = "FCM_KEY"
24+
configCredentialsFile = "FCM_CREDENTIALS_JSON"
2325
)
2426

2527
var (
26-
sendURL = "https://fcm.googleapis.com/fcm/send"
2728
maxMsgLength = 1024
2829
)
2930

@@ -33,10 +34,15 @@ func init() {
3334

3435
type handler struct {
3536
handlers.BaseHandler
37+
38+
fetchTokenMutex sync.Mutex
3639
}
3740

3841
func newHandler() courier.ChannelHandler {
39-
return &handler{handlers.NewBaseHandler(courier.ChannelType("FCM"), "Firebase", handlers.WithRedactConfigKeys(configKey))}
42+
return &handler{
43+
BaseHandler: handlers.NewBaseHandler(courier.ChannelType("FCM"), "Firebase", handlers.WithRedactConfigKeys(configKey)),
44+
fetchTokenMutex: sync.Mutex{},
45+
}
4046
}
4147

4248
func (h *handler) Initialize(s courier.Server) error {
@@ -121,40 +127,10 @@ func (h *handler) registerContact(ctx context.Context, channel courier.Channel,
121127
return nil, err
122128
}
123129

124-
type mtPayload struct {
125-
Data struct {
126-
Type string `json:"type"`
127-
Title string `json:"title"`
128-
Message string `json:"message"`
129-
MessageID int64 `json:"message_id"`
130-
SessionStatus string `json:"session_status"`
131-
QuickReplies []string `json:"quick_replies,omitempty"`
132-
} `json:"data"`
133-
Notification *mtNotification `json:"notification,omitempty"`
134-
ContentAvailable bool `json:"content_available"`
135-
To string `json:"to"`
136-
Priority string `json:"priority"`
137-
}
138-
139-
type mtNotification struct {
140-
Title string `json:"title"`
141-
Body string `json:"body"`
142-
}
143-
144130
func (h *handler) Send(ctx context.Context, msg courier.MsgOut, res *courier.SendResult, clog *courier.ChannelLog) error {
145131
title := msg.Channel().StringConfigForKey(configTitle, "")
146-
fcmKey := msg.Channel().StringConfigForKey(configKey, "")
147-
fcmAuthJSON := msg.Channel().StringConfigForKey(configAuthJSON, "")
148-
if fcmAuthJSON == "" && (title == "" || fcmKey == "") {
149-
return courier.ErrChannelConfig
150-
}
151132

152-
app, err := firebase.NewApp(ctx, nil, option.WithCredentialsJSON([]byte(fcmAuthJSON)))
153-
if err != nil {
154-
return err
155-
}
156-
157-
fcmClient, err := app.Messaging(ctx)
133+
fcmClient, projectID, err := h.GetFCMClient(ctx, msg.Channel(), clog)
158134
if err != nil {
159135
return err
160136
}
@@ -173,7 +149,7 @@ func (h *handler) Send(ctx context.Context, msg courier.MsgOut, res *courier.Sen
173149
payload.Data = map[string]string{"type": "rapidpro", "title": title, "message": part, "message_id": msg.ID().String(), "session_status": msg.SessionStatus()}
174150

175151
payload.Token = msg.URNAuth()
176-
payload.Android.Priority = "high"
152+
payload.Android = &messaging.AndroidConfig{Priority: "high"}
177153

178154
if notification {
179155
payload.Notification = &messaging.Notification{
@@ -182,12 +158,49 @@ func (h *handler) Send(ctx context.Context, msg courier.MsgOut, res *courier.Sen
182158
}
183159
}
184160

185-
_, err := fcmClient.Send(ctx, &payload)
161+
result, err := fcmClient.Send(ctx, &payload)
186162
if err != nil {
187163
return courier.ErrResponseUnexpected
188164
}
189165

166+
if !strings.Contains(result, fmt.Sprintf("projects/%s/messages/", projectID)) {
167+
return courier.ErrResponseUnexpected
168+
}
169+
externalID := strings.TrimLeft(result, fmt.Sprintf("projects/%s/messages/", projectID))
170+
if externalID == "" {
171+
return courier.ErrResponseUnexpected
172+
}
173+
174+
res.AddExternalID(externalID)
175+
190176
}
191177

192178
return nil
193179
}
180+
181+
type FCMClient interface {
182+
Send(ctx context.Context, message *messaging.Message) (string, error)
183+
}
184+
185+
func (h *handler) GetFCMClient(ctx context.Context, channel courier.Channel, clog *courier.ChannelLog) (FCMClient, string, error) {
186+
credentialsFile := channel.StringConfigForKey(configCredentialsFile, "")
187+
if credentialsFile == "" {
188+
return nil, "", courier.ErrChannelConfig
189+
}
190+
191+
var credentialsFileJSON map[string]string
192+
193+
err := json.Unmarshal([]byte(credentialsFile), &credentialsFileJSON)
194+
if err != nil {
195+
return nil, "", courier.ErrChannelConfig
196+
}
197+
198+
app, err := firebase.NewApp(ctx, nil, option.WithCredentialsJSON([]byte(credentialsFile)))
199+
if err != nil {
200+
return nil, "", err
201+
}
202+
203+
fcmClient, err := app.Messaging(ctx)
204+
205+
return fcmClient, credentialsFileJSON["project_id"], err
206+
}

Diff for: handlers/firebase/handler_test.go

+70-16
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package firebase
22

33
import (
4+
"context"
5+
"errors"
6+
"slices"
47
"testing"
58
"time"
69

10+
"firebase.google.com/go/v4/messaging"
711
"github.com/nyaruka/courier"
812
. "github.com/nyaruka/courier/handlers"
913
"github.com/nyaruka/courier/test"
@@ -36,13 +40,39 @@ var testChannels = []courier.Channel{
3640
map[string]any{
3741
configKey: "FCMKey",
3842
configTitle: "FCMTitle",
43+
configCredentialsFile: `{
44+
"type": "service_account",
45+
"project_id": "foo-project-id",
46+
"private_key_id": "123",
47+
"private_key": "BLAH",
48+
"client_email": "[email protected]",
49+
"client_id": "123123",
50+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
51+
"token_uri": "https://oauth2.googleapis.com/token",
52+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
53+
"client_x509_cert_url": "",
54+
"universe_domain": "googleapis.com"
55+
}`,
3956
}),
4057
test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "FCM", "1234", "",
4158
[]string{urns.Firebase.Prefix},
4259
map[string]any{
4360
configKey: "FCMKey",
4461
configNotification: true,
4562
configTitle: "FCMTitle",
63+
configCredentialsFile: `{
64+
"type": "service_account",
65+
"project_id": "foo-project-id",
66+
"private_key_id": "123",
67+
"private_key": "BLAH",
68+
"client_email": "[email protected]",
69+
"client_id": "123123",
70+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
71+
"token_uri": "https://oauth2.googleapis.com/token",
72+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
73+
"client_x509_cert_url": "",
74+
"universe_domain": "googleapis.com"
75+
}`,
4676
}),
4777
}
4878

@@ -99,20 +129,11 @@ func BenchmarkHandler(b *testing.B) {
99129

100130
var notificationSendTestCases = []OutgoingTestCase{
101131
{
102-
Label: "Plain Send",
103-
MsgText: "Simple Message",
104-
MsgURN: "fcm:250788123123",
105-
MsgURNAuth: "auth1",
106-
MockResponses: map[string][]*httpx.MockResponse{
107-
"https://fcm.googleapis.com/fcm/send": {
108-
httpx.NewMockResponse(200, nil, []byte(`{"success":1, "multicast_id": 123456}`)),
109-
},
110-
},
111-
ExpectedRequests: []ExpectedRequest{{
112-
Headers: map[string]string{"Authorization": "key=FCMKey"},
113-
Body: `{"data":{"type":"rapidpro","title":"FCMTitle","message":"Simple Message","message_id":10,"session_status":""},"notification":{"title":"FCMTitle","body":"Simple Message"},"content_available":true,"to":"auth1","priority":"high"}`,
114-
}},
115-
ExpectedExtIDs: []string{"123456"},
132+
Label: "Plain Send",
133+
MsgText: "Simple Message",
134+
MsgURN: "fcm:250788123123",
135+
MsgURNAuth: "auth1",
136+
ExpectedExtIDs: []string{"123456-a"},
116137
},
117138
}
118139

@@ -224,7 +245,40 @@ var sendTestCases = []OutgoingTestCase{
224245
},
225246
}
226247

248+
type MockFCMClient struct {
249+
// list of valid FCM tokens
250+
ValidTokens []string
251+
252+
// log of messages sent to this client
253+
Messages []*messaging.Message
254+
}
255+
256+
func (fc *MockFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) {
257+
var err error
258+
result := ""
259+
260+
fc.Messages = append(fc.Messages, message)
261+
if slices.Contains(fc.ValidTokens, message.Token) {
262+
return "projects/foo-project-id/messages/123456-a", err
263+
}
264+
return result, errors.New("401 error: 401 Unauthorized")
265+
}
266+
267+
type FCMHandler struct {
268+
courier.ChannelHandler
269+
FCMClient FCMClient
270+
}
271+
272+
func newFCMHandler(FCMClient FCMClient) *FCMHandler {
273+
return &FCMHandler{test.NewMockHandler(), FCMClient}
274+
}
275+
276+
func (h *FCMHandler) GetFCMClient(ctx context.Context, channel courier.Channel, clog *courier.ChannelLog) (FCMClient, string, error) {
277+
return h.FCMClient, "foo-project-id", nil
278+
}
279+
227280
func TestOutgoing(t *testing.T) {
228-
RunOutgoingTestCases(t, testChannels[0], newHandler(), sendTestCases, []string{"FCMKey"}, nil)
229-
RunOutgoingTestCases(t, testChannels[1], newHandler(), notificationSendTestCases, []string{"FCMKey"}, nil)
281+
282+
RunOutgoingTestCases(t, testChannels[0], newFCMHandler(&MockFCMClient{ValidTokens: []string{"auth1"}}), sendTestCases, []string{"FCMKey"}, nil)
283+
RunOutgoingTestCases(t, testChannels[1], newFCMHandler(&MockFCMClient{ValidTokens: []string{"auth1"}}), notificationSendTestCases, []string{"FCMKey"}, nil)
230284
}

0 commit comments

Comments
 (0)