// See documentation here: // // Sending notifications // // https://firebase.google.com/docs/cloud-messaging/send-message?hl=en // // OAuth2.0 // // 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" "github.com/go-logr/logr" "git.bit5.ru/backend/errors" "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 logger logr.Logger } func NewClient(cfg ClientConfig, ts oauth2.TokenSource, hc *http.Client, logger logr.Logger) *Client { return &Client{ cfg: cfg, ts: ts, hc: hc, logger: logger, } } func (c *Client) SendMessage(msg Message) (SendResponse, error) { sendRequest := SendRequest{ ValidateOnly: false, Message: msg, } return c.doSendRequest(sendRequest) } func (c *Client) ValidateMessage(msg Message) (SendResponse, error) { sendRequest := SendRequest{ ValidateOnly: true, Message: msg, } return c.doSendRequest(sendRequest) } func (c *Client) doSendRequest(req SendRequest) (SendResponse, error) { accessToken, err := c.ts.Token() if err != nil { return SendResponse{}, err } data, err := json.Marshal(req) if err != nil { return SendResponse{}, errors.WithStack(err) } c.logger.Info("sending", "message", data) 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 } 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() { c.logger.Info("fail", "error", fmt.Sprintf("%+v", *resp.Error)) 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 } 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"` }