feat(messages): implement integrated chat with voice/calls and tidy root go module

Add Discord-like messaging APIs, websocket realtime, smart suggestions, password vault flows, semantic indexing integration, and new /app/messages UI.

Add typing indicators, advanced message search filters, voice notes, browser-local optional transcription, and WebRTC call signaling (offer/answer/ice/hangup).

Clean root go.mod via go mod tidy and remove stale root go.sum.
This commit is contained in:
Tomas Dvorak
2026-02-26 10:54:19 +01:00
parent 55d0284b2a
commit 4c812e376d
18 changed files with 5296 additions and 152 deletions
+4 -3
View File
@@ -3,20 +3,23 @@ module github.com/trackeep/backend
go 1.24.0
require (
github.com/PuerkitoBio/goquery v1.11.0
github.com/chromedp/chromedp v0.9.3
github.com/gin-gonic/gin v1.9.1
github.com/go-redis/redis/v8 v8.11.5
github.com/gocolly/colly/v2 v2.3.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/pquerna/otp v1.5.0
golang.org/x/crypto v0.47.0
golang.org/x/net v0.48.0
golang.org/x/oauth2 v0.17.0
gorm.io/driver/postgres v1.5.4
gorm.io/gorm v1.25.5
)
require (
github.com/PuerkitoBio/goquery v1.11.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/antchfx/htmlquery v1.3.5 // indirect
github.com/antchfx/xmlquery v1.5.0 // indirect
@@ -34,7 +37,6 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
@@ -65,7 +67,6 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
+2
View File
@@ -77,6 +77,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+8
View File
@@ -137,6 +137,11 @@ func AuthMiddleware() gin.HandlerFunc {
}
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
if tokenParam := c.Query("token"); tokenParam != "" {
authHeader = "Bearer " + tokenParam
}
}
if authHeader == "" {
c.JSON(401, gin.H{"error": "Authorization header required"})
c.Abort()
@@ -230,6 +235,9 @@ func Register(c *gin.Context) {
return
}
// Provision messaging defaults (self chat, password vault, global channels).
_ = ensureMessagingDefaults(db, user.ID)
// Generate JWT token
token, err := GenerateJWT(user)
if err != nil {
File diff suppressed because it is too large Load Diff
+233 -67
View File
@@ -17,7 +17,7 @@ import (
// SemanticSearchRequest represents a semantic search request
type SemanticSearchRequest struct {
Query string `json:"query" binding:"required"`
ContentType string `json:"content_type"` // 'all' | 'bookmarks' | 'tasks' | 'notes' | 'files'
ContentType string `json:"content_type"` // all | bookmarks | tasks | notes | files | calendar_events | youtube_videos | learning_paths | chat_messages
Limit int `json:"limit"`
Threshold float64 `json:"threshold"` // Similarity threshold (0-1)
}
@@ -32,24 +32,24 @@ type SemanticSearchResponse struct {
// SemanticSearchResult represents a semantic search result
type SemanticSearchResult struct {
ID uint `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
Content string `json:"content"`
Similarity float64 `json:"similarity"`
Highlights []string `json:"highlights"`
Tags []models.Tag `json:"tags,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
URL string `json:"url,omitempty"`
Status string `json:"status,omitempty"`
Priority string `json:"priority,omitempty"`
ID uint `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
Content string `json:"content"`
Similarity float64 `json:"similarity"`
Highlights []string `json:"highlights"`
Tags []models.Tag `json:"tags,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
URL string `json:"url,omitempty"`
Status string `json:"status,omitempty"`
Priority string `json:"priority,omitempty"`
}
// GenerateEmbeddingRequest represents request to generate embeddings
type GenerateEmbeddingRequest struct {
Text string `json:"text" binding:"required"`
Text string `json:"text" binding:"required"`
ContentType string `json:"content_type"`
ContentID uint `json:"content_id"`
}
@@ -87,7 +87,7 @@ func SemanticSearch(c *gin.Context) {
queryEmbedding, err := generateEmbedding(req.Query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate query embedding",
"error": "Failed to generate query embedding",
"details": err.Error(),
})
return
@@ -97,7 +97,7 @@ func SemanticSearch(c *gin.Context) {
results, err := findSimilarContent(db, userID, queryEmbedding, req.ContentType, req.Limit, req.Threshold)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to search similar content",
"error": "Failed to search similar content",
"details": err.Error(),
})
return
@@ -127,7 +127,7 @@ func GenerateEmbedding(c *gin.Context) {
embedding, err := generateEmbedding(req.Text)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate embedding",
"error": "Failed to generate embedding",
"details": err.Error(),
})
return
@@ -139,15 +139,15 @@ func GenerateEmbedding(c *gin.Context) {
userID := c.GetUint("user_id")
embeddingJSON, _ := json.Marshal(embedding)
contentEmbedding := models.ContentEmbedding{
ContentType: req.ContentType,
ContentID: req.ContentID,
Embedding: string(embeddingJSON),
Model: "text-embedding-ada-002",
Dimensions: len(embedding),
TextContent: req.Text,
UserID: userID,
ContentType: req.ContentType,
ContentID: req.ContentID,
Embedding: string(embeddingJSON),
Model: "text-embedding-ada-002",
Dimensions: len(embedding),
TextContent: req.Text,
UserID: userID,
}
if err := db.Create(&contentEmbedding).Error; err != nil {
@@ -179,7 +179,7 @@ func ReindexContent(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "Content reindexing started in background",
"status": "processing",
"status": "processing",
})
}
@@ -188,13 +188,13 @@ func generateEmbedding(text string) ([]float64, error) {
// TODO: Replace with actual OpenAI API call
// For now, return a mock embedding for demonstration
embedding := make([]float64, 1536) // OpenAI embedding dimensions
// Generate pseudo-random but deterministic embedding based on text
hash := simpleHash(text)
for i := range embedding {
embedding[i] = math.Sin(float64(hash+i)) * 0.5
}
return embedding, nil
}
@@ -214,11 +214,11 @@ func findSimilarContent(db *gorm.DB, userID uint, queryEmbedding []float64, cont
// Get all embeddings for the user
var embeddings []models.ContentEmbedding
query := db.Where("user_id = ?", userID)
if contentType != "all" && contentType != "" {
query = query.Where("content_type = ?", contentType)
query = query.Where("content_type = ?", normalizeSemanticContentType(contentType))
}
if err := query.Find(&embeddings).Error; err != nil {
return results, err
}
@@ -228,15 +228,15 @@ func findSimilarContent(db *gorm.DB, userID uint, queryEmbedding []float64, cont
embedding models.ContentEmbedding
score float64
}
var scores []similarityScore
for _, embedding := range embeddings {
var storedEmbedding []float64
if err := json.Unmarshal([]byte(embedding.Embedding), &storedEmbedding); err != nil {
continue
}
similarity := cosineSimilarity(queryEmbedding, storedEmbedding)
if similarity >= threshold {
scores = append(scores, similarityScore{
@@ -279,17 +279,17 @@ func cosineSimilarity(a, b []float64) float64 {
}
var dotProduct, normA, normB float64
for i := range a {
dotProduct += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
if normA == 0 || normB == 0 {
return 0
}
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
}
@@ -305,7 +305,7 @@ func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, s
if err := db.Preload("Tags").First(&bookmark, embedding.ContentID).Error; err != nil {
return result, err
}
result.ID = bookmark.ID
result.Type = "bookmark"
result.Title = bookmark.Title
@@ -321,7 +321,7 @@ func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, s
if err := db.Preload("Tags").First(&task, embedding.ContentID).Error; err != nil {
return result, err
}
result.ID = task.ID
result.Type = "task"
result.Title = task.Title
@@ -337,7 +337,7 @@ func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, s
if err := db.Preload("Tags").First(&note, embedding.ContentID).Error; err != nil {
return result, err
}
result.ID = note.ID
result.Type = "note"
result.Title = note.Title
@@ -352,7 +352,7 @@ func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, s
if err := db.Preload("Tags").First(&file, embedding.ContentID).Error; err != nil {
return result, err
}
result.ID = file.ID
result.Type = "file"
result.Title = file.OriginalName
@@ -361,6 +361,68 @@ func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, s
result.Tags = file.Tags
result.CreatedAt = file.CreatedAt
result.UpdatedAt = file.UpdatedAt
case "calendar_event":
var event models.CalendarEvent
if err := db.First(&event, embedding.ContentID).Error; err != nil {
return result, err
}
result.ID = event.ID
result.Type = "calendar_event"
result.Title = event.Title
result.Description = event.Description
result.Content = event.Description
result.CreatedAt = event.CreatedAt
result.UpdatedAt = event.UpdatedAt
result.Priority = event.Priority
case "youtube_video":
var video models.VideoBookmark
if err := db.First(&video, embedding.ContentID).Error; err != nil {
return result, err
}
result.ID = video.ID
result.Type = "youtube_video"
result.Title = video.Title
result.Description = video.Description
result.Content = video.Description
result.CreatedAt = video.CreatedAt
result.UpdatedAt = video.UpdatedAt
result.URL = video.URL
case "learning_path":
var path models.LearningPath
if err := db.First(&path, embedding.ContentID).Error; err != nil {
return result, err
}
result.ID = path.ID
result.Type = "learning_path"
result.Title = path.Title
result.Description = path.Description
result.Content = path.Description
result.CreatedAt = path.CreatedAt
result.UpdatedAt = path.UpdatedAt
case "chat_message":
var message models.Message
if err := db.First(&message, embedding.ContentID).Error; err != nil {
return result, err
}
if message.IsSensitive {
return result, fmt.Errorf("sensitive message excluded from semantic search")
}
result.ID = message.ID
result.Type = "chat_message"
result.Title = "Chat message"
result.Description = compactSemanticText(message.Body, 140)
result.Content = message.Body
result.CreatedAt = message.CreatedAt
result.UpdatedAt = message.UpdatedAt
result.URL = fmt.Sprintf("/app/messages?conversationId=%d&messageId=%d", message.ConversationID, message.ID)
}
// Generate highlights (simplified)
@@ -402,35 +464,139 @@ func reindexUserContent(db *gorm.DB, userID uint) {
// Reindex bookmarks
var bookmarks []models.Bookmark
db.Where("user_id = ?", userID).Find(&bookmarks)
for _, bookmark := range bookmarks {
text := bookmark.Title + " " + bookmark.Description + " " + bookmark.Content
embedding, err := generateEmbedding(text)
if err != nil {
continue
}
embeddingJSON, _ := json.Marshal(embedding)
contentEmbedding := models.ContentEmbedding{
ContentType: "bookmark",
ContentID: bookmark.ID,
Embedding: string(embeddingJSON),
Model: "text-embedding-ada-002",
Dimensions: len(embedding),
TextContent: text,
UserID: userID,
}
// Delete existing embedding for this content
db.Where("content_type = ? AND content_id = ?", "bookmark", bookmark.ID).Delete(&models.ContentEmbedding{})
// Create new embedding
db.Create(&contentEmbedding)
upsertEmbedding(db, userID, "bookmark", bookmark.ID, text)
}
// Similar reindexing for tasks, notes, files...
// TODO: Implement reindexing for other content types
// Tasks
var tasks []models.Task
db.Where("user_id = ?", userID).Find(&tasks)
for _, task := range tasks {
text := task.Title + " " + task.Description
upsertEmbedding(db, userID, "task", task.ID, text)
}
// Notes
var notes []models.Note
db.Where("user_id = ?", userID).Find(&notes)
for _, note := range notes {
if note.IsEncrypted {
continue
}
text := note.Title + " " + note.Description + " " + note.Content
upsertEmbedding(db, userID, "note", note.ID, text)
}
// Files
var files []models.File
db.Where("user_id = ?", userID).Find(&files)
for _, file := range files {
text := file.OriginalName + " " + file.Description + " " + file.Content
upsertEmbedding(db, userID, "file", file.ID, text)
}
// Calendar events
var events []models.CalendarEvent
db.Where("user_id = ?", userID).Find(&events)
for _, event := range events {
text := event.Title + " " + event.Description + " " + event.Type + " " + event.Priority
upsertEmbedding(db, userID, "calendar_event", event.ID, text)
}
// YouTube bookmarks
var videos []models.VideoBookmark
db.Where("user_id = ?", userID).Find(&videos)
for _, video := range videos {
text := video.Title + " " + video.Description + " " + video.Channel + " " + video.URL
upsertEmbedding(db, userID, "youtube_video", video.ID, text)
}
// Learning paths
var learningPaths []models.LearningPath
db.Where("creator_id = ?", userID).Find(&learningPaths)
for _, path := range learningPaths {
text := path.Title + " " + path.Description + " " + path.Category + " " + path.Difficulty
upsertEmbedding(db, userID, "learning_path", path.ID, text)
}
// Chat messages (skip sensitive/vault content)
var messages []models.Message
db.Model(&models.Message{}).
Joins("JOIN conversation_members cm ON cm.conversation_id = messages.conversation_id").
Joins("JOIN conversations ON conversations.id = messages.conversation_id").
Where("cm.user_id = ?", userID).
Where("conversations.type <> ?", models.ConversationTypePasswordVault).
Where("messages.deleted_at IS NULL").
Find(&messages)
for _, message := range messages {
if message.IsSensitive {
continue
}
upsertEmbedding(db, userID, "chat_message", message.ID, message.Body)
}
fmt.Printf("Reindexing completed for user %d\n", userID)
}
func upsertEmbedding(db *gorm.DB, userID uint, contentType string, contentID uint, text string) {
text = strings.TrimSpace(text)
if text == "" {
return
}
embedding, err := generateEmbedding(text)
if err != nil {
return
}
embeddingJSON, _ := json.Marshal(embedding)
contentEmbedding := models.ContentEmbedding{
ContentType: contentType,
ContentID: contentID,
Embedding: string(embeddingJSON),
Model: "text-embedding-ada-002",
Dimensions: len(embedding),
TextContent: text,
UserID: userID,
}
db.Where("content_type = ? AND content_id = ? AND user_id = ?", contentType, contentID, userID).Delete(&models.ContentEmbedding{})
db.Create(&contentEmbedding)
}
func normalizeSemanticContentType(contentType string) string {
switch strings.ToLower(strings.TrimSpace(contentType)) {
case "bookmarks":
return "bookmark"
case "tasks":
return "task"
case "notes":
return "note"
case "files":
return "file"
case "calendar_events":
return "calendar_event"
case "youtube_videos":
return "youtube_video"
case "learning_paths":
return "learning_path"
case "chat_messages":
return "chat_message"
default:
return strings.ToLower(strings.TrimSpace(contentType))
}
}
func compactSemanticText(text string, limit int) string {
text = strings.TrimSpace(text)
if len(text) <= limit {
return text
}
if limit < 4 {
return text
}
return strings.TrimSpace(text[:limit-3]) + "..."
}
+29
View File
@@ -349,6 +349,35 @@ func main() {
chat.DELETE("/sessions/:id", handlers.DeleteSession)
}
// Messaging routes (Discord-like user communication)
messages := v1.Group("/messages")
messages.Use(handlers.AuthMiddleware())
{
messages.GET("/conversations", handlers.GetConversations)
messages.POST("/conversations", handlers.CreateConversation)
messages.GET("/conversations/:id", handlers.GetConversation)
messages.PATCH("/conversations/:id", handlers.UpdateConversation)
messages.POST("/conversations/:id/members", handlers.AddConversationMember)
messages.DELETE("/conversations/:id/members/:userId", handlers.RemoveConversationMember)
messages.GET("/conversations/:id/messages", handlers.GetConversationMessages)
messages.POST("/conversations/:id/messages", handlers.CreateConversationMessage)
messages.PATCH("/messages/:id", handlers.UpdateMessage)
messages.DELETE("/messages/:id", handlers.DeleteMessage)
messages.POST("/messages/:id/reactions", handlers.AddMessageReaction)
messages.DELETE("/messages/:id/reactions/:emoji", handlers.RemoveMessageReaction)
messages.POST("/messages/search", handlers.SearchMessages)
messages.GET("/messages/:id/suggestions", handlers.GetMessageSuggestions)
messages.POST("/messages/:id/suggestions/:suggestionId/accept", handlers.AcceptMessageSuggestion)
messages.POST("/messages/:id/suggestions/:suggestionId/dismiss", handlers.DismissMessageSuggestion)
messages.GET("/ws", handlers.MessagesWebSocket)
messages.GET("/password-vault/items", handlers.GetPasswordVaultItems)
messages.POST("/password-vault/items", handlers.CreatePasswordVaultItem)
messages.POST("/password-vault/items/:id/share", handlers.SharePasswordVaultItem)
messages.POST("/password-vault/items/:id/reveal", handlers.RevealPasswordVaultItem)
messages.POST("/password-vault/items/:id/unshare", handlers.UnsharePasswordVaultItem)
}
// Member routes (protected)
members := v1.Group("/members")
members.Use(handlers.AuthMiddleware())
+200
View File
@@ -0,0 +1,200 @@
package models
import (
"time"
"gorm.io/gorm"
)
// ConversationType represents the type of a conversation.
type ConversationType string
const (
ConversationTypeGlobal ConversationType = "global"
ConversationTypeTeam ConversationType = "team"
ConversationTypeGroup ConversationType = "group"
ConversationTypeDM ConversationType = "dm"
ConversationTypeSelf ConversationType = "self"
ConversationTypePasswordVault ConversationType = "password_vault"
)
// ConversationMemberRole represents the role of a user in a conversation.
type ConversationMemberRole string
const (
ConversationMemberRoleOwner ConversationMemberRole = "owner"
ConversationMemberRoleAdmin ConversationMemberRole = "admin"
ConversationMemberRoleMember ConversationMemberRole = "member"
ConversationMemberRoleViewer ConversationMemberRole = "viewer"
)
// SuggestionStatus is the lifecycle state of a message suggestion.
type SuggestionStatus string
const (
SuggestionStatusPending SuggestionStatus = "pending"
SuggestionStatusAccepted SuggestionStatus = "accepted"
SuggestionStatusDismissed SuggestionStatus = "dismissed"
)
// Conversation is a user-to-user chat space (global/team/group/dm/self/password).
type Conversation struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
Type ConversationType `json:"type" gorm:"not null;index"`
Name string `json:"name" gorm:"not null"`
Topic string `json:"topic"`
TeamID *uint `json:"team_id,omitempty" gorm:"index"`
Team *Team `json:"team,omitempty" gorm:"foreignKey:TeamID"`
CreatedBy uint `json:"created_by" gorm:"not null;index"`
Creator User `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"`
IsDefault bool `json:"is_default" gorm:"default:false;index"`
IsArchived bool `json:"is_archived" gorm:"default:false;index"`
LastMessageAt *time.Time `json:"last_message_at"`
Members []ConversationMember `json:"members,omitempty" gorm:"foreignKey:ConversationID"`
Messages []Message `json:"messages,omitempty" gorm:"foreignKey:ConversationID"`
}
// ConversationMember links users to conversations.
type ConversationMember struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
ConversationID uint `json:"conversation_id" gorm:"not null;index:idx_conv_member,unique"`
UserID uint `json:"user_id" gorm:"not null;index:idx_conv_member,unique"`
Role ConversationMemberRole `json:"role" gorm:"not null;default:member"`
JoinedAt time.Time `json:"joined_at"`
LastReadMessageID *uint `json:"last_read_message_id,omitempty" gorm:"index"`
LastReadAt *time.Time `json:"last_read_at,omitempty"`
MutedUntil *time.Time `json:"muted_until,omitempty"`
IsHidden bool `json:"is_hidden" gorm:"default:false"`
Conversation Conversation `json:"conversation,omitempty" gorm:"foreignKey:ConversationID"`
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
}
// Message is a single chat message in a conversation.
type Message struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ConversationID uint `json:"conversation_id" gorm:"not null;index"`
Conversation Conversation `json:"conversation,omitempty" gorm:"foreignKey:ConversationID"`
SenderID uint `json:"sender_id" gorm:"not null;index"`
Sender User `json:"sender,omitempty" gorm:"foreignKey:SenderID"`
Body string `json:"body" gorm:"type:text"`
IsSensitive bool `json:"is_sensitive" gorm:"default:false"`
EditedAt *time.Time `json:"edited_at,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty" gorm:"index"`
MetadataJSON string `json:"metadata_json" gorm:"type:text"`
Attachments []MessageAttachment `json:"attachments,omitempty" gorm:"foreignKey:MessageID"`
References []MessageReference `json:"references,omitempty" gorm:"foreignKey:MessageID"`
Suggestions []MessageSuggestion `json:"suggestions,omitempty" gorm:"foreignKey:MessageID"`
Reactions []MessageReaction `json:"reactions,omitempty" gorm:"foreignKey:MessageID"`
}
// MessageAttachment represents file/link-style message attachments.
type MessageAttachment struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MessageID uint `json:"message_id" gorm:"not null;index"`
Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"`
Kind string `json:"kind" gorm:"not null;index"` // file,image,youtube,github,website,bookmark,task,event,calendar,activity,learning_path,saved_search,voice_note
FileID *uint `json:"file_id,omitempty" gorm:"index"`
URL string `json:"url"`
Title string `json:"title"`
PreviewJSON string `json:"preview_json" gorm:"type:text"`
}
// MessageReference maps chat messages to Trackeep entities.
type MessageReference struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MessageID uint `json:"message_id" gorm:"not null;index"`
Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"`
EntityType string `json:"entity_type" gorm:"not null;index"`
EntityID uint `json:"entity_id" gorm:"not null;index"`
DeepLink string `json:"deep_link" gorm:"not null"`
}
// MessageSuggestion stores non-blocking smart suggestions triggered by message text.
type MessageSuggestion struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MessageID uint `json:"message_id" gorm:"not null;index"`
Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"`
Type string `json:"type" gorm:"not null;index"` // create_task, create_event, save_bookmark, ...
PayloadJSON string `json:"payload_json" gorm:"type:text"`
Status SuggestionStatus `json:"status" gorm:"not null;default:pending;index"`
}
// MessageReaction stores emoji reactions.
type MessageReaction struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MessageID uint `json:"message_id" gorm:"not null;index:idx_message_reaction,unique"`
Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"`
UserID uint `json:"user_id" gorm:"not null;index:idx_message_reaction,unique"`
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
Emoji string `json:"emoji" gorm:"not null;index:idx_message_reaction,unique"`
}
// PasswordVaultItem is encrypted secret data owned by a user.
type PasswordVaultItem struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
OwnerUserID uint `json:"owner_user_id" gorm:"not null;index"`
OwnerUser User `json:"owner_user,omitempty" gorm:"foreignKey:OwnerUserID"`
Label string `json:"label" gorm:"not null"`
EncryptedSecret string `json:"-" gorm:"type:text;not null"`
EncryptedNotes string `json:"-" gorm:"type:text"`
SourceMessageID *uint `json:"source_message_id,omitempty" gorm:"index"`
SourceMessage *Message `json:"source_message,omitempty" gorm:"foreignKey:SourceMessageID"`
CreatedBy uint `json:"created_by" gorm:"not null;index"`
LastAccessedAt *time.Time `json:"last_accessed_at,omitempty"`
Shares []PasswordVaultShare `json:"shares,omitempty" gorm:"foreignKey:VaultItemID"`
}
// PasswordVaultShare controls explicit sharing of vault items.
type PasswordVaultShare struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
VaultItemID uint `json:"vault_item_id" gorm:"not null;index"`
VaultItem PasswordVaultItem `json:"vault_item,omitempty" gorm:"foreignKey:VaultItemID"`
SharedByUserID uint `json:"shared_by_user_id" gorm:"not null;index"`
SharedByUser User `json:"shared_by_user,omitempty" gorm:"foreignKey:SharedByUserID"`
TargetConversationID uint `json:"target_conversation_id" gorm:"not null;index"`
TargetConversation Conversation `json:"target_conversation,omitempty" gorm:"foreignKey:TargetConversationID"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
AllowReveal bool `json:"allow_reveal" gorm:"default:false"`
}
+10
View File
@@ -113,5 +113,15 @@ func AutoMigrate() {
&YouTubeChannelCache{},
// Video bookmark models
&VideoBookmark{},
// Messaging models
&Conversation{},
&ConversationMember{},
&Message{},
&MessageAttachment{},
&MessageReference{},
&MessageSuggestion{},
&MessageReaction{},
&PasswordVaultItem{},
&PasswordVaultShare{},
)
}
+139
View File
@@ -0,0 +1,139 @@
package services
import (
"net/url"
"regexp"
"strings"
)
// DetectedSuggestion represents a suggestion detected from message text.
type DetectedSuggestion struct {
Type string `json:"type"`
Payload map[string]interface{} `json:"payload"`
}
// DetectedAttachment is an inferred attachment from message content.
type DetectedAttachment struct {
Kind string `json:"kind"`
URL string `json:"url"`
Title string `json:"title"`
PreviewMap map[string]interface{} `json:"preview_map"`
}
var (
urlRegex = regexp.MustCompile(`https?://[^\s]+`)
passwordRegex = regexp.MustCompile(`(?i)(password|pass:|pwd|api[_-]?key|access[_-]?token|secret|bearer\s+[a-z0-9\-_\.]+)`)
taskIntentRegex = regexp.MustCompile(`(?i)(todo|to do|task|need to|should|must|remember to|follow up)`)
eventIntentRegex = regexp.MustCompile(`(?i)(meeting|calendar|event|schedule|tomorrow|next week|deadline|at [0-9]{1,2}(:[0-9]{2})?\s?(am|pm)?)`)
searchIntentRegex = regexp.MustCompile(`(?i)(search for|track query|alert me for|watch for|monitor query)`)
)
// DetectMessageContent inspects a message and returns suggestions, inferred attachments,
// and whether the message appears sensitive.
func DetectMessageContent(body string) ([]DetectedSuggestion, []DetectedAttachment, bool) {
trimmed := strings.TrimSpace(body)
if trimmed == "" {
return nil, nil, false
}
suggestions := make([]DetectedSuggestion, 0, 8)
attachments := make([]DetectedAttachment, 0, 8)
seenSuggestion := map[string]bool{}
// URL and service detections
for _, rawURL := range urlRegex.FindAllString(trimmed, -1) {
u, err := url.Parse(rawURL)
if err != nil {
continue
}
host := strings.ToLower(u.Host)
kind := "website"
sType := "save_bookmark"
title := rawURL
switch {
case strings.Contains(host, "youtube.com") || strings.Contains(host, "youtu.be"):
kind = "youtube"
sType = "save_youtube"
case strings.Contains(host, "github.com"):
kind = "github"
sType = "link_github"
}
attachments = append(attachments, DetectedAttachment{
Kind: kind,
URL: rawURL,
Title: title,
PreviewMap: map[string]interface{}{
"host": host,
},
})
key := sType + ":" + rawURL
if !seenSuggestion[key] {
seenSuggestion[key] = true
suggestions = append(suggestions, DetectedSuggestion{
Type: sType,
Payload: map[string]interface{}{
"url": rawURL,
"title": title,
},
})
}
}
if taskIntentRegex.MatchString(trimmed) {
suggestions = append(suggestions, DetectedSuggestion{
Type: "create_task",
Payload: map[string]interface{}{
"title": buildCompactTitle(trimmed, 80),
"from_text": trimmed,
},
})
}
if eventIntentRegex.MatchString(trimmed) {
suggestions = append(suggestions, DetectedSuggestion{
Type: "create_event",
Payload: map[string]interface{}{
"title": buildCompactTitle(trimmed, 80),
"from_text": trimmed,
},
})
}
if searchIntentRegex.MatchString(trimmed) {
suggestions = append(suggestions, DetectedSuggestion{
Type: "save_search",
Payload: map[string]interface{}{
"query": trimmed,
},
})
}
isSensitive := passwordRegex.MatchString(trimmed)
if isSensitive {
suggestions = append(suggestions, DetectedSuggestion{
Type: "password_warning",
Payload: map[string]interface{}{
"message": "Sensitive data detected. We recommend a dedicated password manager like Proton Pass (not affiliated).",
},
})
suggestions = append(suggestions, DetectedSuggestion{
Type: "move_to_password_vault",
Payload: map[string]interface{}{
"message": "Move this message to your encrypted password vault.",
},
})
}
return suggestions, attachments, isSensitive
}
func buildCompactTitle(input string, limit int) string {
s := strings.TrimSpace(input)
if len(s) <= limit {
return s
}
return strings.TrimSpace(s[:limit-3]) + "..."
}
+83
View File
@@ -0,0 +1,83 @@
package services
import "testing"
func TestDetectMessageContent_URLsAndSuggestions(t *testing.T) {
body := "Check this out https://github.com/trackeep/backend and video https://youtu.be/dQw4w9WgXcQ"
suggestions, attachments, isSensitive := DetectMessageContent(body)
if isSensitive {
t.Fatalf("expected non-sensitive message")
}
if len(attachments) < 2 {
t.Fatalf("expected at least 2 attachments, got %d", len(attachments))
}
hasGitHub := false
hasYouTube := false
for _, s := range suggestions {
if s.Type == "link_github" {
hasGitHub = true
}
if s.Type == "save_youtube" {
hasYouTube = true
}
}
if !hasGitHub {
t.Fatalf("expected link_github suggestion")
}
if !hasYouTube {
t.Fatalf("expected save_youtube suggestion")
}
}
func TestDetectMessageContent_TaskAndEventIntents(t *testing.T) {
body := "TODO: schedule meeting tomorrow at 10am to review the release plan"
suggestions, _, _ := DetectMessageContent(body)
hasTask := false
hasEvent := false
for _, s := range suggestions {
if s.Type == "create_task" {
hasTask = true
}
if s.Type == "create_event" {
hasEvent = true
}
}
if !hasTask {
t.Fatalf("expected create_task suggestion")
}
if !hasEvent {
t.Fatalf("expected create_event suggestion")
}
}
func TestDetectMessageContent_PasswordWarning(t *testing.T) {
body := "password: SuperSecret123!"
suggestions, _, isSensitive := DetectMessageContent(body)
if !isSensitive {
t.Fatalf("expected sensitive message")
}
hasWarning := false
hasVaultMove := false
for _, s := range suggestions {
if s.Type == "password_warning" {
hasWarning = true
}
if s.Type == "move_to_password_vault" {
hasVaultMove = true
}
}
if !hasWarning {
t.Fatalf("expected password_warning suggestion")
}
if !hasVaultMove {
t.Fatalf("expected move_to_password_vault suggestion")
}
}
+172
View File
@@ -0,0 +1,172 @@
package services
import (
"encoding/json"
"sync"
"time"
"github.com/gorilla/websocket"
)
// WsEvent is a realtime event payload emitted by the messaging hub.
type WsEvent struct {
Type string `json:"type"`
ConversationID uint `json:"conversation_id,omitempty"`
Data interface{} `json:"data,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// MessagesWSClient represents one websocket connection.
type MessagesWSClient struct {
UserID uint
Conn *websocket.Conn
Send chan []byte
Conversations map[uint]struct{}
}
// MessagesHub coordinates room-based websocket fanout.
type MessagesHub struct {
mu sync.RWMutex
conversationClients map[uint]map[*MessagesWSClient]struct{}
clientConversations map[*MessagesWSClient]map[uint]struct{}
}
var defaultMessagesHub = NewMessagesHub()
// GetMessagesHub returns the shared messaging websocket hub.
func GetMessagesHub() *MessagesHub {
return defaultMessagesHub
}
// NewMessagesHub creates a new messages websocket hub.
func NewMessagesHub() *MessagesHub {
return &MessagesHub{
conversationClients: make(map[uint]map[*MessagesWSClient]struct{}),
clientConversations: make(map[*MessagesWSClient]map[uint]struct{}),
}
}
// NewWSClient creates a ws client wrapper.
func NewWSClient(userID uint, conn *websocket.Conn) *MessagesWSClient {
return &MessagesWSClient{
UserID: userID,
Conn: conn,
Send: make(chan []byte, 128),
Conversations: make(map[uint]struct{}),
}
}
// AddClientToConversation subscribes a client to a conversation room.
func (h *MessagesHub) AddClientToConversation(client *MessagesWSClient, conversationID uint) {
h.mu.Lock()
defer h.mu.Unlock()
if _, exists := h.conversationClients[conversationID]; !exists {
h.conversationClients[conversationID] = make(map[*MessagesWSClient]struct{})
}
h.conversationClients[conversationID][client] = struct{}{}
if _, exists := h.clientConversations[client]; !exists {
h.clientConversations[client] = make(map[uint]struct{})
}
h.clientConversations[client][conversationID] = struct{}{}
client.Conversations[conversationID] = struct{}{}
}
// RemoveClientFromConversation unsubscribes a client from one room.
func (h *MessagesHub) RemoveClientFromConversation(client *MessagesWSClient, conversationID uint) {
h.mu.Lock()
defer h.mu.Unlock()
if clients, exists := h.conversationClients[conversationID]; exists {
delete(clients, client)
if len(clients) == 0 {
delete(h.conversationClients, conversationID)
}
}
if convs, exists := h.clientConversations[client]; exists {
delete(convs, conversationID)
if len(convs) == 0 {
delete(h.clientConversations, client)
}
}
delete(client.Conversations, conversationID)
}
// RemoveClient fully unregisters a client from all rooms.
func (h *MessagesHub) RemoveClient(client *MessagesWSClient) {
h.mu.Lock()
defer h.mu.Unlock()
if convs, exists := h.clientConversations[client]; exists {
for convID := range convs {
if clients, ok := h.conversationClients[convID]; ok {
delete(clients, client)
if len(clients) == 0 {
delete(h.conversationClients, convID)
}
}
}
delete(h.clientConversations, client)
}
close(client.Send)
}
// Broadcast emits an event to all clients in one conversation room.
func (h *MessagesHub) Broadcast(conversationID uint, eventType string, data interface{}) {
event := WsEvent{
Type: eventType,
ConversationID: conversationID,
Data: data,
Timestamp: time.Now(),
}
raw, err := json.Marshal(event)
if err != nil {
return
}
h.mu.RLock()
clients := h.conversationClients[conversationID]
h.mu.RUnlock()
for client := range clients {
select {
case client.Send <- raw:
default:
go h.RemoveClient(client)
}
}
}
// SendToUser emits an event to one user in a conversation room.
func (h *MessagesHub) SendToUser(conversationID, userID uint, eventType string, data interface{}) {
event := WsEvent{
Type: eventType,
ConversationID: conversationID,
Data: data,
Timestamp: time.Now(),
}
raw, err := json.Marshal(event)
if err != nil {
return
}
h.mu.RLock()
clients := h.conversationClients[conversationID]
h.mu.RUnlock()
for client := range clients {
if client.UserID != userID {
continue
}
select {
case client.Send <- raw:
default:
go h.RemoveClient(client)
}
}
}