多租户更改

This commit is contained in:
richjohnrambo
2026-08-11 15:59:57 +08:00
parent 6c800de67d
commit c2076721fc
453 changed files with 102349 additions and 47 deletions
+543
View File
@@ -0,0 +1,543 @@
package common
import (
"fmt"
"net/http"
"reflect"
"strings"
"github.com/tinode/chat/server/push"
"google.golang.org/api/googleapi"
)
// Payload to be sent for a specific notification type.
type Payload struct {
// Common for APNS and Android
Body string `json:"body,omitempty"`
Title string `json:"title,omitempty"`
TitleLocKey string `json:"title_loc_key,omitempty"`
TitleLocArgs []string `json:"title_loc_args,omitempty"`
// Android
BodyLocKey string `json:"body_loc_key,omitempty"`
BodyLocArgs []string `json:"body_loc_args,omitempty"`
Icon string `json:"icon,omitempty"`
Color string `json:"color,omitempty"`
ClickAction string `json:"click_action,omitempty"`
Sound string `json:"sound,omitempty"`
Image string `json:"image,omitempty"`
// APNS
Action string `json:"action,omitempty"`
ActionLocKey string `json:"action_loc_key,omitempty"`
LaunchImage string `json:"launch_image,omitempty"`
LocArgs []string `json:"loc_args,omitempty"`
LocKey string `json:"loc_key,omitempty"`
Subtitle string `json:"subtitle,omitempty"`
SummaryArg string `json:"summary_arg,omitempty"`
SummaryArgCount int `json:"summary_arg_count,omitempty"`
}
// Config is the configuration of a Notification payload.
type Config struct {
Enabled bool `json:"enabled,omitempty"`
// Common defaults for all push types.
Payload
// Configs for specific push types.
Msg Payload `json:"msg,omitempty"`
Sub Payload `json:"sub,omitempty"`
}
func (cp Payload) getStringAttr(field string) string {
val := reflect.ValueOf(cp).FieldByName(field)
if !val.IsValid() {
return ""
}
if val.Kind() == reflect.String {
return val.String()
}
return ""
}
func (cp Payload) getIntAttr(field string) int {
val := reflect.ValueOf(cp).FieldByName(field)
if !val.IsValid() {
return 0
}
if val.Kind() == reflect.Int {
return int(val.Int())
}
return 0
}
func (cc *Config) GetStringField(what, field string) string {
var val string
if what == push.ActMsg {
val = cc.Msg.getStringAttr(field)
} else if what == push.ActSub {
val = cc.Sub.getStringAttr(field)
}
if val == "" {
val = cc.Payload.getStringAttr(field)
}
return val
}
func (cc *Config) GetIntField(what, field string) int {
var val int
if what == push.ActMsg {
val = cc.Msg.getIntAttr(field)
} else if what == push.ActSub {
val = cc.Sub.getIntAttr(field)
}
if val == 0 {
val = cc.Payload.getIntAttr(field)
}
return val
}
// AndroidVisibilityType defines notification visibility constants
// https://developer.android.com/reference/android/app/Notification.html#visibility
type AndroidVisibilityType string
const (
// AndroidVisibilityUnspecified if unspecified, default to `Visibility.PRIVATE`.
AndroidVisibilityUnspecified AndroidVisibilityType = "VISIBILITY_UNSPECIFIED"
// AndroidVisibilityPrivate show this notification on all lockscreens, but conceals
// sensitive or private information on secure lockscreens.
AndroidVisibilityPrivate AndroidVisibilityType = "PRIVATE"
// AndroidVisibilityPublic show this notification in its entirety on all lockscreens.
AndroidVisibilityPublic AndroidVisibilityType = "PUBLIC"
// AndroidVisibilitySecret do not reveal any part of this notification on a secure lockscreen.
AndroidVisibilitySecret AndroidVisibilityType = "SECRET"
)
// AndroidNotificationPriorityType defines notification priority consumeed by the client
// after it receives the notification. Does not affect FCM sending.
type AndroidNotificationPriorityType string
const (
// If priority is unspecified, notification priority is set to `PRIORITY_DEFAULT`.
AndroidNotificationPriorityUnspecified AndroidNotificationPriorityType = "PRIORITY_UNSPECIFIED"
// Lowest notification priority. Notifications with this `PRIORITY_MIN` might not be
// shown to the user except under special circumstances, such as detailed notification logs.
AndroidNotificationPriorityMin AndroidNotificationPriorityType = "PRIORITY_MIN"
// Lower notification priority. The UI may choose to show the notifications smaller,
// or at a different position in the list, compared with notifications with `PRIORITY_DEFAULT`.
AndroidNotificationPriorityLow AndroidNotificationPriorityType = "PRIORITY_LOW"
// Default notification priority. If the application does not prioritize its own notifications,
// use this value for all notifications.
AndroidNotificationPriorityDefault AndroidNotificationPriorityType = "PRIORITY_DEFAULT"
// Higher notification priority. 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 `PRIORITY_DEFAULT`.
AndroidNotificationPriorityHigh AndroidNotificationPriorityType = "PRIORITY_HIGH"
// Highest notification priority. Use this for the application's most important items that
// require the user's prompt attention or input.
AndroidNotificationPriorityMax AndroidNotificationPriorityType = "PRIORITY_MAX"
)
// AndroidPriorityType defines the server-side priorities https://goo.gl/GjONJv. It affects how soon
// FCM sends the push.
type AndroidPriorityType string
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. For less time-sensitive messages, such as notifications of new email
// or other data to sync, choose normal delivery priority.
AndroidPriorityNormal AndroidPriorityType = "NORMAL"
// 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. Apps with instant messaging, chat,
// or voice call alerts, for example, generally need to open a network connection and make
// sure FCM delivers the message to the device without delay. Set high priority if the message
// is time-critical and requires the user's immediate interaction, but beware that setting
// your messages to high priority contributes more to battery drain compared with normal priority messages.
AndroidPriorityHigh AndroidPriorityType = "HIGH"
)
// InterruptionLevelType defines the values for the APNS payload.aps.InterruptionLevel.
type InterruptionLevelType string
const (
// InterruptionLevelPassive is used to indicate that notification be delivered in a passive manner.
InterruptionLevelPassive InterruptionLevelType = "passive"
// InterruptionLevelActive is used to indicate the importance and delivery timing of a notification.
InterruptionLevelActive InterruptionLevelType = "active"
// InterruptionLevelTimeSensitive is used to indicate the importance and delivery timing of a notification.
InterruptionLevelTimeSensitive InterruptionLevelType = "time-sensitive"
// InterruptionLevelCritical is used to indicate the importance and delivery timing of a notification.
// This interruption level requires an approved entitlement from Apple.
// See: https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/
InterruptionLevelCritical InterruptionLevelType = "critical"
)
const (
// A canonical UUID that identifies the notification. If there is an error sending the notification,
// APNs uses this value to identify the notification to your server.
// The canonical form is 32 lowercase hexadecimal digits, displayed in five groups separated by hyphens
// in the form 8-4-4-4-12. An example UUID is as follows: 123e4567-e89b-12d3-a456-42665544000
// If you omit this header, a new UUID is created by APNs and returned in the response.
HeaderApnsID = "apns-id"
// A UNIX epoch date expressed in seconds (UTC). This header identifies the date when the notification
// is no longer valid and can be discarded.
// If this value is nonzero, APNs stores the notification and tries to deliver it at least once, repeating
// the attempt as needed if it is unable to deliver the notification the first time. If the value is 0,
// APNs treats the notification as if it expires immediately and does not store the notification or attempt
// to redeliver it.
HeaderApnsExpiration = "apns-expiration"
// The priority of the notification. Specify one of the following values:
// 10–Send the push message immediately. Notifications with this priority must trigger an alert, sound,
// or badge on the target device. It is an error to use this priority for a push notification that
// contains only the content-available key.
// 5—Send the push message at a time that takes into account power considerations for the device.
// Notifications with this priority might be grouped and delivered in bursts. They are throttled,
// and in some cases are not delivered.
// If you omit this header, the APNs server sets the priority to 10.
HeaderApnsPriority = "apns-priority"
// The topic of the remote notification, which is typically the bundle ID for your app.
// The certificate you create in your developer account must include the capability for this topic.
// If your certificate includes multiple topics, you must specify a value for this header.
// If you omit this request header and your APNs certificate does not specify multiple topics,
// the APNs server uses the certificate’s Subject as the default topic.
// If you are using a provider token instead of a certificate, you must specify a value for this
// request header. The topic you provide should be provisioned for the your team named in your developer account.
HeaderApnsTopic = "apns-topic"
// Multiple notifications with the same collapse identifier are displayed to the user as a single notification.
// The value of this key must not exceed 64 bytes. For more information, see Quality of Service,
// Store-and-Forward, and Coalesced Notifications.
HeaderApnsCollapseID = "apns-collapse-id"
// The value of this header must accurately reflect the contents of your notification’s payload.
// If there’s a mismatch, or if the header is missing on required systems, APNs may return an error,
// delay the delivery of the notification, or drop it altogether.
HeaderApnsPushType = "apns-push-type"
)
type ApnsPushTypeType string
const (
// Use the alert push type for notifications that trigger a user interaction—for example, an alert, badge, or sound.
// If you set this push type, the apns-topic header field must use your app’s bundle ID as the topic.
// For more information, see Generating a remote notification.
// If the notification requires immediate action from the user, set notification priority to 10; otherwise use 5.
ApnsPushTypeAlert ApnsPushTypeType = "alert"
// Use the background push type for notifications that deliver content in the background, and don’t trigger any user interactions.
// If you set this push type, the apns-topic header field must use your app’s bundle ID as the topic. Always use priority 5.
// Using priority 10 is an error. For more information, see Pushing Background Updates to Your App.
ApnsPushTypeBackground ApnsPushTypeType = "background"
// Use the location push type for notifications that request a user’s location. If you set this push type,
// the apns-topic header field must use your app’s bundle ID with .location-query appended to the end.
// If the location query requires an immediate response from the Location Push Service Extension, set notification
// apns-priority to 10; otherwise, use 5. The location push type supports only token-based authentication.
ApnsPushTypeLocation ApnsPushTypeType = "location"
// Use the voip push type for notifications that provide information about an incoming Voice-over-IP (VoIP) call.
// For more information, see Responding to VoIP Notifications from PushKit.
// If you set this push type, the apns-topic header field must use your app’s bundle ID with .voip appended to the end.
// If you’re using certificate-based authentication, you must also register the certificate for VoIP services.
// The topic is then part of the 1.2.840.113635.100.6.3.4 or 1.2.840.113635.100.6.3.6 extension.
ApnsPushTypeVoip ApnsPushTypeType = "voip"
// Use the fileprovider push type to signal changes to a File Provider extension. If you set this push type,
// the apns-topic header field must use your app’s bundle ID with .pushkit.fileprovider appended to the end.
// For more information, see Using Push Notifications to Signal Changes.
ApnsPushTypeFileprovider ApnsPushTypeType = "fileprovider"
)
// Aps is the APNS payload. See explanation here:
// https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification#2943363
type Aps struct {
Alert *ApsAlert `json:"alert,omitempty"`
Badge int `json:"badge,omitempty"`
Category string `json:"category,omitempty"`
ContentAvailable int `json:"content-available,omitempty"`
InterruptionLevel InterruptionLevelType `json:"interruption-level,omitempty"`
MutableContent int `json:"mutable-content,omitempty"`
RelevanceScore any `json:"relevance-score,omitempty"`
Sound any `json:"sound,omitempty"`
ThreadID string `json:"thread-id,omitempty"`
URLArgs []string `json:"url-args,omitempty"`
}
// ApsAlert is the content of the aps.Alert field.
type ApsAlert struct {
Action string `json:"action,omitempty"`
ActionLocKey string `json:"action-loc-key,omitempty"`
Body string `json:"body,omitempty"`
LaunchImage string `json:"launch-image,omitempty"`
LocArgs []string `json:"loc-args,omitempty"`
LocKey string `json:"loc-key,omitempty"`
Title string `json:"title,omitempty"`
Subtitle string `json:"subtitle,omitempty"`
TitleLocArgs []string `json:"title-loc-args,omitempty"`
TitleLocKey string `json:"title-loc-key,omitempty"`
SummaryArg string `json:"summary-arg,omitempty"`
SummaryArgCount int `json:"summary-arg-count,omitempty"`
}
// FCM error codes
const (
// No more information is available about this error.
ErrorUnspecified = "UNSPECIFIED_ERROR"
// Request parameters were invalid (HTTP error code = 400). An extension of type google.rpc.BadRequest is returned
// to specify which field was invalid.
// Potential causes:
// - Invalid registration: Check the format of the registration token you pass to the server. Make sure it matches
// the registration token the client app receives from registering with Firebase Notifications.
// Do not truncate or add additional characters.
// - Invalid package name: Make sure the message was addressed to a registration token whose package name matches
// the value passed in the request.
// - Message too big: Check that the total size of the payload data included in a message does not exceed FCM limits:
// 4096 bytes for most messages, or 2048 bytes in the case of messages to topics. This includes both the keys and the values.
// - Invalid data key: Check that the payload data does not contain a key (such as from, or gcm, or any value prefixed
// by google) that is used internally by FCM. Note that some words (such as collapse_key) are also used by FCM but are
// allowed in the payload, in which case the payload value will be overridden by the FCM value.
// - Invalid TTL: Check that the value used in ttl is an integer representing a duration in seconds between 0 and
// 2,419,200 (4 weeks).
// - Invalid parameters: Check that the provided parameters have the right name and type.
ErrorInvalidArgument = "INVALID_ARGUMENT"
// App instance was unregistered from FCM (HTTP error code = 404). This usually means that the token used is no
// longer valid and a new one must be used.
// This error can be caused by missing registration tokens, or unregistered tokens.
// - Missing Registration: If the message's target is a token value, check that the request contains a registration token.
// - Not registered: An existing registration token may cease to be valid in a number of scenarios, including:
// - If the client app unregisters with FCM.
// - If the client app is automatically unregistered, which can happen if the user uninstalls the application.
// For example, on iOS, if the APNS Feedback Service reported the APNS token as invalid.
// - If the registration token expires (for example, Google might decide to refresh registration tokens,
// or the APNS token has expired for iOS devices).
// - If the client app is updated but the new version is not configured to receive messages.
// For all these cases, remove this registration token from the app server and stop using it to send messages.
ErrorUnregistered = "UNREGISTERED"
// The authenticated sender ID is different from the sender ID for the registration token (HTTP error code = 403).
// A registration token is tied to a certain group of senders. When a client app registers for FCM, it must specify
// which senders are allowed to send messages. You should use one of those sender IDs when sending messages to
// the client app. If you switch to a different sender, the existing registration tokens won't work.
ErrorSenderIDMismatch = "SENDER_ID_MISMATCH"
// Sending limit exceeded for the message target (HTTP error code = 429). An extension of type google.rpc.QuotaFailure
// is returned to specify which quota got exceeded. This error can be caused by exceeded message rate quota,
// exceeded device message rate quota, or exceeded topic message rate quota.
// - Message rate exceeded: The sending rate of messages is too high. Reduce the number of messages sent and use
// exponential backoff to retry sending.
// - Device message rate exceeded: The rate of messages to a particular device is too high. If an iOS app sends
// messages at a rate exceeding APNs limits, it may receive this error message. Reduce the number of messages
// sent to this device and use exponential backoff to retry sending.
// - Topic message rate exceeded: The rate of messages to subscribers to a particular topic is too high.
// Reduce the number of messages sent for this topic and use exponential backoff to retry sending.
ErrorQuotaExceeded = "QUOTA_EXCEEDED"
// The server is overloaded (HTTP error code = 503). The server couldn't process the request in time. Retry the
// same request, but you must:
// - Honor the Retry-After header if it is included in the response from the FCM Connection Server.
// - Implement exponential back-off in your retry mechanism. (e.g. if you waited one second before the first retry,
// wait at least two second before the next one, then 4 seconds and so on). If you're sending multiple messages,
// delay each one independently by an additional random amount to avoid issuing a new request for all messages
// at the same time. Senders that cause problems risk being denylisted.
ErrorUnavailable = "UNAVAILABLE"
// An unknown internal error occurred (HTTP error code = 500). The server encountered an error while trying to process
// the request. You could retry the same request following the requirements listed in "Timeout" (see row above).
// If the error persists, please contact Firebase support.
ErrorInternal = "INTERNAL"
// APNs certificate or web push auth key was invalid or missing (HTTP error code = 401). A message targeted to an
// iOS device or a web push registration could not be sent. Check the validity of your development and production
// credentials.
ErrorThirdPartyAuth = "THIRD_PARTY_AUTH_ERROR"
)
// APNS error messages
const (
// The collapse identifier exceeds the maximum allowed size (HTTP error code = 400).
ErrorApnsBadCollapseId = "BadCollapseId"
// The specified device token was bad. Verify that the request contains a valid token and that the
// token matches the environment (HTTP error code = 400).
ErrorApnsBadDeviceToken = "BadDeviceToken"
// The apns-expiration value is bad (HTTP error code = 400).
ErrorApnsBadExpirationDate = "BadExpirationDate"
// The apns-id value is bad (HTTP error code = 400).
ErrorApnsBadMessageId = "BadMessageId"
// The apns-priority value is bad (HTTP error code = 400).
ErrorApnsBadPriority = "BadPriority"
// The apns-topic was invalid (HTTP error code = 400).
ErrorApnsBadTopic = "BadTopic"
// The device token does not match the specified topic (HTTP error code = 400).
ErrorApnsDeviceTokenNotForTopic = "DeviceTokenNotForTopic"
// One or more headers were repeated (HTTP error code = 400).
ErrorApnsDuplicateHeaders = "DuplicateHeaders"
// Idle time out (HTTP error code = 400).
ErrorApnsIdleTimeout = "IdleTimeout"
// The device token is not specified in the request :path. Verify that the :path header
// contains the device token (HTTP error code = 400).
ErrorApnsMissingDeviceToken = "MissingDeviceToken"
// The apns-topic header of the request was not specified and was required.
// The apns-topic header is mandatory when the client is connected using a certificate
// that supports multiple topics (HTTP error code = 400).
ErrorApnsMissingTopic = "MissingTopic"
// The message payload was empty (HTTP error code = 400).
ErrorApnsPayloadEmpty = "PayloadEmpty"
// Pushing to this topic is not allowed (HTTP error code = 400).
ErrorApnsTopicDisallowed = "TopicDisallowed"
// The certificate was bad (HTTP error code = 403).
ErrorApnsBadCertificate = "BadCertificate"
// The client certificate was for the wrong environment (HTTP error code = 403).
ErrorApnsBadCertificateEnvironment = "BadCertificateEnvironment"
// The provider token is stale and a new token should be generated (HTTP error code = 403).
ErrorApnsExpiredProviderToken = "ExpiredProviderToken"
// The specified action is not allowed (HTTP error code = 403).
ErrorApnsForbidden = "Forbidden"
// The provider token is not valid or the token signature could not be verified (HTTP error code = 403).
ErrorApnsInvalidProviderToken = "InvalidProviderToken"
// No provider certificate was used to connect to APNs and Authorization header was missing
// or no provider token was specified (HTTP error code = 403).
ErrorApnsMissingProviderToken = "MissingProviderToken"
// The request contained a bad :path value (HTTP error code = 404).
ErrorApnsBadPath = "BadPath"
// The specified :method was not POST (HTTP error code = 405).
ErrorApnsMethodNotAllowed = "MethodNotAllowed"
// The device token is inactive for the specified topic (HTTP error code = 410).
ErrorApnsUnregistered = "Unregistered"
// The message payload was too large. See Creating the Remote Notification Payload
// for details on maximum payload size (HTTP error code = 413).
ErrorApnsPayloadTooLarge = "PayloadTooLarge"
// The provider token is being updated too often (HTTP error code = 429).
ErrorApnsTooManyProviderTokenUpdates = "TooManyProviderTokenUpdates"
// Too many requests were made consecutively to the same device token (HTTP error code = 429).
ErrorApnsTooManyRequests = "TooManyRequests"
// An internal server error occurred (HTTP error code = 500).
ErrorApnsInternalServerError = "InternalServerError"
// The service is unavailable (HTTP error code = 503).
ErrorApnsServiceUnavailable = "ServiceUnavailable"
// The server is shutting down (HTTP error code = 503).
ErrorApnsShutdown = "Shutdown"
)
// GApiError stores a simplified representation of an error returned by a call to Google API.
type GApiError struct {
HttpCode int
// This one is not informative, but can be logged for user consideration.
ErrMessage string
// FCM error code, informative but may be missing.
FcmErrCode string
// Extended error info dependent on the fcmErrCode.
ExtendedInfo string
}
// DecodeGoogleApiError converts very complex googleapi.Error to a bit more manageable structure.
func DecodeGoogleApiError(err error) (decoded *GApiError, errs []error) {
decoded = &GApiError{}
if gerr, ok := err.(*googleapi.Error); ok {
// HTTP status code.
decoded.HttpCode = gerr.Code
decoded.ErrMessage = gerr.Message
for _, errInfo := range gerr.Errors {
decoded.ErrMessage += "; " + errInfo.Reason + "/" + errInfo.Message
}
// Decode the FCM error.
for _, iface := range gerr.Details {
details, ok := iface.(map[string]any)
if !ok {
errs = append(errs, fmt.Errorf("error.Details unrecognized format %T", iface))
continue
}
switch details["@type"] {
case "type.googleapis.com/google.firebase.fcm.v1.FcmError":
if errCode, ok := details["errorCode"].(string); ok {
if decoded.FcmErrCode != "" {
// This has not been observed but FCM is uncler if it can happen.
errs = append(errs, fmt.Errorf("multiple FcmError codes '%s', '%s'", errCode, decoded.FcmErrCode))
} else {
decoded.FcmErrCode = errCode
}
} else {
errs = append(errs, fmt.Errorf("error.Details errorCode is not a string: %T", details["errorCode"]))
}
case "type.googleapis.com/google.rpc.BadRequest":
// dst.fcmErrCode == INVALID_ARGUMENT
if fieldViolations, ok := details["fieldViolations"].([]any); !ok {
errs = append(errs, fmt.Errorf("wrong type of error.Details 'fieldViolations': %T", details["fieldViolations"]))
} else {
var fields []string
for _, violationIface := range fieldViolations {
if violation, ok := violationIface.(map[string]any); !ok {
errs = append(errs, fmt.Errorf("wrong type of error.Details.fieldViolations item: %T", iface))
} else if field, ok := violation["field"].(string); ok && field != "" {
fields = append(fields, field)
} else {
errs = append(errs, fmt.Errorf("error.Details 'fieldViolation' has no 'field': %T, %s", violation["field"], violation["description"]))
}
}
decoded.ExtendedInfo = strings.Join(fields, ",")
}
case "type.googleapis.com/google.rpc.QuotaFailure":
// dst.fcmErrCode == QUOTA_EXCEEDED
// TODO: this error has not been observed, don't know how to handle it.
errs = append(errs, fmt.Errorf("quota exceeded %v", details))
default:
errs = append(errs, fmt.Errorf("unknown error '@type': %v", details))
}
}
} else {
decoded.HttpCode = http.StatusBadRequest
decoded.ErrMessage = err.Error()
errs = append(errs, fmt.Errorf("not googleapi.Error %w", err))
}
if decoded.FcmErrCode == "" {
decoded.FcmErrCode = string(ErrorUnspecified)
}
return
}
+25
View File
@@ -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
+388
View File
@@ -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
}
+273
View File
@@ -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)
}
+21
View File
@@ -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)
}
}
+212
View File
@@ -0,0 +1,212 @@
// Package push contains interfaces to be implemented by push notification plugins.
package push
import (
"encoding/json"
"errors"
"time"
t "github.com/tinode/chat/server/store/types"
)
// Push actions
const (
// New message.
ActMsg = "msg"
// New subscription.
ActSub = "sub"
// Messages read: clear unread count.
ActRead = "read"
)
// MaxPayloadLength is the maximum length of push payload in multibyte characters.
const MaxPayloadLength = 128
// Recipient is a user targeted by the push.
type Recipient struct {
// Count of user's connections that were live when the packet was dispatched from the server
Delivered int `json:"delivered"`
// List of user's devices that the packet was delivered to (if known). Len(Devices) >= Delivered
Devices []string `json:"devices,omitempty"`
// Unread count to include in the push
Unread int `json:"unread"`
// Indicates whether unread counter in the cache should be incremented before sending the push.
ShouldIncrementUnreadCountInCache bool `json:"-"`
}
// Receipt is the push payload with a list of recipients.
type Receipt struct {
TenantID t.TenantID `json:"-"`
// List of individual recipients, including those who did not receive the message.
To map[t.Uid]Recipient `json:"to"`
// Push topic for group notifications.
Channel string `json:"channel"`
// Actual content to be delivered to the client.
Payload Payload `json:"payload"`
}
// ChannelReq is a request to subscribe/unsubscribe device ID(s) to channel(s) (FCM topic).
// - If DeviceID is provided, it's subscribed/unsubscribed to all user's channels.
// - If Channel is provided, then all user's devices are subscribed/unsubscribed from the channel.
type ChannelReq struct {
TenantID t.TenantID
// Uid is the ID of the user making request.
Uid t.Uid
// DeviceID is the device-provided token in case a single device is being subscribed to all channels.
DeviceID string
// Channel to subscribe to or unsubscribe from.
Channel string
// Unsub is set to true to unsubscribe devices, otherwise subscribe them.
Unsub bool
}
// Payload is content of the push.
type Payload struct {
// Action type of the push: new message (msg), new subscription (sub), etc.
What string `json:"what"`
// If this is a silent push: perform action but do not show a notification to the user.
Silent bool `json:"silent"`
// Topic which was affected by the action.
Topic string `json:"topic"`
// Timestamp of the action.
Timestamp time.Time `json:"ts"`
// {data} notification.
// Message sender 'usrXXX'
From string `json:"from"`
// Sequential ID of the message.
SeqId int `json:"seq"`
// MIME-Type of the message content, text/x-drafty or text/plain
ContentType string `json:"mime"`
// Actual Data.Content of the message, if requested
Content any `json:"content,omitempty"`
// State of the video call (available in video call messages only).
Webrtc string `json:"webrtc,omitempty"`
// If call is audio-only (available only if Webrtc is present).
AudioOnly bool `json:"aonly,omitempty"`
// Seq id the message is supposed to replace.
Replace string `json:"replace,omitempty"`
// Subscription change notification.
// New access mode when notifying of a subscription change.
// ModeNone for both means the subscription is removed.
ModeWant t.AccessMode `json:"want,omitempty"`
ModeGiven t.AccessMode `json:"given,omitempty"`
}
// Handler is an interface which must be implemented by handlers.
type Handler interface {
// Init initializes the handler.
Init(jsonconf json.RawMessage) (bool, error)
// IsReady сhecks if the handler is initialized.
IsReady() bool
// Push returns a channel that the server will use to send messages to.
// The message will be dropped if the channel blocks.
Push() chan<- *Receipt
// Subscribe/unsubscribe device from FCM topic (channel).
Channel() chan<- *ChannelReq
// Stop terminates the handler's worker and stops sending pushes.
Stop()
}
type configType struct {
Name string `json:"name"`
Config json.RawMessage `json:"config"`
}
var handlers map[string]Handler
// Register a push handler
func Register(name string, hnd Handler) {
if handlers == nil {
handlers = make(map[string]Handler)
}
if hnd == nil {
panic("Register: push handler is nil")
}
if _, dup := handlers[name]; dup {
panic("Register: called twice for handler " + name)
}
handlers[name] = hnd
}
// Init initializes registered handlers.
func Init(jsconfig json.RawMessage) ([]string, error) {
var config []configType
if err := json.Unmarshal(jsconfig, &config); err != nil {
return nil, errors.New("failed to parse config: " + err.Error())
}
var enabled []string
for _, cc := range config {
if hnd := handlers[cc.Name]; hnd != nil {
if ok, err := hnd.Init(cc.Config); err != nil {
return nil, err
} else if ok {
enabled = append(enabled, cc.Name)
}
}
}
return enabled, nil
}
// Push a single message to devices.
func Push(msg *Receipt) {
if handlers == nil {
return
}
for _, hnd := range handlers {
if !hnd.IsReady() {
continue
}
// Push without delay or skip
select {
case hnd.Push() <- msg:
default:
}
}
}
// ChannelSub handles a channel (FCM topic) subscription/unsubscription request.
func ChannelSub(msg *ChannelReq) {
if handlers == nil {
return
}
for _, hnd := range handlers {
if !hnd.IsReady() {
continue
}
// Send without delay or skip.
select {
case hnd.Channel() <- msg:
default:
}
}
}
// Stop all pushes
func Stop() {
if handlers == nil {
return
}
for _, hnd := range handlers {
if hnd.IsReady() {
// Will potentially block
hnd.Stop()
}
}
}
+4
View File
@@ -0,0 +1,4 @@
# `stdout` push adapter
This is an adapter which logs push notifications to `STDOUT` where they can be redirected to file or processed by some other service.
This adapter is primarily intended for debugging and logging.
+26
View File
@@ -0,0 +1,26 @@
# TNPG: Push Gateway
This is a push notifications adapter which communicates with Tinode Push Gateway (TNPG).
TNPG is a proprietary service intended to simplify deployment of on-premise installations.
Deploying a Tinode server without TNPG requires [configuring Google FCM](../fcm/) with your own credentials, recompiling Android and iOS clients, releasing them to PlayStore and AppStore under your own accounts. It's usually time consuming and relatively complex.
TNPG solves this problem by letting Tinode LLC (the company behind Tinode) to send push notifications on your behalf: you hand a notification over to TNPG, TNPG sends it to the clients using its own credentials and certificates. Internally it uses [Google FCM](https://firebase.google.com/docs/cloud-messaging/) and as such supports the same platforms as FCM. The main advantage of using TNPG over FCM is simplicity of configuration: you can use stock mobile clients with your custom Tinode server, all is needed is a configuration update on the server.
## Configuring TNPG adapter
### Obtain TNPG token
1. Register at https://console.tinode.co and create an organization.
2. Get the TPNG token from the _Self hosting_ &rarr; _Push Gateway_ section by following the instructions there.
### Configure the server
Update the server config [`tinode.conf`](../../tinode.conf#L413), section `"push"` -> `"name": "tnpg"`:
```js
{
"enabled": true,
"org": "myorg", // Short name (URL) of the organization you registered at console.tinode.co
"token": "SoMe_LonG.RaNDoM-StRiNg.12345" // authentication token obtained from console.tinode.co
}
```
Make sure the `fcm` section is disabled `"enabled": false` or removed altogether.
+382
View File
@@ -0,0 +1,382 @@
// Package tnpg implements push notification plugin for Tinode Push Gateway.
package tnpg
import (
"bytes"
"compress/gzip"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"sync"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/push"
"github.com/tinode/chat/server/push/common"
"github.com/tinode/chat/server/push/fcm"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
fcmv1 "google.golang.org/api/fcm/v1"
)
const (
baseTargetAddress = "https://pushgw.tinode.co/"
pushPath = "pushv1"
subsPath = "sub"
pushBatchSize = 100
subBatchSize = 1000
bufferSize = 1024
)
var handler Handler
const maxPooledPostBodyCap = 1 << 16
var postBodyPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
var gzipWriterPool = sync.Pool{
New: func() any {
return gzip.NewWriter(nil)
},
}
// Handler represents state of TNPG push client.
type Handler struct {
input chan *push.Receipt
channel chan *push.ChannelReq
stop chan bool
pushUrl string
subUrl string
}
type configType struct {
Enabled bool `json:"enabled"`
OrgID string `json:"org"`
AuthToken string `json:"token"`
DebugPushGWHost string `json:"debug_server"`
}
// subUnsubReq is a request to subscribe/unsubscribe device ID(s) to channel(s) (FCM topic).
// One device to multiple channels or multiple devices to one channel.
type subUnsubReq struct {
Channel string `json:"channel,omitempty"`
Channels []string `json:"channels,omitempty"`
Device string `json:"device,omitempty"`
Devices []string `json:"devices,omitempty"`
Unsub bool `json:"unsub"`
}
type tnpgResponse struct {
// Push message response only.
MessageID string `json:"msg_id,omitempty"`
// Server response HTTP code.
Code int `json:"code,omitempty"`
// FCM response code. Both push and sub/unsub response.
ErrorCode string `json:"errcode,omitempty"`
ExtendedError string `json:"exerr,omitempty"`
ErrorMessage string `json:"errmsg,omitempty"`
// Channel sub/unsub response only.
Index int `json:"index,omitempty"`
}
type batchResponse struct {
// Number of successfully sent messages.
SuccessCount int `json:"sent_count"`
// Number of failures.
FailureCount int `json:"fail_count"`
// Error code and message if the entire batch failed.
FatalCode string `json:"errcode,omitempty"`
FatalMessage string `json:"errmsg,omitempty"`
// Individual reponses in the same order as messages. Could be nil if the entire batch failed.
Responses []*tnpgResponse `json:"resp,omitempty"`
// Local values
httpCode int
httpStatus string
}
// Error codes copied from https://github.com/firebase/firebase-admin-go/blob/master/messaging/messaging.go
const (
internalError = "internal-error"
invalidAPNSCredentials = "invalid-apns-credentials"
invalidArgument = "invalid-argument"
messageRateExceeded = "message-rate-exceeded"
mismatchedCredential = "mismatched-credential"
registrationTokenNotRegistered = "registration-token-not-registered"
serverUnavailable = "server-unavailable"
tooManyTopics = "too-many-topics"
unknownError = "unknown-error"
)
// Init initializes the handler
func (Handler) Init(jsonconf json.RawMessage) (bool, error) {
var config configType
if err := json.Unmarshal(jsonconf, &config); err != nil {
return false, errors.New("failed to parse config: " + err.Error())
}
if !config.Enabled {
return false, nil
}
config.OrgID = strings.TrimSpace(config.OrgID)
if config.OrgID == "" {
return false, errors.New("organization name is missing")
}
// Convert to lower case to avoid confusion.
config.OrgID = strings.ToLower(config.OrgID)
// Construct server URLs.
serverAddr := baseTargetAddress
if config.DebugPushGWHost != "" {
serverAddr = config.DebugPushGWHost
}
serverUrl, err := url.Parse(serverAddr)
if err != nil {
return false, err
}
serverUrl.Path += pushPath + "/" + config.OrgID
handler.pushUrl = serverUrl.String()
serverUrl, _ = url.Parse(serverAddr)
serverUrl.Path += subsPath + "/" + config.OrgID
handler.subUrl = serverUrl.String()
handler.input = make(chan *push.Receipt, bufferSize)
handler.channel = make(chan *push.ChannelReq, bufferSize)
handler.stop = make(chan bool, 1)
go func() {
for {
select {
case rcpt := <-handler.input:
go sendPushes(rcpt, &config)
case sub := <-handler.channel:
go processSubscription(sub, &config)
case <-handler.stop:
return
}
}
}()
return true, nil
}
func postMessage(endpoint string, body any, config *configType) (*batchResponse, error) {
buf := postBodyPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
if cap(buf.Bytes()) > maxPooledPostBodyCap {
return
}
postBodyPool.Put(buf)
}()
gzw := gzipWriterPool.Get().(*gzip.Writer)
defer func() {
gzw.Reset(nil)
gzipWriterPool.Put(gzw)
}()
gzw.Reset(buf)
err := json.NewEncoder(gzw).Encode(body)
if closeErr := gzw.Close(); err == nil {
err = closeErr
}
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, endpoint, buf)
if err != nil {
return nil, err
}
req.Header.Add("Authorization", "Bearer "+config.AuthToken)
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Add("Content-Encoding", "gzip")
req.Header.Add("Accept-Encoding", "gzip")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
var batch batchResponse
var reader io.ReadCloser
if strings.Contains(resp.Header.Get("Content-Encoding"), "gzip") {
reader, err = gzip.NewReader(resp.Body)
if err == nil {
defer reader.Close()
}
} else {
reader = resp.Body
}
if err == nil {
err = json.NewDecoder(reader).Decode(&batch)
}
resp.Body.Close()
if err != nil {
// Just log the error, but don't report it to caller. The push succeeded.
logs.Warn.Println("tnpg failed to decode response:", err)
}
batch.httpCode = resp.StatusCode
batch.httpStatus = resp.Status
return &batch, nil
}
func sendPushes(rcpt *push.Receipt, config *configType) {
messages, uids := fcm.PrepareV1Notifications(rcpt, nil)
n := len(messages)
for i := 0; i < n; i += pushBatchSize {
upper := min(i+pushBatchSize, n)
var payloads []any
for j := i; j < upper; j++ {
payloads = append(payloads, messages[j])
}
resp, err := postMessage(handler.pushUrl, payloads, config)
if err != nil {
logs.Warn.Println("tnpg push request failed:", err)
break
}
if resp.httpCode >= 300 {
logs.Warn.Println("tnpg push rejected:", resp.httpStatus)
break
}
if resp.FatalCode != "" {
logs.Err.Println("tnpg push failed:", resp.FatalMessage)
break
}
// Check for expired tokens and other errors.
handlePushResponse(rcpt.TenantID, resp, messages[i:upper], uids[i:upper])
}
}
func processSubscription(req *push.ChannelReq, config *configType) {
su := subUnsubReq{
Unsub: req.Unsub,
}
if req.Channel != "" {
su.Devices = fcm.DevicesForUser(req.TenantID, req.Uid)
su.Channel = req.Channel
} else if req.DeviceID != "" {
su.Channels = fcm.ChannelsForUser(req.TenantID, req.Uid)
su.Device = req.DeviceID
}
if (len(su.Devices) == 0 && su.Device == "") || (len(su.Channels) == 0 && su.Channel == "") {
return
}
if len(su.Devices) > subBatchSize {
// It's extremely unlikely for a single user to have this many devices.
su.Devices = su.Devices[0:subBatchSize]
logs.Warn.Println("tnpg: user", req.Uid.UserId(), "has more than", subBatchSize, "devices")
}
resp, err := postMessage(handler.subUrl, &su, config)
if err != nil {
logs.Warn.Println("tnpg channel sub request failed:", err)
return
}
if resp.httpCode >= 300 {
logs.Warn.Println("tnpg channel sub rejected:", resp.httpStatus)
return
}
if resp.FatalCode != "" {
logs.Err.Println("tnpg channel sub failed:", resp.FatalMessage)
return
}
// Check for expired tokens and other errors.
handleSubResponse(resp, req, su.Devices, su.Channels)
}
func handlePushResponse(tenantID types.TenantID, batch *batchResponse, messages []*fcmv1.Message, uids []types.Uid) {
if batch.FailureCount <= 0 {
return
}
for i, resp := range batch.Responses {
switch resp.ErrorCode {
case "": // no error
case common.ErrorQuotaExceeded, common.ErrorUnavailable, common.ErrorInternal, common.ErrorUnspecified:
// Transient errors. Stop sending this batch.
logs.Warn.Println("tnpg transient failure:", resp.ErrorMessage)
return
case common.ErrorInvalidArgument:
// Usually an invalid token.
logs.Warn.Println("tnpg invalid argument:", resp.ExtendedError, resp.ErrorMessage)
if strings.Contains(resp.ExtendedError, "message.token") {
if err := store.Devices.Delete(tenantID, uids[i], messages[i].Token); err != nil {
logs.Warn.Println("tnpg failed to delete invalid token:", err)
}
}
case common.ErrorSenderIDMismatch, common.ErrorThirdPartyAuth:
// Config errors
logs.Warn.Println("tnpg invalid config:", resp.ExtendedError, resp.ErrorMessage)
return
case common.ErrorUnregistered:
// Token is no longer valid.
logs.Info.Println("tnpg invalid token:", resp.ErrorMessage, resp.ExtendedError, resp.MessageID)
if err := store.Devices.Delete(tenantID, uids[i], messages[i].Token); err != nil {
logs.Warn.Println("tnpg failed to delete invalid token:", err)
}
default:
logs.Warn.Println("tnpg unrecognized error:", resp.ErrorCode, resp.ErrorMessage, resp.ExtendedError, resp.Code)
}
}
}
func handleSubResponse(batch *batchResponse, req *push.ChannelReq, devices, channels []string) {
if batch.FailureCount <= 0 {
return
}
var src string
for _, resp := range batch.Responses {
if len(devices) > 0 {
src = devices[resp.Index]
} else {
src = channels[resp.Index]
}
// 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", resp.ErrorCode, req.Uid, src)
}
}
// IsReady checks if the handler is 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 that the server will use to send group requests to.
// If the adapter blocks, the message will be dropped.
func (Handler) Channel() chan<- *push.ChannelReq {
return handler.channel
}
// Stop terminates the handler's worker and stops sending pushes.
func (Handler) Stop() {
handler.stop <- true
}
func init() {
push.Register("tnpg", &handler)
}