added hybrid posting mode and fixed kind20 formating

This commit is contained in:
Enki 2025-05-11 17:45:26 -07:00
parent 9670d04d68
commit 77643c2154
9 changed files with 659 additions and 80 deletions

View File

@ -174,15 +174,105 @@ func main() {
logger, logger,
) )
// Create standard post content function // Create standard post content function - updated to support post modes
postContentFunc := poster.CreatePostContentFunc( postContentFunc := func(pubkey, contentPath, contentType, mediaURL, mediaHash, caption string, hashtags []string) error {
eventManager, // Need to get the bot's post mode from the database
relayManager, var botID int64
mediaPrep, var postMode string
logger,
)
// Create the NIP-19 encoded post content function // Try to get the bot ID and post mode from the database
err := database.Get(&botID, "SELECT id FROM bots WHERE pubkey = ?", pubkey)
if err == nil {
// Get the post mode for this bot
err = database.Get(&postMode, "SELECT post_mode FROM post_config WHERE bot_id = ?", botID)
if err != nil || postMode == "" {
// Default to kind20 if no post mode is set
postMode = "kind20"
logger.Info("No post mode found, using default", zap.String("pubkey", pubkey))
} else {
logger.Info("Retrieved post mode from database",
zap.String("post_mode", postMode),
zap.Int64("bot_id", botID))
}
} else {
logger.Warn("Could not find bot ID for pubkey", zap.String("pubkey", pubkey), zap.Error(err))
postMode = "kind20" // Default
}
// Create a poster instance with the retrieved post mode
p := poster.NewPoster(eventManager, relayManager, mediaPrep, logger)
// Call the appropriate function based on post mode
if postMode == "hybrid" {
logger.Info("Using hybrid post mode", zap.String("pubkey", pubkey))
// For hybrid mode, we need to post both kinds
// First create a kind1 media event
var textEvent *nostr.Event
var textErr error
// Create text event
// Create tags for better compatibility
var tags []nostr.Tag
// Add hashtags to tags
for _, tag := range hashtags {
tags = append(tags, nostr.Tag{"t", tag})
}
// Create imeta tag for metadata
imeta := []string{"imeta", "url " + mediaURL, "m " + contentType}
if mediaHash != "" {
imeta = append(imeta, "x "+mediaHash)
}
tags = append(tags, imeta)
// Add multiple tag types for maximum client compatibility
tags = append(tags, nostr.Tag{"url", mediaURL})
tags = append(tags, nostr.Tag{"image", mediaURL})
tags = append(tags, nostr.Tag{"media", mediaURL})
// Create content with URL directly embedded for better client compatibility
content := ""
if caption != "" {
content = caption + "\n\n"
}
// Add the media URL directly in the content
content += mediaURL + "\n\n"
// Add hashtags at the end if any
if len(hashtags) > 0 {
hashtagArr := make([]string, len(hashtags))
for i, tag := range hashtags {
hashtagArr[i] = "#" + tag
}
content += strings.Join(hashtagArr, " ")
}
// Create text event with embedded URL and better tagging
textEvent, textErr = eventManager.CreateAndSignTextNoteEvent(
pubkey,
content,
tags,
)
if textErr == nil {
// Publish the kind1 event
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
relayManager.PublishEvent(ctx, textEvent)
logger.Info("Published kind1 text event in hybrid mode", zap.String("event_id", textEvent.ID))
} else {
logger.Error("Failed to create kind1 event in hybrid mode", zap.Error(textErr))
}
}
// Always call PostContent (will use kind20 for hybrid mode)
return p.PostContent(pubkey, contentPath, contentType, mediaURL, mediaHash, caption, hashtags)
}
// Create the NIP-19 encoded post content function - updated to share code with the regular function
postContentEncodedFunc := func( postContentEncodedFunc := func(
pubkey string, pubkey string,
contentPath string, contentPath string,
@ -192,6 +282,29 @@ func main() {
caption string, caption string,
hashtags []string, hashtags []string,
) (*models.EventResponse, error) { ) (*models.EventResponse, error) {
// Need to get the bot's post mode from the database
var botID int64
var postMode string
// Try to get the bot ID and post mode from the database
err := database.Get(&botID, "SELECT id FROM bots WHERE pubkey = ?", pubkey)
if err == nil {
// Get the post mode for this bot
err = database.Get(&postMode, "SELECT post_mode FROM post_config WHERE bot_id = ?", botID)
if err != nil || postMode == "" {
// Default to kind20 if no post mode is set
postMode = "kind20"
logger.Info("No post mode found, using default for encoded function", zap.String("pubkey", pubkey))
} else {
logger.Info("Retrieved post mode from database for encoded function",
zap.String("post_mode", postMode),
zap.Int64("bot_id", botID))
}
} else {
logger.Warn("Could not find bot ID for pubkey in encoded function", zap.String("pubkey", pubkey), zap.Error(err))
postMode = "kind20" // Default
}
// Create a context // Create a context
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
@ -199,14 +312,14 @@ func main() {
// Determine the type of content // Determine the type of content
isImage := strings.HasPrefix(contentType, "image/") isImage := strings.HasPrefix(contentType, "image/")
isVideo := strings.HasPrefix(contentType, "video/") isVideo := strings.HasPrefix(contentType, "video/")
// Create alt text if not provided // Create alt text if not provided
altText := caption altText := caption
if altText == "" { if altText == "" {
// Use the filename without extension as a fallback // Use the filename without extension as a fallback
altText = strings.TrimSuffix(filepath.Base(contentPath), filepath.Ext(contentPath)) altText = strings.TrimSuffix(filepath.Base(contentPath), filepath.Ext(contentPath))
} }
// Extract media dimensions if it's an image // Extract media dimensions if it's an image
if isImage { if isImage {
dims, err := mediaPrep.GetMediaDimensions(contentPath) dims, err := mediaPrep.GetMediaDimensions(contentPath)
@ -221,11 +334,11 @@ func main() {
} }
} }
} }
// Create an appropriate event // Create an appropriate event
var event *nostr.Event var event *nostr.Event
var err error var eventErr error
if isImage { if isImage {
// Create hashtag string for post content // Create hashtag string for post content
var hashtagStr string var hashtagStr string
@ -236,22 +349,118 @@ func main() {
} }
hashtagStr = "\n\n" + strings.Join(hashtagArr, " ") hashtagStr = "\n\n" + strings.Join(hashtagArr, " ")
} }
content := caption + hashtagStr // Only include hashtags in content, not caption
content := hashtagStr
// Log mediaURL for debugging
logger.Debug("Creating picture event with media URL", zap.String("mediaURL", mediaURL)) // Log mediaURL and postMode for debugging
logger.Debug("Creating picture event with media URL and post mode",
// Extract a title from the caption or use the filename zap.String("mediaURL", mediaURL),
title := caption zap.String("postMode", postMode))
if title == "" {
title = strings.TrimSuffix(filepath.Base(contentPath), filepath.Ext(contentPath)) // For hybrid mode, first create and post a kind1 text note
if postMode == "hybrid" {
// Create media tags for kind 1
var tags []nostr.Tag
// Add hashtags to tags
for _, tag := range hashtags {
tags = append(tags, nostr.Tag{"t", tag})
}
// Create imeta tag
imeta := []string{"imeta", "url " + mediaURL, "m " + contentType}
if mediaHash != "" {
imeta = append(imeta, "x "+mediaHash)
}
if altText != "" {
imeta = append(imeta, "alt "+altText)
}
tags = append(tags, imeta)
// Also add a direct URL tag for better client compatibility
tags = append(tags, nostr.Tag{"url", mediaURL})
// Log tags for debugging
logger.Info("Creating kind1 event with media tags",
zap.Any("tags", tags),
zap.String("mediaURL", mediaURL))
// Create and post a kind1 text note with media attachment
// Create content with the image URL directly in the content for better compatibility
content := ""
if caption != "" {
content = caption + "\n\n"
}
// Add the image URL in the content
content += mediaURL + "\n\n"
// Add hashtags at the end
content += hashtagStr
textEvent, textErr := eventManager.CreateAndSignTextNoteEvent(
pubkey,
content,
tags,
)
if textErr == nil {
ctx1, cancel1 := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel1()
// Publish the kind1 event
relayManager.PublishEvent(ctx1, textEvent)
logger.Info("Published kind1 text event in hybrid mode (encoded function)",
zap.String("event_id", textEvent.ID))
}
} else if postMode == "kind1" {
// If mode is kind1 only, use a kind1 event with media attachment
var tags []nostr.Tag
// Add hashtags to tags
for _, tag := range hashtags {
tags = append(tags, nostr.Tag{"t", tag})
}
// Create media tags for kind 1
imeta := []string{"imeta", "url " + mediaURL, "m " + contentType}
if mediaHash != "" {
imeta = append(imeta, "x "+mediaHash)
}
if altText != "" {
imeta = append(imeta, "alt "+altText)
}
tags = append(tags, imeta)
// Create and return a kind1 text note event
// Create content with the image URL directly in the content for better compatibility
content := ""
if caption != "" {
content = caption + "\n\n"
}
// Add the image URL in the content
content += mediaURL + "\n\n"
// Add hashtags at the end
content += hashtagStr
event, eventErr = eventManager.CreateAndSignTextNoteEvent(
pubkey,
content,
tags,
)
// Skip kind20 creation for kind1 mode
return relayManager.PublishEventWithEncoding(ctx, event)
} }
event, err = eventManager.CreateAndSignPictureEvent( // Always create kind20 for "kind20" mode or "hybrid" mode
// For picture events, use an empty title to avoid filename issues
event, eventErr = eventManager.CreateAndSignPictureEvent(
pubkey, pubkey,
title, // Title parameter "", // Empty title instead of using caption
content, // Description parameter content, // Description with hashtags only
mediaURL, mediaURL,
contentType, contentType,
mediaHash, mediaHash,
@ -263,7 +472,7 @@ func main() {
isShortVideo := false // Just a placeholder, would need logic to determine isShortVideo := false // Just a placeholder, would need logic to determine
// Create the video event // Create the video event
event, err = eventManager.CreateAndSignVideoEvent( event, eventErr = eventManager.CreateAndSignVideoEvent(
pubkey, pubkey,
caption, // Title caption, // Title
caption, // Description caption, // Description
@ -278,7 +487,7 @@ func main() {
) )
} else { } else {
// For other types, use a regular text note with attachment // For other types, use a regular text note with attachment
event, err = eventManager.CreateAndSignMediaEvent( event, eventErr = eventManager.CreateAndSignMediaEvent(
pubkey, pubkey,
caption, caption,
mediaURL, mediaURL,
@ -289,8 +498,8 @@ func main() {
) )
} }
if err != nil { if eventErr != nil {
return nil, fmt.Errorf("failed to create event: %w", err) return nil, fmt.Errorf("failed to create event: %w", eventErr)
} }
// Publish the event with NIP-19 encoding // Publish the event with NIP-19 encoding

View File

@ -412,6 +412,7 @@ func (s *BotService) UpdateBotConfig(
hashtags = ?, hashtags = ?,
interval_minutes = ?, interval_minutes = ?,
post_template = ?, post_template = ?,
post_mode = ?,
enabled = ? enabled = ?
WHERE bot_id = ? WHERE bot_id = ?
` `
@ -419,7 +420,7 @@ func (s *BotService) UpdateBotConfig(
_, err = tx.Exec( _, err = tx.Exec(
query, query,
postConfig.Hashtags, postConfig.IntervalMinutes, postConfig.Hashtags, postConfig.IntervalMinutes,
postConfig.PostTemplate, postConfig.Enabled, postConfig.PostTemplate, postConfig.PostMode, postConfig.Enabled,
botID, botID,
) )
if err != nil { if err != nil {

View File

@ -2,8 +2,10 @@
package api package api
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -384,16 +386,31 @@ func (a *API) updateBotConfig(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
return return
} }
// DEBUG: Log the raw request body
rawData, _ := c.GetRawData()
a.logger.Info("Raw config update request",
zap.String("raw_body", string(rawData)),
zap.Int64("botID", botID))
// Need to restore the body for binding
c.Request.Body = io.NopCloser(bytes.NewBuffer(rawData))
var config struct { var config struct {
PostConfig *models.PostConfig `json:"post_config"` PostConfig *models.PostConfig `json:"post_config"`
MediaConfig *models.MediaConfig `json:"media_config"` MediaConfig *models.MediaConfig `json:"media_config"`
} }
if err := c.ShouldBindJSON(&config); err != nil { if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config data"}) a.logger.Error("Failed to bind JSON", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config data: " + err.Error()})
return return
} }
// Log the parsed configuration
a.logger.Info("Parsed bot config update",
zap.Int64("botID", botID),
zap.Any("post_config", config.PostConfig))
// Set the bot ID // Set the bot ID
if config.PostConfig != nil { if config.PostConfig != nil {
@ -403,6 +420,15 @@ func (a *API) updateBotConfig(c *gin.Context) {
config.MediaConfig.BotID = botID config.MediaConfig.BotID = botID
} }
// Update the configs - log before update
if config.PostConfig != nil {
a.logger.Info("Updating post config in database",
zap.Int64("botID", botID),
zap.String("post_mode", config.PostConfig.PostMode),
zap.Int("interval", config.PostConfig.IntervalMinutes),
zap.String("hashtags", config.PostConfig.Hashtags))
}
// Update the configs // Update the configs
err = a.botService.UpdateBotConfig(botID, pubkey, config.PostConfig, config.MediaConfig) err = a.botService.UpdateBotConfig(botID, pubkey, config.PostConfig, config.MediaConfig)
if err != nil { if err != nil {
@ -410,6 +436,15 @@ func (a *API) updateBotConfig(c *gin.Context) {
a.logger.Error("Failed to update bot config", zap.Error(err)) a.logger.Error("Failed to update bot config", zap.Error(err))
return return
} }
// Log after update - query the database to confirm update
var dbPostMode string
dbErr := a.botService.db.Get(&dbPostMode, "SELECT post_mode FROM post_config WHERE bot_id = ?", botID)
if dbErr != nil {
a.logger.Warn("Failed to verify post_mode update in database", zap.Error(dbErr))
} else {
a.logger.Info("Post mode after database update", zap.String("db_post_mode", dbPostMode))
}
// Get the updated bot // Get the updated bot
bot, err := a.botService.GetBotByID(botID, pubkey) bot, err := a.botService.GetBotByID(botID, pubkey)
@ -923,6 +958,7 @@ func (a *API) createManualPost(c *gin.Context) {
Title string `json:"title"` Title string `json:"title"`
Alt string `json:"alt"` Alt string `json:"alt"`
Hashtags []string `json:"hashtags"` Hashtags []string `json:"hashtags"`
PostMode string `json:"post_mode"` // Added post_mode parameter for hybrid support
} }
if err := c.BindJSON(&req); err != nil { if err := c.BindJSON(&req); err != nil {
@ -952,8 +988,13 @@ func (a *API) createManualPost(c *gin.Context) {
// Create the appropriate event // Create the appropriate event
var event *nostr.Event var event *nostr.Event
var textEvent *nostr.Event // For hybrid mode
var eventErr error var eventErr error
var hybridMode bool
// Check if we're using hybrid mode
hybridMode = (req.PostMode == "hybrid")
switch req.Kind { switch req.Kind {
case 1: case 1:
// Standard text note // Standard text note
@ -961,10 +1002,53 @@ func (a *API) createManualPost(c *gin.Context) {
for _, tag := range req.Hashtags { for _, tag := range req.Hashtags {
tags = append(tags, nostr.Tag{"t", tag}) tags = append(tags, nostr.Tag{"t", tag})
} }
event, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags) event, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags)
case 20: case 20:
// Picture post // Picture post
if hybridMode && strings.HasPrefix(mediaType, "image/") {
// In hybrid mode, create both kind 1 and kind 20 events
// First create kind 1 event
var tags []nostr.Tag
// Add hashtags to tags
for _, tag := range req.Hashtags {
tags = append(tags, nostr.Tag{"t", tag})
}
// Create media tags for kind 1
if mediaURL != "" {
imeta := []string{"imeta", "url " + mediaURL, "m " + mediaType}
if mediaHash != "" {
imeta = append(imeta, "x "+mediaHash)
}
if req.Alt != "" {
imeta = append(imeta, "alt "+req.Alt)
}
tags = append(tags, imeta)
}
textEvent, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags)
if eventErr != nil {
a.logger.Error("Failed to create kind 1 event in hybrid mode", zap.Error(eventErr))
// Continue with kind 20 even if kind 1 fails
} else {
// Publish kind 1 event separately
ctxText, cancelText := context.WithTimeout(context.Background(), 30*time.Second)
defer cancelText()
textEncoded, textErr := a.botService.relayMgr.PublishEventWithEncoding(ctxText, textEvent)
if textErr != nil {
a.logger.Error("Failed to publish kind 1 event in hybrid mode", zap.Error(textErr))
} else {
a.logger.Info("Published kind 1 event in hybrid mode",
zap.String("event_id", textEncoded.ID))
}
}
}
// Then create kind 20 event (for both normal and hybrid modes)
event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent( event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent(
bot.Pubkey, bot.Pubkey,
req.Title, req.Title,
@ -975,6 +1059,64 @@ func (a *API) createManualPost(c *gin.Context) {
req.Alt, req.Alt,
req.Hashtags, req.Hashtags,
) )
case 0: // Special case for hybrid mode selection
if !hybridMode {
c.JSON(http.StatusBadRequest, gin.H{"error": "Kind 0 is only valid with hybrid mode"})
return
}
// Create both a kind 1 and kind 20 event
var tags []nostr.Tag
for _, tag := range req.Hashtags {
tags = append(tags, nostr.Tag{"t", tag})
}
// Add media tags for kind 1
if mediaURL != "" {
imeta := []string{"imeta", "url " + mediaURL, "m " + mediaType}
if mediaHash != "" {
imeta = append(imeta, "x "+mediaHash)
}
if req.Alt != "" {
imeta = append(imeta, "alt "+req.Alt)
}
tags = append(tags, imeta)
}
// Create and publish kind 1 event
textEvent, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags)
if eventErr == nil {
ctxText, cancelText := context.WithTimeout(context.Background(), 30*time.Second)
defer cancelText()
textEncoded, textErr := a.botService.relayMgr.PublishEventWithEncoding(ctxText, textEvent)
if textErr != nil {
a.logger.Error("Failed to publish kind 1 event in hybrid mode", zap.Error(textErr))
} else {
a.logger.Info("Published kind 1 event in hybrid mode",
zap.String("event_id", textEncoded.ID))
}
}
// If it's an image, create kind 20 event too
if strings.HasPrefix(mediaType, "image/") {
event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent(
bot.Pubkey,
req.Title,
req.Content,
mediaURL,
mediaType,
mediaHash,
req.Alt,
req.Hashtags,
)
} else {
// For non-images, just use the kind 1 event we already created
c.JSON(http.StatusOK, gin.H{
"message": "Kind 1 post published successfully in hybrid mode",
})
return
}
default: default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported post kind"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported post kind"})
return return
@ -1018,9 +1160,14 @@ func (a *API) createManualPost(c *gin.Context) {
return return
} }
// Return the encoded event response // Return the encoded event response with indication of hybrid mode if used
message := "Post published successfully"
if hybridMode {
message = "Post published successfully (hybrid mode - both kind:1 and kind:20)"
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "Post published successfully", "message": message,
"event": encodedEvent, "event": encodedEvent,
}) })
} }

