多租户更改
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# FCM push adapter
|
||||
|
||||
This adapter sends push notifications to mobile clients and web browsers using [Google FCM](https://firebase.google.com/docs/cloud-messaging/). As of the time of this writing it supports Android with [Play Services](https://developers.google.com/android/guides/overview), iOS devices, and all major web browsers [excluding Safari](https://caniuse.com/#feat=push-api).
|
||||
|
||||
This adapter requires you to obtain your own credentials from Goole Firebase. If you want to use iOS and Android mobile apps with your service, they must be recompiled with your credentials obtained from Google. If you do not want to recompile mobile clients, consider using TNPG adapter instead.
|
||||
|
||||
|
||||
## Configuring FCM adapter
|
||||
|
||||
### Server and TinodeWeb
|
||||
|
||||
1. Create a project at https://firebase.google.com/ if you have not done so already.
|
||||
2. Follow instructions at https://cloud.google.com/iam/docs/creating-managing-service-account-keys to download the credentials file.
|
||||
3. Update the server config [`tinode.conf`](../server/tinode.conf#L255), section `"push"` -> `"name": "fcm"`. Do _ONE_ of the following:
|
||||
* _Either_ enter the path to the downloaded credentials file into `"credentials_file"`.
|
||||
* _OR_ copy the file contents to `"credentials"`.<br/><br/>
|
||||
Remove the other entry. I.e. if you have updated `"credentials_file"`, remove `"credentials"` and vice versa.
|
||||
4. Update [TinodeWeb](/tinode/webapp/) config [`firebase-init.js`](https://github.com/tinode/webapp/blob/master/firebase-init.js): update `apiKey`, `messagingSenderId`, `projectId`, `appId`, `messagingVapidKey`. See more info at https://github.com/tinode/webapp/#push_notifications
|
||||
|
||||
### iOS and Android
|
||||
|
||||
1. If you are using an Android client, add `google-services.json` to [Tindroid](/tinode/tindroid/) by following instructions at https://developers.google.com/android/guides/google-services-plugin and recompile the client. You may also optionally submit it to Google Play Store.
|
||||
See more info at https://github.com/tinode/tindroid/#push_notifications
|
||||
2. If you are using an iOS client, add `GoogleService-Info.plist` to [Tinodios](/tinode/ios/) by following instructions at https://firebase.google.com/docs/cloud-messaging/ios/client) and recompile the client. You may optionally submit the app to Apple AppStore.
|
||||
See more info at https://github.com/tinode/ios/#push_notifications
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
package fcm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
fcmv1 "google.golang.org/api/fcm/v1"
|
||||
|
||||
"github.com/tinode/chat/server/drafty"
|
||||
"github.com/tinode/chat/server/logs"
|
||||
"github.com/tinode/chat/server/push"
|
||||
"github.com/tinode/chat/server/push/common"
|
||||
"github.com/tinode/chat/server/store"
|
||||
t "github.com/tinode/chat/server/store/types"
|
||||
"maps"
|
||||
)
|
||||
|
||||
const (
|
||||
// TTL of a VOIP push notification in seconds.
|
||||
voipTimeToLive = 10
|
||||
// TTL of a regular push notification in seconds.
|
||||
defaultTimeToLive = 3600
|
||||
)
|
||||
|
||||
func payloadToData(pl *push.Payload) (map[string]string, error) {
|
||||
if pl == nil {
|
||||
return nil, errors.New("empty push payload")
|
||||
}
|
||||
data := make(map[string]string)
|
||||
var err error
|
||||
data["what"] = pl.What
|
||||
if pl.Silent {
|
||||
data["silent"] = "true"
|
||||
}
|
||||
data["topic"] = pl.Topic
|
||||
data["ts"] = pl.Timestamp.Format(time.RFC3339Nano)
|
||||
// Must use "xfrom" because "from" is a reserved word. Google did not bother to document it anywhere.
|
||||
data["xfrom"] = pl.From
|
||||
if pl.What == push.ActMsg {
|
||||
data["seq"] = strconv.Itoa(pl.SeqId)
|
||||
if pl.ContentType != "" {
|
||||
data["mime"] = pl.ContentType
|
||||
}
|
||||
|
||||
// Convert Drafty content to plain text (clients 0.16 and below).
|
||||
data["content"], err = drafty.PlainText(pl.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Trim long strings to 128 runes.
|
||||
// Check byte length first and don't waste time converting short strings.
|
||||
if len(data["content"]) > push.MaxPayloadLength {
|
||||
runes := []rune(data["content"])
|
||||
if len(runes) > push.MaxPayloadLength {
|
||||
data["content"] = string(runes[:push.MaxPayloadLength]) + "…"
|
||||
}
|
||||
}
|
||||
|
||||
// Rich content for clients version 0.17 and above.
|
||||
data["rc"], err = drafty.Preview(pl.Content, push.MaxPayloadLength)
|
||||
|
||||
if pl.Webrtc != "" {
|
||||
data["webrtc"] = pl.Webrtc
|
||||
if pl.AudioOnly {
|
||||
data["aonly"] = "true"
|
||||
}
|
||||
// Video call push notifications are silent.
|
||||
data["silent"] = "true"
|
||||
}
|
||||
if pl.Replace != "" {
|
||||
// Notification of a message edit should be silent too.
|
||||
data["silent"] = "true"
|
||||
data["replace"] = pl.Replace
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if pl.What == push.ActSub {
|
||||
data["modeWant"] = pl.ModeWant.String()
|
||||
data["modeGiven"] = pl.ModeGiven.String()
|
||||
} else if pl.What == push.ActRead {
|
||||
data["seq"] = strconv.Itoa(pl.SeqId)
|
||||
data["silent"] = "true"
|
||||
} else {
|
||||
return nil, errors.New("unknown push type")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func clonePayload(src map[string]string) map[string]string {
|
||||
dst := make(map[string]string, len(src))
|
||||
maps.Copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
|
||||
// PrepareV1Notifications creates notification payloads ready to be posted
|
||||
// to push notification server for the provided receipt.
|
||||
func PrepareV1Notifications(rcpt *push.Receipt, config *configType) ([]*fcmv1.Message, []t.Uid) {
|
||||
data, err := payloadToData(&rcpt.Payload)
|
||||
if err != nil {
|
||||
logs.Warn.Println("fcm push: could not parse payload:", err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Device IDs to send pushes to.
|
||||
var devices map[t.Uid][]t.DeviceDef
|
||||
// Count of device IDs to push to.
|
||||
var count int
|
||||
// Devices which were online in the topic when the message was sent.
|
||||
skipDevices := make(map[string]struct{})
|
||||
if len(rcpt.To) > 0 {
|
||||
// List of UIDs for querying the database
|
||||
|
||||
uids := make([]t.Uid, len(rcpt.To))
|
||||
i := 0
|
||||
for uid, to := range rcpt.To {
|
||||
uids[i] = uid
|
||||
i++
|
||||
// Some devices were online and received the message. Skip them.
|
||||
for _, deviceID := range to.Devices {
|
||||
skipDevices[deviceID] = struct{}{}
|
||||
}
|
||||
}
|
||||
devices, count, err = store.Devices.GetAll(rcpt.TenantID, uids...)
|
||||
if err != nil {
|
||||
logs.Warn.Println("fcm push: db error", err)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
if count == 0 && rcpt.Channel == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if config == nil {
|
||||
// config is nil when called from tnpg adapter; provide a blank one for simplicity.
|
||||
config = &configType{}
|
||||
}
|
||||
|
||||
var messages []*fcmv1.Message
|
||||
var uids []t.Uid
|
||||
for uid, devList := range devices {
|
||||
topic := rcpt.Payload.Topic
|
||||
userData := data
|
||||
tcat := t.GetTopicCat(topic)
|
||||
if rcpt.To[uid].Delivered > 0 || tcat == t.TopicCatP2P {
|
||||
userData = clonePayload(data)
|
||||
// Fix topic name for P2P pushes.
|
||||
if tcat == t.TopicCatP2P {
|
||||
topic, _ = t.P2PNameForUser(uid, topic)
|
||||
userData["topic"] = topic
|
||||
}
|
||||
// Silence the push for user who have received the data interactively.
|
||||
if rcpt.To[uid].Delivered > 0 {
|
||||
userData["silent"] = "true"
|
||||
}
|
||||
}
|
||||
|
||||
for i := range devList {
|
||||
d := &devList[i]
|
||||
if _, ok := skipDevices[d.DeviceId]; !ok && d.DeviceId != "" {
|
||||
msg := fcmv1.Message{
|
||||
Token: d.DeviceId,
|
||||
Data: userData,
|
||||
}
|
||||
|
||||
switch d.Platform {
|
||||
case "android":
|
||||
msg.Android = androidNotificationConfig(rcpt.Payload.What, topic, userData, config)
|
||||
case "ios":
|
||||
msg.Apns = apnsNotificationConfig(rcpt.Payload.What, topic, userData, rcpt.To[uid].Unread, config)
|
||||
case "web":
|
||||
if config != nil && config.Webpush != nil && config.Webpush.Enabled {
|
||||
msg.Webpush = &fcmv1.WebpushConfig{}
|
||||
}
|
||||
case "":
|
||||
// ignore
|
||||
default:
|
||||
logs.Warn.Println("fcm: unknown device platform", d.Platform)
|
||||
}
|
||||
|
||||
uids = append(uids, uid)
|
||||
messages = append(messages, &msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rcpt.Channel != "" {
|
||||
topic := TenantChannelName(rcpt.TenantID, rcpt.Channel)
|
||||
userData := clonePayload(data)
|
||||
userData["topic"] = rcpt.Channel
|
||||
// Channel receiver should not know the ID of the message sender.
|
||||
delete(userData, "xfrom")
|
||||
msg := fcmv1.Message{
|
||||
Topic: topic,
|
||||
Data: userData,
|
||||
}
|
||||
|
||||
// We don't know the platform of the receiver, must provide payload for all platforms.
|
||||
msg.Android = androidNotificationConfig(rcpt.Payload.What, topic, userData, config)
|
||||
msg.Apns = apnsNotificationConfig(rcpt.Payload.What, topic, userData, 0, config)
|
||||
// TODO: add webpush payload.
|
||||
messages = append(messages, &msg)
|
||||
// UID is not used in handling Topic pushes, but should keep the same count as messages.
|
||||
uids = append(uids, t.ZeroUid)
|
||||
}
|
||||
|
||||
return messages, uids
|
||||
}
|
||||
|
||||
func TenantChannelName(tenantID t.TenantID, channel string) string {
|
||||
if !tenantID.IsValid() || channel == "" {
|
||||
return channel
|
||||
}
|
||||
return "tenant-" + strconv.FormatInt(int64(tenantID), 10) + "-" + channel
|
||||
}
|
||||
|
||||
// DevicesForUser loads device IDs of the given user.
|
||||
func DevicesForUser(tenantID t.TenantID, uid t.Uid) []string {
|
||||
ddef, count, err := store.Devices.GetAll(tenantID, uid)
|
||||
if err != nil {
|
||||
logs.Warn.Println("fcm devices for user: db error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
devices := make([]string, count)
|
||||
for i, dd := range ddef[uid] {
|
||||
devices[i] = dd.DeviceId
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
// ChannelsForUser loads user's channel subscriptions with P permission.
|
||||
func ChannelsForUser(tenantID t.TenantID, uid t.Uid) []string {
|
||||
channels, err := store.Users.GetChannels(tenantID, uid)
|
||||
if err != nil {
|
||||
logs.Warn.Println("fcm channels for user: db error", err)
|
||||
return nil
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
func androidNotificationConfig(what, topic string, data map[string]string, config *configType) *fcmv1.AndroidConfig {
|
||||
timeToLive := strconv.Itoa(defaultTimeToLive) + "s"
|
||||
if config != nil && config.TimeToLive > 0 {
|
||||
timeToLive = strconv.Itoa(config.TimeToLive) + "s"
|
||||
}
|
||||
|
||||
if what == push.ActRead {
|
||||
return &fcmv1.AndroidConfig{
|
||||
Priority: string(common.AndroidPriorityNormal),
|
||||
Notification: nil,
|
||||
Ttl: timeToLive,
|
||||
}
|
||||
}
|
||||
|
||||
_, videoCall := data["webrtc"]
|
||||
if videoCall {
|
||||
timeToLive = "0s"
|
||||
}
|
||||
|
||||
// Sending priority.
|
||||
priority := string(common.AndroidPriorityHigh)
|
||||
ac := &fcmv1.AndroidConfig{
|
||||
Priority: priority,
|
||||
Ttl: timeToLive,
|
||||
}
|
||||
|
||||
// When this notification type is included and the app is not in the foreground
|
||||
// Android won't wake up the app and won't call FirebaseMessagingService:onMessageReceived.
|
||||
// See dicussion: https://github.com/firebase/quickstart-js/issues/71
|
||||
if config.Android == nil || !config.Android.Enabled {
|
||||
return ac
|
||||
}
|
||||
|
||||
body := config.Android.GetStringField(what, "Body")
|
||||
if body == "$content" {
|
||||
body = data["content"]
|
||||
}
|
||||
|
||||
// Client-side display priority.
|
||||
priority = string(common.AndroidNotificationPriorityHigh)
|
||||
if videoCall {
|
||||
priority = string(common.AndroidNotificationPriorityMax)
|
||||
}
|
||||
|
||||
ac.Notification = &fcmv1.AndroidNotification{
|
||||
// Android uses Tag value to group notifications together:
|
||||
// show just one notification per topic.
|
||||
Tag: topic,
|
||||
NotificationPriority: priority,
|
||||
Visibility: string(common.AndroidVisibilityPrivate),
|
||||
TitleLocKey: config.Android.GetStringField(what, "TitleLocKey"),
|
||||
Title: config.Android.GetStringField(what, "Title"),
|
||||
BodyLocKey: config.Android.GetStringField(what, "BodyLocKey"),
|
||||
Body: body,
|
||||
Icon: config.Android.GetStringField(what, "Icon"),
|
||||
Color: config.Android.GetStringField(what, "Color"),
|
||||
ClickAction: config.Android.GetStringField(what, "ClickAction"),
|
||||
}
|
||||
|
||||
return ac
|
||||
}
|
||||
|
||||
func apnsShouldPresentAlert(what, callStatus, isSilent string, config *configType) bool {
|
||||
return config.Apns != nil && config.Apns.Enabled && what != push.ActRead && callStatus == "" && isSilent == ""
|
||||
}
|
||||
|
||||
func apnsNotificationConfig(what, topic string, data map[string]string, unread int, config *configType) *fcmv1.ApnsConfig {
|
||||
callStatus := data["webrtc"]
|
||||
expires := time.Now().UTC().Add(time.Duration(defaultTimeToLive) * time.Second)
|
||||
if config.TimeToLive > 0 {
|
||||
expires = time.Now().UTC().Add(time.Duration(config.TimeToLive) * time.Second)
|
||||
}
|
||||
bundleId := config.ApnsBundleID
|
||||
pushType := common.ApnsPushTypeAlert
|
||||
priority := 10
|
||||
interruptionLevel := common.InterruptionLevelTimeSensitive
|
||||
if callStatus == "started" {
|
||||
// Send VOIP push only when a new call is started, otherwise send normal alert.
|
||||
interruptionLevel = common.InterruptionLevelCritical
|
||||
// FIXME: PushKit notifications do not work with the current FCM adapter.
|
||||
// Using normal pushes as a poor-man's replacement for VOIP pushes.
|
||||
// Uncomment the following two lines when FCM fixes its problem or when we switch to
|
||||
// a different adapter.
|
||||
// pushType = common.ApnsPushTypeVoip
|
||||
// bundleId += ".voip"
|
||||
expires = time.Now().UTC().Add(time.Duration(voipTimeToLive) * time.Second)
|
||||
} else if what == push.ActRead {
|
||||
priority = 5
|
||||
interruptionLevel = common.InterruptionLevelPassive
|
||||
pushType = common.ApnsPushTypeBackground
|
||||
}
|
||||
|
||||
apsPayload := common.Aps{
|
||||
Badge: unread,
|
||||
ContentAvailable: 1,
|
||||
MutableContent: 1,
|
||||
InterruptionLevel: interruptionLevel,
|
||||
Sound: "default",
|
||||
ThreadID: topic,
|
||||
}
|
||||
|
||||
// Do not present alert for read notifications and video calls.
|
||||
if apnsShouldPresentAlert(what, callStatus, data["silent"], config) {
|
||||
body := config.Apns.GetStringField(what, "Body")
|
||||
if body == "$content" {
|
||||
body = data["content"]
|
||||
}
|
||||
|
||||
apsPayload.Alert = &common.ApsAlert{
|
||||
Action: config.Apns.GetStringField(what, "Action"),
|
||||
ActionLocKey: config.Apns.GetStringField(what, "ActionLocKey"),
|
||||
Body: body,
|
||||
LaunchImage: config.Apns.GetStringField(what, "LaunchImage"),
|
||||
LocKey: config.Apns.GetStringField(what, "LocKey"),
|
||||
Title: config.Apns.GetStringField(what, "Title"),
|
||||
Subtitle: config.Apns.GetStringField(what, "Subtitle"),
|
||||
TitleLocKey: config.Apns.GetStringField(what, "TitleLocKey"),
|
||||
SummaryArg: config.Apns.GetStringField(what, "SummaryArg"),
|
||||
SummaryArgCount: config.Apns.GetIntField(what, "SummaryArgCount"),
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]any{"aps": apsPayload})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
headers := map[string]string{
|
||||
common.HeaderApnsExpiration: strconv.FormatInt(expires.Unix(), 10),
|
||||
common.HeaderApnsPriority: strconv.Itoa(priority),
|
||||
common.HeaderApnsTopic: bundleId,
|
||||
common.HeaderApnsCollapseID: topic,
|
||||
common.HeaderApnsPushType: string(pushType),
|
||||
}
|
||||
|
||||
ac := &fcmv1.ApnsConfig{
|
||||
Headers: headers,
|
||||
Payload: payload,
|
||||
}
|
||||
|
||||
return ac
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
// Package fcm implements push notification plugin for Google FCM backend.
|
||||
// Push notifications for Android, iOS and web clients are sent through Google's Firebase Cloud Messaging service.
|
||||
// Package fcm is push notification plugin using Google FCM.
|
||||
// https://firebase.google.com/docs/cloud-messaging
|
||||
package fcm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
fbase "firebase.google.com/go"
|
||||
legacy "firebase.google.com/go/messaging"
|
||||
fcmv1 "google.golang.org/api/fcm/v1"
|
||||
|
||||
"github.com/tinode/chat/server/logs"
|
||||
"github.com/tinode/chat/server/push"
|
||||
"github.com/tinode/chat/server/push/common"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
|
||||
"golang.org/x/oauth2/google"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
var handler Handler
|
||||
|
||||
const (
|
||||
// Size of the input channel buffer.
|
||||
bufferSize = 1024
|
||||
|
||||
// The number of push messages sent in one batch. FCM constant.
|
||||
pushBatchSize = 100
|
||||
|
||||
// The number of sub/unsub requests sent in one batch. FCM constant.
|
||||
subBatchSize = 1000
|
||||
)
|
||||
|
||||
// Handler represents the push handler; implements push.PushHandler interface.
|
||||
type Handler struct {
|
||||
input chan *push.Receipt
|
||||
channel chan *push.ChannelReq
|
||||
stop chan bool
|
||||
projectID string
|
||||
|
||||
client *legacy.Client
|
||||
v1 *fcmv1.Service
|
||||
}
|
||||
|
||||
type configType struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Credentials json.RawMessage `json:"credentials"`
|
||||
CredentialsFile string `json:"credentials_file"`
|
||||
TimeToLive int `json:"time_to_live,omitempty"`
|
||||
ApnsBundleID string `json:"apns_bundle_id,omitempty"`
|
||||
Android *common.Config `json:"android,omitempty"`
|
||||
Apns *common.Config `json:"apns,omitempty"`
|
||||
Webpush *common.Config `json:"webpush,omitempty"`
|
||||
}
|
||||
|
||||
// Init initializes the push handler
|
||||
func (Handler) Init(jsonconf json.RawMessage) (bool, error) {
|
||||
|
||||
var config configType
|
||||
err := json.Unmarshal([]byte(jsonconf), &config)
|
||||
if err != nil {
|
||||
return false, errors.New("failed to parse config: " + err.Error())
|
||||
}
|
||||
|
||||
if !config.Enabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if config.Credentials == nil && config.CredentialsFile != "" {
|
||||
config.Credentials, err = os.ReadFile(config.CredentialsFile)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
if config.Credentials == nil {
|
||||
return false, errors.New("missing credentials")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
credentials, err := google.CredentialsFromJSON(ctx, config.Credentials, "https://www.googleapis.com/auth/firebase.messaging")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if credentials.ProjectID == "" {
|
||||
return false, errors.New("missing project ID")
|
||||
}
|
||||
|
||||
app, err := fbase.NewApp(ctx, &fbase.Config{}, option.WithCredentials(credentials))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
handler.client, err = app.Messaging(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
handler.v1, err = fcmv1.NewService(ctx, option.WithCredentials(credentials), option.WithScopes(fcmv1.FirebaseMessagingScope))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
handler.input = make(chan *push.Receipt, bufferSize)
|
||||
handler.channel = make(chan *push.ChannelReq, bufferSize)
|
||||
handler.stop = make(chan bool, 1)
|
||||
handler.projectID = credentials.ProjectID
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case rcpt := <-handler.input:
|
||||
go sendFcmV1(rcpt, &config)
|
||||
case sub := <-handler.channel:
|
||||
go processSubscription(sub)
|
||||
case <-handler.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func sendFcmV1(rcpt *push.Receipt, config *configType) {
|
||||
messages, uids := PrepareV1Notifications(rcpt, config)
|
||||
for i := range messages {
|
||||
req := &fcmv1.SendMessageRequest{
|
||||
Message: messages[i],
|
||||
ValidateOnly: config.DryRun,
|
||||
}
|
||||
_, err := handler.v1.Projects.Messages.Send("projects/"+handler.projectID, req).Do()
|
||||
if err != nil {
|
||||
gerr, decodingErrs := common.DecodeGoogleApiError(err)
|
||||
for _, err := range decodingErrs {
|
||||
logs.Info.Println("fcm googleapi.Error decoding:", err)
|
||||
}
|
||||
switch strings.ToUpper(gerr.FcmErrCode) {
|
||||
case "": // no error
|
||||
case common.ErrorQuotaExceeded, common.ErrorUnavailable, common.ErrorInternal, common.ErrorUnspecified:
|
||||
// Transient errors. Stop sending this batch.
|
||||
logs.Warn.Println("fcm transient failure:", gerr.FcmErrCode, gerr.ErrMessage)
|
||||
return
|
||||
case common.ErrorSenderIDMismatch, common.ErrorInvalidArgument, common.ErrorThirdPartyAuth:
|
||||
// Config errors. Stop.
|
||||
logs.Warn.Println("fcm invalid config:", gerr.FcmErrCode, gerr.ErrMessage)
|
||||
return
|
||||
case common.ErrorUnregistered:
|
||||
// Token is no longer valid. Delete token from DB and continue sending.
|
||||
logs.Warn.Println("fcm invalid token:", gerr.FcmErrCode, gerr.ErrMessage)
|
||||
if err := store.Devices.Delete(rcpt.TenantID, uids[i], messages[i].Token); err != nil {
|
||||
logs.Warn.Println("tnpg failed to delete invalid token:", err)
|
||||
}
|
||||
default:
|
||||
// Unknown error. Stop sending just in case.
|
||||
logs.Warn.Println("tnpg unrecognized error:", gerr.FcmErrCode, gerr.ErrMessage)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func processSubscription(req *push.ChannelReq) {
|
||||
var channel string
|
||||
var devices []string
|
||||
var device string
|
||||
var channels []string
|
||||
|
||||
if req.Channel != "" {
|
||||
devices = DevicesForUser(req.TenantID, req.Uid)
|
||||
channel = TenantChannelName(req.TenantID, req.Channel)
|
||||
} else if req.DeviceID != "" {
|
||||
channels = ChannelsForUser(req.TenantID, req.Uid)
|
||||
device = req.DeviceID
|
||||
}
|
||||
|
||||
if (len(devices) == 0 && device == "") || (len(channels) == 0 && channel == "") {
|
||||
// No channels or devces to subscribe or unsubscribe.
|
||||
return
|
||||
}
|
||||
|
||||
if len(devices) > subBatchSize {
|
||||
// It's extremely unlikely for a single user to have this many devices.
|
||||
devices = devices[0:subBatchSize]
|
||||
logs.Warn.Println("fcm: user", req.Uid.UserId(), "has more than", subBatchSize, "devices")
|
||||
}
|
||||
|
||||
var err error
|
||||
var resp *legacy.TopicManagementResponse
|
||||
if channel != "" && len(devices) > 0 {
|
||||
if req.Unsub {
|
||||
resp, err = handler.client.UnsubscribeFromTopic(context.Background(), devices, channel)
|
||||
} else {
|
||||
resp, err = handler.client.SubscribeToTopic(context.Background(), devices, channel)
|
||||
}
|
||||
if err != nil {
|
||||
// Complete failure.
|
||||
logs.Warn.Println("fcm: sub or upsub failed", req.Unsub, err)
|
||||
} else {
|
||||
// Check for partial failure.
|
||||
handleSubErrors(resp, req.Uid, devices)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if device != "" && len(channels) > 0 {
|
||||
devices := []string{device}
|
||||
for _, channel := range channels {
|
||||
channel = TenantChannelName(req.TenantID, channel)
|
||||
if req.Unsub {
|
||||
resp, err = handler.client.UnsubscribeFromTopic(context.Background(), devices, channel)
|
||||
} else {
|
||||
resp, err = handler.client.SubscribeToTopic(context.Background(), devices, channel)
|
||||
}
|
||||
if err != nil {
|
||||
// Complete failure.
|
||||
logs.Warn.Println("fcm: sub or upsub failed", req.Unsub, err)
|
||||
break
|
||||
}
|
||||
// Check for partial failure.
|
||||
handleSubErrors(resp, req.Uid, devices)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Invalid request: either multiple channels & multiple devices (not supported) or no channels and no devices.
|
||||
logs.Err.Println("fcm: user", req.Uid.UserId(), "invalid combination of sub/unsub channels/devices",
|
||||
len(devices), len(channels))
|
||||
}
|
||||
|
||||
func handleSubErrors(response *legacy.TopicManagementResponse, uid types.Uid, devices []string) {
|
||||
if response.FailureCount <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, errinfo := range response.Errors {
|
||||
// FCM documentation sucks. There is no list of possible errors so no action can be taken but logging.
|
||||
logs.Warn.Println("fcm sub/unsub error", errinfo.Reason, uid, devices[errinfo.Index])
|
||||
}
|
||||
}
|
||||
|
||||
// IsReady checks if the push handler has been initialized.
|
||||
func (Handler) IsReady() bool {
|
||||
return handler.input != nil
|
||||
}
|
||||
|
||||
// Push returns a channel that the server will use to send messages to.
|
||||
// If the adapter blocks, the message will be dropped.
|
||||
func (Handler) Push() chan<- *push.Receipt {
|
||||
return handler.input
|
||||
}
|
||||
|
||||
// Channel returns a channel for subscribing/unsubscribing devices to FCM topics.
|
||||
func (Handler) Channel() chan<- *push.ChannelReq {
|
||||
return handler.channel
|
||||
}
|
||||
|
||||
// Stop shuts down the handler
|
||||
func (Handler) Stop() {
|
||||
handler.stop <- true
|
||||
}
|
||||
|
||||
func init() {
|
||||
push.Register("fcm", &handler)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package fcm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
func TestTenantChannelName(t *testing.T) {
|
||||
got := TenantChannelName(types.TenantID(42), "chnAbc")
|
||||
if got != "tenant-42-chnAbc" {
|
||||
t.Fatalf("TenantChannelName = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantChannelNameInvalidTenantFallsBack(t *testing.T) {
|
||||
got := TenantChannelName(types.ZeroTenantID, "chnAbc")
|
||||
if got != "chnAbc" {
|
||||
t.Fatalf("TenantChannelName zero tenant = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user