diff --git a/internal/babble/babble.go b/internal/babble/babble.go new file mode 100644 index 0000000..b2afe57 --- /dev/null +++ b/internal/babble/babble.go @@ -0,0 +1,350 @@ +package babble + +import ( + "fmt" + "strings" + "time" + + "dk/internal/database" + + "zombiezen.com/go/sqlite" +) + +// Babble represents a global chat message in the database +type Babble struct { + ID int `json:"id"` + Posted int64 `json:"posted"` + Author string `json:"author"` + Babble string `json:"babble"` + + db *database.DB +} + +// Find retrieves a babble message by ID +func Find(db *database.DB, id int) (*Babble, error) { + babble := &Babble{db: db} + + query := "SELECT id, posted, author, babble FROM babble WHERE id = ?" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + babble.ID = stmt.ColumnInt(0) + babble.Posted = stmt.ColumnInt64(1) + babble.Author = stmt.ColumnText(2) + babble.Babble = stmt.ColumnText(3) + return nil + }, id) + + if err != nil { + return nil, fmt.Errorf("failed to find babble: %w", err) + } + + if babble.ID == 0 { + return nil, fmt.Errorf("babble with ID %d not found", id) + } + + return babble, nil +} + +// All retrieves all babble messages ordered by posted time (newest first) +func All(db *database.DB) ([]*Babble, error) { + var babbles []*Babble + + query := "SELECT id, posted, author, babble FROM babble ORDER BY posted DESC, id DESC" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + babble := &Babble{ + ID: stmt.ColumnInt(0), + Posted: stmt.ColumnInt64(1), + Author: stmt.ColumnText(2), + Babble: stmt.ColumnText(3), + db: db, + } + babbles = append(babbles, babble) + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve all babble: %w", err) + } + + return babbles, nil +} + +// ByAuthor retrieves babble messages by a specific author +func ByAuthor(db *database.DB, author string) ([]*Babble, error) { + var babbles []*Babble + + query := "SELECT id, posted, author, babble FROM babble WHERE LOWER(author) = LOWER(?) ORDER BY posted DESC, id DESC" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + babble := &Babble{ + ID: stmt.ColumnInt(0), + Posted: stmt.ColumnInt64(1), + Author: stmt.ColumnText(2), + Babble: stmt.ColumnText(3), + db: db, + } + babbles = append(babbles, babble) + return nil + }, author) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve babble by author: %w", err) + } + + return babbles, nil +} + +// Recent retrieves the most recent babble messages (limited by count) +func Recent(db *database.DB, limit int) ([]*Babble, error) { + var babbles []*Babble + + query := "SELECT id, posted, author, babble FROM babble ORDER BY posted DESC, id DESC LIMIT ?" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + babble := &Babble{ + ID: stmt.ColumnInt(0), + Posted: stmt.ColumnInt64(1), + Author: stmt.ColumnText(2), + Babble: stmt.ColumnText(3), + db: db, + } + babbles = append(babbles, babble) + return nil + }, limit) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve recent babble: %w", err) + } + + return babbles, nil +} + +// Since retrieves babble messages since a specific timestamp +func Since(db *database.DB, since int64) ([]*Babble, error) { + var babbles []*Babble + + query := "SELECT id, posted, author, babble FROM babble WHERE posted >= ? ORDER BY posted DESC, id DESC" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + babble := &Babble{ + ID: stmt.ColumnInt(0), + Posted: stmt.ColumnInt64(1), + Author: stmt.ColumnText(2), + Babble: stmt.ColumnText(3), + db: db, + } + babbles = append(babbles, babble) + return nil + }, since) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve babble since timestamp: %w", err) + } + + return babbles, nil +} + +// Between retrieves babble messages between two timestamps (inclusive) +func Between(db *database.DB, start, end int64) ([]*Babble, error) { + var babbles []*Babble + + query := "SELECT id, posted, author, babble FROM babble WHERE posted >= ? AND posted <= ? ORDER BY posted DESC, id DESC" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + babble := &Babble{ + ID: stmt.ColumnInt(0), + Posted: stmt.ColumnInt64(1), + Author: stmt.ColumnText(2), + Babble: stmt.ColumnText(3), + db: db, + } + babbles = append(babbles, babble) + return nil + }, start, end) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve babble between timestamps: %w", err) + } + + return babbles, nil +} + +// Search retrieves babble messages containing the search term (case-insensitive) +func Search(db *database.DB, term string) ([]*Babble, error) { + var babbles []*Babble + + query := "SELECT id, posted, author, babble FROM babble WHERE LOWER(babble) LIKE LOWER(?) ORDER BY posted DESC, id DESC" + searchTerm := "%" + term + "%" + + err := db.Query(query, func(stmt *sqlite.Stmt) error { + babble := &Babble{ + ID: stmt.ColumnInt(0), + Posted: stmt.ColumnInt64(1), + Author: stmt.ColumnText(2), + Babble: stmt.ColumnText(3), + db: db, + } + babbles = append(babbles, babble) + return nil + }, searchTerm) + + if err != nil { + return nil, fmt.Errorf("failed to search babble: %w", err) + } + + return babbles, nil +} + +// RecentByAuthor retrieves recent messages from a specific author +func RecentByAuthor(db *database.DB, author string, limit int) ([]*Babble, error) { + var babbles []*Babble + + query := "SELECT id, posted, author, babble FROM babble WHERE LOWER(author) = LOWER(?) ORDER BY posted DESC, id DESC LIMIT ?" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + babble := &Babble{ + ID: stmt.ColumnInt(0), + Posted: stmt.ColumnInt64(1), + Author: stmt.ColumnText(2), + Babble: stmt.ColumnText(3), + db: db, + } + babbles = append(babbles, babble) + return nil + }, author, limit) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve recent babble by author: %w", err) + } + + return babbles, nil +} + +// Save updates an existing babble message in the database +func (b *Babble) Save() error { + if b.ID == 0 { + return fmt.Errorf("cannot save babble without ID") + } + + query := `UPDATE babble SET posted = ?, author = ?, babble = ? WHERE id = ?` + return b.db.Exec(query, b.Posted, b.Author, b.Babble, b.ID) +} + +// Delete removes the babble message from the database +func (b *Babble) Delete() error { + if b.ID == 0 { + return fmt.Errorf("cannot delete babble without ID") + } + + query := "DELETE FROM babble WHERE id = ?" + return b.db.Exec(query, b.ID) +} + +// PostedTime returns the posted timestamp as a time.Time +func (b *Babble) PostedTime() time.Time { + return time.Unix(b.Posted, 0) +} + +// SetPostedTime sets the posted timestamp from a time.Time +func (b *Babble) SetPostedTime(t time.Time) { + b.Posted = t.Unix() +} + +// IsRecent returns true if the babble message was posted within the last hour +func (b *Babble) IsRecent() bool { + return time.Since(b.PostedTime()) < time.Hour +} + +// Age returns how long ago the babble message was posted +func (b *Babble) Age() time.Duration { + return time.Since(b.PostedTime()) +} + +// IsAuthor returns true if the given username is the author of this babble message +func (b *Babble) IsAuthor(username string) bool { + return strings.EqualFold(b.Author, username) +} + +// Preview returns a truncated version of the babble for previews +func (b *Babble) Preview(maxLength int) string { + if len(b.Babble) <= maxLength { + return b.Babble + } + + if maxLength < 3 { + return b.Babble[:maxLength] + } + + return b.Babble[:maxLength-3] + "..." +} + +// WordCount returns the number of words in the babble message +func (b *Babble) WordCount() int { + if b.Babble == "" { + return 0 + } + + // Simple word count by splitting on whitespace + words := 0 + inWord := false + + for _, char := range b.Babble { + if char == ' ' || char == '\t' || char == '\n' || char == '\r' { + if inWord { + words++ + inWord = false + } + } else { + inWord = true + } + } + + if inWord { + words++ + } + + return words +} + +// Length returns the character length of the babble message +func (b *Babble) Length() int { + return len(b.Babble) +} + +// Contains returns true if the babble message contains the given term (case-insensitive) +func (b *Babble) Contains(term string) bool { + return strings.Contains(strings.ToLower(b.Babble), strings.ToLower(term)) +} + +// IsEmpty returns true if the babble message is empty or whitespace-only +func (b *Babble) IsEmpty() bool { + return strings.TrimSpace(b.Babble) == "" +} + +// IsLongMessage returns true if the message exceeds the typical chat length +func (b *Babble) IsLongMessage(threshold int) bool { + return b.Length() > threshold +} + +// GetMentions returns a slice of usernames mentioned in the message (starting with @) +func (b *Babble) GetMentions() []string { + words := strings.Fields(b.Babble) + var mentions []string + + for _, word := range words { + if strings.HasPrefix(word, "@") && len(word) > 1 { + // Clean up punctuation from the end + mention := strings.TrimRight(word[1:], ".,!?;:") + if mention != "" { + mentions = append(mentions, mention) + } + } + } + + return mentions +} + +// HasMention returns true if the message mentions the given username +func (b *Babble) HasMention(username string) bool { + mentions := b.GetMentions() + for _, mention := range mentions { + if strings.EqualFold(mention, username) { + return true + } + } + return false +} \ No newline at end of file diff --git a/internal/babble/babble_test.go b/internal/babble/babble_test.go new file mode 100644 index 0000000..b9d4e7b --- /dev/null +++ b/internal/babble/babble_test.go @@ -0,0 +1,625 @@ +package babble + +import ( + "os" + "testing" + "time" + + "dk/internal/database" +) + +func setupTestDB(t *testing.T) *database.DB { + testDB := "test_babble.db" + t.Cleanup(func() { + os.Remove(testDB) + }) + + db, err := database.Open(testDB) + if err != nil { + t.Fatalf("Failed to open test database: %v", err) + } + + // Create babble table + createTable := `CREATE TABLE babble ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + posted INTEGER NOT NULL DEFAULT (unixepoch()), + author TEXT NOT NULL DEFAULT '', + babble TEXT NOT NULL DEFAULT '' + )` + + if err := db.Exec(createTable); err != nil { + t.Fatalf("Failed to create babble table: %v", err) + } + + // Insert test data with specific timestamps for predictable testing + now := time.Now().Unix() + testBabble := `INSERT INTO babble (posted, author, babble) VALUES + (?, 'Alice', 'Hello everyone! Welcome to the game'), + (?, 'Bob', 'Thanks Alice! @Alice this game is great'), + (?, 'Charlie', 'Anyone want to team up for the dungeon?'), + (?, 'Alice', 'I can help @Charlie, let me know'), + (?, 'David', 'Server lag is really bad right now...'), + (?, 'Eve', 'Quick question about spell mechanics')` + + timestamps := []interface{}{ + now - 3600*6, // 6 hours ago + now - 3600*4, // 4 hours ago + now - 3600*2, // 2 hours ago + now - 3600*1, // 1 hour ago + now - 1800, // 30 minutes ago + now - 300, // 5 minutes ago + } + + if err := db.Exec(testBabble, timestamps...); err != nil { + t.Fatalf("Failed to insert test babble: %v", err) + } + + return db +} + +func TestFind(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test finding existing babble + babble, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find babble: %v", err) + } + + if babble.ID != 1 { + t.Errorf("Expected ID 1, got %d", babble.ID) + } + if babble.Author != "Alice" { + t.Errorf("Expected author 'Alice', got '%s'", babble.Author) + } + if babble.Babble != "Hello everyone! Welcome to the game" { + t.Errorf("Expected specific message, got '%s'", babble.Babble) + } + if babble.Posted == 0 { + t.Error("Expected non-zero posted timestamp") + } + + // Test finding non-existent babble + _, err = Find(db, 999) + if err == nil { + t.Error("Expected error when finding non-existent babble") + } +} + +func TestAll(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + babbles, err := All(db) + if err != nil { + t.Fatalf("Failed to get all babble: %v", err) + } + + if len(babbles) != 6 { + t.Errorf("Expected 6 babble messages, got %d", len(babbles)) + } + + // Check ordering (newest first) + if len(babbles) >= 2 { + if babbles[0].Posted < babbles[1].Posted { + t.Error("Expected babble to be ordered by posted time (newest first)") + } + } + + // First message should be the most recent (5 minutes ago) + if babbles[0].Author != "Eve" { + t.Errorf("Expected newest message from Eve, got from '%s'", babbles[0].Author) + } +} + +func TestByAuthor(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test messages by Alice + aliceMessages, err := ByAuthor(db, "Alice") + if err != nil { + t.Fatalf("Failed to get babble by author: %v", err) + } + + if len(aliceMessages) != 2 { + t.Errorf("Expected 2 messages by Alice, got %d", len(aliceMessages)) + } + + // Verify all messages are by Alice + for _, message := range aliceMessages { + if message.Author != "Alice" { + t.Errorf("Expected author 'Alice', got '%s'", message.Author) + } + } + + // Check ordering (newest first) + if len(aliceMessages) == 2 { + if aliceMessages[0].Babble != "I can help @Charlie, let me know" { + t.Errorf("Expected newest message by Alice first") + } + } + + // Test case insensitive search + aliceMessagesLower, err := ByAuthor(db, "alice") + if err != nil { + t.Fatalf("Failed to get babble by lowercase author: %v", err) + } + + if len(aliceMessagesLower) != 2 { + t.Errorf("Expected case insensitive search to find 2 messages, got %d", len(aliceMessagesLower)) + } + + // Test author with no messages + noMessages, err := ByAuthor(db, "NonexistentUser") + if err != nil { + t.Fatalf("Failed to query non-existent author: %v", err) + } + + if len(noMessages) != 0 { + t.Errorf("Expected 0 messages by non-existent author, got %d", len(noMessages)) + } +} + +func TestRecent(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test getting 3 most recent messages + recentMessages, err := Recent(db, 3) + if err != nil { + t.Fatalf("Failed to get recent babble: %v", err) + } + + if len(recentMessages) != 3 { + t.Errorf("Expected 3 recent messages, got %d", len(recentMessages)) + } + + // Check ordering (newest first) + if len(recentMessages) >= 2 { + if recentMessages[0].Posted < recentMessages[1].Posted { + t.Error("Expected recent messages to be ordered newest first") + } + } + + // Test getting more messages than exist + allRecentMessages, err := Recent(db, 10) + if err != nil { + t.Fatalf("Failed to get recent babble with high limit: %v", err) + } + + if len(allRecentMessages) != 6 { + t.Errorf("Expected 6 messages (all available), got %d", len(allRecentMessages)) + } +} + +func TestSince(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test messages since 3 hours ago + threeHoursAgo := time.Now().Add(-3 * time.Hour).Unix() + recentMessages, err := Since(db, threeHoursAgo) + if err != nil { + t.Fatalf("Failed to get babble since timestamp: %v", err) + } + + // Should get messages from 2 hours ago, 1 hour ago, 30 minutes ago, and 5 minutes ago + expectedCount := 4 + if len(recentMessages) != expectedCount { + t.Errorf("Expected %d messages since 3 hours ago, got %d", expectedCount, len(recentMessages)) + } + + // Verify all messages are since the timestamp + for _, message := range recentMessages { + if message.Posted < threeHoursAgo { + t.Errorf("Message with timestamp %d is before the 'since' timestamp %d", message.Posted, threeHoursAgo) + } + } + + // Test with future timestamp (should return no messages) + futureMessages, err := Since(db, time.Now().Add(time.Hour).Unix()) + if err != nil { + t.Fatalf("Failed to query future timestamp: %v", err) + } + + if len(futureMessages) != 0 { + t.Errorf("Expected 0 messages since future timestamp, got %d", len(futureMessages)) + } +} + +func TestBetween(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test messages between 5 hours ago and 1 hour ago + start := time.Now().Add(-5 * time.Hour).Unix() + end := time.Now().Add(-1 * time.Hour).Unix() + + betweenMessages, err := Between(db, start, end) + if err != nil { + t.Fatalf("Failed to get babble between timestamps: %v", err) + } + + // Should get messages from 4 hours ago, 2 hours ago, and 1 hour ago (inclusive end) + expectedCount := 3 + if len(betweenMessages) != expectedCount { + t.Errorf("Expected %d messages between timestamps, got %d", expectedCount, len(betweenMessages)) + } + + // Verify all messages are within the range + for _, message := range betweenMessages { + if message.Posted < start || message.Posted > end { + t.Errorf("Message with timestamp %d is outside range [%d, %d]", message.Posted, start, end) + } + } +} + +func TestSearch(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test searching for "game" + gameMessages, err := Search(db, "game") + if err != nil { + t.Fatalf("Failed to search babble: %v", err) + } + + expectedCount := 2 // Alice's welcome message and Bob's response + if len(gameMessages) != expectedCount { + t.Errorf("Expected %d messages containing 'game', got %d", expectedCount, len(gameMessages)) + } + + // Verify all messages contain the search term + for _, message := range gameMessages { + if !message.Contains("game") { + t.Errorf("Message '%s' does not contain search term 'game'", message.Babble) + } + } + + // Test case insensitive search + gameMessagesUpper, err := Search(db, "GAME") + if err != nil { + t.Fatalf("Failed to search babble with uppercase: %v", err) + } + + if len(gameMessagesUpper) != expectedCount { + t.Error("Expected case insensitive search to find same results") + } + + // Test search with no results + noResults, err := Search(db, "nonexistentterm") + if err != nil { + t.Fatalf("Failed to search for non-existent term: %v", err) + } + + if len(noResults) != 0 { + t.Errorf("Expected 0 results for non-existent term, got %d", len(noResults)) + } +} + +func TestRecentByAuthor(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test recent messages by Alice (limit 1) + aliceRecent, err := RecentByAuthor(db, "Alice", 1) + if err != nil { + t.Fatalf("Failed to get recent babble by author: %v", err) + } + + if len(aliceRecent) != 1 { + t.Errorf("Expected 1 recent message by Alice, got %d", len(aliceRecent)) + } + + if len(aliceRecent) > 0 && aliceRecent[0].Babble != "I can help @Charlie, let me know" { + t.Error("Expected most recent message by Alice") + } + + // Test with higher limit + aliceAll, err := RecentByAuthor(db, "Alice", 5) + if err != nil { + t.Fatalf("Failed to get all recent messages by Alice: %v", err) + } + + if len(aliceAll) != 2 { + t.Errorf("Expected 2 total messages by Alice, got %d", len(aliceAll)) + } +} + +func TestBuilder(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Create new babble using builder + testTime := time.Now() + babble, err := NewBuilder(db). + WithAuthor("TestUser"). + WithBabble("Test message from builder"). + WithPostedTime(testTime). + Create() + + if err != nil { + t.Fatalf("Failed to create babble with builder: %v", err) + } + + if babble.ID == 0 { + t.Error("Expected non-zero ID after creation") + } + if babble.Author != "TestUser" { + t.Errorf("Expected author 'TestUser', got '%s'", babble.Author) + } + if babble.Babble != "Test message from builder" { + t.Errorf("Expected specific message, got '%s'", babble.Babble) + } + if babble.Posted != testTime.Unix() { + t.Errorf("Expected posted time %d, got %d", testTime.Unix(), babble.Posted) + } + + // Test WithMessage alias + babble2, err := NewBuilder(db). + WithAuthor("TestUser2"). + WithMessage("Using WithMessage alias"). + Create() + + if err != nil { + t.Fatalf("Failed to create babble with WithMessage: %v", err) + } + + if babble2.Babble != "Using WithMessage alias" { + t.Errorf("WithMessage alias failed, got '%s'", babble2.Babble) + } + + // Verify it was saved to database + foundBabble, err := Find(db, babble.ID) + if err != nil { + t.Fatalf("Failed to find created babble: %v", err) + } + + if foundBabble.Babble != "Test message from builder" { + t.Errorf("Created babble not found in database") + } + + // Test builder with default timestamp + defaultBabble, err := NewBuilder(db). + WithAuthor("DefaultUser"). + WithBabble("Message with default timestamp"). + Create() + + if err != nil { + t.Fatalf("Failed to create babble with default timestamp: %v", err) + } + + // Should have recent timestamp (within last minute) + if time.Since(defaultBabble.PostedTime()) > time.Minute { + t.Error("Expected default timestamp to be recent") + } +} + +func TestSave(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + babble, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find babble: %v", err) + } + + // Modify babble + babble.Author = "UpdatedAuthor" + babble.Babble = "Updated message content" + babble.Posted = time.Now().Unix() + + // Save changes + err = babble.Save() + if err != nil { + t.Fatalf("Failed to save babble: %v", err) + } + + // Verify changes were saved + updatedBabble, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find updated babble: %v", err) + } + + if updatedBabble.Author != "UpdatedAuthor" { + t.Errorf("Expected updated author 'UpdatedAuthor', got '%s'", updatedBabble.Author) + } + if updatedBabble.Babble != "Updated message content" { + t.Errorf("Expected updated message, got '%s'", updatedBabble.Babble) + } +} + +func TestDelete(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + babble, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find babble: %v", err) + } + + // Delete babble + err = babble.Delete() + if err != nil { + t.Fatalf("Failed to delete babble: %v", err) + } + + // Verify babble was deleted + _, err = Find(db, 1) + if err == nil { + t.Error("Expected error when finding deleted babble") + } +} + +func TestUtilityMethods(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + babble, _ := Find(db, 1) + + // Test PostedTime + postedTime := babble.PostedTime() + if postedTime.IsZero() { + t.Error("Expected non-zero posted time") + } + + // Test SetPostedTime + newTime := time.Now().Add(-30 * time.Minute) + babble.SetPostedTime(newTime) + if babble.Posted != newTime.Unix() { + t.Errorf("Expected posted timestamp %d, got %d", newTime.Unix(), babble.Posted) + } + + // Test IsRecent (should be true for 30 minutes ago) + if !babble.IsRecent() { + t.Error("Expected message from 30 minutes ago to be recent") + } + + // Test Age + age := babble.Age() + if age < 0 { + t.Error("Expected positive age") + } + + // Test IsAuthor + if !babble.IsAuthor("Alice") { + t.Error("Expected IsAuthor to return true for correct author") + } + if !babble.IsAuthor("alice") { // Test case insensitive + t.Error("Expected IsAuthor to be case insensitive") + } + if babble.IsAuthor("Bob") { + t.Error("Expected IsAuthor to return false for incorrect author") + } + + // Test Preview + longMessage := "This is a very long chat message that should be truncated when preview is called for display purposes" + babble.Babble = longMessage + + preview := babble.Preview(20) + if len(preview) > 20 { + t.Errorf("Expected preview length <= 20, got %d", len(preview)) + } + if preview[len(preview)-3:] != "..." { + t.Error("Expected preview to end with ellipsis") + } + + shortPreview := babble.Preview(200) // Longer than message + if shortPreview != longMessage { + t.Error("Expected short message to not be truncated") + } + + // Test WordCount + babble.Babble = "This is a test with five words" + wordCount := babble.WordCount() + if wordCount != 7 { + t.Errorf("Expected 7 words, got %d", wordCount) + } + + // Test Length + expectedLength := len(babble.Babble) + if babble.Length() != expectedLength { + t.Errorf("Expected length %d, got %d", expectedLength, babble.Length()) + } + + // Test Contains + if !babble.Contains("test") { + t.Error("Expected message to contain 'test'") + } + if !babble.Contains("TEST") { // Case insensitive + t.Error("Expected Contains to be case insensitive") + } + if babble.Contains("nonexistent") { + t.Error("Expected message not to contain 'nonexistent'") + } + + // Test IsEmpty + babble.Babble = "" + if !babble.IsEmpty() { + t.Error("Expected empty message to be empty") + } + + babble.Babble = " " + if !babble.IsEmpty() { + t.Error("Expected whitespace-only message to be empty") + } + + babble.Babble = "Not empty" + if babble.IsEmpty() { + t.Error("Expected non-empty message not to be empty") + } + + // Test IsLongMessage + shortMsg := "Short" + babble.Babble = shortMsg + if babble.IsLongMessage(100) { + t.Error("Expected short message not to be long") + } + if !babble.IsLongMessage(3) { + t.Error("Expected message longer than threshold to be long") + } +} + +func TestMentionMethods(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test GetMentions + babble, _ := Find(db, 2) // Bob's message: "Thanks Alice! @Alice this game is great" + mentions := babble.GetMentions() + + expectedMentions := []string{"Alice"} + if len(mentions) != len(expectedMentions) { + t.Errorf("Expected %d mentions, got %d", len(expectedMentions), len(mentions)) + } + + for i, expected := range expectedMentions { + if i < len(mentions) && mentions[i] != expected { + t.Errorf("Expected mention '%s' at position %d, got '%s'", expected, i, mentions[i]) + } + } + + // Test HasMention + if !babble.HasMention("Alice") { + t.Error("Expected message to mention Alice") + } + if !babble.HasMention("alice") { // Case insensitive + t.Error("Expected HasMention to be case insensitive") + } + if babble.HasMention("Bob") { + t.Error("Expected message not to mention Bob") + } + + // Test message with multiple mentions and punctuation + babble.Babble = "Hey @Alice, @Bob! Can you help @Charlie?" + mentions = babble.GetMentions() + expectedMentions = []string{"Alice", "Bob", "Charlie"} + + if len(mentions) != len(expectedMentions) { + t.Errorf("Expected %d mentions, got %d: %v", len(expectedMentions), len(mentions), mentions) + } + + for _, expected := range expectedMentions { + if !babble.HasMention(expected) { + t.Errorf("Expected message to mention %s", expected) + } + } + + // Test message with no mentions + babble.Babble = "No mentions in this message" + mentions = babble.GetMentions() + + if len(mentions) != 0 { + t.Errorf("Expected 0 mentions, got %d", len(mentions)) + } + + // Test malformed mentions (should be ignored) + babble.Babble = "Just @ alone or @" + mentions = babble.GetMentions() + + if len(mentions) != 0 { + t.Errorf("Expected 0 mentions for malformed @, got %d", len(mentions)) + } +} \ No newline at end of file diff --git a/internal/babble/builder.go b/internal/babble/builder.go new file mode 100644 index 0000000..ca9d647 --- /dev/null +++ b/internal/babble/builder.go @@ -0,0 +1,90 @@ +package babble + +import ( + "fmt" + "time" + + "dk/internal/database" + + "zombiezen.com/go/sqlite" +) + +// Builder provides a fluent interface for creating babble messages +type Builder struct { + babble *Babble + db *database.DB +} + +// NewBuilder creates a new babble builder +func NewBuilder(db *database.DB) *Builder { + return &Builder{ + babble: &Babble{ + db: db, + Posted: time.Now().Unix(), // Default to current time + }, + db: db, + } +} + +// WithAuthor sets the author username +func (b *Builder) WithAuthor(author string) *Builder { + b.babble.Author = author + return b +} + +// WithBabble sets the message content +func (b *Builder) WithBabble(message string) *Builder { + b.babble.Babble = message + return b +} + +// WithMessage is an alias for WithBabble for more intuitive usage +func (b *Builder) WithMessage(message string) *Builder { + return b.WithBabble(message) +} + +// WithPosted sets the posted timestamp +func (b *Builder) WithPosted(posted int64) *Builder { + b.babble.Posted = posted + return b +} + +// WithPostedTime sets the posted timestamp from a time.Time +func (b *Builder) WithPostedTime(t time.Time) *Builder { + b.babble.Posted = t.Unix() + return b +} + +// Create saves the babble message to the database and returns the created babble with ID +func (b *Builder) Create() (*Babble, error) { + // Use a transaction to ensure we can get the ID + var babble *Babble + err := b.db.Transaction(func(tx *database.Tx) error { + query := `INSERT INTO babble (posted, author, babble) + VALUES (?, ?, ?)` + + if err := tx.Exec(query, b.babble.Posted, b.babble.Author, b.babble.Babble); err != nil { + return fmt.Errorf("failed to insert babble: %w", err) + } + + // Get the last insert ID + var id int + err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error { + id = stmt.ColumnInt(0) + return nil + }) + if err != nil { + return fmt.Errorf("failed to get insert ID: %w", err) + } + + b.babble.ID = id + babble = b.babble + return nil + }) + + if err != nil { + return nil, err + } + + return babble, nil +} \ No newline at end of file diff --git a/internal/babble/doc.go b/internal/babble/doc.go new file mode 100644 index 0000000..030024e --- /dev/null +++ b/internal/babble/doc.go @@ -0,0 +1,514 @@ +/* +Package babble is the active record implementation for global chat messages in the game. + +Babble represents the global chat system where players can communicate with each other in real-time. The package provides comprehensive chat message management with features like mentions, search, time-based queries, and moderation utilities. + +# Basic Usage + +To retrieve a babble message by ID: + + message, err := babble.Find(db, 1) + if err != nil { + log.Fatal(err) + } + fmt.Printf("<%s> %s\n", message.Author, message.Babble) + +To get all babble messages: + + allMessages, err := babble.All(db) + if err != nil { + log.Fatal(err) + } + for _, message := range allMessages { + fmt.Printf("[%s] <%s> %s\n", + message.PostedTime().Format("15:04"), + message.Author, + message.Babble) + } + +To get recent chat messages: + + recentChat, err := babble.Recent(db, 50) + if err != nil { + log.Fatal(err) + } + +To filter messages by author: + + userMessages, err := babble.ByAuthor(db, "PlayerName") + if err != nil { + log.Fatal(err) + } + +# Creating Messages with Builder Pattern + +The package provides a fluent builder interface for creating new chat messages: + + message, err := babble.NewBuilder(db). + WithAuthor("PlayerName"). + WithBabble("Hello everyone! Ready for some adventure?"). + WithPostedTime(time.Now()). + Create() + + if err != nil { + log.Fatal(err) + } + fmt.Printf("Posted message with ID: %d\n", message.ID) + +The builder automatically sets the current time if no posted time is specified: + + message, err := babble.NewBuilder(db). + WithAuthor("Admin"). + WithMessage("Server restart in 5 minutes!"). + Create() // Uses current timestamp + +You can use either `WithBabble()` or `WithMessage()` - they are aliases for the same functionality. + +# Updating Messages + +Chat messages can be modified and saved back to the database: + + message, _ := babble.Find(db, 1) + message.Babble = "[EDITED] Original message was inappropriate" + + err := message.Save() + if err != nil { + log.Fatal(err) + } + +# Deleting Messages + +Messages can be removed from the database (for moderation): + + message, _ := babble.Find(db, 1) + err := message.Delete() + if err != nil { + log.Fatal(err) + } + +# Database Schema + +The babble table has the following structure: + + CREATE TABLE babble ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + posted INTEGER NOT NULL DEFAULT (unixepoch()), + author TEXT NOT NULL DEFAULT '', + babble TEXT NOT NULL DEFAULT '' + ) + +Where: + - id: Unique identifier for the chat message + - posted: Unix timestamp when the message was posted + - author: Username of the player who posted the message + - babble: The text content of the chat message + +# Time-Based Queries + +## Recent Messages + +Get the most recent chat messages: + + // Get 100 most recent messages for chat display + chatHistory, err := babble.Recent(db, 100) + if err != nil { + log.Fatal(err) + } + + fmt.Println("=== Recent Chat ===") + for _, msg := range chatHistory { + age := msg.Age() + timeStr := "" + if age < time.Minute { + timeStr = "just now" + } else if age < time.Hour { + timeStr = fmt.Sprintf("%dm ago", int(age.Minutes())) + } else { + timeStr = msg.PostedTime().Format("15:04") + } + + fmt.Printf("[%s] <%s> %s\n", timeStr, msg.Author, msg.Babble) + } + +## Messages Since Timestamp + +Get messages posted since a specific time: + + // Get messages since user's last login + lastLogin := getUserLastLogin(userID) + newMessages, err := babble.Since(db, lastLogin) + if err != nil { + log.Fatal(err) + } + + if len(newMessages) > 0 { + fmt.Printf("You missed %d messages while you were away\n", len(newMessages)) + } + +## Messages Between Timestamps + +Get messages within a time range: + + // Get today's chat history + startOfDay := time.Now().Truncate(24 * time.Hour).Unix() + endOfDay := time.Now().Unix() + + todaysChat, err := babble.Between(db, startOfDay, endOfDay) + if err != nil { + log.Fatal(err) + } + +# Search and Filtering + +## Text Search + +Search for messages containing specific terms: + + // Search for messages about "boss fight" + bossMessages, err := babble.Search(db, "boss fight") + if err != nil { + log.Fatal(err) + } + + for _, msg := range bossMessages { + fmt.Printf("<%s> %s\n", msg.Author, msg.Preview(60)) + } + +Search is case-insensitive and matches partial words. + +## Author-Based Queries + +Get messages from specific players: + + // Get recent messages from a player + playerRecent, err := babble.RecentByAuthor(db, "PlayerName", 10) + if err != nil { + log.Fatal(err) + } + + // Get all messages from a player (for moderation) + allPlayerMessages, err := babble.ByAuthor(db, "ReportedPlayer") + if err != nil { + log.Fatal(err) + } + +All author searches are case-insensitive. + +# Mention System + +## Finding Mentions + +The package includes a comprehensive mention system using @username syntax: + + message, _ := babble.Find(db, someID) + + // Get all mentioned usernames + mentions := message.GetMentions() + for _, username := range mentions { + fmt.Printf("Message mentions: @%s\n", username) + } + + // Check if specific user is mentioned + if message.HasMention("PlayerName") { + fmt.Println("You were mentioned in this message!") + } + +## Mention Parsing + +The mention system handles various formats: +- `@username` - Basic mention +- `@username!` - With punctuation +- `@username,` - In lists +- `@username?` - In questions + +Mentions are extracted without the punctuation and are case-insensitive. + +## Notification Integration + +Use mentions for player notifications: + + // Process new messages for mentions + recentMessages, _ := babble.Recent(db, 50) + + for _, msg := range recentMessages { + mentions := msg.GetMentions() + for _, mentionedUser := range mentions { + // Send notification to mentioned user + notifyUser(mentionedUser, fmt.Sprintf("%s mentioned you: %s", + msg.Author, msg.Preview(50))) + } + } + +# Message Analysis + +## Content Analysis + +Analyze message content for moderation or statistics: + + message, _ := babble.Find(db, someID) + + // Basic content metrics + fmt.Printf("Length: %d characters\n", message.Length()) + fmt.Printf("Words: %d\n", message.WordCount()) + + // Content checks + if message.IsEmpty() { + fmt.Println("Empty message detected") + } + + if message.IsLongMessage(200) { + fmt.Println("Very long message - possible spam") + } + + // Search within message + if message.Contains("inappropriate_term") { + fmt.Println("Message flagged for review") + } + +## Time Analysis + +Analyze posting patterns: + + message, _ := babble.Find(db, someID) + + age := message.Age() + fmt.Printf("Message posted %v ago\n", age) + + if message.IsRecent() { + fmt.Println("This is a recent message (within 1 hour)") + } + + // Format for display + postedTime := message.PostedTime() + if age < 24*time.Hour { + fmt.Printf("Posted at %s\n", postedTime.Format("15:04")) + } else { + fmt.Printf("Posted on %s\n", postedTime.Format("Jan 2 15:04")) + } + +# Chat Display Patterns + +## Live Chat Feed + +Display real-time chat messages: + + func displayChatFeed(db *database.DB) { + messages, _ := babble.Recent(db, 50) + + fmt.Println("=== Global Chat ===") + for i := len(messages) - 1; i >= 0; i-- { // Reverse for chronological order + msg := messages[i] + + // Format timestamp + age := msg.Age() + var timeStr string + if age < time.Minute { + timeStr = "now" + } else if age < time.Hour { + timeStr = fmt.Sprintf("%dm", int(age.Minutes())) + } else { + timeStr = msg.PostedTime().Format("15:04") + } + + // Handle mentions highlighting + content := msg.Babble + if msg.HasMention(currentUser) { + content = fmt.Sprintf("🔔 %s", content) // Highlight mentions + } + + fmt.Printf("[%s] <%s> %s\n", timeStr, msg.Author, content) + } + } + +## Chat History Browser + +Browse historical messages: + + func browseChatHistory(db *database.DB, page int, pageSize int) { + offset := page * pageSize + + // Get paginated results (implement with LIMIT/OFFSET) + allMessages, _ := babble.All(db) + + start := offset + end := offset + pageSize + if end > len(allMessages) { + end = len(allMessages) + } + + if start >= len(allMessages) { + fmt.Println("No more messages") + return + } + + pageMessages := allMessages[start:end] + + fmt.Printf("=== Chat History (Page %d) ===\n", page+1) + for _, msg := range pageMessages { + fmt.Printf("%s <%s> %s\n", + msg.PostedTime().Format("Jan 2 15:04"), + msg.Author, + msg.Babble) + } + } + +# Moderation Features + +## Content Moderation + +Tools for chat moderation: + + // Flag inappropriate messages + func moderateMessage(db *database.DB, messageID int) { + msg, err := babble.Find(db, messageID) + if err != nil { + return + } + + // Check for spam (very short or very long) + if msg.WordCount() < 2 { + fmt.Printf("Possible spam: %s\n", msg.Preview(30)) + } + + if msg.IsLongMessage(500) { + fmt.Printf("Very long message from %s\n", msg.Author) + } + + // Check for excessive mentions + mentions := msg.GetMentions() + if len(mentions) > 5 { + fmt.Printf("Message with %d mentions from %s\n", len(mentions), msg.Author) + } + } + +## User Activity Analysis + +Analyze user posting patterns: + + // Check user activity + func analyzeUserActivity(db *database.DB, username string) { + // Recent activity + recentMessages, _ := babble.RecentByAuthor(db, username, 10) + + fmt.Printf("User %s recent activity:\n", username) + fmt.Printf("- Recent messages: %d\n", len(recentMessages)) + + if len(recentMessages) > 0 { + totalWords := 0 + for _, msg := range recentMessages { + totalWords += msg.WordCount() + } + avgWords := totalWords / len(recentMessages) + fmt.Printf("- Average words per message: %d\n", avgWords) + + latest := recentMessages[0] + fmt.Printf("- Last message: %s (%s ago)\n", + latest.Preview(40), latest.Age()) + } + + // Check for mention patterns + allUserMessages, _ := babble.ByAuthor(db, username) + mentionCount := 0 + for _, msg := range allUserMessages { + mentionCount += len(msg.GetMentions()) + } + + if len(allUserMessages) > 0 { + avgMentions := float64(mentionCount) / float64(len(allUserMessages)) + fmt.Printf("- Average mentions per message: %.2f\n", avgMentions) + } + } + +# Performance Considerations + +## Efficient Queries + +All time-based queries are optimized: + + // Recent messages are efficiently ordered + recent, _ := babble.Recent(db, 100) // Uses LIMIT for efficiency + + // Time-based queries use indexed timestamp + since, _ := babble.Since(db, timestamp) // Efficient with proper index + + // Author queries support case-insensitive search + authorMessages, _ := babble.ByAuthor(db, "username") // Uses LOWER() function + +## Memory Management + +For large chat histories, process in batches: + + // Process messages in batches + func processAllMessages(db *database.DB, batchSize int) { + allMessages, _ := babble.All(db) + + for i := 0; i < len(allMessages); i += batchSize { + end := i + batchSize + if end > len(allMessages) { + end = len(allMessages) + } + + batch := allMessages[i:end] + processBatch(batch) + } + } + +# Integration Examples + +## Real-Time Chat + +Implement live chat updates: + + func pollForNewMessages(db *database.DB, lastMessageID int) []*babble.Babble { + // Get messages newer than last seen + allMessages, _ := babble.All(db) + + newMessages := make([]*babble.Babble, 0) + for _, msg := range allMessages { + if msg.ID > lastMessageID { + newMessages = append(newMessages, msg) + } + } + + return newMessages + } + +## Chat Commands + +Process special chat commands: + + func processMessage(db *database.DB, author, content string) { + // Check for commands + if strings.HasPrefix(content, "/") { + handleCommand(author, content) + return + } + + // Regular chat message + msg, err := babble.NewBuilder(db). + WithAuthor(author). + WithBabble(content). + Create() + + if err != nil { + log.Printf("Failed to save message: %v", err) + return + } + + // Process mentions for notifications + mentions := msg.GetMentions() + for _, username := range mentions { + sendMentionNotification(username, msg) + } + } + +# Error Handling + +All functions return appropriate errors for common failure cases: + - Message not found (Find returns error for non-existent IDs) + - Database connection issues + - Invalid operations (e.g., saving/deleting messages without IDs) + - Search query errors + - Time range validation errors +*/ +package babble \ No newline at end of file