View File

@ -85,12 +85,31 @@ func (db *DB) Initialize() error {
hashtags TEXT, hashtags TEXT,
interval_minutes INTEGER NOT NULL DEFAULT 60, interval_minutes INTEGER NOT NULL DEFAULT 60,
post_template TEXT, post_template TEXT,
post_mode TEXT DEFAULT 'kind20',
enabled BOOLEAN NOT NULL DEFAULT 0, enabled BOOLEAN NOT NULL DEFAULT 0,
FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE
)`) )`)
if err != nil { if err != nil {
return fmt.Errorf("failed to create post_config table: %w", err) return fmt.Errorf("failed to create post_config table: %w", err)
} }
// Check if post_mode column exists in post_config table, add it if it doesn't
var postModeExists int
err = db.Get(&postModeExists, `
SELECT COUNT(*) FROM pragma_table_info('post_config') WHERE name = 'post_mode'
`)
if err != nil {
return fmt.Errorf("failed to check if post_mode column exists: %w", err)
}
if postModeExists == 0 {
// Add the post_mode column
_, err = db.Exec(`ALTER TABLE post_config ADD COLUMN post_mode TEXT DEFAULT 'kind20'`)
if err != nil {
return fmt.Errorf("failed to add post_mode column: %w", err)
}
}
// Create media_config table // Create media_config table
_, err = db.Exec(` _, err = db.Exec(`

View File

@ -37,6 +37,7 @@ type PostConfig struct {
Hashtags string `db:"hashtags" json:"hashtags"` // JSON array stored as string Hashtags string `db:"hashtags" json:"hashtags"` // JSON array stored as string
IntervalMinutes int `db:"interval_minutes" json:"interval_minutes"` IntervalMinutes int `db:"interval_minutes" json:"interval_minutes"`
PostTemplate string `db:"post_template" json:"post_template"` PostTemplate string `db:"post_template" json:"post_template"`
PostMode string `db:"post_mode" json:"post_mode"` // "kind1", "kind20", or "hybrid"
Enabled bool `db:"enabled" json:"enabled"` Enabled bool `db:"enabled" json:"enabled"`
} }

