383 lines
10 KiB
Go
383 lines
10 KiB
Go
// 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)
|
|
}
|