From 47c0bcbb0a6058eb197d21eca6ab4f77a5faa8af Mon Sep 17 00:00:00 2001 From: Sisi Date: Sat, 11 Apr 2026 13:40:13 +0200 Subject: [PATCH] rewrite connections, messages system, add dm message history, fix multiple bugs, update machine-client --- cache.go | 51 +++++++- convertions.go | 6 + database.go | 22 +++- http.go | 267 ++++++++++++++++++++++++++++++++++---- machine-client/index.html | 28 ++-- main.go | 2 +- structs.go | 16 ++- wsServer.go | 11 +- 8 files changed, 346 insertions(+), 57 deletions(-) diff --git a/cache.go b/cache.go index ff3ca7e..374bef8 100644 --- a/cache.go +++ b/cache.go @@ -3,12 +3,14 @@ package main import ( "fmt" "sync" + + "github.com/google/uuid" ) var ( - mu sync.RWMutex - CacheUsers = make(map[uint32]*User) - Groups = make(map[uint32]*Group) + mu sync.RWMutex + CacheUsers = make(map[uint32]*User) + CacheGroups = make(map[uint32]*Group) ) func CacheGetUserById(id uint32) (*User, error) { @@ -52,14 +54,53 @@ func CacheSaveGroup(group *Group) { mu.Lock() defer mu.Unlock() - Groups[group.Id] = group + CacheGroups[group.Id] = group +} + +func CacheDeleteGroup(id uint32) { + mu.Lock() + defer mu.Unlock() + delete(CacheGroups, id) +} + +func CacheAddConnection(a, b *User, conn *Connection) { + first, second := a, b + if a.Id > b.Id { + first, second = b, a + } + first.Mu.Lock() + second.Mu.Lock() + a.Connections[conn.Id] = conn + b.Connections[conn.Id] = conn + second.Mu.Unlock() + first.Mu.Unlock() +} + +func CacheDeleteConnection(a, b *User, id uuid.UUID) { + first, second := a, b + if a.Id > b.Id { + first, second = b, a + } + first.Mu.Lock() + second.Mu.Lock() + delete(a.Connections, id) + delete(b.Connections, id) + second.Mu.Unlock() + first.Mu.Unlock() +} + +func CacheGetConnection(user *User, id uuid.UUID) (*Connection, bool) { + user.Mu.RLock() + defer user.Mu.RUnlock() + conn, ok := user.Connections[id] + return conn, ok } func CacheGetGroup(id uint32) (*Group, error) { mu.RLock() defer mu.RUnlock() - group, ok := Groups[id] + group, ok := CacheGroups[id] if !ok { return nil, fmt.Errorf("group %d not found", id) } diff --git a/convertions.go b/convertions.go index 06195ed..f2c295e 100644 --- a/convertions.go +++ b/convertions.go @@ -4,6 +4,8 @@ import ( "fmt" "strconv" "strings" + + "github.com/google/uuid" ) func ConvertStringUint32(s string) (uint32, error) { @@ -26,3 +28,7 @@ func ConvertStringToRgb(str string) ([3]uint8, error) { } return rgb, nil } + +func ConvertStringUuid(str string) (uuid.UUID, error) { + return uuid.Parse(str) +} diff --git a/database.go b/database.go index ab59847..42b38f0 100644 --- a/database.go +++ b/database.go @@ -20,6 +20,9 @@ func DbInit(ctx context.Context) { } _, err = dbConn.Exec(ctx, `CREATE EXTENSION IF NOT EXISTS "gen_random_uuid";`) + if err != nil { + panic(err) + } _, err = dbConn.Exec(ctx, ` CREATE TABLE IF NOT EXISTS users ( @@ -55,6 +58,7 @@ func DbInit(ctx context.Context) { id UUID PRIMARY KEY DEFAULT gen_random_uuid(), sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, receiver_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), content TEXT NOT NULL, is_group_message BOOLEAN DEFAULT FALSE ) @@ -110,7 +114,7 @@ func DbUserDelete(ctx context.Context, id uint32) error { return err } -func DbUserGetByName(ctx context.Context, user *User) error { +func DbUserGetStandardInfoByName(ctx context.Context, user *User) error { err := dbConn.QueryRow(ctx, ` SELECT id, name, pass_hash, pronouns, color_red, color_green, color_blue, created_at FROM users WHERE name = $1 `, user.Name).Scan(&user.Id, &user.Name, &user.PasswordHash, &user.Pronouns, &user.Color[0], &user.Color[1], &user.Color[2], &user.CreatedAt) @@ -173,6 +177,13 @@ func DbConnectionSave(ctx context.Context, conn *Connection) error { `, conn.RequestorId, conn.RecipientId, conn.State, conn.CreatedAt).Scan(&conn.Id) } +func DbConnectionDelete(ctx context.Context, conn *Connection) error { + _, err := dbConn.Exec(ctx, ` + DELETE FROM user_connections WHERE id = $1 + `, conn.Id) + return err +} + func DbConnectionsGetBelongingToUser(ctx context.Context, user *User) error { rows, err := dbConn.Query(ctx, ` SELECT id, requestor_id, recipient_id, state, created_at @@ -198,13 +209,20 @@ func DbConnectionsGetBelongingToUser(ctx context.Context, user *User) error { return rows.Err() } -func DbConnectionSet(ctx context.Context, conn *Connection) error { +func DbConnectionUpdateState(ctx context.Context, conn *Connection) error { _, err := dbConn.Exec(ctx, ` UPDATE user_connections SET state = $1 WHERE id = $2 `, conn.State, conn.Id) return err } +func DbMessageSave(ctx context.Context, message *Message) error { + return dbConn.QueryRow(ctx, ` + INSERT INTO messages (sender_id, receiver_id, created_at, content, is_group_message) VALUES ($1, $2, $3, $4, $5) + RETURNING id + `, message.Sender, message.Receiver, message.CreatedAt, message.Content, message.IsGroupMessage).Scan(&message.Id) +} + func DbGroupSave(ctx context.Context, group *Group) error { err := dbConn.QueryRow(ctx, ` INSERT INTO chat_groups (name, creator_id, owner_id, enable_client_colors, color_red, color_green, color_blue, created_at) diff --git a/http.go b/http.go index f37104b..312f662 100644 --- a/http.go +++ b/http.go @@ -12,6 +12,8 @@ import ( "strings" "time" + "go-socket/Enums/ConnectionState" + "golang.org/x/crypto/bcrypt" ) @@ -23,20 +25,29 @@ func isMethodAllowed(response *http.ResponseWriter, request *http.Request) bool return true } +func getWholeUserFromDb(ctx context.Context, user *User) error { + if err := DbUserGetById(ctx, user); err != nil { + return err + } + if err := DbUserGetGroups(ctx, user); err != nil { + return err + } + if err := DbConnectionsGetBelongingToUser(ctx, user); err != nil { + return err + } + + CacheSaveUser(user) + return nil +} + func getUserById(ctx context.Context, userId uint32) (*User, error) { user, err := CacheGetUserById(userId) if err != nil { user = &User{Id: userId} - if err = DbUserGetById(ctx, user); err != nil { + err = getWholeUserFromDb(ctx, user) + if err != nil { return nil, err } - if err = DbUserGetGroups(ctx, user); err != nil { - return nil, err - } - if err = DbConnectionsGetBelongingToUser(ctx, user); err != nil { - return nil, err - } - CacheSaveUser(user) } return user, nil @@ -98,6 +109,25 @@ func getIfOwnerUserAndGroup(ctx context.Context, response *http.ResponseWriter, return user, group, nil } +func getUserByTokenGetUserByIdStrHandleHttp(ctx context.Context, response *http.ResponseWriter, token string, userIdStr string) (*User, *User, error) { + user, err := getUserByToken(ctx, token) + if err != nil { + http.Error(*response, "invalid token", http.StatusUnauthorized) + return nil, nil, err + } + affectedUserId, err := ConvertStringUint32(userIdStr) + if err != nil { + http.Error(*response, "no such user", http.StatusUnauthorized) + return nil, nil, err + } + user2, err := getUserById(ctx, affectedUserId) + if err != nil { + http.Error(*response, "no such user", http.StatusUnauthorized) + return nil, nil, err + } + return user, user2, nil +} + func HttpHandleUserNew(response http.ResponseWriter, request *http.Request) { if !isMethodAllowed(&response, request) { return @@ -234,19 +264,97 @@ func HttpHandleUserMessage(response http.ResponseWriter, request *http.Request) return } + targetConnection, err := ConvertStringUuid(request.FormValue("connectionid")) + if err != nil { + http.Error(response, "invalid connectionid", http.StatusBadRequest) + return + } + conn, ok := CacheGetConnection(user, targetConnection) + if !ok { + http.Error(response, "invalid connectionid", http.StatusBadRequest) + return + } + + msgContent := request.FormValue("msgContent") + if msgContent == "" { + http.Error(response, "empty msgContent", http.StatusBadRequest) + return + } + + var target *User + + if user.Id == conn.RequestorId { + target, err = getUserById(ctx, conn.RecipientId) + } else if user.Id == conn.RecipientId { + target, err = getUserById(ctx, conn.RequestorId) + } else { + http.Error(response, "invalid connectionid", http.StatusBadRequest) + return + } + + if err != nil { + http.Error(response, "internal server error", http.StatusInternalServerError) + return + } + message := &Message{ + Content: msgContent, + CreatedAt: time.Now(), + Sender: user.Id, + Receiver: target.Id, + IsGroupMessage: false, + } + + err = DbMessageSave(ctx, message) + if err != nil { + http.Error(response, "internal server error", http.StatusInternalServerError) + return + } + + WsSendToUser(target, message) + response.WriteHeader(http.StatusAccepted) } func HttpHandleUserNewConnection(response http.ResponseWriter, request *http.Request) { if !isMethodAllowed(&response, request) { return } - ctx := request.Context() - user, err := getUserByToken(ctx, request.FormValue("token")) + requestor, recipient, err := getUserByTokenGetUserByIdStrHandleHttp(ctx, &response, request.FormValue("token"), request.FormValue("recipient")) if err != nil { - http.Error(response, "invalid token", http.StatusUnauthorized) return } + + if requestor.Id == recipient.Id { + http.Error(response, "cannot connect to yourself", http.StatusBadRequest) + return + } + + requestor.Mu.RLock() + for _, connection := range requestor.Connections { + if (connection.RequestorId == requestor.Id && connection.RecipientId == recipient.Id) || + (connection.RecipientId == requestor.Id && connection.RequestorId == recipient.Id) { + requestor.Mu.RUnlock() + http.Error(response, "connection already exists", http.StatusBadRequest) + return + } + } + requestor.Mu.RUnlock() + + connection := &Connection{ + CreatedAt: time.Now(), + RequestorId: requestor.Id, + RecipientId: recipient.Id, + State: ConnectionState.Stranger, + } + err = DbConnectionSave(ctx, connection) + if err != nil { + http.Error(response, "internal server error", http.StatusInternalServerError) + return + } + CacheAddConnection(requestor, recipient, connection) + + response.WriteHeader(http.StatusCreated) + return } func HttpHandleUserDeleteConnection(response http.ResponseWriter, request *http.Request) { @@ -260,19 +368,94 @@ func HttpHandleUserDeleteConnection(response http.ResponseWriter, request *http. http.Error(response, "invalid token", http.StatusUnauthorized) return } + + connectionId, err := ConvertStringUuid(request.FormValue("connectionid")) + if err != nil { + http.Error(response, "invalid connectionid", http.StatusBadRequest) + return + } + + conn, ok := CacheGetConnection(user, connectionId) + if !ok { + http.Error(response, "invalid connectionid", http.StatusBadRequest) + return + } + + var user2 *User + if conn.RequestorId == user.Id { + recipient, err := getUserById(ctx, conn.RecipientId) + if err != nil { + http.Error(response, "internal server error", http.StatusInternalServerError) + return + } + user2 = recipient + } else if conn.RecipientId == user.Id { + requestor, err := getUserById(ctx, conn.RequestorId) + if err != nil { + http.Error(response, "internal server error", http.StatusInternalServerError) + return + } + user2 = requestor + } else { + http.Error(response, "invalid connectionid", http.StatusBadRequest) + return + } + + err = DbConnectionDelete(ctx, conn) + if err != nil { + http.Error(response, "internal server error", http.StatusInternalServerError) + return + } + + CacheDeleteConnection(user, user2, connectionId) + + response.WriteHeader(http.StatusAccepted) } -func HttpHandleUserAcceptConnection(response http.ResponseWriter, request *http.Request) { +func HttpHandleUserElevateConnection(response http.ResponseWriter, request *http.Request) { if !isMethodAllowed(&response, request) { return } ctx := request.Context() - user, err := getUserByToken(ctx, request.FormValue("token")) if err != nil { http.Error(response, "invalid token", http.StatusUnauthorized) return } + connectionId, err := ConvertStringUuid(request.FormValue("connectionid")) + if err != nil { + http.Error(response, "invalid connectionid", http.StatusBadRequest) + return + } + conn, ok := CacheGetConnection(user, connectionId) + if !ok { + http.Error(response, "invalid connectionid", http.StatusBadRequest) + return + } + + if conn.RecipientId != user.Id { + http.Error(response, "invalid connectionid", http.StatusBadRequest) + return + } + + switch conn.State { + case ConnectionState.Stranger: + conn.State = ConnectionState.Friend + break + case ConnectionState.GroupFellow: + conn.State = ConnectionState.Stranger + break + default: + http.Error(response, "cannot elevate further", http.StatusBadRequest) + return + } + + err = DbConnectionUpdateState(ctx, conn) + if err != nil { + http.Error(response, "internal server error", http.StatusInternalServerError) + return + } + response.WriteHeader(http.StatusAccepted) } func HttpHandleUserGetConnections(response http.ResponseWriter, request *http.Request) { @@ -285,6 +468,18 @@ func HttpHandleUserGetConnections(response http.ResponseWriter, request *http.Re http.Error(response, "invalid token", http.StatusUnauthorized) return } + + user.Mu.RLock() + connections := slices.Collect(maps.Values(user.Connections)) + user.Mu.RUnlock() + json, err := json2.Marshal(connections) + if err != nil { + http.Error(response, "internal server error", http.StatusInternalServerError) + return + } + + response.WriteHeader(http.StatusOK) + response.Write(json) } func HttpHandleTokenNew(response http.ResponseWriter, request *http.Request) { @@ -314,19 +509,14 @@ func HttpHandleTokenNew(response http.ResponseWriter, request *http.Request) { user, err = CacheGetUserByName(username) if err != nil { user = &User{Name: username} - if err = DbUserGetByName(ctx, user); err != nil { + if err = DbUserGetStandardInfoByName(ctx, user); err != nil { http.Error(response, "bad login", http.StatusUnauthorized) return } - if err = DbUserGetGroups(ctx, user); err != nil { - http.Error(response, "bad login", http.StatusUnauthorized) + if err = getWholeUserFromDb(ctx, user); err != nil { + http.Error(response, "internal server error", http.StatusInternalServerError) return } - if err = DbUserGetConnections(ctx, user); err != nil { - http.Error(response, "bad login", http.StatusUnauthorized) - return - } - CacheSaveUser(user) } err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) @@ -342,9 +532,13 @@ func HttpHandleTokenNew(response http.ResponseWriter, request *http.Request) { } json, err := json2.Marshal(LoginReturn{Token: token, UserId: user.Id}) + if err != nil { + http.Error(response, "internal server error", http.StatusInternalServerError) + return + } response.WriteHeader(http.StatusCreated) - response.Write([]byte(json)) + response.Write(json) } func HttpHandeGroupCreate(response http.ResponseWriter, request *http.Request) { @@ -411,6 +605,7 @@ func HttpHandleGroupDelete(response http.ResponseWriter, request *http.Request) http.Error(response, "internal server error", http.StatusInternalServerError) return } + CacheDeleteGroup(group.Id) response.WriteHeader(http.StatusAccepted) } @@ -466,6 +661,11 @@ func HttpHandleGroupAddUsers(response http.ResponseWriter, request *http.Request for i := uint32(0); i < idx; i++ { group.Users[ids[i]] = struct{}{} + if cachedUser, err := CacheGetUserById(ids[i]); err == nil { + cachedUser.Mu.Lock() + cachedUser.Groups[group.Id] = struct{}{} + cachedUser.Mu.Unlock() + } } response.WriteHeader(http.StatusAccepted) @@ -513,6 +713,11 @@ func HttpHandleGroupRemoveUser(response http.ResponseWriter, request *http.Reque for i := uint32(0); i < idx; i++ { delete(group.Users, ids[i]) + if cachedUser, err := CacheGetUserById(ids[i]); err == nil { + cachedUser.Mu.Lock() + delete(cachedUser.Groups, group.Id) + cachedUser.Mu.Unlock() + } } response.WriteHeader(http.StatusAccepted) @@ -563,13 +768,16 @@ func HttpHandleGroupChangeOwner(response http.ResponseWriter, request *http.Requ newOwner, err := CacheGetUserByName(newOwnerName) if err != nil { newOwner = &User{Name: newOwnerName} - err = DbUserGetByName(ctx, newOwner) + err = DbUserGetStandardInfoByName(ctx, newOwner) if err != nil { http.Error(response, "user not in group", http.StatusBadRequest) return } - CacheSaveUser(newOwner) + if err = getWholeUserFromDb(ctx, newOwner); err != nil { + http.Error(response, "internal server error", http.StatusInternalServerError) + return + } } _, ok := group.Users[newOwner.Id] @@ -646,8 +854,15 @@ func HttpHandleGroupsGetWithoutMembers(response http.ResponseWriter, request *ht return } - groups := make(map[uint32]*Group, len(user.Groups)) + user.Mu.RLock() + groupIds := make([]uint32, 0, len(user.Groups)) for groupId := range user.Groups { + groupIds = append(groupIds, groupId) + } + user.Mu.RUnlock() + + groups := make(map[uint32]*Group, len(groupIds)) + for _, groupId := range groupIds { group, err := getGroup(ctx, groupId) if err != nil { continue @@ -684,7 +899,9 @@ func HttpHandleGroupMembersGet(response http.ResponseWriter, request *http.Reque return } + user.Mu.RLock() _, ok := user.Groups[groupId] + user.Mu.RUnlock() if !ok { http.Error(response, "no such group", http.StatusUnauthorized) return diff --git a/machine-client/index.html b/machine-client/index.html index ec5b9e7..0896579 100644 --- a/machine-client/index.html +++ b/machine-client/index.html @@ -115,10 +115,10 @@ 'new-connection': { title: 'POST /new/connection — send connection request', fields: [ - { id: 'nconn-token', label: 'token', ph: '' }, - { id: 'nconn-recipientid', label: 'recipientid', ph: 'uint32' }, + { id: 'nconn-token', label: 'token', ph: '' }, + { id: 'nconn-recipient', label: 'recipient', ph: 'uint32' }, ], - submit: () => httpPost('/new/connection', { token:'nconn-token', recipientid:'nconn-recipientid' }) + submit: () => httpPost('/new/connection', { token:'nconn-token', recipient:'nconn-recipient' }) }, 'new-token': { title: 'POST /new/token — login / get token', @@ -157,18 +157,18 @@ 'accept-connection': { title: 'POST /mod/connection/accept — accept connection request', fields: [ - { id: 'ca-token', label: 'token', ph: '' }, - { id: 'ca-connectedid', label: 'connectedid', ph: 'uint32' }, + { id: 'ca-token', label: 'token', ph: '' }, + { id: 'ca-connectionid', label: 'connectionid', ph: 'UUID' }, ], - submit: () => httpPost('/mod/connection/accept', { token:'ca-token', connectedid:'ca-connectedid' }) + submit: () => httpPost('/mod/connection/accept', { token:'ca-token', connectionid:'ca-connectionid' }) }, 'delete-connection': { title: 'POST /mod/connection/delete — delete connection', fields: [ - { id: 'cd-token', label: 'token', ph: '' }, - { id: 'cd-connectedid', label: 'connectedid', ph: 'uint32' }, + { id: 'cd-token', label: 'token', ph: '' }, + { id: 'cd-connectionid', label: 'connectionid', ph: 'UUID' }, ], - submit: () => httpPost('/mod/connection/delete', { token:'cd-token', connectedid:'cd-connectedid' }) + submit: () => httpPost('/mod/connection/delete', { token:'cd-token', connectionid:'cd-connectionid' }) }, 'add-users': { title: 'POST /mod/group/addusers — add users (owner only)', @@ -239,11 +239,11 @@ 'msg-user': { title: 'POST /msg/user — send direct message to user', fields: [ - { id: 'mu-token', label: 'token', ph: '' }, - { id: 'mu-recipientid', label: 'recipientid', ph: 'uint32' }, - { id: 'mu-message', label: 'message', ph: 'message text' }, + { id: 'mu-token', label: 'token', ph: '' }, + { id: 'mu-connectionid', label: 'connectionid', ph: 'UUID' }, + { id: 'mu-msgContent', label: 'msgContent', ph: 'message text' }, ], - submit: () => httpPost('/msg/user', { token:'mu-token', recipientid:'mu-recipientid', message:'mu-message' }) + submit: () => httpPost('/msg/user', { token:'mu-token', connectionid:'mu-connectionid', msgContent:'mu-msgContent' }) }, 'msg-group': { title: 'POST /msg/group — send message to group', @@ -351,7 +351,7 @@ const text = await resp.text(); log(`HTTP ${resp.status}`, text, resp.ok ? 'log-http' : 'log-err'); if (path === '/new/token' && resp.ok) { - currentToken = text.trim(); + try { currentToken = JSON.parse(text).token; } catch(e) {} autofillTokens(); wsConnectAndAuth(); } diff --git a/main.go b/main.go index eee820a..4cb770c 100644 --- a/main.go +++ b/main.go @@ -24,7 +24,7 @@ func main() { http.HandleFunc("/mod/user/appearence", withCORS(HttpHandleUserModifyAppearance)) http.HandleFunc("/mod/user/about", withCORS(HttpHandleUserModifyAbout)) - http.HandleFunc("/mod/connection/accept", withCORS(HttpHandleUserAcceptConnection)) + http.HandleFunc("/mod/connection/accept", withCORS(HttpHandleUserElevateConnection)) http.HandleFunc("/mod/connection/delete", withCORS(HttpHandleUserDeleteConnection)) http.HandleFunc("/mod/group/addusers", withCORS(HttpHandleGroupAddUsers)) http.HandleFunc("/mod/group/removeusers", withCORS(HttpHandleGroupRemoveUser)) diff --git a/structs.go b/structs.go index 0e72d7c..9371d04 100644 --- a/structs.go +++ b/structs.go @@ -1,14 +1,17 @@ package main import ( - "go-socket/Enums/ConnectionState" + "sync" "time" + "go-socket/Enums/ConnectionState" + "github.com/coder/websocket" "github.com/google/uuid" ) type User struct { + Mu sync.RWMutex Name string Pronouns string PasswordHash string @@ -21,11 +24,11 @@ type User struct { } type Connection struct { - Id uuid.UUID `json:"id"` - CreatedAt time.Time `json:"createdAt"` - MessagesBuf [MaxDirectMsgCache]*Message `json:"-"` - RequestorId uint32 `json:"requestorId"` - RecipientId uint32 `json:"recipientId"` + Id uuid.UUID `json:"id"` + CreatedAt time.Time `json:"createdAt"` + MessagesBuf [MaxDirectMsgCache]*Message `json:"-"` + RequestorId uint32 `json:"requestorId"` + RecipientId uint32 `json:"recipientId"` State ConnectionState.ConnectionState `json:"state"` } @@ -34,6 +37,7 @@ type Message struct { Content string `json:"content"` CreatedAt time.Time `json:"createdAt"` Sender uint32 `json:"sender"` + Receiver uint32 `json:"receiver"` IsGroupMessage bool `json:"isGroupMessage"` } diff --git a/wsServer.go b/wsServer.go index b52e2fc..8557ef0 100644 --- a/wsServer.go +++ b/wsServer.go @@ -3,11 +3,12 @@ package main import ( "context" "errors" - "go-socket/Enums/WsMessageFrom" "log" "net/http" "time" + "go-socket/Enums/WsMessageFrom" + "github.com/coder/websocket" "github.com/coder/websocket/wsjson" ) @@ -83,11 +84,13 @@ func sendToAllMessageCloseIfTimeout(message *map[string]any) { } } -func WsSendToUser(from *User, to *User, message string) { +func WsSendToUser(to *User, message *Message) { var msg = map[string]any{ "type": WsMessageFrom.DirectMessage, - "from": from.Id, - "content": message, + "id": message.Id, + "from": message.Sender, + "created": message.CreatedAt, + "content": message.Content, } sendMessageCloseIfTimeout(to, &msg) }