View File

@ -10,6 +10,7 @@ import (
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"git.sovbit.dev/Enki/nostr-poster/internal/media/prepare" "git.sovbit.dev/Enki/nostr-poster/internal/media/prepare"
"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/events"
"git.sovbit.dev/Enki/nostr-poster/internal/nostr/relay" "git.sovbit.dev/Enki/nostr-poster/internal/nostr/relay"
"go.uber.org/zap" "go.uber.org/zap"
@ -58,17 +59,23 @@ func (p *Poster) PostContent(
caption string, caption string,
hashtags []string, hashtags []string,
) error { ) error {
// Default to kind20 if not found
postMode := "kind20"
// Logger post mode for easier debugging
p.logger.Info("Using post mode in PostContent", zap.String("post_mode", postMode))
// Determine the type of content // Determine the type of content
isImage := strings.HasPrefix(contentType, "image/") isImage := strings.HasPrefix(contentType, "image/")
isVideo := strings.HasPrefix(contentType, "video/") isVideo := strings.HasPrefix(contentType, "video/")
// Create alt text if not provided (initialize it here) // Create alt text if not provided (initialize it here)
altText := caption altText := caption
if altText == "" { if altText == "" {
// Use the filename without extension as a fallback // Use the filename without extension as a fallback
altText = strings.TrimSuffix(filepath.Base(contentPath), filepath.Ext(contentPath)) altText = strings.TrimSuffix(filepath.Base(contentPath), filepath.Ext(contentPath))
} }
// Extract media dimensions if it's an image // Extract media dimensions if it's an image
if isImage { if isImage {
dims, err := p.mediaPrep.GetMediaDimensions(contentPath) dims, err := p.mediaPrep.GetMediaDimensions(contentPath)
@ -78,55 +85,149 @@ func (p *Poster) PostContent(
zap.Error(err)) zap.Error(err))
} else { } else {
// Log the dimensions // Log the dimensions
p.logger.Debug("Image dimensions", p.logger.Debug("Image dimensions",
zap.Int("width", dims.Width), zap.Int("width", dims.Width),
zap.Int("height", dims.Height)) zap.Int("height", dims.Height))
// Add dimensions to alt text if available // Add dimensions to alt text if available
if altText != "" { if altText != "" {
altText = fmt.Sprintf("%s [%dx%d]", altText, dims.Width, dims.Height) altText = fmt.Sprintf("%s [%dx%d]", altText, dims.Width, dims.Height)
} }
} }
} }
// Create a context with timeout // Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
var event *nostr.Event var event *nostr.Event
var err error var err error
// Create hashtag string for post content
var hashtagStr string
if len(hashtags) > 0 {
hashtagArr := make([]string, len(hashtags))
for i, tag := range hashtags {
hashtagArr[i] = "#" + tag
}
hashtagStr = "\n\n" + strings.Join(hashtagArr, " ")
}
// Don't use filename as content, just use hashtags
content := hashtagStr
// If postMode is empty, default to kind20
if postMode == "" {
postMode = "kind20"
}
// Determine the appropriate event kind and create the event // Determine the appropriate event kind and create the event
if isImage { if isImage {
// Log the media URL for debugging // Log the media URL for debugging
p.logger.Info("Creating image post with media URL", p.logger.Info("Creating image post with media URL",
zap.String("mediaURL", mediaURL), zap.String("mediaURL", mediaURL),
zap.String("mediaHash", mediaHash), zap.String("mediaHash", mediaHash),
zap.String("contentType", contentType)) zap.String("contentType", contentType),
zap.String("postMode", postMode))
// Create hashtag string for post content
var hashtagStr string // Handle different post modes
if len(hashtags) > 0 { if postMode == "hybrid" {
hashtagArr := make([]string, len(hashtags)) // In hybrid mode, we post both a kind1 and kind20 event
for i, tag := range hashtags { // Create a kind1 text post with media attachment and both imeta and URL tags for maximum compatibility
hashtagArr[i] = "#" + tag var tags []nostr.Tag
// Add hashtags to tags
for _, tag := range hashtags {
tags = append(tags, nostr.Tag{"t", tag})
} }
hashtagStr = "\n\n" + strings.Join(hashtagArr, " ")
// Create imeta tag for metadata
imeta := []string{"imeta", "url " + mediaURL, "m " + contentType}
if mediaHash != "" {
imeta = append(imeta, "x "+mediaHash)
}
if altText != "" {
imeta = append(imeta, "alt "+altText)
}
tags = append(tags, imeta)
// Add multiple tag types for maximum client compatibility
tags = append(tags, nostr.Tag{"url", mediaURL})
tags = append(tags, nostr.Tag{"image", mediaURL})
tags = append(tags, nostr.Tag{"media", mediaURL})
tags = append(tags, nostr.Tag{"picture", mediaURL})
// Log the tags we're using
p.logger.Debug("Creating kind1 event with media tags",
zap.Any("tags", tags),
zap.String("mediaURL", mediaURL))
// Create content with URL directly embedded for better client compatibility
content := ""
if caption != "" {
content = caption + "\n\n"
}
// Add the media URL directly in the content
content += mediaURL + "\n\n"
// Add hashtags at the end
content += hashtagStr
// Use CreateAndSignTextNoteEvent instead of Media event for more control over tags
textEvent, textErr := p.eventMgr.CreateAndSignTextNoteEvent(
pubkey,
content,
tags,
)
if textErr == nil {
// Publish the kind1 event
ctx1, cancel1 := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel1()
p.relayMgr.PublishEvent(ctx1, textEvent)
p.logger.Info("Published kind1 text event in hybrid mode",
zap.String("event_id", textEvent.ID))
} else {
p.logger.Error("Failed to create kind1 event in hybrid mode",
zap.Error(textErr))
}
// Then continue with the kind20 event
event, err = p.eventMgr.CreateAndSignPictureEvent(
pubkey,
"", // Empty title instead of using caption
content, // Description
mediaURL,
contentType,
mediaHash,
altText,
hashtags,
)
} else if postMode == "kind1" {
// Use kind 1 (text note) with media attachment
event, err = p.eventMgr.CreateAndSignMediaEvent(
pubkey,
caption,
mediaURL,
contentType,
mediaHash,
altText,
hashtags,
)
} else {
// Default: Use kind 20 (picture post) for better media compatibility
event, err = p.eventMgr.CreateAndSignPictureEvent(
pubkey,
"", // Empty title instead of using caption
content, // Description
mediaURL,
contentType,
mediaHash,
altText,
hashtags,
)
} }
content := caption + hashtagStr
// Use kind 20 (picture post) for better media compatibility
event, err = p.eventMgr.CreateAndSignPictureEvent(
pubkey,
caption, // Title
content, // Description
mediaURL,
contentType,
mediaHash,
altText,
hashtags,
)
} else if isVideo { } else if isVideo {
// For videos, determine if it's a short video // For videos, determine if it's a short video
isShortVideo := false // Just a placeholder, would need logic to determine isShortVideo := false // Just a placeholder, would need logic to determine
@ -176,6 +277,7 @@ func (p *Poster) PostContent(
} }
// CreatePostContentFunc creates a function for posting content // CreatePostContentFunc creates a function for posting content
// This returns the OLD function signature for backward compatibility
func CreatePostContentFunc( func CreatePostContentFunc(
eventMgr *events.EventManager, eventMgr *events.EventManager,
relayMgr *relay.Manager, relayMgr *relay.Manager,
@ -183,8 +285,54 @@ func CreatePostContentFunc(
logger *zap.Logger, logger *zap.Logger,
) func(string, string, string, string, string, string, []string) error { ) func(string, string, string, string, string, string, []string) error {
poster := NewPoster(eventMgr, relayMgr, mediaPrep, logger) poster := NewPoster(eventMgr, relayMgr, mediaPrep, logger)
return func(pubkey, contentPath, contentType, mediaURL, mediaHash, caption string, hashtags []string) error { return func(pubkey, contentPath, contentType, mediaURL, mediaHash, caption string, hashtags []string) error {
return poster.PostContent(pubkey, contentPath, contentType, mediaURL, mediaHash, caption, hashtags) return poster.PostContent(pubkey, contentPath, contentType, mediaURL, mediaHash, caption, hashtags)
} }
}
// CreatePostContentEncodedFunc creates a function for posting content with NIP-19 encoding
// This returns the OLD function signature for backward compatibility
func CreatePostContentEncodedFunc(
eventMgr *events.EventManager,
relayMgr *relay.Manager,
mediaPrep *prepare.Manager,
logger *zap.Logger,
) func(string, string, string, string, string, string, []string) (*models.EventResponse, error) {
poster := NewPoster(eventMgr, relayMgr, mediaPrep, logger)
return func(pubkey, contentPath, contentType, mediaURL, mediaHash, caption string, hashtags []string) (*models.EventResponse, error) {
return poster.PostContentWithEncoding(pubkey, contentPath, contentType, mediaURL, mediaHash, caption, hashtags)
}
}
// PostContentWithEncoding posts content to Nostr and returns NIP-19 encoded event references
func (p *Poster) PostContentWithEncoding(
pubkey string,
contentPath string,
contentType string,
mediaURL string,
mediaHash string,
caption string,
hashtags []string,
) (*models.EventResponse, error) {
// Default to kind20 if not found
postMode := "kind20"
// Log post mode for easier debugging
p.logger.Info("Using post mode in PostContentWithEncoding", zap.String("post_mode", postMode))
// Post content as usual
err := p.PostContent(pubkey, contentPath, contentType, mediaURL, mediaHash, caption, hashtags)
if err != nil {
return nil, err
}
// For simplicity, we'll just return a basic response
// In a real implementation, you would capture the event ID from PostContent
return &models.EventResponse{
ID: "event_id",
Note: "note1...",
Nevent: "nevent1...",
}, nil
} }

View File

@ -5,7 +5,6 @@ import (
"context" "context"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"encoding/json" "encoding/json"
@ -120,7 +119,8 @@ func (s *Scheduler) Start() error {
// Load all bots with enabled post configs // Load all bots with enabled post configs
query := ` query := `
SELECT b.id, b.pubkey, b.name, b.display_name, pc.enabled, pc.interval_minutes, pc.hashtags, SELECT b.id, b.pubkey, b.name, b.display_name, pc.enabled, pc.interval_minutes, pc.hashtags,
mc.primary_service, mc.fallback_service, mc.nip94_server_url, mc.blossom_server_url mc.primary_service, mc.fallback_service, mc.nip94_server_url, mc.blossom_server_url,
pc.post_mode
FROM bots b FROM bots b
JOIN post_config pc ON b.id = pc.bot_id JOIN post_config pc ON b.id = pc.bot_id
JOIN media_config mc ON b.id = mc.bot_id JOIN media_config mc ON b.id = mc.bot_id
@ -144,6 +144,7 @@ func (s *Scheduler) Start() error {
&postConfig.Enabled, &postConfig.IntervalMinutes, &postConfig.Hashtags, &postConfig.Enabled, &postConfig.IntervalMinutes, &postConfig.Hashtags,
&mediaConfig.PrimaryService, &mediaConfig.FallbackService, &mediaConfig.PrimaryService, &mediaConfig.FallbackService,
&mediaConfig.Nip94ServerURL, &mediaConfig.BlossomServerURL, &mediaConfig.Nip94ServerURL, &mediaConfig.BlossomServerURL,
&postConfig.PostMode,
) )
if err != nil { if err != nil {
s.logger.Error("Failed to scan bot row", zap.Error(err)) s.logger.Error("Failed to scan bot row", zap.Error(err))
@ -284,9 +285,8 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
} }
} }
// Get the base filename without extension // Use empty caption
filename := filepath.Base(contentPath) caption := ""
caption := strings.TrimSuffix(filename, filepath.Ext(filename))
// Get the owner pubkey of the bot // Get the owner pubkey of the bot
var ownerPubkey string var ownerPubkey string
@ -322,11 +322,40 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
zap.String("bot_name", bot.Name)) zap.String("bot_name", bot.Name))
} }
// Rest of the function remains the same // Get post mode from bot config or default to kind20
postMode := "kind20"
if bot.PostConfig != nil && bot.PostConfig.PostMode != "" {
postMode = bot.PostConfig.PostMode
}
s.logger.Info("Using post mode",
zap.String("post_mode", postMode),
zap.Int64("bot_id", bot.ID))
// Double check post mode directly from the database
var dbPostMode string
dbErr := s.db.Get(&dbPostMode, "SELECT post_mode FROM post_config WHERE bot_id = ?", bot.ID)
if dbErr != nil {
s.logger.Warn("Failed to get post_mode from database directly",
zap.Error(dbErr),
zap.Int64("bot_id", bot.ID))
} else {
s.logger.Info("Post mode from direct DB query",
zap.String("db_post_mode", dbPostMode),
zap.Int64("bot_id", bot.ID))
}
// Additional logging for content details
s.logger.Info("Content details",
zap.String("content_path", contentPath),
zap.String("content_type", contentType),
zap.String("media_url", mediaURL),
zap.String("media_hash", mediaHash))
var postErr error var postErr error
if s.postContentEncoded != nil { if s.postContentEncoded != nil {
// Use the NIP-19 encoded version // Use the NIP-19 encoded version
// Call the function with the original signature
encodedEvent, err := s.postContentEncoded( encodedEvent, err := s.postContentEncoded(
bot.Pubkey, bot.Pubkey,
contentPath, contentPath,
@ -356,6 +385,7 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
} }
} else { } else {
// Fall back to original function // Fall back to original function
// Call the function with the original signature
postErr = s.postContent( postErr = s.postContent(
bot.Pubkey, bot.Pubkey,
contentPath, contentPath,

View File

@ -1286,7 +1286,7 @@ async function checkAuth() {
// If post_config is present // If post_config is present
if (bot.post_config) { if (bot.post_config) {
document.getElementById('botSettingsInterval').value = bot.post_config.interval_minutes || 60; document.getElementById('botSettingsInterval').value = bot.post_config.interval_minutes || 60;
// Parse hashtags // Parse hashtags
try { try {
const hashtags = JSON.parse(bot.post_config.hashtags || '[]'); const hashtags = JSON.parse(bot.post_config.hashtags || '[]');
@ -1295,6 +1295,13 @@ async function checkAuth() {
console.error('Failed to parse hashtags', e); console.error('Failed to parse hashtags', e);
document.getElementById('botSettingsHashtags').value = ''; document.getElementById('botSettingsHashtags').value = '';
} }
// Set post mode if present
const postModeSelect = document.getElementById('botSettingsPostMode');
if (postModeSelect && bot.post_config.post_mode) {
// Default to kind20 if not specified
postModeSelect.value = bot.post_config.post_mode || 'kind20';
}
} }
// Show the modal // Show the modal
@ -1411,10 +1418,18 @@ async function checkAuth() {
const hashtagsStr = document.getElementById('botSettingsHashtags').value.trim(); const hashtagsStr = document.getElementById('botSettingsHashtags').value.trim();
const hashtagsArr = hashtagsStr.length ? hashtagsStr.split(',').map(s => s.trim()) : []; const hashtagsArr = hashtagsStr.length ? hashtagsStr.split(',').map(s => s.trim()) : [];
// Get selected post mode
const postMode = document.getElementById('botSettingsPostMode').value;
// Log the post mode selected for debugging
console.log('Post mode selected:', postMode);
alert('Setting post mode to: ' + postMode);
const configPayload = { const configPayload = {
post_config: { post_config: {
interval_minutes: intervalValue, interval_minutes: intervalValue,
hashtags: JSON.stringify(hashtagsArr) hashtags: JSON.stringify(hashtagsArr),
post_mode: postMode
// We do not override 'enabled' here, so it remains as is // We do not override 'enabled' here, so it remains as is
} }
}; };

View File

@ -508,6 +508,15 @@
<label for="botSettingsHashtags" class="form-label">Hashtags (comma-separated)</label> <label for="botSettingsHashtags" class="form-label">Hashtags (comma-separated)</label>
<input type="text" class="form-control" id="botSettingsHashtags"> <input type="text" class="form-control" id="botSettingsHashtags">
</div> </div>
<div class="mb-3">
<label for="botSettingsPostMode" class="form-label">Post Mode</label>
<select class="form-select" id="botSettingsPostMode">
<option value="kind20">Kind 20 - Picture Posts (NIP-68)</option>
<option value="kind1">Kind 1 - Text Notes with Media (NIP-92)</option>
<option value="hybrid">Hybrid - Both Kind 1 and Kind 20</option>
</select>
<div class="form-text">Choose how this bot will post images to Nostr</div>
</div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">