620 lines
16 KiB
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"
|
|
}
|