多租户更改

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
+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)
}
}