nostr-poster/internal/api/bot_service.go

620 lines
16 KiB
Go

// 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"
}