teg/backend/chat_websocket.go
2026-07-03 16:56:20 +02:00

528 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// backend\chat_websocket.go
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
type chatSocketCommand struct {
Type string `json:"type"`
ClientID string `json:"clientId"`
ConversationID string `json:"conversationId"`
Body string `json:"body"`
AttachmentIDs []string `json:"attachmentIds"`
ReplyToMessageID string `json:"replyToMessageId"`
ForwardedFromMessageID string `json:"forwardedFromMessageId"`
Location *ChatLocation `json:"location"`
}
type chatSocketEvent struct {
Type string `json:"type"`
ClientID string `json:"clientId,omitempty"`
ConversationID string `json:"conversationId,omitempty"`
UserID string `json:"userId,omitempty"`
UserDisplayName string `json:"userDisplayName,omitempty"`
Typing bool `json:"typing,omitempty"`
Message *ChatMessage `json:"message,omitempty"`
Conversation *ChatConversation `json:"conversation,omitempty"`
Error string `json:"error,omitempty"`
}
type chatSocketClient struct {
userID string
ch chan chatSocketEvent
}
type chatSocketBroker struct {
mu sync.RWMutex
clients map[*chatSocketClient]struct{}
}
var chatSockets = &chatSocketBroker{
clients: map[*chatSocketClient]struct{}{},
}
func (b *chatSocketBroker) subscribe(userID string) *chatSocketClient {
client := &chatSocketClient{
userID: userID,
ch: make(chan chatSocketEvent, 32),
}
b.mu.Lock()
b.clients[client] = struct{}{}
b.mu.Unlock()
return client
}
func (b *chatSocketBroker) unsubscribe(client *chatSocketClient) {
b.mu.Lock()
delete(b.clients, client)
b.mu.Unlock()
}
func (b *chatSocketBroker) publish(userID string, event chatSocketEvent) {
b.mu.RLock()
defer b.mu.RUnlock()
for client := range b.clients {
if client.userID != userID {
continue
}
select {
case client.ch <- event:
default:
}
}
}
func (c *chatSocketClient) send(event chatSocketEvent) {
select {
case c.ch <- event:
default:
}
}
func (s *Server) handleChatWebSocket(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
// coder/websocket matcht OriginPatterns gegen den Host der Origin (z. B.
// "localhost:5173"). Daher nur den Host übergeben schema-unabhängig, damit
// http/https sowie ein Dev-Proxy mit geändertem Host (changeOrigin) den
// Upgrade nicht blockieren.
originPatterns := []string{}
if frontendOrigin := strings.TrimRight(s.frontendOrigin, "/"); frontendOrigin != "" {
host := frontendOrigin
if parsed, err := url.Parse(frontendOrigin); err == nil && parsed.Host != "" {
host = parsed.Host
}
originPatterns = append(originPatterns, host)
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: originPatterns,
})
if err != nil {
return
}
defer conn.CloseNow()
conn.SetReadLimit(8 * 1024)
client := chatSockets.subscribe(user.ID)
defer chatSockets.unsubscribe(client)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go s.readChatSocket(ctx, cancel, conn, client, user)
client.send(chatSocketEvent{Type: "connected"})
pingTicker := time.NewTicker(30 * time.Second)
defer pingTicker.Stop()
for {
select {
case <-ctx.Done():
return
case event, open := <-client.ch:
if !open {
return
}
writeCtx, writeCancel := context.WithTimeout(ctx, 10*time.Second)
err := wsjson.Write(writeCtx, conn, event)
writeCancel()
if err != nil {
return
}
case <-pingTicker.C:
pingCtx, pingCancel := context.WithTimeout(ctx, 10*time.Second)
err := conn.Ping(pingCtx)
pingCancel()
if err != nil {
return
}
}
}
}
func (s *Server) readChatSocket(
ctx context.Context,
cancel context.CancelFunc,
conn *websocket.Conn,
client *chatSocketClient,
user User,
) {
defer cancel()
for {
var command chatSocketCommand
if err := wsjson.Read(ctx, conn, &command); err != nil {
return
}
switch command.Type {
case "typing.start", "typing.stop":
if !s.canPostToConversation(ctx, command.ConversationID, user.ID) {
client.send(chatSocketEvent{
Type: "error",
Error: "Dieser Chat ist schreibgeschützt",
})
continue
}
s.publishChatTyping(
ctx,
user,
command.ConversationID,
command.Type == "typing.start",
)
continue
case "message.send":
message, err := s.createChatMessage(
ctx,
user,
command.ConversationID,
createChatMessageInput{
Body: command.Body,
AttachmentIDs: command.AttachmentIDs,
ReplyToMessageID: command.ReplyToMessageID,
ForwardedFromMessageID: command.ForwardedFromMessageID,
Location: command.Location,
},
)
if err != nil {
client.send(chatSocketEvent{
Type: "error",
ClientID: command.ClientID,
Error: chatSocketErrorMessage(err),
})
continue
}
s.publishChatMessage(ctx, message, command.ClientID)
continue
default:
client.send(chatSocketEvent{
Type: "error",
ClientID: command.ClientID,
Error: "Unbekannte Chat-Aktion",
})
}
}
}
func chatSocketErrorMessage(err error) string {
switch {
case errors.Is(err, errChatAccessDenied):
return "Kein Zugriff auf diesen Chat"
case errors.Is(err, errChatMessageEmpty):
return "Nachricht darf nicht leer sein"
case errors.Is(err, errChatMessageTooLong):
return "Nachricht darf höchstens 4000 Zeichen enthalten"
case errors.Is(err, errChatLocationInvalid):
return "Ungültiger Standort"
case errors.Is(err, errChatForwardSame):
return "Nachrichten können nicht in denselben Chat weitergeleitet werden"
case errors.Is(err, errChatReadOnly):
return "Channels sind schreibgeschützt"
default:
return "Nachricht konnte nicht gesendet werden"
}
}
func (s *Server) publishChatTyping(
ctx context.Context,
user User,
conversationID string,
typing bool,
) {
memberIDs, err := s.chatConversationMemberIDs(ctx, conversationID)
if err != nil {
return
}
event := chatSocketEvent{
Type: "typing.updated",
ConversationID: conversationID,
UserID: user.ID,
UserDisplayName: user.DisplayName,
Typing: typing,
}
if event.UserDisplayName == "" {
event.UserDisplayName = user.Username
}
if event.UserDisplayName == "" {
event.UserDisplayName = user.Email
}
for _, memberID := range memberIDs {
if memberID != user.ID {
chatSockets.publish(memberID, event)
}
}
}
func (s *Server) publishChatMessage(
ctx context.Context,
message ChatMessage,
clientID string,
) {
rows, err := s.db.Query(
ctx,
`
SELECT cm.user_id::TEXT, cm.muted
FROM conversation_members cm
WHERE cm.conversation_id = $1
`,
message.ConversationID,
)
if err != nil {
return
}
defer rows.Close()
event := chatSocketEvent{
Type: "message.created",
ClientID: clientID,
Message: &message,
}
recipientIDs := []string{}
for rows.Next() {
var recipientID string
var muted bool
if err := rows.Scan(&recipientID, &muted); err != nil {
continue
}
chatSockets.publish(recipientID, event)
if recipientID != message.Sender.ID && !muted {
recipientIDs = append(recipientIDs, recipientID)
}
}
rows.Close()
// System-Nachrichten (Gruppen-Ereignisse, Selbstlöschen) erscheinen im
// Verlauf, lösen aber keine Benachrichtigung / Web-Push aus.
if message.Type == "system" {
return
}
title, conversationType, conversationImage, err := s.chatNotificationTitle(ctx, message)
if err != nil {
return
}
preview := strings.TrimSpace(message.Body)
if preview == "" && len(message.Attachments) > 0 {
preview = "Datei angehängt"
}
previewRunes := []rune(preview)
if len(previewRunes) > 160 {
preview = string(previewRunes[:160]) + "..."
}
data, err := json.Marshal(map[string]any{
"conversationId": message.ConversationID,
"messageId": message.ID,
"senderId": message.Sender.ID,
"senderAvatar": message.Sender.Avatar,
"senderName": chatUserDisplayName(message.Sender),
"conversationType": conversationType,
"conversationImage": conversationImage,
})
if err != nil {
return
}
for _, recipientID := range recipientIDs {
notificationType := "chat.message"
if conversationType == "channel" {
notificationType = "channel.message"
}
_ = s.createAndPublishNotification(
ctx,
recipientID,
notificationType,
title,
preview,
"chat",
message.ConversationID,
data,
false,
)
}
s.scheduleLinkPreview(message)
}
func (s *Server) chatConversationMemberIDs(ctx context.Context, conversationID string) ([]string, error) {
rows, err := s.db.Query(
ctx,
`
SELECT cm.user_id::TEXT
FROM conversation_members cm
WHERE cm.conversation_id = $1
`,
conversationID,
)
if err != nil {
return nil, err
}
defer rows.Close()
memberIDs := []string{}
for rows.Next() {
var memberID string
if err := rows.Scan(&memberID); err != nil {
return nil, err
}
memberIDs = append(memberIDs, memberID)
}
return memberIDs, rows.Err()
}
func (s *Server) publishChatMessageUpdate(ctx context.Context, message ChatMessage) {
memberIDs, err := s.chatConversationMemberIDs(ctx, message.ConversationID)
if err != nil {
return
}
event := chatSocketEvent{
Type: "message.updated",
Message: &message,
}
for _, memberID := range memberIDs {
chatSockets.publish(memberID, event)
}
}
// publishConversationUpdate sendet jedem Mitglied die aktualisierte
// Konversation (Name/Bild/Selbstlöschen/Mitglieder), damit Seitenleiste und
// geöffneter Chat live aktualisiert werden.
func (s *Server) publishConversationUpdate(ctx context.Context, conversationID string) {
rows, err := s.db.Query(
ctx,
`
SELECT cm.user_id::TEXT, u.show_last_seen
FROM conversation_members cm
JOIN users u ON u.id = cm.user_id
WHERE cm.conversation_id = $1
`,
conversationID,
)
if err != nil {
return
}
defer rows.Close()
type member struct {
id string
showLastSeen bool
}
members := []member{}
for rows.Next() {
var m member
if err := rows.Scan(&m.id, &m.showLastSeen); err != nil {
return
}
members = append(members, m)
}
rows.Close()
for _, m := range members {
conversation, err := s.getChatConversation(ctx, conversationID, m.id, m.showLastSeen)
if err != nil {
continue
}
chatSockets.publish(m.id, chatSocketEvent{
Type: "conversation.updated",
Conversation: &conversation,
})
}
}
func (s *Server) publishChatMessagesReload(ctx context.Context, conversationID string) {
memberIDs, err := s.chatConversationMemberIDs(ctx, conversationID)
if err != nil {
return
}
for _, memberID := range memberIDs {
chatSockets.publish(memberID, chatSocketEvent{
Type: "messages.reload",
ConversationID: conversationID,
})
}
}
func chatUserDisplayName(user ChatUser) string {
if user.DisplayName != "" {
return user.DisplayName
}
if user.Username != "" {
return user.Username
}
return user.Email
}
func (s *Server) chatNotificationTitle(
ctx context.Context,
message ChatMessage,
) (string, string, string, error) {
var conversationType string
var conversationName string
var conversationImage string
err := s.db.QueryRow(
ctx,
`
SELECT type, name, channel_image
FROM conversations
WHERE id = $1
`,
message.ConversationID,
).Scan(&conversationType, &conversationName, &conversationImage)
if err != nil {
return "", "", "", err
}
senderName := chatUserDisplayName(message.Sender)
if conversationType == "group" && conversationName != "" {
return senderName + " in " + conversationName, conversationType, conversationImage, nil
}
if conversationType == "channel" && conversationName != "" {
return "Neue Meldung in " + conversationName, conversationType, conversationImage, nil
}
return "Neue Nachricht von " + senderName, conversationType, conversationImage, nil
}