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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user