2022-10-26 11:23:56 +03:00
// See documentation here:
//
2024-03-22 17:47:28 +03:00
// # Sending notifications
2022-10-26 11:23:56 +03:00
//
// https://firebase.google.com/docs/cloud-messaging/send-message?hl=en
//
2024-03-22 17:47:28 +03:00
// # OAuth2.0
2022-10-26 11:23:56 +03:00
//
// https://developers.google.com/identity/protocols/oauth2/service-account?hl=en
package fcm
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
2024-03-22 17:47:28 +03:00
"sync"
2022-10-26 11:23:56 +03:00
"git.bit5.ru/backend/errors"
2024-03-22 17:47:28 +03:00
"github.com/go-logr/logr"
2022-10-26 11:23:56 +03:00
"golang.org/x/oauth2"
)
const (
fcmErrorType = "type.googleapis.com/google.firebase.fcm.v1.FcmError"
maxMessages = 500
multipartBoundary = "msg_boundary"
BatchSendEndpoint = "https://fcm.googleapis.com/batch"
)
var (
AuthScopes = [ ] string { "https://www.googleapis.com/auth/firebase.messaging" }
)
func MakeSendEndpoint ( projectId string ) string {
return fmt . Sprintf ( "https://fcm.googleapis.com/v1/projects/%s/messages:send" , projectId )
}
type Credentials struct {
Type string ` json:"type" `
ProjectID string ` json:"project_id" `
PrivateKeyID string ` json:"private_key_id" `
PrivateKey string ` json:"private_key" `
ClientID string ` json:"client_id" `
ClientEmail string ` json:"client_email" `
AuthURL string ` json:"auth_uri" `
TokenURL string ` json:"token_uri" `
}
func ReadCredentialsFromFile ( filename string ) ( Credentials , error ) {
data , err := ioutil . ReadFile ( filename )
if err != nil {
return Credentials { } , errors . WithStack ( err )
}
var c Credentials
if err := json . Unmarshal ( data , & c ) ; err != nil {
return Credentials { } , errors . WithStack ( err )
}
return c , nil
}
type ClientConfig struct {
SendEndpoint string
BatchSendEndpoint string
}
type Client struct {
cfg ClientConfig
ts oauth2 . TokenSource
hc * http . Client
2022-11-03 17:00:28 +03:00
logger logr . Logger
2022-10-26 11:23:56 +03:00
}
2022-11-03 17:00:28 +03:00
func NewClient ( cfg ClientConfig , ts oauth2 . TokenSource , hc * http . Client , logger logr . Logger ) * Client {
2022-10-26 11:23:56 +03:00
return & Client {
cfg : cfg ,
ts : ts ,
hc : hc ,
logger : logger ,
}
}
func ( c * Client ) SendMessage ( msg Message ) ( SendResponse , error ) {
sendRequest := SendRequest {
ValidateOnly : false ,
Message : msg ,
}
2024-03-22 17:47:28 +03:00
return c . doSendRequest ( sendRequest , true )
2022-10-26 11:23:56 +03:00
}
func ( c * Client ) ValidateMessage ( msg Message ) ( SendResponse , error ) {
sendRequest := SendRequest {
ValidateOnly : true ,
Message : msg ,
}
2024-03-22 17:47:28 +03:00
return c . doSendRequest ( sendRequest , true )
2022-10-26 11:23:56 +03:00
}
2024-03-22 17:47:28 +03:00
func ( c * Client ) doSendRequest ( req SendRequest , loggerEnabled bool ) ( SendResponse , error ) {
2022-10-26 11:23:56 +03:00
accessToken , err := c . ts . Token ( )
if err != nil {
return SendResponse { } , err
}
data , err := json . Marshal ( req )
if err != nil {
return SendResponse { } , errors . WithStack ( err )
}
2024-03-22 17:47:28 +03:00
if loggerEnabled {
c . logger . Info ( "sending" , "message" , data )
}
2022-10-26 11:23:56 +03:00
request , err := http . NewRequest ( http . MethodPost , c . cfg . SendEndpoint , bytes . NewReader ( data ) )
if err != nil {
return SendResponse { } , errors . WithStack ( err )
}
accessToken . SetAuthHeader ( request )
request . Header . Set ( "Content-Type" , "application/json" )
response , err := c . hc . Do ( request )
if err != nil {
return SendResponse { } , errors . WithStack ( err )
}
defer response . Body . Close ( )
if response . StatusCode != http . StatusOK {
body , err := ioutil . ReadAll ( response . Body )
if err != nil {
return SendResponse { } , errors . WithStack ( err )
}
return SendResponse { } , errors . New ( string ( body ) )
}
var resp SendResponse
if err := json . NewDecoder ( response . Body ) . Decode ( & resp ) ; err != nil {
return SendResponse { } , errors . WithStack ( err )
}
return resp , nil
}
2024-03-22 17:47:28 +03:00
// Deprecated: Use SendEach instead.
2022-10-26 11:23:56 +03:00
func ( c * Client ) SendMessages ( messages [ ] Message ) ( MultiSendResponse , error ) {
return c . doSendMessages ( messages , false )
}
func ( c * Client ) ValidateMessages ( messages [ ] Message ) ( MultiSendResponse , error ) {
return c . doSendMessages ( messages , true )
}
func ( c * Client ) doSendMessages ( messages [ ] Message , validateOnly bool ) ( MultiSendResponse , error ) {
messageCount := len ( messages )
if messageCount == 0 {
return MultiSendResponse { } , nil
}
if messageCount > maxMessages {
return MultiSendResponse { } , errors . New ( fmt . Sprintf ( "messages limit (%d) exceeded: %d" , maxMessages , messageCount ) )
}
accessToken , err := c . ts . Token ( )
if err != nil {
return MultiSendResponse { } , err
}
var body bytes . Buffer
w := multipart . NewWriter ( & body )
w . SetBoundary ( multipartBoundary )
for index , msg := range messages {
req := SendRequest {
ValidateOnly : validateOnly ,
Message : msg ,
}
body , err := c . makeMessageRequest ( req )
if err != nil {
return MultiSendResponse { } , err
}
if err := writePartTo ( w , body , index ) ; err != nil {
return MultiSendResponse { } , err
}
}
if err := w . Close ( ) ; err != nil {
return MultiSendResponse { } , errors . WithStack ( err )
}
request , err := http . NewRequest ( http . MethodPost , c . cfg . BatchSendEndpoint , & body )
if err != nil {
return MultiSendResponse { } , errors . WithStack ( err )
}
accessToken . SetAuthHeader ( request )
request . Header . Set ( "Content-Type" , fmt . Sprintf ( ` multipart/mixed; boundary="%s" ` , multipartBoundary ) )
response , err := c . hc . Do ( request )
if err != nil {
return MultiSendResponse { } , errors . WithStack ( err )
}
defer response . Body . Close ( )
return c . makeMultiSendResponse ( response , messageCount )
}
func ( c * Client ) makeMessageRequest ( req SendRequest ) ( [ ] byte , error ) {
reqJson , err := json . Marshal ( req )
if err != nil {
return nil , errors . WithStack ( err )
}
request , err := http . NewRequest ( http . MethodPost , c . cfg . SendEndpoint , bytes . NewBuffer ( reqJson ) )
if err != nil {
return nil , errors . WithStack ( err )
}
request . Header . Set ( "Content-Type" , "application/json; charset=UTF-8" )
request . Header . Set ( "User-Agent" , "" )
var body bytes . Buffer
if err := request . Write ( & body ) ; err != nil {
return nil , errors . WithStack ( err )
}
return body . Bytes ( ) , nil
}
func writePartTo ( w * multipart . Writer , bytes [ ] byte , index int ) error {
header := make ( textproto . MIMEHeader )
header . Set ( "Content-Type" , "application/http" )
header . Set ( "Content-Transfer-Encoding" , "binary" )
header . Set ( "Content-ID" , fmt . Sprintf ( "%d" , index + 1 ) )
part , err := w . CreatePart ( header )
if err != nil {
return errors . WithStack ( err )
}
if _ , err := part . Write ( bytes ) ; err != nil {
return errors . WithStack ( err )
}
return nil
}
func ( c * Client ) makeMultiSendResponse ( response * http . Response , totalCount int ) ( MultiSendResponse , error ) {
responses := make ( [ ] SendResponse , 0 , totalCount )
var fails int
_ , params , err := mime . ParseMediaType ( response . Header . Get ( "Content-Type" ) )
if err != nil {
return MultiSendResponse { } , errors . WithStack ( err )
}
reader := multipart . NewReader ( response . Body , params [ "boundary" ] )
for {
part , err := reader . NextPart ( )
if err == io . EOF {
break
} else if err != nil {
return MultiSendResponse { } , errors . WithStack ( err )
}
resp , err := makeSendResponseFromPart ( part )
if err != nil {
return MultiSendResponse { } , err
}
responses = append ( responses , resp )
if resp . HasError ( ) {
2024-03-22 17:47:28 +03:00
c . logger . Info ( "fail" , "error" , fmt . Sprintf ( "%+v" , * resp . Error ) )
2022-10-26 11:23:56 +03:00
fails ++
}
}
return MultiSendResponse {
Responses : responses ,
Sent : totalCount - fails ,
Failed : fails ,
} , nil
}
func makeSendResponseFromPart ( part * multipart . Part ) ( SendResponse , error ) {
response , err := http . ReadResponse ( bufio . NewReader ( part ) , nil )
if err != nil {
return SendResponse { } , errors . WithStack ( err )
}
defer response . Body . Close ( )
body , err := ioutil . ReadAll ( response . Body )
if err != nil {
return SendResponse { } , errors . WithStack ( err )
}
var resp SendResponse
if err := json . Unmarshal ( body , & resp ) ; err != nil {
return SendResponse { } , errors . WithMessagef ( err , "response body: %s" , body )
}
return resp , nil
}
2024-03-22 17:47:28 +03:00
func ( c * Client ) SendEach ( messages [ ] Message ) ( MessageMultiSendResponse , error ) {
return c . doSendEachInBatch ( messages , false )
}
func ( c * Client ) doSendEachInBatch ( messages [ ] Message , validateOnly bool ) ( MessageMultiSendResponse , error ) {
messageCount := len ( messages )
if messageCount == 0 {
return MessageMultiSendResponse { } , nil
}
if messageCount > maxMessages {
return MessageMultiSendResponse { } , errors . New ( fmt . Sprintf ( "messages limit (%d) exceeded: %d" , maxMessages , messageCount ) )
}
var responses = make ( [ ] MessageSendResponse , len ( messages ) )
var wg sync . WaitGroup
for idx , m := range messages {
//if err := validateMessage(m); err != nil {
// return nil, fmt.Errorf("invalid message at index %d: %v", idx, err)
//}
wg . Add ( 1 )
go func ( idx int , m Message , validateOnly bool , responses [ ] MessageSendResponse ) {
defer wg . Done ( )
var resp string
var err error
if validateOnly {
resp , err = c . Validate ( m )
} else {
resp , err = c . Send ( m )
}
if err == nil {
responses [ idx ] = MessageSendResponse {
Success : true ,
MessageID : resp ,
}
} else {
responses [ idx ] = MessageSendResponse {
Success : false ,
Error : err ,
}
}
} ( idx , m , validateOnly , responses )
}
// Wait for all Validate/Send calls to finish
wg . Wait ( )
successCount := 0
for _ , r := range responses {
if r . Success {
successCount ++
}
}
return MessageMultiSendResponse {
Responses : responses ,
Sent : successCount ,
Failed : len ( responses ) - successCount ,
} , nil
}
func ( c * Client ) Send ( message Message ) ( string , error ) {
sendRequest := SendRequest {
ValidateOnly : false ,
Message : message ,
}
resp , err := c . doSendRequest ( sendRequest , false )
return resp . MessageName , err
}
func ( c * Client ) Validate ( message Message ) ( string , error ) {
sendRequest := SendRequest {
ValidateOnly : true ,
Message : message ,
}
resp , err := c . doSendRequest ( sendRequest , false )
return resp . MessageName , err
}
type MessageSendResponse struct {
Success bool
MessageID string
Error error
}
type MessageMultiSendResponse struct {
Responses [ ] MessageSendResponse
Sent int
Failed int
}
2022-10-26 11:23:56 +03:00
type SendRequest struct {
// Flag for testing the request without actually delivering the message.
ValidateOnly bool ` json:"validate_only,omitempty" `
Message Message ` json:"message" `
}
type SendResponse struct {
MessageName string ` json:"name" `
Error * SendError ` json:"error" `
}
func ( sr SendResponse ) HasError ( ) bool {
return sr . Error != nil
}
type SendErrorCode string
const (
SendErrorCode_UNSPECIFIED_ERROR SendErrorCode = "UNSPECIFIED_ERROR"
SendErrorCode_UNREGISTERED SendErrorCode = "UNREGISTERED"
SendErrorCode_SENDER_ID_MISMATCH SendErrorCode = "SENDER_ID_MISMATCH"
SendErrorCode_QUOTA_EXCEEDED SendErrorCode = "QUOTA_EXCEEDED"
SendErrorCode_THIRD_PARTY_AUTH_ERROR SendErrorCode = "THIRD_PARTY_AUTH_ERROR"
)
type SendError struct {
Code int ` json:"code" `
Message string ` json:"message" `
Status string ` json:"status" `
Details [ ] struct {
Type string ` json:"@type" `
ErrorCode SendErrorCode ` json:"errorCode" `
} ` json:"details" `
}
func ( se * SendError ) IsUnregistered ( ) bool {
if se == nil {
return false
}
for _ , d := range se . Details {
if d . Type == fcmErrorType && d . ErrorCode == SendErrorCode_UNREGISTERED {
return true
}
}
return false
}
type MultiSendResponse struct {
Responses [ ] SendResponse
Sent int
Failed int
}
type Message struct {
Token string ` json:"token,omitempty" `
Topic string ` json:"topic,omitempty" `
Condition string ` json:"condition,omitempty" `
Data map [ string ] string ` json:"data,omitempty" `
Notification * Notification ` json:"notification,omitempty" `
FcmOptions * FcmOptions ` json:"fcm_options,omitempty" `
Android * AndroidConfig ` json:"android,omitempty" `
Webpush * WebpushConfig ` json:"webpush,omitempty" `
Apns * ApnsConfig ` json:"apns,omitempty" `
}
// Basic notification template to use across all platforms.
type Notification struct {
Title string ` json:"title,omitempty" `
Body string ` json:"body,omitempty" `
Image string ` json:"image,omitempty" `
}
type FcmOptions struct {
// Label associated with the message's analytics data.
// Format: ^[a-zA-Z0-9-_.~%]{1,50}$
AnalyticsLabel string ` json:"analytics_label,omitempty" `
}
type AndroidConfig struct {
CollapseKey string ` json:"collapse_key,omitempty" `
Priority AndroidMessagePriority ` json:"priority,omitempty" `
TTL string ` json:"ttl,omitempty" `
RestrictedPackageName string ` json:"restricted_package_name,omitempty" `
Data map [ string ] string ` json:"data,omitempty" `
Notification * AndroidNotification ` json:"notification,omitempty" `
FcmOptions * AndroidFcmOptions ` json:"fcm_options,omitempty" `
DirectBootOk bool ` json:"direct_boot_ok,omitempty" `
}
type AndroidMessagePriority uint8
const (
// Default priority for data messages.
//
// Normal priority messages won't open network connections on a sleeping device,
// and their delivery may be delayed to conserve the battery.
AndroidMessagePriority_NORMAL AndroidMessagePriority = 0
// Default priority for notification messages.
//
// FCM attempts to deliver high priority messages immediately,
// allowing the FCM service to wake a sleeping device when possible
// and open a network connection to your app server.
AndroidMessagePriority_HIGH AndroidMessagePriority = 1
)
type AndroidFcmOptions struct {
AnalyticsLabel string ` json:"analytics_label,omitempty" `
}
type NotificationPriority uint8
const (
NotificationPriority_UNSPECIFIED NotificationPriority = 0
// Notifications might not be shown to the user except under special circumstances, such as detailed notification logs.
NotificationPriority_MIN NotificationPriority = 1
// The UI may choose to show the notifications smaller, or at a different position in the list, compared with notifications with DEFAULT.
NotificationPriority_LOW NotificationPriority = 2
// If the application does not prioritize its own notifications, use this value for all notifications.
NotificationPriority_DEFAULT NotificationPriority = 3
// Use this for more important notifications or alerts.
//
// The UI may choose to show these notifications larger, or at a different position in the notification lists, compared with notifications with DEFAULT.
NotificationPriority_HIGH NotificationPriority = 4
// Use this for the application's most important items that require the user's prompt attention or input.
NotificationPriority_MAX NotificationPriority = 5
)
type NotificationVisibility uint8
const (
NotificationVisibility_UNSPECIFIED NotificationVisibility = 0
// Show this notification on all lockscreens, but conceal sensitive or private information on secure lockscreens.
NotificationVisibility_PRIVATE NotificationVisibility = 1
// Show this notification in its entirety on all lockscreens.
NotificationVisibility_PUBLIC NotificationVisibility = 2
// Do not reveal any part of this notification on a secure lockscreen.
NotificationVisibility_SECRET NotificationVisibility = 3
)
type LightSettings struct {
Color Color ` json:"color,omitempty" `
OnDuration string ` json:"light_on_duration,omitempty" `
OffDuration string ` json:"light_off_duration,omitempty" `
}
type Color struct {
Red float32 ` json:"red,omitempty" `
Green float32 ` json:"green,omitempty" `
Blue float32 ` json:"blue,omitempty" `
Alpha float32 ` json:"alpha,omitempty" `
}
type AndroidNotification struct {
Title string ` json:"title,omitempty" `
Body string ` json:"body,omitempty" `
Image string ` json:"image,omitempty" `
Icon string ` json:"icon,omitempty" `
Color string ` json:"color,omitempty" `
Sound string ` json:"sound,omitempty" `
Tag string ` json:"tag,omitempty" `
ClickAction string ` json:"click_action,omitempty" `
ChannelID string ` json:"channel_id,omitempty" `
BodyLocalizationKey string ` json:"body_loc_key,omitempty" `
BodyLocalizationArgs [ ] string ` json:"body_loc_args,omitempty" `
TitleLocalizationKey string ` json:"title_loc_key,omitempty" `
TitleLocalizationArgs [ ] string ` json:"title_loc_args,omitempty" `
Ticker string ` json:"ticker,omitempty" `
Sticky bool ` json:"sticky,omitempty" `
EventTime string ` json:"event_time,omitempty" `
LocalOnly bool ` json:"local_only,omitempty" `
Priority NotificationPriority ` json:"notification_priority,omitempty" `
UseDefaultSound bool ` json:"default_sound,omitempty" `
UseDefaultVibrateTimings bool ` json:"default_vibrate_timings,omitempty" `
UseDefaultLightSettings bool ` json:"default_light_settings,omitempty" `
VibrateTimings [ ] string ` json:"vibrate_timings,omitempty" `
Visibility NotificationVisibility ` json:"visibility,omitempty" `
Count int ` json:"notification_count,omitempty" `
LightSettings * LightSettings ` json:"light_settings,omitempty" `
}
type WebpushConfig struct {
Headers map [ string ] string ` json:"headers,omitempty" `
Data map [ string ] string ` json:"data,omitempty" `
Notification WebpushNotification ` json:"notification,omitempty" `
FcmOptions WebpushFcmOptions ` json:"fcm_options,omitempty" `
}
type WebpushNotification struct { }
type WebpushFcmOptions struct {
Link string ` json:"link,omitempty" `
AnalyticsLabel string ` json:"analytics_label,omitempty" `
}
// Apple specific options for message
type ApnsConfig struct {
Headers * ApnsHeaders ` json:"headers,omitempty" `
Notification * ApnsPayload ` json:"payload,omitempty" `
FcmOptions * ApnsFcmOptions ` json:"fcm_options,omitempty" `
}
type ApnsHeaders struct {
PushType string ` json:"apns-push-type,omitempty" `
Id string ` json:"apns-id,omitempty" `
Expiration int64 ` json:"apns-expiration,omitempty" `
Priority uint8 ` json:"apns-priority,omitempty" `
Topic string ` json:"apns-topic,omitempty" `
CollapseId string ` json:"apns-collapse-id,omitempty" `
}
type ApnsPayload struct {
Aps ApnsPayloadKeys ` json:"aps" `
// TODO: add support for custom keys
// https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification
customKeys map [ string ] interface { }
}
type ApnsPayloadKeys struct {
Alert * ApnsAlert ` json:"alert,omitempty" `
Badge int ` json:"badge,omitempty" `
Sound string ` json:"sound,omitempty" `
ThreadId string ` json:"thread-id,omitempty" `
Category string ` json:"category,omitempty" `
ContentAvailable int ` json:"content-available,omitempty" `
MutableContent int ` json:"mutable-content,omitempty" `
TargetContentId string ` json:"target-content-id,omitempty" `
InterruptionLevel string ` json:"interruption-level,omitempty" `
RelevanceScore int ` json:"relevance-score,omitempty" `
CriticalSound * ApnsCriticalSound ` json:"-" `
}
type ApnsAlert struct {
Title string ` json:"title,omitempty" `
Subtitle string ` json:"subtitle,omitempty" `
Body string ` json:"body,omitempty" `
LaunchImg string ` json:"launch-image,omitempty" `
TitleLocKey string ` json:"title-loc-key,omitempty" `
TitleLocArgs [ ] string ` json:"title-loc-args,omitempty" `
SubtitleLocKey string ` json:"subtitle-loc-key,omitempty" `
SubtitleLocArgs [ ] string ` json:"subtitle-loc-args,omitempty" `
LocKey string ` json:"loc-key,omitempty" `
LocArgs [ ] string ` json:"loc-args,omitempty" `
}
type ApnsCriticalSound struct {
Critical int ` json:"critical,omitempty" `
Name string ` json:"name,omitempty" `
Volume int ` json:"volume,omitempty" `
}
type ApnsFcmOptions struct {
AnalyticsLabel string ` json:"analytics_label,omitempty" `
Image string ` json:"image,omitempty" `
}