// internal/api/bot_service.go package api import ( "context" "errors" "fmt" "time" "github.com/nbd-wtf/go-nostr" // required for key derivation "git.sovbit.dev/Enki/nostr-poster/internal/crypto" "git.sovbit.dev/Enki/nostr-poster/internal/db" "git.sovbit.dev/Enki/nostr-poster/internal/models" "git.sovbit.dev/Enki/nostr-poster/internal/nostr/events" "git.sovbit.dev/Enki/nostr-poster/internal/nostr/relay" "go.uber.org/zap" ) // BotService provides functionality for managing bots type BotService struct { db *db.DB keyStore *crypto.KeyStore eventMgr *events.EventManager relayMgr *relay.Manager logger *zap.Logger } // NewBotService creates a new BotService func NewBotService( db *db.DB, keyStore *crypto.KeyStore, eventMgr *events.EventManager, relayMgr *relay.Manager, logger *zap.Logger, ) *BotService { return &BotService{ db: db, keyStore: keyStore, eventMgr: eventMgr, relayMgr: relayMgr, logger: logger, } } // GetPrivateKey returns the private key for the given pubkey from the keystore. func (s *BotService) GetPrivateKey(pubkey string) (string, error) { return s.keyStore.GetPrivateKey(pubkey) } // ListUserBots lists all bots owned by a user func (s *BotService) ListUserBots(ownerPubkey string) ([]*models.Bot, error) { query := ` SELECT b.* FROM bots b WHERE b.owner_pubkey = ? ORDER BY b.created_at DESC ` var bots []*models.Bot err := s.db.Select(&bots, query, ownerPubkey) if err != nil { return nil, fmt.Errorf("failed to list bots: %w", err) } // Load associated data for each bot for _, bot := range bots { if err := s.loadBotRelatedData(bot); err != nil { s.logger.Warn("Failed to load related data for bot", zap.Int64("botID", bot.ID), zap.Error(err)) } } return bots, nil } // GetBotByID gets a bot by ID and verifies ownership func (s *BotService) GetBotByID(botID int64, ownerPubkey string) (*models.Bot, error) { query := ` SELECT b.* FROM bots b WHERE b.id = ? AND b.owner_pubkey = ? ` var bot models.Bot err := s.db.Get(&bot, query, botID, ownerPubkey) if err != nil { return nil, fmt.Errorf("failed to get bot: %w", err) } // Load associated data if err := s.loadBotRelatedData(&bot); err != nil { return &bot, fmt.Errorf("failed to load related data: %w", err) } return &bot, nil } // CreateBot creates a new bot func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) { // Start a transaction tx, err := s.db.Beginx() if err != nil { s.logger.Error("Failed to start transaction", zap.Error(err)) return nil, fmt.Errorf("failed to start transaction: %w", err) } defer tx.Rollback() s.logger.Info("Creating new bot", zap.String("name", bot.Name), zap.String("privkey_type", detectKeyType(bot.EncryptedPrivkey))) // Check if we need to generate a keypair if bot.Pubkey == "" && bot.EncryptedPrivkey == "" { // Generate a new keypair s.logger.Info("Generating new keypair") pubkey, privkey, err := s.keyStore.GenerateKey() if err != nil { s.logger.Error("Failed to generate keypair", zap.Error(err)) return nil, fmt.Errorf("failed to generate keypair: %w", err) } bot.Pubkey = pubkey bot.EncryptedPrivkey = privkey // This will be encrypted by the KeyStore s.logger.Info("Generated keypair successfully", zap.String("pubkey", pubkey)) } else if bot.EncryptedPrivkey != "" { // If only privkey is provided, derive the pubkey if bot.Pubkey == "" { privkey := bot.EncryptedPrivkey s.logger.Info("Deriving pubkey from provided privkey", zap.String("key_type", detectKeyType(privkey))) if len(privkey) > 4 && privkey[:4] == "nsec" { s.logger.Debug("Decoding nsec key") var decodeErr error privkey, decodeErr = s.keyStore.DecodeNsecKey(privkey) if decodeErr != nil { s.logger.Error("Failed to decode nsec key", zap.Error(decodeErr)) return nil, fmt.Errorf("failed to decode nsec key: %w", decodeErr) } s.logger.Debug("Successfully decoded nsec key to hex format") } derivedPub, err := nostr.GetPublicKey(privkey) if err != nil { s.logger.Error("Invalid private key", zap.Error(err)) return nil, fmt.Errorf("invalid private key: %w", err) } bot.Pubkey = derivedPub s.logger.Info("Successfully derived pubkey", zap.String("pubkey", derivedPub)) } // Import the provided keypair s.logger.Debug("Importing keypair to keystore") if err := s.keyStore.AddKey(bot.Pubkey, bot.EncryptedPrivkey); err != nil { s.logger.Error("Failed to import keypair", zap.Error(err)) return nil, fmt.Errorf("failed to import keypair: %w", err) } s.logger.Debug("Successfully imported keypair to keystore") } else { s.logger.Error("Missing key information", zap.Bool("has_pubkey", bot.Pubkey != ""), zap.Bool("has_privkey", bot.EncryptedPrivkey != "")) return nil, errors.New("either provide both pubkey and privkey or none for auto-generation") } // Set created time bot.CreatedAt = time.Now() // Insert the bot query := ` INSERT INTO bots ( pubkey, encrypted_privkey, name, display_name, bio, nip05, zap_address, profile_picture, banner, created_at, owner_pubkey ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` result, err := tx.Exec( query, bot.Pubkey, bot.EncryptedPrivkey, bot.Name, bot.DisplayName, bot.Bio, bot.Nip05, bot.ZapAddress, bot.ProfilePicture, bot.Banner, bot.CreatedAt, bot.OwnerPubkey, ) if err != nil { s.logger.Error("Failed to insert bot", zap.Error(err)) return nil, fmt.Errorf("failed to insert bot: %w", err) } // Get the inserted ID botID, err := result.LastInsertId() if err != nil { s.logger.Error("Failed to get inserted ID", zap.Error(err)) return nil, fmt.Errorf("failed to get inserted ID: %w", err) } bot.ID = botID // Create default post config postConfig := &models.PostConfig{ BotID: botID, Hashtags: "[]", IntervalMinutes: 60, PostTemplate: "", Enabled: false, } postConfigQuery := ` INSERT INTO post_config ( bot_id, hashtags, interval_minutes, post_template, enabled ) VALUES (?, ?, ?, ?, ?) ` _, err = tx.Exec( postConfigQuery, postConfig.BotID, postConfig.Hashtags, postConfig.IntervalMinutes, postConfig.PostTemplate, postConfig.Enabled, ) if err != nil { s.logger.Error("Failed to insert post config", zap.Error(err)) return nil, fmt.Errorf("failed to insert post config: %w", err) } // Create default media config mediaConfig := &models.MediaConfig{ BotID: botID, PrimaryService: "nip94", FallbackService: "blossom", Nip94ServerURL: "", BlossomServerURL: "", } mediaConfigQuery := ` INSERT INTO media_config ( bot_id, primary_service, fallback_service, nip94_server_url, blossom_server_url ) VALUES (?, ?, ?, ?, ?) ` _, err = tx.Exec( mediaConfigQuery, mediaConfig.BotID, mediaConfig.PrimaryService, mediaConfig.FallbackService, mediaConfig.Nip94ServerURL, mediaConfig.BlossomServerURL, ) if err != nil { s.logger.Error("Failed to insert media config", zap.Error(err)) return nil, fmt.Errorf("failed to insert media config: %w", err) } // Add default relays defaultRelays := []struct { URL string Read bool Write bool }{ {"wss://relay.damus.io", true, true}, {"wss://nostr.mutinywallet.com", true, true}, {"wss://relay.nostr.band", true, true}, } relayQuery := ` INSERT INTO relays (bot_id, url, read, write) VALUES (?, ?, ?, ?) ` for _, relay := range defaultRelays { _, err = tx.Exec(relayQuery, botID, relay.URL, relay.Read, relay.Write) if err != nil { s.logger.Error("Failed to insert relay", zap.String("url", relay.URL), zap.Error(err)) return nil, fmt.Errorf("failed to insert relay: %w", err) } } // Commit the transaction if err := tx.Commit(); err != nil { s.logger.Error("Failed to commit transaction", zap.Error(err)) return nil, fmt.Errorf("failed to commit transaction: %w", err) } // Load associated data bot.PostConfig = postConfig bot.MediaConfig = mediaConfig bot.Relays = []*models.Relay{ {BotID: botID, URL: "wss://relay.damus.io", Read: true, Write: true}, {BotID: botID, URL: "wss://nostr.mutinywallet.com", Read: true, Write: true}, {BotID: botID, URL: "wss://relay.nostr.band", Read: true, Write: true}, } s.logger.Info("Bot created successfully", zap.Int64("id", botID), zap.String("name", bot.Name), zap.String("pubkey", bot.Pubkey)) return bot, nil } // UpdateBot updates an existing bot func (s *BotService) UpdateBot(bot *models.Bot) (*models.Bot, error) { // Check if the bot exists and belongs to the owner _, err := s.GetBotByID(bot.ID, bot.OwnerPubkey) if err != nil { return nil, fmt.Errorf("bot not found or not owned by user: %w", err) } // We don't update the pubkey or encrypted_privkey query := ` UPDATE bots SET name = ?, display_name = ?, bio = ?, nip05 = ?, zap_address = ?, profile_picture = ?, banner = ? WHERE id = ? AND owner_pubkey = ? ` _, err = s.db.Exec( query, bot.Name, bot.DisplayName, bot.Bio, bot.Nip05, bot.ZapAddress, bot.ProfilePicture, bot.Banner, bot.ID, bot.OwnerPubkey, ) if err != nil { return nil, fmt.Errorf("failed to update bot: %w", err) } // Get the updated bot updatedBot, err := s.GetBotByID(bot.ID, bot.OwnerPubkey) if err != nil { return nil, fmt.Errorf("failed to retrieve updated bot: %w", err) } return updatedBot, nil } // DeleteBot deletes a bot func (s *BotService) DeleteBot(botID int64, ownerPubkey string) error { // Check if the bot exists and belongs to the owner _, err := s.GetBotByID(botID, ownerPubkey) if err != nil { return fmt.Errorf("bot not found or not owned by user: %w", err) } // Start a transaction tx, err := s.db.Beginx() if err != nil { return fmt.Errorf("failed to start transaction: %w", err) } defer tx.Rollback() // Delete the bot (cascade will handle related tables) query := `DELETE FROM bots WHERE id = ? AND owner_pubkey = ?` _, err = tx.Exec(query, botID, ownerPubkey) if err != nil { return fmt.Errorf("failed to delete bot: %w", err) } // Commit the transaction if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil } // UpdateBotConfig updates a bot's configuration func (s *BotService) UpdateBotConfig( botID int64, ownerPubkey string, postConfig *models.PostConfig, mediaConfig *models.MediaConfig, ) error { // Check if the bot exists and belongs to the owner _, err := s.GetBotByID(botID, ownerPubkey) if err != nil { return fmt.Errorf("bot not found or not owned by user: %w", err) } // Start a transaction tx, err := s.db.Beginx() if err != nil { return fmt.Errorf("failed to start transaction: %w", err) } defer tx.Rollback() // Update post config if provided if postConfig != nil { query := ` UPDATE post_config SET hashtags = ?, interval_minutes = ?, post_template = ?, enabled = ? WHERE bot_id = ? ` _, err = tx.Exec( query, postConfig.Hashtags, postConfig.IntervalMinutes, postConfig.PostTemplate, postConfig.Enabled, botID, ) if err != nil { return fmt.Errorf("failed to update post config: %w", err) } } // Update media config if provided if mediaConfig != nil { query := ` UPDATE media_config SET primary_service = ?, fallback_service = ?, nip94_server_url = ?, blossom_server_url = ? WHERE bot_id = ? ` _, err = tx.Exec( query, mediaConfig.PrimaryService, mediaConfig.FallbackService, mediaConfig.Nip94ServerURL, mediaConfig.BlossomServerURL, botID, ) if err != nil { return fmt.Errorf("failed to update media config: %w", err) } } // Commit the transaction if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil } // GetBotRelays gets the relays for a bot func (s *BotService) GetBotRelays(botID int64, ownerPubkey string) ([]*models.Relay, error) { // Check if the bot exists and belongs to the owner _, err := s.GetBotByID(botID, ownerPubkey) if err != nil { return nil, fmt.Errorf("bot not found or not owned by user: %w", err) } // Get the relays query := ` SELECT id, bot_id, url, read, write FROM relays WHERE bot_id = ? ORDER BY id ` var relays []*models.Relay err = s.db.Select(&relays, query, botID) if err != nil { return nil, fmt.Errorf("failed to get relays: %w", err) } return relays, nil } // UpdateBotRelays updates the relays for a bot func (s *BotService) UpdateBotRelays(botID int64, ownerPubkey string, relays []*models.Relay) error { // Check if the bot exists and belongs to the owner _, err := s.GetBotByID(botID, ownerPubkey) if err != nil { return fmt.Errorf("bot not found or not owned by user: %w", err) } // Start a transaction tx, err := s.db.Beginx() if err != nil { return fmt.Errorf("failed to start transaction: %w", err) } defer tx.Rollback() // Delete existing relays _, err = tx.Exec("DELETE FROM relays WHERE bot_id = ?", botID) if err != nil { return fmt.Errorf("failed to delete existing relays: %w", err) } // Insert new relays for _, relay := range relays { _, err = tx.Exec( "INSERT INTO relays (bot_id, url, read, write) VALUES (?, ?, ?, ?)", botID, relay.URL, relay.Read, relay.Write, ) if err != nil { return fmt.Errorf("failed to insert relay: %w", err) } } // Commit the transaction if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil } // PublishBotProfile publishes a bot's profile to Nostr func (s *BotService) PublishBotProfile(botID int64, ownerPubkey string) error { // Get the bot bot, err := s.GetBotByID(botID, ownerPubkey) if err != nil { return fmt.Errorf("bot not found or not owned by user: %w", err) } // Create and sign the metadata event event, err := s.eventMgr.CreateAndSignMetadataEvent(bot) if err != nil { return fmt.Errorf("failed to create metadata event: %w", err) } // Set up relay connections for _, relay := range bot.Relays { if relay.Write { if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil { s.logger.Warn("Failed to add relay", zap.String("url", relay.URL), zap.Error(err)) } } } // Publish the event ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() published, err := s.relayMgr.PublishEvent(ctx, event) if err != nil { return fmt.Errorf("failed to publish profile: %w", err) } s.logger.Info("Published profile to relays", zap.Int64("botID", botID), zap.Strings("relays", published)) return nil } // PublishBotProfileWithEncoding publishes a bot's profile and returns the encoded event func (s *BotService) PublishBotProfileWithEncoding(botID int64, ownerPubkey string) (*models.EventResponse, error) { // Get the bot bot, err := s.GetBotByID(botID, ownerPubkey) if err != nil { return nil, fmt.Errorf("bot not found or not owned by user: %w", err) } // Create and sign the metadata event event, err := s.eventMgr.CreateAndSignMetadataEvent(bot) if err != nil { return nil, fmt.Errorf("failed to create metadata event: %w", err) } // Set up relay connections for _, relay := range bot.Relays { if relay.Write { if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil { s.logger.Warn("Failed to add relay", zap.String("url", relay.URL), zap.Error(err)) } } } // Publish the event with encoding ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() return s.relayMgr.PublishEventWithEncoding(ctx, event) } // Helper function to load related data for a bot func (s *BotService) loadBotRelatedData(bot *models.Bot) error { // Load post config var postConfig models.PostConfig err := s.db.Get(&postConfig, "SELECT * FROM post_config WHERE bot_id = ?", bot.ID) if err != nil { return fmt.Errorf("failed to load post config: %w", err) } bot.PostConfig = &postConfig // Load media config var mediaConfig models.MediaConfig err = s.db.Get(&mediaConfig, "SELECT * FROM media_config WHERE bot_id = ?", bot.ID) if err != nil { return fmt.Errorf("failed to load media config: %w", err) } bot.MediaConfig = &mediaConfig // Load relays var relays []*models.Relay err = s.db.Select(&relays, "SELECT * FROM relays WHERE bot_id = ?", bot.ID) if err != nil { return fmt.Errorf("failed to load relays: %w", err) } bot.Relays = relays return nil } // Helper function to detect key type for logging func detectKeyType(key string) string { if key == "" { return "empty" } if len(key) > 4 { if key[:4] == "nsec" { return "nsec" } else if key[:4] == "npub" { return "npub" } } if len(key) == 64 { return "hex" } return "unknown" }