mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-03 20:12:58 +00:00
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:
@@ -67,6 +67,7 @@ Every feature you see is something I personally needed and use. Your feedback, b
|
|||||||
|
|
||||||
### Advanced Features
|
### Advanced Features
|
||||||
- **AI-Powered Recommendations**: Intelligent content suggestions and organization
|
- **AI-Powered Recommendations**: Intelligent content suggestions and organization
|
||||||
|
- **Integrated Messaging (V1)**: Discord-style conversations (self chat, DMs, groups, team channels, global channels), realtime updates, smart suggestions, deep-link references, encrypted password vault sharing, voice notes, and browser-local optional transcription/call signaling
|
||||||
- **OAuth Integration**: Secure authentication with GitHub and other providers
|
- **OAuth Integration**: Secure authentication with GitHub and other providers
|
||||||
- **Mobile App**: Native React Native application for iOS and Android
|
- **Mobile App**: Native React Native application for iOS and Android
|
||||||
- **Email Ingestion**: Send/forward emails to automatically import content
|
- **Email Ingestion**: Send/forward emails to automatically import content
|
||||||
|
|||||||
+4
-3
@@ -3,20 +3,23 @@ module github.com/trackeep/backend
|
|||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/PuerkitoBio/goquery v1.11.0
|
||||||
github.com/chromedp/chromedp v0.9.3
|
github.com/chromedp/chromedp v0.9.3
|
||||||
github.com/gin-gonic/gin v1.9.1
|
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/gocolly/colly/v2 v2.3.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.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/joho/godotenv v1.5.1
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.47.0
|
||||||
|
golang.org/x/net v0.48.0
|
||||||
golang.org/x/oauth2 v0.17.0
|
golang.org/x/oauth2 v0.17.0
|
||||||
gorm.io/driver/postgres v1.5.4
|
gorm.io/driver/postgres v1.5.4
|
||||||
gorm.io/gorm v1.25.5
|
gorm.io/gorm v1.25.5
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.11.0 // indirect
|
|
||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
github.com/antchfx/htmlquery v1.3.5 // indirect
|
github.com/antchfx/htmlquery v1.3.5 // indirect
|
||||||
github.com/antchfx/xmlquery v1.5.0 // 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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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-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/glob v0.2.3 // indirect
|
||||||
github.com/gobwas/httphead v0.1.0 // indirect
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
github.com/gobwas/pool v0.2.1 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
golang.org/x/arch v0.3.0 // 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/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
google.golang.org/appengine v1.6.8 // indirect
|
google.golang.org/appengine v1.6.8 // indirect
|
||||||
|
|||||||
@@ -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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
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=
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ func AuthMiddleware() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authHeader := c.GetHeader("Authorization")
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
if tokenParam := c.Query("token"); tokenParam != "" {
|
||||||
|
authHeader = "Bearer " + tokenParam
|
||||||
|
}
|
||||||
|
}
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
c.JSON(401, gin.H{"error": "Authorization header required"})
|
c.JSON(401, gin.H{"error": "Authorization header required"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
@@ -230,6 +235,9 @@ func Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provision messaging defaults (self chat, password vault, global channels).
|
||||||
|
_ = ensureMessagingDefaults(db, user.ID)
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
token, err := GenerateJWT(user)
|
token, err := GenerateJWT(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ import (
|
|||||||
// SemanticSearchRequest represents a semantic search request
|
// SemanticSearchRequest represents a semantic search request
|
||||||
type SemanticSearchRequest struct {
|
type SemanticSearchRequest struct {
|
||||||
Query string `json:"query" binding:"required"`
|
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"`
|
Limit int `json:"limit"`
|
||||||
Threshold float64 `json:"threshold"` // Similarity threshold (0-1)
|
Threshold float64 `json:"threshold"` // Similarity threshold (0-1)
|
||||||
}
|
}
|
||||||
@@ -32,24 +32,24 @@ type SemanticSearchResponse struct {
|
|||||||
|
|
||||||
// SemanticSearchResult represents a semantic search result
|
// SemanticSearchResult represents a semantic search result
|
||||||
type SemanticSearchResult struct {
|
type SemanticSearchResult struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Similarity float64 `json:"similarity"`
|
Similarity float64 `json:"similarity"`
|
||||||
Highlights []string `json:"highlights"`
|
Highlights []string `json:"highlights"`
|
||||||
Tags []models.Tag `json:"tags,omitempty"`
|
Tags []models.Tag `json:"tags,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Priority string `json:"priority,omitempty"`
|
Priority string `json:"priority,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateEmbeddingRequest represents request to generate embeddings
|
// GenerateEmbeddingRequest represents request to generate embeddings
|
||||||
type GenerateEmbeddingRequest struct {
|
type GenerateEmbeddingRequest struct {
|
||||||
Text string `json:"text" binding:"required"`
|
Text string `json:"text" binding:"required"`
|
||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
ContentID uint `json:"content_id"`
|
ContentID uint `json:"content_id"`
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ func SemanticSearch(c *gin.Context) {
|
|||||||
queryEmbedding, err := generateEmbedding(req.Query)
|
queryEmbedding, err := generateEmbedding(req.Query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Failed to generate query embedding",
|
"error": "Failed to generate query embedding",
|
||||||
"details": err.Error(),
|
"details": err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -97,7 +97,7 @@ func SemanticSearch(c *gin.Context) {
|
|||||||
results, err := findSimilarContent(db, userID, queryEmbedding, req.ContentType, req.Limit, req.Threshold)
|
results, err := findSimilarContent(db, userID, queryEmbedding, req.ContentType, req.Limit, req.Threshold)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Failed to search similar content",
|
"error": "Failed to search similar content",
|
||||||
"details": err.Error(),
|
"details": err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -127,7 +127,7 @@ func GenerateEmbedding(c *gin.Context) {
|
|||||||
embedding, err := generateEmbedding(req.Text)
|
embedding, err := generateEmbedding(req.Text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Failed to generate embedding",
|
"error": "Failed to generate embedding",
|
||||||
"details": err.Error(),
|
"details": err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -139,15 +139,15 @@ func GenerateEmbedding(c *gin.Context) {
|
|||||||
userID := c.GetUint("user_id")
|
userID := c.GetUint("user_id")
|
||||||
|
|
||||||
embeddingJSON, _ := json.Marshal(embedding)
|
embeddingJSON, _ := json.Marshal(embedding)
|
||||||
|
|
||||||
contentEmbedding := models.ContentEmbedding{
|
contentEmbedding := models.ContentEmbedding{
|
||||||
ContentType: req.ContentType,
|
ContentType: req.ContentType,
|
||||||
ContentID: req.ContentID,
|
ContentID: req.ContentID,
|
||||||
Embedding: string(embeddingJSON),
|
Embedding: string(embeddingJSON),
|
||||||
Model: "text-embedding-ada-002",
|
Model: "text-embedding-ada-002",
|
||||||
Dimensions: len(embedding),
|
Dimensions: len(embedding),
|
||||||
TextContent: req.Text,
|
TextContent: req.Text,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&contentEmbedding).Error; err != nil {
|
if err := db.Create(&contentEmbedding).Error; err != nil {
|
||||||
@@ -179,7 +179,7 @@ func ReindexContent(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "Content reindexing started in background",
|
"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
|
// TODO: Replace with actual OpenAI API call
|
||||||
// For now, return a mock embedding for demonstration
|
// For now, return a mock embedding for demonstration
|
||||||
embedding := make([]float64, 1536) // OpenAI embedding dimensions
|
embedding := make([]float64, 1536) // OpenAI embedding dimensions
|
||||||
|
|
||||||
// Generate pseudo-random but deterministic embedding based on text
|
// Generate pseudo-random but deterministic embedding based on text
|
||||||
hash := simpleHash(text)
|
hash := simpleHash(text)
|
||||||
for i := range embedding {
|
for i := range embedding {
|
||||||
embedding[i] = math.Sin(float64(hash+i)) * 0.5
|
embedding[i] = math.Sin(float64(hash+i)) * 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
return embedding, nil
|
return embedding, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,11 +214,11 @@ func findSimilarContent(db *gorm.DB, userID uint, queryEmbedding []float64, cont
|
|||||||
// Get all embeddings for the user
|
// Get all embeddings for the user
|
||||||
var embeddings []models.ContentEmbedding
|
var embeddings []models.ContentEmbedding
|
||||||
query := db.Where("user_id = ?", userID)
|
query := db.Where("user_id = ?", userID)
|
||||||
|
|
||||||
if contentType != "all" && contentType != "" {
|
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 {
|
if err := query.Find(&embeddings).Error; err != nil {
|
||||||
return results, err
|
return results, err
|
||||||
}
|
}
|
||||||
@@ -228,15 +228,15 @@ func findSimilarContent(db *gorm.DB, userID uint, queryEmbedding []float64, cont
|
|||||||
embedding models.ContentEmbedding
|
embedding models.ContentEmbedding
|
||||||
score float64
|
score float64
|
||||||
}
|
}
|
||||||
|
|
||||||
var scores []similarityScore
|
var scores []similarityScore
|
||||||
|
|
||||||
for _, embedding := range embeddings {
|
for _, embedding := range embeddings {
|
||||||
var storedEmbedding []float64
|
var storedEmbedding []float64
|
||||||
if err := json.Unmarshal([]byte(embedding.Embedding), &storedEmbedding); err != nil {
|
if err := json.Unmarshal([]byte(embedding.Embedding), &storedEmbedding); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
similarity := cosineSimilarity(queryEmbedding, storedEmbedding)
|
similarity := cosineSimilarity(queryEmbedding, storedEmbedding)
|
||||||
if similarity >= threshold {
|
if similarity >= threshold {
|
||||||
scores = append(scores, similarityScore{
|
scores = append(scores, similarityScore{
|
||||||
@@ -279,17 +279,17 @@ func cosineSimilarity(a, b []float64) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var dotProduct, normA, normB float64
|
var dotProduct, normA, normB float64
|
||||||
|
|
||||||
for i := range a {
|
for i := range a {
|
||||||
dotProduct += a[i] * b[i]
|
dotProduct += a[i] * b[i]
|
||||||
normA += a[i] * a[i]
|
normA += a[i] * a[i]
|
||||||
normB += b[i] * b[i]
|
normB += b[i] * b[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
if normA == 0 || normB == 0 {
|
if normA == 0 || normB == 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
|
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 {
|
if err := db.Preload("Tags").First(&bookmark, embedding.ContentID).Error; err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result.ID = bookmark.ID
|
result.ID = bookmark.ID
|
||||||
result.Type = "bookmark"
|
result.Type = "bookmark"
|
||||||
result.Title = bookmark.Title
|
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 {
|
if err := db.Preload("Tags").First(&task, embedding.ContentID).Error; err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result.ID = task.ID
|
result.ID = task.ID
|
||||||
result.Type = "task"
|
result.Type = "task"
|
||||||
result.Title = task.Title
|
result.Title = task.Title
|
||||||
@@ -337,7 +337,7 @@ func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, s
|
|||||||
if err := db.Preload("Tags").First(¬e, embedding.ContentID).Error; err != nil {
|
if err := db.Preload("Tags").First(¬e, embedding.ContentID).Error; err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result.ID = note.ID
|
result.ID = note.ID
|
||||||
result.Type = "note"
|
result.Type = "note"
|
||||||
result.Title = note.Title
|
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 {
|
if err := db.Preload("Tags").First(&file, embedding.ContentID).Error; err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result.ID = file.ID
|
result.ID = file.ID
|
||||||
result.Type = "file"
|
result.Type = "file"
|
||||||
result.Title = file.OriginalName
|
result.Title = file.OriginalName
|
||||||
@@ -361,6 +361,68 @@ func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, s
|
|||||||
result.Tags = file.Tags
|
result.Tags = file.Tags
|
||||||
result.CreatedAt = file.CreatedAt
|
result.CreatedAt = file.CreatedAt
|
||||||
result.UpdatedAt = file.UpdatedAt
|
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)
|
// Generate highlights (simplified)
|
||||||
@@ -402,35 +464,139 @@ func reindexUserContent(db *gorm.DB, userID uint) {
|
|||||||
// Reindex bookmarks
|
// Reindex bookmarks
|
||||||
var bookmarks []models.Bookmark
|
var bookmarks []models.Bookmark
|
||||||
db.Where("user_id = ?", userID).Find(&bookmarks)
|
db.Where("user_id = ?", userID).Find(&bookmarks)
|
||||||
|
|
||||||
for _, bookmark := range bookmarks {
|
for _, bookmark := range bookmarks {
|
||||||
text := bookmark.Title + " " + bookmark.Description + " " + bookmark.Content
|
text := bookmark.Title + " " + bookmark.Description + " " + bookmark.Content
|
||||||
embedding, err := generateEmbedding(text)
|
upsertEmbedding(db, userID, "bookmark", bookmark.ID, 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Similar reindexing for tasks, notes, files...
|
// Tasks
|
||||||
// TODO: Implement reindexing for other content types
|
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(¬es)
|
||||||
|
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)
|
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]) + "..."
|
||||||
|
}
|
||||||
|
|||||||
@@ -349,6 +349,35 @@ func main() {
|
|||||||
chat.DELETE("/sessions/:id", handlers.DeleteSession)
|
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)
|
// Member routes (protected)
|
||||||
members := v1.Group("/members")
|
members := v1.Group("/members")
|
||||||
members.Use(handlers.AuthMiddleware())
|
members.Use(handlers.AuthMiddleware())
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -113,5 +113,15 @@ func AutoMigrate() {
|
|||||||
&YouTubeChannelCache{},
|
&YouTubeChannelCache{},
|
||||||
// Video bookmark models
|
// Video bookmark models
|
||||||
&VideoBookmark{},
|
&VideoBookmark{},
|
||||||
|
// Messaging models
|
||||||
|
&Conversation{},
|
||||||
|
&ConversationMember{},
|
||||||
|
&Message{},
|
||||||
|
&MessageAttachment{},
|
||||||
|
&MessageReference{},
|
||||||
|
&MessageSuggestion{},
|
||||||
|
&MessageReaction{},
|
||||||
|
&PasswordVaultItem{},
|
||||||
|
&PasswordVaultShare{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]) + "..."
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import { AuthCallback } from '@/pages/AuthCallback'
|
|||||||
import { AuthProvider } from '@/lib/auth'
|
import { AuthProvider } from '@/lib/auth'
|
||||||
import { Search } from '@/pages/Search'
|
import { Search } from '@/pages/Search'
|
||||||
import { Analytics } from '@/pages/Analytics'
|
import { Analytics } from '@/pages/Analytics'
|
||||||
|
import { Messages } from '@/pages/Messages'
|
||||||
import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode'
|
import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode'
|
||||||
import { onMount } from 'solid-js'
|
import { onMount } from 'solid-js'
|
||||||
|
|
||||||
@@ -168,6 +169,13 @@ function App() {
|
|||||||
</Layout>
|
</Layout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
)} />
|
)} />
|
||||||
|
<Route path="/app/messages" component={() => (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout title="Messages" fullBleed>
|
||||||
|
<Messages />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
)} />
|
||||||
<Route path="/app/members" component={() => (
|
<Route path="/app/members" component={() => (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Layout title="Members">
|
<Layout title="Members">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
IconBrandGithub,
|
IconBrandGithub,
|
||||||
IconClock,
|
IconClock,
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
|
IconMessageCircle,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconBuilding,
|
IconBuilding,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
@@ -33,6 +34,7 @@ const navigation = [
|
|||||||
{ name: 'Calendar', href: '/app/calendar', icon: IconCalendar },
|
{ name: 'Calendar', href: '/app/calendar', icon: IconCalendar },
|
||||||
{ name: 'Files', href: '/app/files', icon: IconFolder },
|
{ name: 'Files', href: '/app/files', icon: IconFolder },
|
||||||
{ name: 'Notes', href: '/app/notes', icon: IconNotebook },
|
{ name: 'Notes', href: '/app/notes', icon: IconNotebook },
|
||||||
|
{ name: 'Messages', href: '/app/messages', icon: IconMessageCircle },
|
||||||
{ name: 'YouTube', href: '/app/youtube', icon: IconVideo },
|
{ name: 'YouTube', href: '/app/youtube', icon: IconVideo },
|
||||||
{ name: 'Members', href: '/app/members', icon: IconUsers },
|
{ name: 'Members', href: '/app/members', icon: IconUsers },
|
||||||
{ name: 'Learning', href: '/app/learning-paths', icon: IconSchool },
|
{ name: 'Learning', href: '/app/learning-paths', icon: IconSchool },
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
export type ConversationType = 'global' | 'team' | 'group' | 'dm' | 'self' | 'password_vault';
|
||||||
|
|
||||||
|
export interface UserLite {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
full_name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: number;
|
||||||
|
type: ConversationType;
|
||||||
|
name: string;
|
||||||
|
topic?: string;
|
||||||
|
team_id?: number | null;
|
||||||
|
created_by: number;
|
||||||
|
is_default: boolean;
|
||||||
|
is_archived: boolean;
|
||||||
|
last_message_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationListItem {
|
||||||
|
conversation: Conversation;
|
||||||
|
role: string;
|
||||||
|
unread_count: number;
|
||||||
|
last_message?: Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationMember {
|
||||||
|
id: number;
|
||||||
|
conversation_id: number;
|
||||||
|
user_id: number;
|
||||||
|
role: 'owner' | 'admin' | 'member' | 'viewer' | string;
|
||||||
|
joined_at?: string;
|
||||||
|
last_read_message_id?: number | null;
|
||||||
|
last_read_at?: string | null;
|
||||||
|
muted_until?: string | null;
|
||||||
|
is_hidden?: boolean;
|
||||||
|
user?: UserLite;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageAttachment {
|
||||||
|
id: number;
|
||||||
|
message_id: number;
|
||||||
|
kind: string;
|
||||||
|
file_id?: number | null;
|
||||||
|
url?: string;
|
||||||
|
title?: string;
|
||||||
|
preview_json?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageReference {
|
||||||
|
id: number;
|
||||||
|
message_id: number;
|
||||||
|
entity_type: string;
|
||||||
|
entity_id: number;
|
||||||
|
deep_link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageSuggestion {
|
||||||
|
id: number;
|
||||||
|
message_id: number;
|
||||||
|
type: string;
|
||||||
|
payload_json: string;
|
||||||
|
status: 'pending' | 'accepted' | 'dismissed';
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageReaction {
|
||||||
|
id: number;
|
||||||
|
message_id: number;
|
||||||
|
user_id: number;
|
||||||
|
emoji: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: number;
|
||||||
|
conversation_id: number;
|
||||||
|
sender_id: number;
|
||||||
|
sender?: UserLite;
|
||||||
|
body: string;
|
||||||
|
is_sensitive: boolean;
|
||||||
|
edited_at?: string | null;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
metadata_json?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
attachments?: MessageAttachment[];
|
||||||
|
references?: MessageReference[];
|
||||||
|
suggestions?: MessageSuggestion[];
|
||||||
|
reactions?: MessageReaction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VaultItem {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
owner_user_id: number;
|
||||||
|
source_message_id?: number | null;
|
||||||
|
last_accessed_at?: string | null;
|
||||||
|
shared: boolean;
|
||||||
|
allow_reveal: boolean;
|
||||||
|
expires_at?: string | null;
|
||||||
|
target_conversation_id?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsEvent {
|
||||||
|
type: string;
|
||||||
|
conversation_id?: number;
|
||||||
|
data?: any;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
function getToken() {
|
||||||
|
return localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/messages${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || `Request failed (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messagesApi = {
|
||||||
|
listConversations: () => apiRequest<{ conversations: ConversationListItem[] }>('/conversations'),
|
||||||
|
createConversation: (payload: any) => apiRequest<{ conversation: Conversation }>('/conversations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
getConversation: (id: number) =>
|
||||||
|
apiRequest<{ conversation: Conversation; membership: ConversationMember; members: ConversationMember[] }>(
|
||||||
|
`/conversations/${id}`
|
||||||
|
),
|
||||||
|
getMessages: (conversationId: number, cursor?: number, limit: number = 50) =>
|
||||||
|
apiRequest<{ messages: Message[]; next_cursor?: number }>(
|
||||||
|
`/conversations/${conversationId}/messages?limit=${limit}${cursor ? `&cursor=${cursor}` : ''}`
|
||||||
|
),
|
||||||
|
sendMessage: (conversationId: number, payload: any) => apiRequest<{ message: Message; warning?: string }>(
|
||||||
|
`/conversations/${conversationId}/messages`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
updateMessage: (id: number, body: string) =>
|
||||||
|
apiRequest<{ message: Message }>(`/messages/${id}`, { method: 'PATCH', body: JSON.stringify({ body }) }),
|
||||||
|
deleteMessage: (id: number) => apiRequest<{ message: string }>(`/messages/${id}`, { method: 'DELETE' }),
|
||||||
|
addReaction: (id: number, emoji: string) =>
|
||||||
|
apiRequest<{ reaction: MessageReaction }>(`/messages/${id}/reactions`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ emoji }),
|
||||||
|
}),
|
||||||
|
removeReaction: (id: number, emoji: string) =>
|
||||||
|
apiRequest<{ message: string }>(`/messages/${id}/reactions/${encodeURIComponent(emoji)}`, { method: 'DELETE' }),
|
||||||
|
searchMessages: (payload: any) =>
|
||||||
|
apiRequest<{ results: Message[]; total: number; limit: number; offset: number }>(
|
||||||
|
'/messages/search',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
getSuggestions: (messageId: number) => apiRequest<{ suggestions: MessageSuggestion[] }>(`/messages/${messageId}/suggestions`),
|
||||||
|
acceptSuggestion: (messageId: number, suggestionId: number, payload: any = {}) =>
|
||||||
|
apiRequest<any>(`/messages/${messageId}/suggestions/${suggestionId}/accept`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
dismissSuggestion: (messageId: number, suggestionId: number) =>
|
||||||
|
apiRequest<any>(`/messages/${messageId}/suggestions/${suggestionId}/dismiss`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}),
|
||||||
|
listVaultItems: () => apiRequest<{ items: VaultItem[] }>('/password-vault/items'),
|
||||||
|
createVaultItem: (payload: any) => apiRequest<any>('/password-vault/items', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
shareVaultItem: (id: number, payload: any) =>
|
||||||
|
apiRequest<any>(`/password-vault/items/${id}/share`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
revealVaultItem: (id: number) =>
|
||||||
|
apiRequest<{ id: number; label: string; secret: string; notes: string; warning?: string }>(
|
||||||
|
`/password-vault/items/${id}/reveal`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({}) }
|
||||||
|
),
|
||||||
|
unshareVaultItem: (id: number, payload: any) =>
|
||||||
|
apiRequest<any>(`/password-vault/items/${id}/unshare`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function uploadChatFile(file: File): Promise<{ id: number; original_name: string; mime_type: string }> {
|
||||||
|
const token = getToken();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('description', 'Uploaded from chat');
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/files/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || `Upload failed (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MessagesRealtimeClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private reconnectTimer: number | null = null;
|
||||||
|
private shouldReconnect = true;
|
||||||
|
private onEvent: (event: WsEvent) => void;
|
||||||
|
private onStatus?: (status: 'connected' | 'disconnected' | 'error') => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
onEvent: (event: WsEvent) => void,
|
||||||
|
onStatus?: (status: 'connected' | 'disconnected' | 'error') => void
|
||||||
|
) {
|
||||||
|
this.onEvent = onEvent;
|
||||||
|
this.onStatus = onStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.cleanupReconnect();
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const wsBase = API_BASE_URL.replace(/^http/, 'ws');
|
||||||
|
this.ws = new WebSocket(`${wsBase}/api/v1/messages/ws?token=${encodeURIComponent(token)}`);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.onStatus?.('connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (evt) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(evt.data) as WsEvent;
|
||||||
|
this.onEvent(parsed);
|
||||||
|
} catch {
|
||||||
|
// ignore invalid payloads
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
this.onStatus?.('error');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.onStatus?.('disconnected');
|
||||||
|
this.ws = null;
|
||||||
|
if (this.shouldReconnect) {
|
||||||
|
this.reconnectTimer = window.setTimeout(() => this.connect(), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
send(payload: any) {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
this.ws.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.shouldReconnect = false;
|
||||||
|
this.cleanupReconnect();
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupReconnect() {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
window.clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,3 @@
|
|||||||
module test-youtube
|
module test-youtube
|
||||||
|
|
||||||
go 1.25.6
|
go 1.25.6
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
|
||||||
github.com/gin-gonic/gin v1.11.0 // indirect
|
|
||||||
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.27.0 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
|
||||||
golang.org/x/crypto v0.40.0 // indirect
|
|
||||||
golang.org/x/mod v0.25.0 // indirect
|
|
||||||
golang.org/x/net v0.42.0 // indirect
|
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
|
||||||
golang.org/x/text v0.27.0 // indirect
|
|
||||||
golang.org/x/tools v0.34.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
|
||||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
|
||||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
|
||||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
|
||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
|
||||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
|
||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
|
||||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|
||||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
Reference in New Issue
Block a user