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 }