Compare commits
	
		
			2 Commits
		
	
	
		
			41ad467618
			...
			8d78eca070
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 8d78eca070 | |||
| 52d3e4a28d | 
							
								
								
									
										22
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,22 @@ | ||||
| # Ignore build output | ||||
| /build/ | ||||
| 
 | ||||
| # Ignore content and archive folders | ||||
| /content/ | ||||
| /archive/ | ||||
| 
 | ||||
| # Ignore config files except the sample | ||||
| /config/* | ||||
| !/config/config.sample.json | ||||
| 
 | ||||
| # Ignore keys.json | ||||
| /keys.json | ||||
| 
 | ||||
| 
 | ||||
| # Ignore go build files | ||||
| *.o | ||||
| *.a | ||||
| *.so | ||||
| 
 | ||||
| # Ignore Go module cache | ||||
| /pkg/ | ||||
							
								
								
									
										81
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @ -3,39 +3,62 @@ | ||||
| An automated content posting bot for Nostr networks. This tool allows you to schedule regular posting of images and videos to Nostr relays, supporting both NIP-94 and Blossom upload services. | ||||
| 
 | ||||
| 
 | ||||
| ### Core Implementation | ||||
| - [x] replace hex format | ||||
| # Nostr Poster | ||||
| 
 | ||||
| ### Essential Settings | ||||
| - [x] Make enable button functional | ||||
| - [ ] Proper nostr Profile: | ||||
|   - [x] Bio  | ||||
|   - [x] NIP-05  | ||||
|   - [ ] Username/display name | ||||
|   - [ ] Zap address | ||||
|   - [ ] PFP/banner upload | ||||
|   - [ ] Posting interval controls | ||||
|   - [ ] Content album selection | ||||
| ## Table of Contents | ||||
| - [Plan for Nostr Poster Improvements](#plan-for-nostr-poster-improvements) | ||||
| - [To-Do List](#to-do-list) | ||||
| 
 | ||||
| ### Content System | ||||
| - [x] Create upload/organization page | ||||
| - [x] Implement manual post interface: | ||||
|   - [x] Text  | ||||
|   - [x] Media upload | ||||
|   - [x] Kind:20 posts | ||||
| ### Priority Order | ||||
| 
 | ||||
| ### Bot Interaction | ||||
| - [ ] Develop basic bot feed: | ||||
|   - [ ] Display Comments | ||||
|   - [ ] Reply | ||||
| 1. [ ] Bot Profile Management: Complete proper profile creation, editing, and publishing | ||||
| 2. [ ] Relay Management: Improve relay selection and management interface | ||||
| 3. [ ] Test Auto-Posting: Ensure the scheduling system works correctly | ||||
| 
 | ||||
| ### Validation | ||||
| - [x] Test NSEC key import | ||||
| - [ ] Test manual posts: | ||||
|   -[x] Text only  | ||||
|   - [x] blossom Media upload | ||||
|   - [ ] NIP-94 uploads | ||||
| - [ ] Verify bot reply works | ||||
| ## To-Do List | ||||
| 
 | ||||
| ### Bot Profile Management Enhancements | ||||
| 
 | ||||
| - [ ] Add upload fields to both the "Create Bot" modal and "Bot Settings" modal | ||||
| - [ ] Create a consistent interface for profile images across both modals | ||||
| - [ ] Implement preview functionality for selected images | ||||
| - [ ] Connect these uploads to the NIP-96/Blossom media servers | ||||
| - [ ] Ensure proper kind 0 event creation with all required metadata fields | ||||
| - [ ] Display confirmation after successful publishing with NIP-19 encoded identifiers | ||||
| - [ ] Add a visual indicator to show when a bot's profile needs publishing (outdated) | ||||
| - [ ] Create new file input fields in both modals with preview capability | ||||
| - [ ] Create a shared function for uploading profile images to media servers | ||||
| - [ ] Update the `CreateAndSignMetadataEvent` function to include all necessary fields for a proper NIP-01 metadata event | ||||
| - [ ] Add an explicit "Publish Profile" button to push the kind 0 event to relays | ||||
| 
 | ||||
| ### Relay Management Improvements | ||||
| 
 | ||||
| - [ ] Create a more intuitive relay management interface | ||||
| - [ ] Add ability to categorize relays (read, write, both) | ||||
| - [ ] Include relay testing functionality | ||||
| - [ ] Add a "recommended relays" quick-add option | ||||
| - [ ] Create a dedicated relay management component in the bot settings modal | ||||
| - [ ] Add quick-add buttons for popular relays | ||||
| - [ ] Include relay status indicators | ||||
| - [ ] Implement relay tests (ping/connection checks) | ||||
| 
 | ||||
| ### Post Scheduling and Auto-Posting Testing | ||||
| 
 | ||||
| - [ ] Add detailed logging for scheduled posts | ||||
| - [ ] Create a manual trigger for testing scheduled posts | ||||
| - [ ] Implement a post history view | ||||
| - [ ] Add a "test now" button that triggers an immediate post attempt | ||||
| - [ ] Create a dedicated tab or view for post history | ||||
| - [ ] Implement proper error logging and status tracking | ||||
| 
 | ||||
| ### Code Changes Required | ||||
| 
 | ||||
| - [ ] Modify `index.html` - Add profile image/banner upload fields | ||||
| - [ ] Modify `main.js` - Add functions for handling profile image uploads | ||||
| - [ ] Create a new component for relay management | ||||
| - [ ] Modify `events.go` - Ensure metadata events are properly formatted | ||||
| - [ ] Modify `bot_service.go` - Enhance profile publishing logic | ||||
| - [ ] Modify `routes.go` - Add endpoints for profile image management | ||||
| 
 | ||||
| 
 | ||||
| ## Features | ||||
|  | ||||
| @ -2,11 +2,15 @@ | ||||
| package main | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"flag" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/nbd-wtf/go-nostr" | ||||
| 	"git.sovbit.dev/Enki/nostr-poster/internal/api" | ||||
| 	"git.sovbit.dev/Enki/nostr-poster/internal/auth" | ||||
| 	"git.sovbit.dev/Enki/nostr-poster/internal/config" | ||||
| @ -15,6 +19,7 @@ import ( | ||||
| 	"git.sovbit.dev/Enki/nostr-poster/internal/media/prepare" | ||||
| 	"git.sovbit.dev/Enki/nostr-poster/internal/media/upload/blossom" | ||||
| 	"git.sovbit.dev/Enki/nostr-poster/internal/media/upload/nip94" | ||||
| 	"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/poster" | ||||
| 	"git.sovbit.dev/Enki/nostr-poster/internal/nostr/relay" | ||||
| @ -146,15 +151,120 @@ func main() { | ||||
| 		logger, | ||||
| 	) | ||||
| 	 | ||||
| 	// Create post content function | ||||
| 	// Create standard post content function | ||||
| 	postContentFunc := poster.CreatePostContentFunc( | ||||
| 		eventManager, | ||||
| 		relayManager, | ||||
| 		mediaPrep, | ||||
| 		logger, | ||||
| 	) | ||||
| 
 | ||||
| 	// Create the NIP-19 encoded post content function | ||||
| 	postContentEncodedFunc := func( | ||||
| 		pubkey string, | ||||
| 		contentPath string, | ||||
| 		contentType string, | ||||
| 		mediaURL string, | ||||
| 		mediaHash string, | ||||
| 		caption string, | ||||
| 		hashtags []string, | ||||
| 	) (*models.EventResponse, error) { | ||||
| 		// Create a context | ||||
| 		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | ||||
| 		defer cancel() | ||||
| 
 | ||||
| 		// Determine the type of content | ||||
| 		isImage := strings.HasPrefix(contentType, "image/") | ||||
| 		isVideo := strings.HasPrefix(contentType, "video/") | ||||
| 		 | ||||
| 		// Create alt text if not provided | ||||
| 		altText := caption | ||||
| 		if altText == "" { | ||||
| 			// Use the filename without extension as a fallback | ||||
| 			altText = strings.TrimSuffix(filepath.Base(contentPath), filepath.Ext(contentPath)) | ||||
| 		} | ||||
| 		 | ||||
| 		// Extract media dimensions if it's an image | ||||
| 		if isImage { | ||||
| 			dims, err := mediaPrep.GetMediaDimensions(contentPath) | ||||
| 			if err != nil { | ||||
| 				logger.Warn("Failed to get image dimensions, continuing anyway", | ||||
| 					zap.String("file", contentPath), | ||||
| 					zap.Error(err)) | ||||
| 			} else { | ||||
| 				// Add dimensions to alt text if available | ||||
| 				if altText != "" { | ||||
| 					altText = fmt.Sprintf("%s [%dx%d]", altText, dims.Width, dims.Height) | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		// Create an appropriate event | ||||
| 		var event *nostr.Event | ||||
| 		var err error | ||||
| 		 | ||||
| 		if isImage { | ||||
| 			// 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, " ") | ||||
| 			} | ||||
| 			 | ||||
| 			content := caption + hashtagStr | ||||
| 			 | ||||
| 			event, err = eventManager.CreateAndSignMediaEvent( | ||||
| 				pubkey, | ||||
| 				content, | ||||
| 				mediaURL, | ||||
| 				contentType, | ||||
| 				mediaHash, | ||||
| 				altText, | ||||
| 				hashtags, | ||||
| 			) | ||||
| 		} else if isVideo { | ||||
| 			// For videos, determine if it's a short video | ||||
| 			isShortVideo := false // Just a placeholder, would need logic to determine | ||||
| 			 | ||||
| 			// Create the video event | ||||
| 			event, err = eventManager.CreateAndSignVideoEvent( | ||||
| 				pubkey, | ||||
| 				caption, // Title | ||||
| 				caption, // Description | ||||
| 				mediaURL, | ||||
| 				contentType, | ||||
| 				mediaHash, | ||||
| 				"", // Preview image URL | ||||
| 				0,  // Duration | ||||
| 				altText, | ||||
| 				hashtags, | ||||
| 				isShortVideo, | ||||
| 			) | ||||
| 		} else { | ||||
| 			// For other types, use a regular text note with attachment | ||||
| 			event, err = eventManager.CreateAndSignMediaEvent( | ||||
| 				pubkey, | ||||
| 				caption, | ||||
| 				mediaURL, | ||||
| 				contentType, | ||||
| 				mediaHash, | ||||
| 				altText, | ||||
| 				hashtags, | ||||
| 			) | ||||
| 		} | ||||
| 		 | ||||
| 		if err != nil { | ||||
| 			return nil, fmt.Errorf("failed to create event: %w", err) | ||||
| 		} | ||||
| 		 | ||||
| 		// Publish the event with NIP-19 encoding | ||||
| 		return relayManager.PublishEventWithEncoding(ctx, event) | ||||
| 	} | ||||
| 	 | ||||
| 	// Initialize scheduler | ||||
| 	// Initialize scheduler with both posting functions | ||||
| 	posterScheduler := scheduler.NewScheduler( | ||||
| 		database, | ||||
| 		logger, | ||||
| @ -163,6 +273,7 @@ func main() { | ||||
| 		nip94Uploader, | ||||
| 		blossomUploader, | ||||
| 		postContentFunc, | ||||
| 		postContentEncodedFunc, // New encoded function | ||||
| 	) | ||||
| 	 | ||||
| 	// Initialize API | ||||
|  | ||||
							
								
								
									
										
											BIN
										
									
								
								content/bot_18/VeniceAI_9o8uX4y.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 971 KiB | 
							
								
								
									
										
											BIN
										
									
								
								content/bot_18/VeniceAI_Ef25af1.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 827 KiB | 
							
								
								
									
										
											BIN
										
									
								
								content/bot_18/VeniceAI_FbjA9Sf.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.0 MiB | 
							
								
								
									
										
											BIN
										
									
								
								content/bot_18/VeniceAI_ORee53E.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.7 MiB | 
							
								
								
									
										
											BIN
										
									
								
								content/bot_18/VeniceAI_PNQMHHy.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.7 MiB | 
							
								
								
									
										
											BIN
										
									
								
								content/bot_18/VeniceAI_dCaNsqe.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.0 MiB | 
							
								
								
									
										
											BIN
										
									
								
								content/bot_18/wallhaven-lmq39l.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 467 KiB | 
| @ -539,6 +539,38 @@ func (s *BotService) PublishBotProfile(botID int64, ownerPubkey string) error { | ||||
| 	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 | ||||
|  | ||||
| @ -834,7 +834,7 @@ func (a *API) uploadToMediaServer(c *gin.Context) { | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| // Updated createManualPost function in routes.go to properly process kind 20 posts | ||||
| // Updated createManualPost function in routes.go | ||||
| func (a *API) createManualPost(c *gin.Context) { | ||||
| 	pubkey := c.GetString("pubkey") | ||||
| 	botIDStr := c.Param("id") | ||||
| @ -883,14 +883,8 @@ func (a *API) createManualPost(c *gin.Context) { | ||||
| 	if len(matches) > 0 { | ||||
| 		mediaURL = matches[0] // Use the first URL found | ||||
| 		 | ||||
| 		// Try to determine media type - this is a placeholder | ||||
| 		// In a real implementation, you might want to make an HTTP head request | ||||
| 		// or infer from URL extension | ||||
| 		// Try to determine media type | ||||
| 		mediaType = inferMediaTypeFromURL(mediaURL) | ||||
| 		 | ||||
| 		// We don't have a hash yet, but we could calculate it if needed | ||||
| 		// This would require downloading the file, which might be too heavy | ||||
| 		mediaHash = ""  | ||||
| 	} | ||||
| 
 | ||||
| 	// Create the appropriate event | ||||
| @ -941,22 +935,22 @@ func (a *API) createManualPost(c *gin.Context) { | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Publish to relays | ||||
| 	// Publish to relays with NIP-19 encoding | ||||
| 	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | ||||
| 	defer cancel() | ||||
| 
 | ||||
| 	publishedRelays, err := a.botService.relayMgr.PublishEvent(ctx, event) | ||||
| 	// Use the new method that includes NIP-19 encoding | ||||
| 	encodedEvent, err := a.botService.relayMgr.PublishEventWithEncoding(ctx, event) | ||||
| 	if err != nil { | ||||
| 		a.logger.Error("Failed to publish event", zap.Error(err)) | ||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish post: " + err.Error()}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// Return success | ||||
| 	// Return the encoded event response | ||||
| 	c.JSON(http.StatusOK, gin.H{ | ||||
| 		"message":   "Post published successfully", | ||||
| 		"event_id":  event.ID, | ||||
| 		"relays":    publishedRelays, | ||||
| 		"message": "Post published successfully", | ||||
| 		"event":   encodedEvent, | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										16
									
								
								internal/models/response.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,16 @@ | ||||
| // internal/models/response.go | ||||
| package models | ||||
| 
 | ||||
| // EventResponse represents an event with NIP-19 encoded identifiers | ||||
| type EventResponse struct { | ||||
| 	ID        string     `json:"id"`        // Raw hex event ID | ||||
| 	Nevent    string     `json:"nevent"`    // NIP-19 encoded event ID with relay info | ||||
| 	Note      string     `json:"note"`      // NIP-19 encoded event ID without relay info | ||||
| 	Pubkey    string     `json:"pubkey"`    // Raw hex pubkey | ||||
| 	Npub      string     `json:"npub"`      // NIP-19 encoded pubkey | ||||
| 	CreatedAt int64      `json:"created_at"` | ||||
| 	Kind      int        `json:"kind"` | ||||
| 	Content   string     `json:"content"` | ||||
| 	Tags      [][]string `json:"tags"` | ||||
| 	Relays    []string   `json:"relays"`    // Relays where the event was published | ||||
| } | ||||
							
								
								
									
										56
									
								
								internal/nostr/events/encoder.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,56 @@ | ||||
| // internal/nostr/events/encoder.go | ||||
| package events | ||||
| 
 | ||||
| import ( | ||||
|     "github.com/nbd-wtf/go-nostr" | ||||
|     "git.sovbit.dev/Enki/nostr-poster/internal/models" | ||||
|     "git.sovbit.dev/Enki/nostr-poster/internal/utils" | ||||
| ) | ||||
| 
 | ||||
| // EncodeEvent converts a nostr.Event to models.EventResponse with NIP-19 encoded IDs | ||||
| func EncodeEvent(event *nostr.Event, publishedRelays []string) (*models.EventResponse, error) { | ||||
|     // Create the basic response | ||||
|     response := &models.EventResponse{ | ||||
|         ID:        event.ID, | ||||
|         Pubkey:    event.PubKey, | ||||
|         CreatedAt: int64(event.CreatedAt), | ||||
|         Kind:      event.Kind, | ||||
|         Content:   event.Content, | ||||
|         Tags:      convertTags(event.Tags), // Use a helper function to convert tags | ||||
|         Relays:    publishedRelays, | ||||
|     } | ||||
|      | ||||
|     // Encode the event ID as nevent (with relay info) | ||||
|     nevent, err := utils.EncodeEventAsNevent(event.ID, publishedRelays, event.PubKey) | ||||
|     if err == nil { | ||||
|         response.Nevent = nevent | ||||
|     } | ||||
|      | ||||
|     // Encode the event ID as note (without relay info) | ||||
|     note, err := utils.EncodeEventAsNote(event.ID) | ||||
|     if err == nil { | ||||
|         response.Note = note | ||||
|     } | ||||
|      | ||||
|     // Encode the pubkey as npub | ||||
|     npub, err := utils.EncodePubkey(event.PubKey) | ||||
|     if err == nil { | ||||
|         response.Npub = npub | ||||
|     } | ||||
|      | ||||
|     return response, nil | ||||
| } | ||||
| 
 | ||||
| // Helper function to convert nostr.Tags to [][]string | ||||
| func convertTags(tags nostr.Tags) [][]string { | ||||
|     result := make([][]string, len(tags)) | ||||
|     for i, tag := range tags { | ||||
|         // Create a new string slice for each tag | ||||
|         tagStrings := make([]string, len(tag)) | ||||
|         for j, v := range tag { | ||||
|             tagStrings[j] = v | ||||
|         } | ||||
|         result[i] = tagStrings | ||||
|     } | ||||
|     return result | ||||
| } | ||||
| @ -6,7 +6,8 @@ import ( | ||||
| 	"fmt" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"git.sovbit.dev/Enki/nostr-poster/internal/models"   | ||||
|     "git.sovbit.dev/Enki/nostr-poster/internal/nostr/events" | ||||
| 	"github.com/nbd-wtf/go-nostr" | ||||
| 	"go.uber.org/zap" | ||||
| ) | ||||
| @ -20,6 +21,19 @@ type Manager struct { | ||||
| 	logger    *zap.Logger | ||||
| } | ||||
| 
 | ||||
| func convertTags(tags nostr.Tags) [][]string { | ||||
|     result := make([][]string, len(tags)) | ||||
|     for i, tag := range tags { | ||||
|         // Create a new string slice for each tag | ||||
|         tagStrings := make([]string, len(tag)) | ||||
|         for j, v := range tag { | ||||
|             tagStrings[j] = v | ||||
|         } | ||||
|         result[i] = tagStrings | ||||
|     } | ||||
|     return result | ||||
| } | ||||
| 
 | ||||
| // NewManager creates a new relay manager | ||||
| func NewManager(logger *zap.Logger) *Manager { | ||||
| 	if logger == nil { | ||||
| @ -186,6 +200,36 @@ func (m *Manager) PublishEvent(ctx context.Context, event *nostr.Event) ([]strin | ||||
| 	return successful, nil | ||||
| } | ||||
| 
 | ||||
| // PublishEventWithEncoding publishes an event and returns encoded identifiers | ||||
| func (m *Manager) PublishEventWithEncoding(ctx context.Context, event *nostr.Event) (*models.EventResponse, error) { | ||||
|     // First publish the event using the existing method | ||||
|     publishedRelays, err := m.PublishEvent(ctx, event) | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
| 
 | ||||
|     // Now encode the event with NIP-19 identifiers | ||||
|     encodedEvent, err := events.EncodeEvent(event, publishedRelays) | ||||
|     if err != nil { | ||||
|         m.logger.Warn("Failed to encode event with NIP-19",  | ||||
|             zap.String("event_id", event.ID), | ||||
|             zap.Error(err)) | ||||
|          | ||||
|         // Return a basic response if encoding fails | ||||
|         return &models.EventResponse{ | ||||
| 			ID:        event.ID, | ||||
| 			Pubkey:    event.PubKey, | ||||
| 			CreatedAt: int64(event.CreatedAt), | ||||
| 			Kind:      event.Kind, | ||||
| 			Content:   event.Content, | ||||
| 			Tags:      convertTags(event.Tags), // Use the helper function here | ||||
| 			Relays:    publishedRelays, | ||||
| 		}, nil | ||||
|     } | ||||
|      | ||||
|     return encodedEvent, nil | ||||
| } | ||||
| 
 | ||||
| // SubscribeToEvents subscribes to events matching the given filters | ||||
| func (m *Manager) SubscribeToEvents(ctx context.Context, filters []nostr.Filter) (<-chan *nostr.Event, error) { | ||||
| 	m.mu.RLock() | ||||
|  | ||||
| @ -40,18 +40,30 @@ type ContentPoster func( | ||||
| 	hashtags []string, | ||||
| ) error | ||||
| 
 | ||||
| // ContentPosterWithEncoding defines a function to post content and return encoded event | ||||
| type ContentPosterWithEncoding func( | ||||
| 	pubkey string, | ||||
| 	contentPath string, | ||||
| 	contentType string, | ||||
| 	mediaURL string, | ||||
| 	mediaHash string, | ||||
| 	caption string, | ||||
| 	hashtags []string, | ||||
| ) (*models.EventResponse, error) | ||||
| 
 | ||||
| // Scheduler manages scheduled content posting | ||||
| type Scheduler struct { | ||||
| 	db             *db.DB | ||||
| 	cron           *cron.Cron | ||||
| 	logger         *zap.Logger | ||||
| 	contentDir     string | ||||
| 	archiveDir     string | ||||
| 	nip94Uploader  MediaUploader | ||||
| 	blossomUploader MediaUploader | ||||
| 	postContent    ContentPoster | ||||
| 	botJobs        map[int64]cron.EntryID | ||||
| 	mu             sync.RWMutex | ||||
| 	db                 *db.DB | ||||
| 	cron               *cron.Cron | ||||
| 	logger             *zap.Logger | ||||
| 	contentDir         string | ||||
| 	archiveDir         string | ||||
| 	nip94Uploader      MediaUploader | ||||
| 	blossomUploader    MediaUploader | ||||
| 	postContent        ContentPoster | ||||
| 	postContentEncoded ContentPosterWithEncoding // New field for NIP-19 support | ||||
| 	botJobs            map[int64]cron.EntryID | ||||
| 	mu                 sync.RWMutex | ||||
| } | ||||
| 
 | ||||
| // NewScheduler creates a new content scheduler | ||||
| @ -63,6 +75,7 @@ func NewScheduler( | ||||
| 	nip94Uploader MediaUploader, | ||||
| 	blossomUploader MediaUploader, | ||||
| 	postContent ContentPoster, | ||||
| 	postContentEncoded ContentPosterWithEncoding, // New parameter for NIP-19 support | ||||
| ) *Scheduler { | ||||
| 	if logger == nil { | ||||
| 		// Create a default logger | ||||
| @ -77,15 +90,16 @@ func NewScheduler( | ||||
| 	cronScheduler := cron.New(cron.WithSeconds()) | ||||
| 	 | ||||
| 	return &Scheduler{ | ||||
| 		db:             db, | ||||
| 		cron:           cronScheduler, | ||||
| 		logger:         logger, | ||||
| 		contentDir:     contentDir, | ||||
| 		archiveDir:     archiveDir, | ||||
| 		nip94Uploader:  nip94Uploader, | ||||
| 		blossomUploader: blossomUploader, | ||||
| 		postContent:    postContent, | ||||
| 		botJobs:        make(map[int64]cron.EntryID), | ||||
| 		db:                 db, | ||||
| 		cron:               cronScheduler, | ||||
| 		logger:             logger, | ||||
| 		contentDir:         contentDir, | ||||
| 		archiveDir:         archiveDir, | ||||
| 		nip94Uploader:      nip94Uploader, | ||||
| 		blossomUploader:    blossomUploader, | ||||
| 		postContent:        postContent, | ||||
| 		postContentEncoded: postContentEncoded, // Initialize the new field | ||||
| 		botJobs:            make(map[int64]cron.EntryID), | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| @ -262,22 +276,65 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error { | ||||
| 		filename := filepath.Base(contentPath) | ||||
| 		caption := strings.TrimSuffix(filename, filepath.Ext(filename)) | ||||
| 		 | ||||
| 		// Post the content | ||||
| 		err = s.postContent( | ||||
| 			bot.Pubkey, | ||||
| 			contentPath, | ||||
| 			contentType, | ||||
| 			mediaURL, | ||||
| 			mediaHash, | ||||
| 			caption, | ||||
| 			hashtags, | ||||
| 		) | ||||
| 		// Post the content - use encoded version if available, otherwise use the original | ||||
| 		var postErr error | ||||
| 		 | ||||
| 		if err != nil { | ||||
| 			s.logger.Error("Failed to post content", | ||||
| 				zap.Int64("bot_id", bot.ID), | ||||
| 				zap.String("file", contentPath), | ||||
| 				zap.Error(err)) | ||||
| 		if s.postContentEncoded != nil { | ||||
| 			// Use the NIP-19 encoded version | ||||
| 			encodedEvent, err := s.postContentEncoded( | ||||
| 				bot.Pubkey, | ||||
| 				contentPath, | ||||
| 				contentType, | ||||
| 				mediaURL, | ||||
| 				mediaHash, | ||||
| 				caption, | ||||
| 				hashtags, | ||||
| 			) | ||||
| 			 | ||||
| 			if err != nil { | ||||
| 				s.logger.Error("Failed to post content with encoding", | ||||
| 					zap.Int64("bot_id", bot.ID), | ||||
| 					zap.String("file", contentPath), | ||||
| 					zap.Error(err)) | ||||
| 				postErr = err | ||||
| 			} else { | ||||
| 				// Success with encoded version | ||||
| 				s.logger.Info("Successfully posted content with NIP-19 encoding", | ||||
| 					zap.Int64("bot_id", bot.ID), | ||||
| 					zap.String("file", contentPath), | ||||
| 					zap.String("media_url", mediaURL), | ||||
| 					zap.String("event_id", encodedEvent.ID), | ||||
| 					zap.String("note", encodedEvent.Note), | ||||
| 					zap.String("nevent", encodedEvent.Nevent)) | ||||
| 				postErr = nil | ||||
| 			} | ||||
| 		} else { | ||||
| 			// Fall back to original function | ||||
| 			postErr = s.postContent( | ||||
| 				bot.Pubkey, | ||||
| 				contentPath, | ||||
| 				contentType, | ||||
| 				mediaURL, | ||||
| 				mediaHash, | ||||
| 				caption, | ||||
| 				hashtags, | ||||
| 			) | ||||
| 			 | ||||
| 			if postErr != nil { | ||||
| 				s.logger.Error("Failed to post content", | ||||
| 					zap.Int64("bot_id", bot.ID), | ||||
| 					zap.String("file", contentPath), | ||||
| 					zap.Error(postErr)) | ||||
| 			} else { | ||||
| 				s.logger.Info("Successfully posted content", | ||||
| 					zap.Int64("bot_id", bot.ID), | ||||
| 					zap.String("file", contentPath), | ||||
| 					zap.String("media_url", mediaURL)) | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		// If posting failed, return without archiving the file | ||||
| 		if postErr != nil { | ||||
| 			return | ||||
| 		} | ||||
| 		 | ||||
| @ -291,11 +348,6 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error { | ||||
| 				zap.Error(err)) | ||||
| 			return | ||||
| 		} | ||||
| 		 | ||||
| 		s.logger.Info("Successfully posted content", | ||||
| 			zap.Int64("bot_id", bot.ID), | ||||
| 			zap.String("file", contentPath), | ||||
| 			zap.String("media_url", mediaURL)) | ||||
| 	} | ||||
| 	 | ||||
| 	// Schedule the job | ||||
| @ -349,6 +401,7 @@ func (s *Scheduler) GetBlossomUploader() MediaUploader { | ||||
| func (s *Scheduler) GetContentDir() string { | ||||
|     return s.contentDir | ||||
| } | ||||
| 
 | ||||
| // RunNow triggers an immediate post for a bot | ||||
| func (s *Scheduler) RunNow(botID int64) error { | ||||
| 	// Load the bot with its configurations | ||||
| @ -395,4 +448,4 @@ func (s *Scheduler) RunNow(botID int64) error { | ||||
| 	entry.Job.Run() | ||||
| 	 | ||||
| 	return nil | ||||
| } | ||||
| } | ||||
							
								
								
									
										30
									
								
								internal/utils/nip-19.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,30 @@ | ||||
| // internal/utils/nip19.go | ||||
| package utils | ||||
| 
 | ||||
| import ( | ||||
|     "github.com/nbd-wtf/go-nostr/nip19" | ||||
| ) | ||||
| 
 | ||||
| // EncodeEventAsNevent encodes an event ID and relays into a NIP-19 "nevent" identifier | ||||
| func EncodeEventAsNevent(eventID string, relays []string, authorPubkey string) (string, error) { | ||||
|     // Use the direct function signature that your version of nip19 expects | ||||
|     return nip19.EncodeEvent(eventID, relays, authorPubkey) | ||||
| } | ||||
| 
 | ||||
| // EncodeEventAsNote encodes an event ID into a NIP-19 "note" identifier (simpler) | ||||
| func EncodeEventAsNote(eventID string) (string, error) { | ||||
|     note, err := nip19.EncodeNote(eventID) | ||||
|     if err != nil { | ||||
|         return "", err | ||||
|     } | ||||
|     return note, nil | ||||
| } | ||||
| 
 | ||||
| // EncodePubkey encodes a public key into a NIP-19 "npub" identifier | ||||
| func EncodePubkey(pubkey string) (string, error) { | ||||
|     npub, err := nip19.EncodePublicKey(pubkey) | ||||
|     if err != nil { | ||||
|         return "", err | ||||
|     } | ||||
|     return npub, nil | ||||
| } | ||||
| @ -1,16 +1,19 @@ | ||||
| /* ============================================= | ||||
|    VARIABLES | ||||
|    ============================================= */ | ||||
|    :root { | ||||
| :root { | ||||
|     /* Color Palette */ | ||||
|     --primary-black: #121212; | ||||
|     --secondary-black: #1e1e1e; | ||||
|     --primary-gray: #2d2d2d; | ||||
|     --secondary-gray: #3d3d3d; | ||||
|     --light-gray: #aaaaaa; | ||||
|     --primary-purple: #9370DB; /* Medium Purple */ | ||||
|     --secondary-purple: #7B68EE; /* Medium Slate Blue */ | ||||
|     --dark-purple: #6A5ACD; /* Slate Blue */ | ||||
|     --primary-purple: #9370DB; | ||||
|     /* Medium Purple */ | ||||
|     --secondary-purple: #7B68EE; | ||||
|     /* Medium Slate Blue */ | ||||
|     --dark-purple: #6A5ACD; | ||||
|     /* Slate Blue */ | ||||
|     --text-color: #e0e0e0; | ||||
|     --success-color: #4CAF50; | ||||
|     --border-color: #444; | ||||
| @ -93,7 +96,7 @@ body { | ||||
|     border-color: var(--primary-purple) !important; | ||||
| } | ||||
| 
 | ||||
| .btn-primary:hover,  | ||||
| .btn-primary:hover, | ||||
| .btn-primary:focus { | ||||
|     background-color: var(--secondary-purple) !important; | ||||
|     border-color: var(--secondary-purple) !important; | ||||
| @ -112,14 +115,14 @@ body { | ||||
| /* ============================================= | ||||
|    FORMS | ||||
|    ============================================= */ | ||||
| .form-control,  | ||||
| .form-control, | ||||
| .form-select { | ||||
|     background-color: var(--primary-black); | ||||
|     border: 1px solid var(--border-color); | ||||
|     color: var(--text-color); | ||||
| } | ||||
| 
 | ||||
| .form-control:focus,  | ||||
| .form-control:focus, | ||||
| .form-select:focus { | ||||
|     background-color: var(--primary-black); | ||||
|     border-color: var(--primary-purple); | ||||
| @ -224,7 +227,7 @@ body { | ||||
|     margin-right: auto; | ||||
| } | ||||
| 
 | ||||
| #uploadPreviewContainer,  | ||||
| #uploadPreviewContainer, | ||||
| #mediaPreviewContainer { | ||||
|     max-width: 100%; | ||||
|     max-height: 250px; | ||||
| @ -256,7 +259,7 @@ body { | ||||
| } | ||||
| 
 | ||||
| /* Server URL input improvements */ | ||||
| #primaryServerURL,  | ||||
| #primaryServerURL, | ||||
| #fallbackServerURL { | ||||
|     margin-top: 5px; | ||||
|     font-size: 0.9rem; | ||||
| @ -275,6 +278,38 @@ body { | ||||
|     background-color: var(--primary-gray); | ||||
| } | ||||
| 
 | ||||
| /* Event info display */ | ||||
| .event-info { | ||||
|     background-color: var(--primary-gray); | ||||
|     border-left: 3px solid var(--success-color); | ||||
|     margin-top: 15px; | ||||
| } | ||||
| 
 | ||||
| .event-info .card-header { | ||||
|     background-color: rgba(76, 175, 80, 0.1); | ||||
| } | ||||
| 
 | ||||
| .event-info .input-group { | ||||
|     background-color: var(--secondary-black); | ||||
| } | ||||
| 
 | ||||
| .event-info input.form-control { | ||||
|     font-family: monospace; | ||||
|     font-size: 0.85rem; | ||||
|     background-color: var(--secondary-black); | ||||
|     border-color: var(--border-color); | ||||
| } | ||||
| 
 | ||||
| .copy-btn:hover { | ||||
|     background-color: var(--primary-purple); | ||||
|     border-color: var(--primary-purple); | ||||
| } | ||||
| 
 | ||||
| .copy-btn:active { | ||||
|     background-color: var(--dark-purple); | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /* ============================================= | ||||
|    RESPONSIVE STYLES | ||||
|    ============================================= */ | ||||
| @ -282,12 +317,12 @@ body { | ||||
|     .card { | ||||
|         margin-bottom: 20px; | ||||
|     } | ||||
|      | ||||
| 
 | ||||
|     .bot-selection-container { | ||||
|         padding: 15px; | ||||
|         margin-bottom: 20px; | ||||
|     } | ||||
|      | ||||
| 
 | ||||
|     .upload-preview { | ||||
|         max-height: 150px; | ||||
|     } | ||||
|  | ||||
| @ -105,18 +105,18 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|         postKindRadios.forEach(radio => { | ||||
|             radio.addEventListener('change', () => { | ||||
|                 titleField.classList.toggle('d-none', radio.value !== '20'); | ||||
|                  | ||||
| 
 | ||||
|                 // Show/hide required media elements for kind 20
 | ||||
|                 const isKind20 = radio.value === '20'; | ||||
|                 const kind20MediaRequired = document.getElementById('kind20MediaRequired'); | ||||
|                 if (kind20MediaRequired) { | ||||
|                     kind20MediaRequired.style.display = isKind20 ? 'inline-block' : 'none'; | ||||
|                 } | ||||
|                  | ||||
| 
 | ||||
|                 // For kind 20, validate that media is provided before posting
 | ||||
|                 if (submitPostBtn) { | ||||
|                     submitPostBtn.title = isKind20 ?  | ||||
|                         'Picture posts require a title and an image URL' :  | ||||
|                     submitPostBtn.title = isKind20 ? | ||||
|                         'Picture posts require a title and an image URL' : | ||||
|                         'Create a standard text post'; | ||||
|                 } | ||||
|             }); | ||||
| @ -177,7 +177,35 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         createManualPost(currentBotId); | ||||
|         createManualPost(currentBotId) | ||||
|             .then(data => { | ||||
|                 alert('Post created successfully!'); | ||||
|                 console.log('Post success response:', data); | ||||
| 
 | ||||
|                 // Display event information if present
 | ||||
|                 if (data.event) { | ||||
|                     displayEventInfo(data.event); | ||||
|                 } | ||||
| 
 | ||||
|                 // Reset form
 | ||||
|                 manualPostContent.value = ''; | ||||
|                 postHashtags.value = ''; | ||||
|                 postTitle.value = ''; | ||||
|                 postMediaInput.value = ''; | ||||
|                 if (mediaUrlInput) mediaUrlInput.value = ''; | ||||
|                 if (mediaAltText) mediaAltText.value = ''; | ||||
|                 mediaPreviewContainer.classList.add('d-none'); | ||||
| 
 | ||||
|                 // Reset button
 | ||||
|                 submitPostBtn.disabled = false; | ||||
|                 submitPostBtn.textContent = 'Post Now'; | ||||
|             }) | ||||
|             .catch(error => { | ||||
|                 console.error('Error creating post:', error); | ||||
|                 alert('Error creating post: ' + error.message); | ||||
|                 submitPostBtn.disabled = false; | ||||
|                 submitPostBtn.textContent = 'Post Now'; | ||||
|             }); | ||||
|     } | ||||
| 
 | ||||
|     // Handle saving media server settings
 | ||||
| @ -224,13 +252,13 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|     function validatePostData() { | ||||
|         const kind = parseInt(document.querySelector('input[name="postKind"]:checked').value); | ||||
|         const content = manualPostContent.value.trim(); | ||||
|          | ||||
| 
 | ||||
|         // Basic validation for all post types
 | ||||
|         if (!content) { | ||||
|             alert('Post content is required'); | ||||
|             return false; | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         // Additional validation for kind 20 posts
 | ||||
|         if (kind === 20) { | ||||
|             const title = postTitle.value.trim(); | ||||
| @ -238,21 +266,67 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|                 alert('Title is required for Picture Posts (kind: 20)'); | ||||
|                 return false; | ||||
|             } | ||||
|              | ||||
| 
 | ||||
|             // Check if we have a media URL either in the content or in the mediaUrlInput
 | ||||
|             const mediaUrl = mediaUrlInput.value.trim(); | ||||
|             const urlRegex = /(https?:\/\/[^\s]+)/g; | ||||
|             const contentContainsUrl = urlRegex.test(content); | ||||
|              | ||||
| 
 | ||||
|             if (!mediaUrl && !contentContainsUrl) { | ||||
|                 alert('Picture posts require an image URL. Please upload an image or enter a URL in the Media URL field or in the content.'); | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     // Add this function to content.js
 | ||||
|     function displayEventInfo(event) { | ||||
|         // Get the dedicated container
 | ||||
|         const container = document.getElementById('eventInfoContainer'); | ||||
|         if (!container) return; | ||||
|      | ||||
|         // Create HTML for the event info
 | ||||
|         const html = ` | ||||
|         <div class="event-info card mb-3"> | ||||
|             <div class="card-header"> | ||||
|                 <h5>Post Published Successfully</h5> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|                 <div class="mb-2"> | ||||
|                     <label class="form-label">Note ID (NIP-19):</label> | ||||
|                     <div class="input-group"> | ||||
|                         <input type="text" class="form-control" value="${event.note || ''}" readonly> | ||||
|                         <button class="btn btn-outline-secondary copy-btn" data-value="${event.note || ''}"> | ||||
|                             <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16"> | ||||
|                                 <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/> | ||||
|                                 <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/> | ||||
|                             </svg> | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <div class="mb-2"> | ||||
|                     <label class="form-label">Event with Relays (NIP-19):</label> | ||||
|                     <div class="input-group"> | ||||
|                         <input type="text" class="form-control" value="${event.nevent || ''}" readonly> | ||||
|                         <button class="btn btn-outline-secondary copy-btn" data-value="${event.nevent || ''}"> | ||||
|                             <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16"> | ||||
|                                 <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/> | ||||
|                                 <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/> | ||||
|                             </svg> | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|         `;
 | ||||
|      | ||||
|         // Update the container and make it visible
 | ||||
|         container.innerHTML = html; | ||||
|         container.classList.remove('d-none'); | ||||
|     } | ||||
| 
 | ||||
|     // ===================================================
 | ||||
|     // API Functions
 | ||||
|     // ===================================================
 | ||||
| @ -296,17 +370,18 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|             }); | ||||
|     } | ||||
| 
 | ||||
|     // Render the list of content files
 | ||||
|     function renderContentFiles(botId, files) { | ||||
|         const contentArea = document.getElementById('contentArea'); | ||||
|         if (!contentArea) return; | ||||
|     // Updated renderContentFiles function
 | ||||
| function renderContentFiles(botId, files) { | ||||
|     const contentArea = document.getElementById('contentArea'); | ||||
|     if (!contentArea) return; | ||||
| 
 | ||||
|         if (!files || files.length === 0) { | ||||
|             contentArea.innerHTML = '<p>No files found. Upload some content!</p>'; | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         let html = '<ul class="list-group">'; | ||||
|     // Generate ONLY the file list, no upload form
 | ||||
|     let html = ''; | ||||
|      | ||||
|     if (!files || files.length === 0) { | ||||
|         html = '<p>No files found. Upload some content!</p>'; | ||||
|     } else { | ||||
|         html = '<ul class="list-group">'; | ||||
|         for (const file of files) { | ||||
|             html += ` | ||||
|                 <li class="list-group-item d-flex justify-content-between align-items-center"> | ||||
| @ -316,9 +391,11 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|             `;
 | ||||
|         } | ||||
|         html += '</ul>'; | ||||
| 
 | ||||
|         contentArea.innerHTML = html; | ||||
|     } | ||||
|      | ||||
|     // Set the content area HTML to just the files list
 | ||||
|     contentArea.innerHTML = html; | ||||
| } | ||||
| 
 | ||||
|     // Upload media for the manual post
 | ||||
|     function quickUploadMedia(botId, file) { | ||||
| @ -371,7 +448,7 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|                 // Insert the media URL into the post content
 | ||||
|                 const textArea = document.getElementById('manualPostContent'); | ||||
|                 let mediaUrl = data.url; | ||||
|                  | ||||
| 
 | ||||
|                 // Also update the media URL input field
 | ||||
|                 const mediaUrlInput = document.getElementById('mediaUrlInput'); | ||||
|                 if (mediaUrlInput) { | ||||
| @ -402,7 +479,7 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|         if (!validatePostData()) { | ||||
|             return; | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         const content = manualPostContent.value.trim(); | ||||
|         const hashtagsValue = postHashtags.value.trim(); | ||||
|         const hashtags = hashtagsValue | ||||
| @ -419,7 +496,7 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|         // Extract media URLs and alt text
 | ||||
|         const mediaUrl = mediaUrlInput ? mediaUrlInput.value.trim() : ''; | ||||
|         const altText = mediaAltText ? mediaAltText.value.trim() : ''; | ||||
|          | ||||
| 
 | ||||
|         // Build the post data based on kind
 | ||||
|         const postData = { | ||||
|             kind: kind, | ||||
| @ -429,13 +506,13 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
| 
 | ||||
|         if (kind === 20) { | ||||
|             postData.title = title; | ||||
|              | ||||
| 
 | ||||
|             // For kind 20, we need to ensure we have a valid URL
 | ||||
|             // If we have a specific media URL field value, add it to the content if not already there
 | ||||
|             if (mediaUrl && !content.includes(mediaUrl)) { | ||||
|                 postData.content = content + '\n\n' + mediaUrl; | ||||
|             } | ||||
|              | ||||
| 
 | ||||
|             // Add alt text if provided
 | ||||
|             if (altText) { | ||||
|                 postData.alt = altText; | ||||
| @ -510,4 +587,23 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|             console.error('Error parsing saved media config:', e); | ||||
|         } | ||||
|     } | ||||
| }); | ||||
| 
 | ||||
| document.addEventListener('click', function(e) { | ||||
|     if (e.target.closest('.copy-btn')) { | ||||
|         const btn = e.target.closest('.copy-btn'); | ||||
|         const valueToCopy = btn.getAttribute('data-value'); | ||||
|          | ||||
|         navigator.clipboard.writeText(valueToCopy) | ||||
|             .then(() => { | ||||
|                 const originalHTML = btn.innerHTML; | ||||
|                 btn.innerHTML = 'Copied!'; | ||||
|                 setTimeout(() => { | ||||
|                     btn.innerHTML = originalHTML; | ||||
|                 }, 2000); | ||||
|             }) | ||||
|             .catch(err => { | ||||
|                 console.error('Failed to copy: ', err); | ||||
|             }); | ||||
|     } | ||||
| }); | ||||
| @ -476,6 +476,93 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     window.publishBotProfile = async function(botId) { | ||||
|         try { | ||||
|             const token = localStorage.getItem('authToken'); | ||||
|             const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}/profile/publish`, { | ||||
|                 method: 'POST', | ||||
|                 headers: { | ||||
|                     'Authorization': token | ||||
|                 } | ||||
|             }); | ||||
|              | ||||
|             if (!response.ok) { | ||||
|                 const errData = await response.json().catch(() => ({})); | ||||
|                 throw new Error(errData.error || 'Failed to publish profile'); | ||||
|             } | ||||
|              | ||||
|             const data = await response.json(); | ||||
|              | ||||
|             // Check if event data with NIP-19 encoding is available
 | ||||
|             if (data.event) { | ||||
|                 // Create a modal to show the encoded event info
 | ||||
|                 const modalId = 'eventInfoModal'; | ||||
|                  | ||||
|                 // Remove any existing modal with the same ID
 | ||||
|                 const existingModal = document.getElementById(modalId); | ||||
|                 if (existingModal) { | ||||
|                     existingModal.remove(); | ||||
|                 } | ||||
|                  | ||||
|                 const eventModal = document.createElement('div'); | ||||
|                 eventModal.className = 'modal fade'; | ||||
|                 eventModal.id = modalId; | ||||
|                 eventModal.setAttribute('tabindex', '-1'); | ||||
|                 eventModal.innerHTML = ` | ||||
|                     <div class="modal-dialog"> | ||||
|                         <div class="modal-content"> | ||||
|                             <div class="modal-header"> | ||||
|                                 <h5 class="modal-title">Profile Published Successfully</h5> | ||||
|                                 <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
|                             </div> | ||||
|                             <div class="modal-body"> | ||||
|                                 <div class="mb-2"> | ||||
|                                     <label class="form-label">Note ID (NIP-19):</label> | ||||
|                                     <div class="input-group"> | ||||
|                                         <input type="text" class="form-control" value="${data.event.note || ''}" readonly> | ||||
|                                         <button class="btn btn-outline-secondary copy-btn" data-value="${data.event.note || ''}"> | ||||
|                                             <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16"> | ||||
|                                                 <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/> | ||||
|                                                 <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/> | ||||
|                                             </svg> | ||||
|                                         </button> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                                 <div class="mb-2"> | ||||
|                                     <label class="form-label">Event with Relays (NIP-19):</label> | ||||
|                                     <div class="input-group"> | ||||
|                                         <input type="text" class="form-control" value="${data.event.nevent || ''}" readonly> | ||||
|                                         <button class="btn btn-outline-secondary copy-btn" data-value="${data.event.nevent || ''}"> | ||||
|                                             <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16"> | ||||
|                                                 <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/> | ||||
|                                                 <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/> | ||||
|                                             </svg> | ||||
|                                         </button> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                             <div class="modal-footer"> | ||||
|                                 <button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 `;
 | ||||
|                  | ||||
|                 // Add the modal to the document
 | ||||
|                 document.body.appendChild(eventModal); | ||||
|                  | ||||
|                 // Show the modal using Bootstrap
 | ||||
|                 const bsModal = new bootstrap.Modal(document.getElementById(modalId)); | ||||
|                 bsModal.show(); | ||||
|             } | ||||
|              | ||||
|             alert('Profile published successfully!'); | ||||
|              | ||||
|         } catch (err) { | ||||
|             console.error('Error publishing profile:', err); | ||||
|             alert(`Error publishing profile: ${err.message}`); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     /* ---------------------------------------------------- | ||||
|      * Show Create Bot Modal | ||||
|  | ||||
							
								
								
									
										113
									
								
								web/content.html
									
									
									
									
									
								
							
							
						
						| @ -131,13 +131,15 @@ | ||||
|               <h4>Content Files</h4> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|               <div id="contentArea"> | ||||
|               <!-- File list area - with scrollable container --> | ||||
|               <div id="contentArea" style="max-height: 300px; overflow-y: auto;"> | ||||
|                 <!-- Files will be listed here --> | ||||
|                 <p>Select a bot and click "Load Content" to see files.</p> | ||||
|               </div> | ||||
| 
 | ||||
|               <hr> | ||||
| 
 | ||||
|               <!-- Upload section - using existing structure without an ID --> | ||||
|               <h5>Upload New Content</h5> | ||||
|               <div class="mb-3"> | ||||
|                 <input type="file" id="uploadFileInput" class="form-control" accept="image/*,video/*"> | ||||
| @ -150,57 +152,56 @@ | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <!-- Manual Post Section (Right) --> | ||||
|         <div class="card mb-4"> | ||||
|           <div class="card-header"> | ||||
|             <h4>Create Manual Post</h4> | ||||
|           </div> | ||||
|           <div class="card-body"> | ||||
|             <!-- Post Type Selection --> | ||||
|             <div class="mb-3"> | ||||
|               <label class="form-label">Post Type</label> | ||||
|               <div class="form-check"> | ||||
|                 <input class="form-check-input" type="radio" name="postKind" id="postKind1" value="1" checked> | ||||
|                 <label class="form-check-label" for="postKind1"> | ||||
|                   Standard Post (kind: 1) | ||||
|                 </label> | ||||
|         <!-- Manual Post Section (Right) - FIXED: added col-md-6 wrapper --> | ||||
|         <div class="col-md-6 mb-4"> | ||||
|           <div class="card"> | ||||
|             <div class="card-header"> | ||||
|               <h4>Create Manual Post</h4> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|               <!-- Post Type Selection --> | ||||
|               <div class="mb-3"> | ||||
|                 <label class="form-label">Post Type</label> | ||||
|                 <div class="form-check"> | ||||
|                   <input class="form-check-input" type="radio" name="postKind" id="postKind1" value="1" checked> | ||||
|                   <label class="form-check-label" for="postKind1"> | ||||
|                     Standard Post (kind: 1) | ||||
|                   </label> | ||||
|                 </div> | ||||
|                 <div class="form-check"> | ||||
|                   <input class="form-check-input" type="radio" name="postKind" id="postKind20" value="20"> | ||||
|                   <label class="form-check-label" for="postKind20"> | ||||
|                     <strong>Picture Post (kind: 20)</strong> - Images displayed in a gallery-like feed | ||||
|                   </label> | ||||
|                 </div> | ||||
|               </div> | ||||
|               <div class="form-check"> | ||||
|                 <input class="form-check-input" type="radio" name="postKind" id="postKind20" value="20"> | ||||
|                 <label class="form-check-label" for="postKind20"> | ||||
|                   <strong>Picture Post (kind: 20)</strong> - Images displayed in a gallery-like feed | ||||
|                 </label> | ||||
| 
 | ||||
|               <!-- Title Field (only for kind 20) --> | ||||
|               <div class="mb-3 d-none" id="titleField"> | ||||
|                 <label for="postTitle" class="form-label">Title <span class="text-danger">*</span></label> | ||||
|                 <input type="text" class="form-control" id="postTitle" placeholder="Post title (required for kind: 20)"> | ||||
|                 <small class="form-text text-muted">Required for picture posts (kind: 20)</small> | ||||
|               </div> | ||||
|             </div> | ||||
| 
 | ||||
|             <!-- Title Field (only for kind 20) --> | ||||
|             <div class="mb-3 d-none" id="titleField"> | ||||
|               <label for="postTitle" class="form-label">Title <span class="text-danger">*</span></label> | ||||
|               <input type="text" class="form-control" id="postTitle" placeholder="Post title (required for kind: 20)"> | ||||
|               <small class="form-text text-muted">Required for picture posts (kind: 20)</small> | ||||
|             </div> | ||||
|               <!-- Content Field --> | ||||
|               <div class="mb-3"> | ||||
|                 <label for="manualPostContent" class="form-label">Content</label> | ||||
|                 <textarea class="form-control" id="manualPostContent" rows="4" | ||||
|                   placeholder="Write your post content here..."></textarea> | ||||
|               </div> | ||||
| 
 | ||||
|             <!-- Content Field --> | ||||
|             <div class="mb-3"> | ||||
|               <label for="manualPostContent" class="form-label">Content</label> | ||||
|               <textarea class="form-control" id="manualPostContent" rows="4" | ||||
|                 placeholder="Write your post content here..."></textarea> | ||||
|             </div> | ||||
|               <!-- Hashtags Field --> | ||||
|               <div class="mb-3"> | ||||
|                 <label for="postHashtags" class="form-label">Hashtags</label> | ||||
|                 <input type="text" class="form-control" id="postHashtags" | ||||
|                   placeholder="comma separated: art, photography, etc"> | ||||
|               </div> | ||||
| 
 | ||||
|             <!-- Hashtags Field --> | ||||
|             <div class="mb-3"> | ||||
|               <label for="postHashtags" class="form-label">Hashtags</label> | ||||
|               <input type="text" class="form-control" id="postHashtags" | ||||
|                 placeholder="comma separated: art, photography, etc"> | ||||
|             </div> | ||||
| 
 | ||||
|             <!-- Media Section - Enhanced for Kind 20 --> | ||||
|             <div class="card mb-3"> | ||||
|               <div class="card-header"> | ||||
|                 <h5 class="mb-0">Media Attachment</h5> | ||||
|               <!-- Media Section - Enhanced for Kind 20 --> | ||||
|               <div class="mb-3"> | ||||
|                 <h5 class="mb-2">Media Attachment</h5> | ||||
|                 <span class="badge bg-info" id="kind20MediaRequired" style="display: none;">Required for kind: 20</span> | ||||
|               </div> | ||||
|               <div class="card-body"> | ||||
| 
 | ||||
|                 <!-- Upload Section --> | ||||
|                 <div class="mb-3"> | ||||
|                   <label class="form-label">Upload Image</label> | ||||
| @ -216,8 +217,7 @@ | ||||
|                   <label for="mediaUrlInput" class="form-label">Media URL</label> | ||||
|                   <input type="text" class="form-control" id="mediaUrlInput" | ||||
|                     placeholder="Enter media URL or upload an image"> | ||||
|                   <small class="form-text text-muted">For kind: 20 posts, an image URL is required (either here or in | ||||
|                     the content)</small> | ||||
|                   <small class="form-text text-muted">For kind: 20 posts, an image URL is required</small> | ||||
|                 </div> | ||||
| 
 | ||||
|                 <!-- Alt Text Field (for accessibility) --> | ||||
| @ -225,28 +225,25 @@ | ||||
|                   <label for="mediaAltText" class="form-label">Alt Text</label> | ||||
|                   <input type="text" class="form-control" id="mediaAltText" | ||||
|                     placeholder="Describe the image for accessibility"> | ||||
|                   <small class="form-text text-muted">Recommended for image description (improves accessibility)</small> | ||||
|                   <small class="form-text text-muted">Recommended for image description</small> | ||||
|                 </div> | ||||
| 
 | ||||
|                 <!-- Media Preview --> | ||||
|                 <div id="mediaPreviewContainer" class="d-none mt-2"> | ||||
|                   <div class="card"> | ||||
|                     <div class="card-body text-center"> | ||||
|                       <img id="mediaPreview" class="upload-preview img-fluid mb-2"> | ||||
|                       <div id="mediaLinkContainer" class="media-link"> | ||||
|                         <small class="text-muted">Media will appear here after upload</small> | ||||
|                       </div> | ||||
|                     </div> | ||||
|                   <img id="mediaPreview" class="upload-preview img-fluid mb-2"> | ||||
|                   <div id="mediaLinkContainer" class="media-link"> | ||||
|                     <small class="text-muted">Media will appear here after upload</small> | ||||
|                   </div> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </div> | ||||
| 
 | ||||
|             <button type="button" class="btn btn-success mt-2" id="submitPostBtn">Post Now</button> | ||||
|               <button type="button" class="btn btn-success mt-2" id="submitPostBtn">Post Now</button> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div id="eventInfoContainer" class="mt-3"></div> | ||||
|   </div> | ||||
| </body> | ||||
| 
 | ||||
|  | ||||