436 lines
16 KiB
Go
436 lines
16 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"firebase.google.com/go/v4/messaging"
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/sideshow/apns2"
|
|
"github.com/sideshow/apns2/payload"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/types/known/emptypb"
|
|
|
|
"git.solsynth.dev/goatworks/turbine/pkg/ring/clients"
|
|
"git.solsynth.dev/goatworks/turbine/pkg/ring/infra"
|
|
"git.solsynth.dev/goatworks/turbine/pkg/ring/models"
|
|
"git.solsynth.dev/goatworks/turbine/pkg/ring/websocket"
|
|
pb "git.solsynth.dev/goatworks/turbine/pkg/shared/proto/gen"
|
|
)
|
|
|
|
// RingServiceServerImpl implements the RingServiceServer and RingHandlerServiceServer interfaces
|
|
type RingServiceServerImpl struct {
|
|
pb.UnimplementedRingServiceServer
|
|
pb.UnimplementedRingHandlerServiceServer
|
|
WsManager *websocket.Manager // Add WebSocket Manager
|
|
}
|
|
|
|
// sendPushNotification helper function to send notifications via APNs or FCM
|
|
func sendPushNotification(ctx context.Context, notif *models.Notification, sub *models.NotificationPushSubscription) error {
|
|
if sub.Provider == models.PushProviderApple {
|
|
apnsClient := clients.GetAPNsClient()
|
|
if apnsClient == nil {
|
|
return fmt.Errorf("APNs client not initialized")
|
|
}
|
|
|
|
apnsPayload := payload.NewPayload().
|
|
AlertTitle(*notif.Title).
|
|
AlertBody(*notif.Content)
|
|
|
|
if notif.Subtitle != nil {
|
|
apnsPayload.AlertSubtitle(*notif.Subtitle)
|
|
}
|
|
|
|
if notif.Meta != nil {
|
|
metaBytes, err := json.Marshal(notif.Meta)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to marshal notification meta for APNs")
|
|
} else {
|
|
apnsPayload.Custom("meta", string(metaBytes))
|
|
}
|
|
}
|
|
|
|
notification := &apns2.Notification{
|
|
DeviceToken: sub.DeviceToken,
|
|
Topic: notif.Topic,
|
|
Payload: apnsPayload,
|
|
}
|
|
|
|
res, err := apnsClient.PushWithContext(ctx, notification)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send APNs push notification: %w", err)
|
|
}
|
|
if res.Sent() {
|
|
log.Info().Msgf("APNs notification sent successfully to device %s (token: %s)", sub.DeviceId, sub.DeviceToken)
|
|
} else {
|
|
log.Error().Msgf("APNs notification failed to send to device %s (token: %s) with reason: %s", sub.DeviceId, sub.DeviceToken, res.Reason)
|
|
if res.StatusCode == 410 { // Expired token
|
|
log.Warn().Msgf("APNs token for device %s expired. Deleting subscription.", sub.DeviceId)
|
|
// TODO: Delete the subscription
|
|
}
|
|
return fmt.Errorf("APNs push notification failed: %s", res.Reason)
|
|
}
|
|
} else if sub.Provider == models.PushProviderGoogle {
|
|
firebaseClient := clients.GetFirebaseClient()
|
|
if firebaseClient == nil {
|
|
return fmt.Errorf("Firebase client not initialized")
|
|
}
|
|
|
|
message := &messaging.Message{
|
|
Token: sub.DeviceToken,
|
|
Notification: &messaging.Notification{
|
|
Title: *notif.Title,
|
|
Body: *notif.Content,
|
|
},
|
|
Data: make(map[string]string),
|
|
}
|
|
|
|
if notif.Meta != nil {
|
|
for k, v := range notif.Meta {
|
|
message.Data[k] = fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
|
|
res, err := firebaseClient.Send(ctx, message)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send Firebase push notification: %w", err)
|
|
}
|
|
log.Info().Msgf("Firebase notification sent successfully to device %s (token: %s): %s", sub.DeviceId, sub.DeviceToken, res)
|
|
} else {
|
|
return fmt.Errorf("unsupported push provider: %v", sub.Provider)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SendEmail implements proto.RingServiceServer
|
|
func (s *RingServiceServerImpl) SendEmail(ctx context.Context, req *pb.SendEmailRequest) (*emptypb.Empty, error) {
|
|
log.Info().Msgf("Received SendEmail request: %+v", req.GetEmail())
|
|
|
|
email := req.GetEmail()
|
|
if email == nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "email message is nil")
|
|
}
|
|
|
|
if email.ToAddress == "" || email.Subject == "" || email.Body == "" {
|
|
return nil, status.Errorf(codes.InvalidArgument, "to_address, subject, and body cannot be empty")
|
|
}
|
|
|
|
err := clients.SendEmail(email.ToAddress, email.Subject, email.Body)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to send email")
|
|
return nil, status.Errorf(codes.Internal, "failed to send email: %v", err)
|
|
}
|
|
|
|
log.Info().Msgf("Email sent successfully to %s", email.ToAddress)
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
// PushWebSocketPacket implements proto.RingServiceServer
|
|
func (s *RingServiceServerImpl) PushWebSocketPacket(ctx context.Context, req *pb.PushWebSocketPacketRequest) (*emptypb.Empty, error) {
|
|
log.Info().Msgf("Received PushWebSocketPacket request for user %s: %+v", req.GetUserId(), req.GetPacket())
|
|
|
|
if s.WsManager == nil {
|
|
return nil, status.Errorf(codes.Unavailable, "WebSocket manager not initialized")
|
|
}
|
|
|
|
accountID, err := uuid.Parse(req.GetUserId())
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid user ID: %v", err)
|
|
}
|
|
|
|
packet := websocket.FromProtoValue(req.GetPacket())
|
|
s.WsManager.SendPacketToAccount(accountID, packet)
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
// PushWebSocketPacketToUsers implements proto.RingServiceServer
|
|
func (s *RingServiceServerImpl) PushWebSocketPacketToUsers(ctx context.Context, req *pb.PushWebSocketPacketToUsersRequest) (*emptypb.Empty, error) {
|
|
log.Info().Msgf("Received PushWebSocketPacketToUsers request for users %v: %+v", req.GetUserIds(), req.GetPacket())
|
|
|
|
if s.WsManager == nil {
|
|
return nil, status.Errorf(codes.Unavailable, "WebSocket manager not initialized")
|
|
}
|
|
|
|
packet := websocket.FromProtoValue(req.GetPacket())
|
|
for _, userID := range req.GetUserIds() {
|
|
accountID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msgf("Invalid user ID in batch: %s, skipping", userID)
|
|
continue
|
|
}
|
|
s.WsManager.SendPacketToAccount(accountID, packet)
|
|
}
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
// PushWebSocketPacketToDevice implements proto.RingServiceServer
|
|
func (s *RingServiceServerImpl) PushWebSocketPacketToDevice(ctx context.Context, req *pb.PushWebSocketPacketToDeviceRequest) (*emptypb.Empty, error) {
|
|
log.Info().Msgf("Received PushWebSocketPacketToDevice request for device %s: %+v", req.GetDeviceId(), req.GetPacket())
|
|
|
|
if s.WsManager == nil {
|
|
return nil, status.Errorf(codes.Unavailable, "WebSocket manager not initialized")
|
|
}
|
|
|
|
packet := websocket.FromProtoValue(req.GetPacket())
|
|
s.WsManager.SendPacketToDevice(req.GetDeviceId(), packet)
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
// PushWebSocketPacketToDevices implements proto.RingServiceServer
|
|
func (s *RingServiceServerImpl) PushWebSocketPacketToDevices(ctx context.Context, req *pb.PushWebSocketPacketToDevicesRequest) (*emptypb.Empty, error) {
|
|
log.Info().Msgf("Received PushWebSocketPacketToDevices request for devices %v: %+v", req.GetDeviceIds(), req.GetPacket())
|
|
|
|
if s.WsManager == nil {
|
|
return nil, status.Errorf(codes.Unavailable, "WebSocket manager not initialized")
|
|
}
|
|
|
|
packet := websocket.FromProtoValue(req.GetPacket())
|
|
for _, deviceID := range req.GetDeviceIds() {
|
|
s.WsManager.SendPacketToDevice(deviceID, packet)
|
|
}
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
// SendPushNotificationToUser implements proto.RingServiceServer
|
|
func (s *RingServiceServerImpl) SendPushNotificationToUser(ctx context.Context, req *pb.SendPushNotificationToUserRequest) (*emptypb.Empty, error) {
|
|
log.Info().Msgf("Received SendPushNotificationToUser request for user %s: %+v", req.GetUserId(), req.GetNotification())
|
|
|
|
// 1. Parse incoming notification and save to DB
|
|
accountID, err := uuid.Parse(req.GetUserId())
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid account ID: %v", err)
|
|
}
|
|
|
|
notif := &models.Notification{
|
|
Topic: req.GetNotification().GetTopic(),
|
|
Title: &req.GetNotification().Title,
|
|
Content: &req.GetNotification().Body,
|
|
AccountId: accountID,
|
|
}
|
|
|
|
if req.GetNotification().Subtitle != "" {
|
|
subtitle := req.GetNotification().Subtitle
|
|
notif.Subtitle = &subtitle
|
|
}
|
|
|
|
if req.GetNotification().Meta != nil {
|
|
var metaMap map[string]any
|
|
if err := json.Unmarshal(req.GetNotification().GetMeta(), &metaMap); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to unmarshal notification meta, sending without it")
|
|
} else {
|
|
notif.Meta = metaMap
|
|
}
|
|
}
|
|
|
|
if err := infra.Db.WithContext(ctx).Create(notif).Error; err != nil {
|
|
log.Error().Err(err).Msg("Failed to save notification to database")
|
|
return nil, status.Errorf(codes.Internal, "failed to save notification: %v", err)
|
|
}
|
|
log.Info().Msgf("Notification saved to DB: %s", notif.Id.String())
|
|
|
|
// 2. Retrieve push subscriptions for the user
|
|
var subscriptions []models.NotificationPushSubscription
|
|
if err := infra.Db.WithContext(ctx).Where("account_id = ?", req.GetUserId()).Find(&subscriptions).Error; err != nil {
|
|
log.Error().Err(err).Msgf("Failed to retrieve subscriptions for account %s", req.GetUserId())
|
|
return nil, status.Errorf(codes.Internal, "failed to retrieve subscriptions: %v", err)
|
|
}
|
|
|
|
if len(subscriptions) == 0 {
|
|
log.Info().Msgf("No active push subscriptions found for user %s", req.GetUserId())
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
// 3. Send notification to each subscription
|
|
for _, sub := range subscriptions {
|
|
sub := sub // Create a local copy for the goroutine
|
|
go func() {
|
|
if err := sendPushNotification(ctx, notif, &sub); err != nil {
|
|
log.Error().Err(err).Msgf("Failed to send push notification to device %s", sub.DeviceId)
|
|
}
|
|
}()
|
|
}
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
// SendPushNotificationToUsers implements proto.RingServiceServer
|
|
func (s *RingServiceServerImpl) SendPushNotificationToUsers(ctx context.Context, req *pb.SendPushNotificationToUsersRequest) (*emptypb.Empty, error) {
|
|
log.Info().Msgf("Received SendPushNotificationToUsers request for users %v: %+v", req.GetUserIds(), req.GetNotification())
|
|
|
|
// 1. Parse incoming notification (one notification for all users)
|
|
// We'll create separate DB entries for each user if needed, or link to a single notification.
|
|
// For simplicity, let's assume we save one notification per user for now.
|
|
// A better approach for many users might be a single notification record linked to multiple user-notification bridges.
|
|
|
|
// Extract common notification details
|
|
notificationProto := req.GetNotification()
|
|
metaMap := make(map[string]any)
|
|
if notificationProto.Meta != nil {
|
|
if err := json.Unmarshal(notificationProto.GetMeta(), &metaMap); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to unmarshal notification meta for batch, sending without it")
|
|
}
|
|
}
|
|
|
|
for _, userID := range req.GetUserIds() {
|
|
accountID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msgf("Invalid account ID in batch: %s, skipping", userID)
|
|
continue
|
|
}
|
|
|
|
notif := &models.Notification{
|
|
Topic: notificationProto.GetTopic(),
|
|
Title: ¬ificationProto.Title,
|
|
Content: ¬ificationProto.Body,
|
|
Meta: metaMap,
|
|
AccountId: accountID,
|
|
}
|
|
|
|
if notificationProto.Subtitle != "" {
|
|
subtitle := notificationProto.Subtitle
|
|
notif.Subtitle = &subtitle
|
|
}
|
|
|
|
if err := infra.Db.WithContext(ctx).Create(notif).Error; err != nil {
|
|
log.Error().Err(err).Msgf("Failed to save notification to database for user %s: %v", userID, err)
|
|
continue
|
|
}
|
|
log.Info().Msgf("Notification saved to DB for user %s: %s", userID, notif.Id.String())
|
|
|
|
// Retrieve push subscriptions for the current user
|
|
var subscriptions []models.NotificationPushSubscription
|
|
if err := infra.Db.WithContext(ctx).Where("account_id = ?", userID).Find(&subscriptions).Error; err != nil {
|
|
log.Error().Err(err).Msgf("Failed to retrieve subscriptions for account %s in batch: %v", userID, err)
|
|
continue
|
|
}
|
|
|
|
if len(subscriptions) == 0 {
|
|
log.Info().Msgf("No active push subscriptions found for user %s in batch", userID)
|
|
continue
|
|
}
|
|
|
|
// Send notification to each subscription for the current user
|
|
for _, sub := range subscriptions {
|
|
sub := sub // Create a local copy for the goroutine
|
|
go func() {
|
|
if err := sendPushNotification(ctx, notif, &sub); err != nil {
|
|
log.Error().Err(err).Msgf("Failed to send push notification to device %s for user %s", sub.DeviceId, userID)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
// UnsubscribePushNotifications implements proto.RingServiceServer
|
|
func (s *RingServiceServerImpl) UnsubscribePushNotifications(ctx context.Context, req *pb.UnsubscribePushNotificationsRequest) (*emptypb.Empty, error) {
|
|
log.Info().Msgf("Received UnsubscribePushNotifications request for device %s", req.GetDeviceId())
|
|
|
|
if req.GetDeviceId() == "" {
|
|
return nil, status.Errorf(codes.InvalidArgument, "device_id cannot be empty")
|
|
}
|
|
|
|
result := infra.Db.WithContext(ctx).Where("device_id = ?", req.GetDeviceId()).Delete(&models.NotificationPushSubscription{})
|
|
if result.Error != nil {
|
|
log.Error().Err(result.Error).Msgf("Failed to unsubscribe device %s", req.GetDeviceId())
|
|
return nil, status.Errorf(codes.Internal, "failed to unsubscribe: %v", result.Error)
|
|
}
|
|
|
|
if result.RowsAffected == 0 {
|
|
log.Info().Msgf("No subscription found for device %s to unsubscribe", req.GetDeviceId())
|
|
} else {
|
|
log.Info().Msgf("Successfully unsubscribed device %s", req.GetDeviceId())
|
|
}
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
// GetWebsocketConnectionStatus implements proto.RingServiceServer
|
|
func (s *RingServiceServerImpl) GetWebsocketConnectionStatus(ctx context.Context, req *pb.GetWebsocketConnectionStatusRequest) (*pb.GetWebsocketConnectionStatusResponse, error) {
|
|
log.Info().Msgf("Received GetWebsocketConnectionStatus request: %+v", req)
|
|
|
|
if s.WsManager == nil {
|
|
return nil, status.Errorf(codes.Unavailable, "WebSocket manager not initialized")
|
|
}
|
|
|
|
isConnected := false
|
|
switch id := req.GetId().(type) {
|
|
case *pb.GetWebsocketConnectionStatusRequest_DeviceId:
|
|
isConnected = s.WsManager.GetDeviceIsConnected(id.DeviceId)
|
|
case *pb.GetWebsocketConnectionStatusRequest_UserId:
|
|
accountID, err := uuid.Parse(id.UserId)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid user ID: %v", err)
|
|
}
|
|
isConnected = s.WsManager.GetAccountIsConnected(accountID)
|
|
default:
|
|
return nil, status.Errorf(codes.InvalidArgument, "either device_id or user_id must be provided")
|
|
}
|
|
|
|
return &pb.GetWebsocketConnectionStatusResponse{IsConnected: isConnected}, nil
|
|
}
|
|
|
|
// GetWebsocketConnectionStatusBatch implements proto.RingServiceServer
|
|
func (s *RingServiceServerImpl) GetWebsocketConnectionStatusBatch(ctx context.Context, req *pb.GetWebsocketConnectionStatusBatchRequest) (*pb.GetWebsocketConnectionStatusBatchResponse, error) {
|
|
log.Info().Msgf("Received GetWebsocketConnectionStatusBatch request: %+v", req)
|
|
|
|
if s.WsManager == nil {
|
|
return nil, status.Errorf(codes.Unavailable, "WebSocket manager not initialized")
|
|
}
|
|
|
|
response := &pb.GetWebsocketConnectionStatusBatchResponse{
|
|
IsConnected: make(map[string]bool),
|
|
}
|
|
|
|
for _, userID := range req.GetUsersId() {
|
|
accountID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msgf("Invalid user ID in batch: %s, skipping", userID)
|
|
response.IsConnected[userID] = false // Indicate connection status as false for invalid IDs
|
|
continue
|
|
}
|
|
response.IsConnected[userID] = s.WsManager.GetAccountIsConnected(accountID)
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
// ReceiveWebSocketPacket implements proto.RingHandlerServiceServer
|
|
func (s *RingServiceServerImpl) ReceiveWebSocketPacket(ctx context.Context, req *pb.ReceiveWebSocketPacketRequest) (*emptypb.Empty, error) {
|
|
log.Info().Msgf("Received ReceiveWebSocketPacket request: %+v", req)
|
|
|
|
if s.WsManager == nil {
|
|
return nil, status.Errorf(codes.Unavailable, "WebSocket manager not initialized")
|
|
}
|
|
|
|
packet := websocket.FromProtoValue(req.GetPacket())
|
|
|
|
// The C# HandlePacket expects current user and device ID.
|
|
// For this gRPC endpoint, we can use the account and device_id from the request.
|
|
if req.GetAccount() == nil || req.GetAccount().GetId() == "" {
|
|
return nil, status.Errorf(codes.InvalidArgument, "account information missing in request")
|
|
}
|
|
if req.GetDeviceId() == "" {
|
|
return nil, status.Errorf(codes.InvalidArgument, "device_id missing in request")
|
|
}
|
|
|
|
// Assuming the request comes from a trusted source, we can use the provided account.
|
|
// We don't have a direct *websocket.Conn here, so we can't send error responses back on a direct WebSocket.
|
|
// Errors will be logged and returned as gRPC errors.
|
|
err := s.WsManager.HandlePacket(req.GetAccount(), req.GetDeviceId(), packet, nil) // Pass nil for *websocket.Conn
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to handle received WebSocket packet via gRPC")
|
|
return nil, status.Errorf(codes.Internal, "failed to process packet: %v", err)
|
|
}
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|