680 lines
20 KiB
Go
680 lines
20 KiB
Go
// 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"
|
|
"sync"
|
|
|
|
"git.bit5.ru/backend/errors"
|
|
"github.com/go-logr/logr"
|
|
"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, true)
|
|
}
|
|
|
|
func (c *Client) ValidateMessage(msg Message) (SendResponse, error) {
|
|
sendRequest := SendRequest{
|
|
ValidateOnly: true,
|
|
Message: msg,
|
|
}
|
|
|
|
return c.doSendRequest(sendRequest, true)
|
|
}
|
|
|
|
func (c *Client) doSendRequest(req SendRequest, loggerEnabled bool) (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)
|
|
}
|
|
if loggerEnabled {
|
|
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
|
|
}
|
|
|
|
// Deprecated: Use SendEach instead.
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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"`
|
|
}
|