Files
Turbine/pkg/ring/services/ring_service.go
2025-12-13 22:51:11 +08:00

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: &notificationProto.Title,
Content: &notificationProto.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
}