Skip to content

Commit b63db48

Browse files
authored
Merge pull request #76 from NicolasHaas/feat/persistent-chat-server
feat(server): serve and moderate persistent chat
2 parents 1f616b0 + c125b72 commit b63db48

18 files changed

Lines changed: 863 additions & 68 deletions

‎cmd/server/main.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ func main() {
2727
flag.StringVar(&cfg.MediaCipher, "media-cipher", cfg.MediaCipher, "Voice and screen AEAD: aes128, aes256, or chacha20")
2828
flag.StringVar(&cfg.ChannelsFile, "channels-file", "", "YAML file defining channels to create on startup")
2929
flag.StringVar(&cfg.MetricsAddr, "metrics", cfg.MetricsAddr, "HTTP bind address for plaintext /metrics and /healthz (empty to disable)")
30+
flag.IntVar(&cfg.ChatHistoryLimit, "chat-history-limit", cfg.ChatHistoryLimit, "Messages retained per channel (0 disables persistence, 1..10000 retains history)")
31+
flag.DurationVar(&cfg.ChatMaxAge, "chat-max-age", cfg.ChatMaxAge, "Maximum message age (0 disables expiry)")
3032
flag.IntVar(&cfg.MaxSessions, "max-sessions", cfg.MaxSessions, "Maximum concurrent authenticated sessions")
3133
flag.IntVar(&cfg.MaxSessionsPerUser, "max-sessions-per-user", cfg.MaxSessionsPerUser, "Maximum concurrent sessions per account")
3234
flag.IntVar(&cfg.ControlMessageBurst, "control-message-burst", cfg.ControlMessageBurst, "Maximum control-message cost burst per session and account (minimum 5)")

‎docs/protocol.md‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -127,17 +127,13 @@ Temporary sub-channels are removed five minutes after becoming empty. Occupied t
127127

128128
### Chat
129129

130-
```mermaid
131-
sequenceDiagram
132-
participant A as Client A
133-
participant S as Server
134-
participant B as Client B
130+
When persistence is enabled, the server stores a message before broadcasting it. It takes sender identity from the authenticated session, not the request. A client may send `ChatMessage{channel_id, text}` for an existing channel without joining voice; a zero channel ID still means the current voice channel for older clients. An invite scoped to one channel cannot read or write another channel's chat. Unscoped accounts may read and write any existing channel, matching the current channel model.
135131

136-
A->>S: ChatMessage{channelID, text}
137-
S->>S: Attach senderID, senderName, timestamp
138-
S->>A: ChatEvent (echo back)
139-
S->>B: ChatEvent (to all in channel)
140-
```
132+
`ChatHistoryRequest{channel_id, before_id, limit}` returns `ChatHistoryResponse{channel_id, messages, has_more}` in descending message-ID order. The first page uses `before_id = 0`; subsequent pages use the last message ID from the previous page. Pages default to 40 and cannot exceed 40. Requesting history also selects that channel for live chat events on the control connection, independently of voice membership. Older clients that never request history continue to receive live events for their voice channel. The server clears the text selection on disconnect. History responses are snapshots, not ordered barriers against live events: a delete event may arrive before an in-flight history response containing the deleted row. Clients must retain deleted message IDs for the active text selection and filter both later history pages and late chat events against those IDs; reset that set when selecting a different channel or reconnecting.
133+
134+
`ChatEvent` includes a stored message ID when persistence is enabled. The server targets 500 retained messages per channel and 30 days of history by default. Operators can change `-chat-history-limit` (0 disables storage and history; 1..10000 retains messages) and `-chat-max-age` (a Go duration; `0` disables age expiry). With storage disabled, live chat still works and events have ID 0. Existing stored rows are left untouched, hidden from history, and not swept while disabled; re-enabling persistence exposes any rows still within the configured retention period. Each write prunes at most 1000 excess rows in its channel; a large imported count backlog can remain above the target until further writes. A one-minute sweep removes up to 1000 age-expired rows per pass, including idle channels. Oversized legacy messages are truncated for history responses without changing their stored contents. Live events can overlap a history page, so clients should deduplicate by nonzero message ID.
135+
136+
A moderator or administrator may send `ChatDeleteRequest{channel_id, message_id}` to remove any stored message in a channel their account can access, regardless of the author's role. The server deletes only a matching channel/message pair and emits `ChatDeleteEvent{channel_id, message_id}` to text subscribers; the requester also receives it as an acknowledgement. The client should treat duplicate deletion events as harmless. Older clients receive live chat but not deletion events because they reject unknown protocol fields, so an already displayed message can remain visible until they disconnect. Deletion is unavailable while persistence is disabled; live-only messages (ID 0) cannot be deleted after delivery. The current desktop client does not request history, select a separate text channel, or offer a delete action yet.
141137

142138
### Screen Sharing Signalling
143139

‎pkg/datastore/interface.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,5 @@ type MessageWriteProvider interface {
133133
CreateMessageWithRetention(message *model.Message, maxPerChannel int, maxAge time.Duration) error
134134
PruneExpiredMessages(maxAge time.Duration, limit int) (int64, error)
135135
DeleteMessage(messageID int64) error
136+
DeleteMessageInChannel(messageID, channelID int64) (bool, error)
136137
}

‎pkg/datastore/sql.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,3 +1508,16 @@ func (s *baseProvider) DeleteMessage(messageID int64) error {
15081508
}
15091509
return nil
15101510
}
1511+
1512+
// DeleteMessageInChannel checks channel identity in the write, not in a separate read.
1513+
func (s *baseProvider) DeleteMessageInChannel(messageID, channelID int64) (bool, error) {
1514+
if messageID <= 0 || channelID <= 0 {
1515+
return false, fmt.Errorf("datastore: invalid message identity")
1516+
}
1517+
result, err := s.ExecContext(context.Background(), "DELETE FROM messages WHERE id = ? AND channel_id = ?", messageID, channelID)
1518+
if err != nil {
1519+
return false, fmt.Errorf("datastore: delete channel message: %w", err)
1520+
}
1521+
count, err := result.RowsAffected()
1522+
return count != 0, err
1523+
}

‎pkg/protocol/pb/messages.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ type ControlMessage struct {
2828
UnbanResp *UnbanResponse `json:"unban_response,omitempty"`
2929
ChatMsg *ChatMessage `json:"chat_message,omitempty"`
3030
ChatEvent *ChatMessage `json:"chat_event,omitempty"`
31+
ChatHistoryReq *ChatHistoryRequest `json:"chat_history_request,omitempty"`
32+
ChatHistoryResp *ChatHistoryResponse `json:"chat_history_response,omitempty"`
33+
ChatDeleteReq *ChatDeleteRequest `json:"chat_delete_request,omitempty"`
34+
ChatDeleteEvent *ChatDeleteEvent `json:"chat_delete_event,omitempty"`
3135
ScreenShareStartReq *ScreenShareStartRequest `json:"screen_share_start_request,omitempty"`
3236
ScreenShareStopReq *ScreenShareStopRequest `json:"screen_share_stop_request,omitempty"`
3337
ScreenShareSubReq *ScreenShareSubscribeRequest `json:"screen_share_subscribe_request,omitempty"`
@@ -215,13 +219,36 @@ type Pong struct {
215219
// ----- Chat -----
216220

217221
type ChatMessage struct {
222+
ID int64 `json:"id,omitempty"`
218223
ChannelID int64 `json:"channel_id"`
219224
SenderID int64 `json:"sender_id"`
220225
SenderName string `json:"sender_name"`
221226
Text string `json:"text"`
222227
Timestamp int64 `json:"timestamp"`
223228
}
224229

230+
type ChatHistoryRequest struct {
231+
ChannelID int64 `json:"channel_id"`
232+
BeforeID int64 `json:"before_id,omitempty"`
233+
Limit int64 `json:"limit,omitempty"`
234+
}
235+
236+
type ChatHistoryResponse struct {
237+
ChannelID int64 `json:"channel_id"`
238+
Messages []ChatMessage `json:"messages"`
239+
HasMore bool `json:"has_more"`
240+
}
241+
242+
type ChatDeleteRequest struct {
243+
ChannelID int64 `json:"channel_id"`
244+
MessageID int64 `json:"message_id"`
245+
}
246+
247+
type ChatDeleteEvent struct {
248+
ChannelID int64 `json:"channel_id"`
249+
MessageID int64 `json:"message_id"`
250+
}
251+
225252
// ----- Screen Sharing -----
226253

227254
type ScreenShareStartRequest struct {

‎pkg/protocol/protocol.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ var controlMessageFields = map[string]struct{}{
4747
"kick_user_request": {}, "ban_user_request": {},
4848
"list_bans_request": {}, "list_bans_response": {},
4949
"unban_request": {}, "unban_response": {},
50-
"chat_message": {}, "chat_event": {},
50+
"chat_message": {}, "chat_event": {}, "chat_history_request": {}, "chat_history_response": {},
51+
"chat_delete_request": {}, "chat_delete_event": {},
5152
"screen_share_start_request": {}, "screen_share_stop_request": {},
5253
"screen_share_subscribe_request": {}, "screen_share_share_request": {},
5354
"screen_share_unsubscribe_request": {}, "screen_share_event": {},

‎pkg/rbac/rbac.go‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,24 @@ const (
1414
PermManageTokens
1515
PermEditChannel
1616
PermManageRoles
17+
PermDeleteChatMessage
1718
)
1819

1920
// permissionMatrix maps roles to their allowed permissions.
2021
var permissionMatrix = map[model.Role]map[Permission]bool{
2122
model.RoleAdmin: {
22-
PermCreateChannel: true,
23-
PermDeleteChannel: true,
24-
PermKickUser: true,
25-
PermBanUser: true,
26-
PermManageTokens: true,
27-
PermEditChannel: true,
28-
PermManageRoles: true,
23+
PermCreateChannel: true,
24+
PermDeleteChannel: true,
25+
PermKickUser: true,
26+
PermBanUser: true,
27+
PermManageTokens: true,
28+
PermEditChannel: true,
29+
PermManageRoles: true,
30+
PermDeleteChatMessage: true,
2931
},
3032
model.RoleModerator: {
31-
PermKickUser: true,
33+
PermKickUser: true,
34+
PermDeleteChatMessage: true,
3235
},
3336
model.RoleUser: {
3437
// No special permissions — can only join channels and talk
@@ -69,6 +72,8 @@ func permName(p Permission) string {
6972
return "edit_channel"
7073
case PermManageRoles:
7174
return "manage_roles"
75+
case PermDeleteChatMessage:
76+
return "delete_chat_message"
7277
default:
7378
return "unknown"
7479
}

‎pkg/server/chat.go‎

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package server
2+
3+
import (
4+
"log/slog"
5+
"net"
6+
"strings"
7+
"time"
8+
"unicode/utf8"
9+
10+
"github.com/NicolasHaas/gospeak/pkg/datastore"
11+
"github.com/NicolasHaas/gospeak/pkg/model"
12+
"github.com/NicolasHaas/gospeak/pkg/protocol/pb"
13+
"github.com/NicolasHaas/gospeak/pkg/rbac"
14+
)
15+
16+
const chatPageLimit = 40 // JSON can escape each of 2000 runes as six bytes; 40 messages fit 512 KiB.
17+
18+
func (s *Server) chatChannel(sessionID uint32, channelID int64, st datastore.DataProviderFactory) bool {
19+
session, ok := s.sessions.GetSnapshot(sessionID)
20+
if !ok || channelID <= 0 || session.ChannelScope != 0 && session.ChannelScope != channelID {
21+
return false
22+
}
23+
channel, err := st.NonTx().GetChannel(channelID)
24+
return err == nil && channel != nil
25+
}
26+
27+
func (s *Server) handleChatMessage(handler *ControlHandler, sessionID uint32, chat *pb.ChatMessage, st datastore.DataProviderFactory, conn net.Conn) {
28+
session, ok := s.sessions.GetSnapshot(sessionID)
29+
if !ok {
30+
return
31+
}
32+
channelID := chat.ChannelID
33+
if channelID == 0 {
34+
channelID = s.channels.ChannelOf(sessionID)
35+
} // old clients use their voice channel
36+
if !s.chatChannel(sessionID, channelID, st) {
37+
sendError(conn, 3, "channel not found or inaccessible")
38+
return
39+
}
40+
text := sanitizeText(strings.TrimSpace(chat.Text))
41+
if text == "" || utf8.RuneCountInString(text) > model.MessageMaxBodyLength {
42+
return
43+
}
44+
var id, timestamp int64
45+
if s.cfg.ChatHistoryLimit > 0 {
46+
message := &model.Message{ChannelID: channelID, SenderID: session.UserID, SenderName: session.Username, Body: text}
47+
if err := st.NonTx().CreateMessageWithRetention(message, s.cfg.ChatHistoryLimit, s.cfg.ChatMaxAge); err != nil {
48+
slog.Error("store chat message", "err", err)
49+
sendError(conn, 3, "could not save message")
50+
return
51+
}
52+
id, timestamp = message.ID, message.CreatedAt.Unix()
53+
} else {
54+
timestamp = time.Now().Unix()
55+
}
56+
handler.broadcastChat(channelID, &pb.ControlMessage{ChatEvent: &pb.ChatMessage{
57+
ID: id, ChannelID: channelID, SenderID: session.UserID, SenderName: session.Username,
58+
Text: text, Timestamp: timestamp,
59+
}}, true)
60+
s.metrics.ChatMessagesSent.Add(1)
61+
}
62+
63+
func (s *Server) handleChatHistory(handler *ControlHandler, sessionID uint32, req *pb.ChatHistoryRequest, st datastore.DataProviderFactory, conn net.Conn) {
64+
if req.BeforeID < 0 || req.Limit < 0 || req.Limit > chatPageLimit || !s.chatChannel(sessionID, req.ChannelID, st) {
65+
sendError(conn, 3, "channel not found or inaccessible")
66+
return
67+
}
68+
// Select before reading history so a concurrent write reaches either the page or live fanout.
69+
// A message can appear in both; clients should deduplicate by its stored ID.
70+
handler.mu.Lock()
71+
if _, registered := handler.connMap[sessionID]; registered {
72+
handler.chatSelection[sessionID] = req.ChannelID
73+
}
74+
handler.mu.Unlock()
75+
limit := req.Limit
76+
if limit == 0 {
77+
limit = chatPageLimit
78+
}
79+
if s.cfg.ChatHistoryLimit == 0 {
80+
if err := writeControlMessage(conn, &pb.ControlMessage{ChatHistoryResp: &pb.ChatHistoryResponse{ChannelID: req.ChannelID, Messages: []pb.ChatMessage{}}}); err != nil {
81+
slog.Warn("send empty chat history", "session", sessionID, "err", err)
82+
}
83+
return
84+
}
85+
fetch := limit + 1
86+
filters := model.MessageFilters{LimitToChannelID: &req.ChannelID, BeforeID: req.BeforeID, PageSize: &fetch}
87+
if s.cfg.ChatMaxAge > 0 {
88+
filters.Since = time.Now().Add(-s.cfg.ChatMaxAge)
89+
}
90+
rows, err := st.NonTx().ListMessages(filters)
91+
if err != nil {
92+
slog.Error("load chat history", "err", err)
93+
sendError(conn, 3, "could not load history")
94+
return
95+
}
96+
resp := &pb.ChatHistoryResponse{ChannelID: req.ChannelID, Messages: []pb.ChatMessage{}, HasMore: int64(len(rows)) > limit}
97+
if resp.HasMore {
98+
rows = rows[:limit]
99+
}
100+
// ponytail: cap legacy rows at current wire limits; stored originals remain untouched.
101+
for _, m := range rows {
102+
resp.Messages = append(resp.Messages, pb.ChatMessage{ID: m.ID, ChannelID: m.ChannelID,
103+
SenderID: m.SenderID, SenderName: truncateRunes(m.SenderName, model.MaxUsernameLength),
104+
Text: truncateRunes(m.Body, model.MessageMaxBodyLength), Timestamp: m.CreatedAt.Unix()})
105+
}
106+
if err := writeControlMessage(conn, &pb.ControlMessage{ChatHistoryResp: resp}); err != nil {
107+
slog.Warn("send chat history", "session", sessionID, "err", err)
108+
}
109+
}
110+
111+
func (s *Server) handleChatDelete(handler *ControlHandler, sessionID uint32, req *pb.ChatDeleteRequest, st datastore.DataProviderFactory, conn net.Conn) {
112+
if s.cfg.ChatHistoryLimit == 0 {
113+
sendError(conn, 3, "chat history disabled")
114+
return
115+
}
116+
if req.MessageID <= 0 || !s.chatChannel(sessionID, req.ChannelID, st) {
117+
sendError(conn, 3, "channel not found or inaccessible")
118+
return
119+
}
120+
// Role updates use the same lock: no demotion can interleave with this delete.
121+
s.remoteModerationMu.Lock()
122+
session, ok := s.sessions.GetSnapshot(sessionID)
123+
if !ok || !rbac.HasPermission(session.Role, rbac.PermDeleteChatMessage) {
124+
s.remoteModerationMu.Unlock()
125+
sendError(conn, 30, "permission denied")
126+
return
127+
}
128+
actor, err := st.NonTx().GetUserByID(session.UserID)
129+
if err != nil || actor == nil || !rbac.HasPermission(actor.Role, rbac.PermDeleteChatMessage) {
130+
s.remoteModerationMu.Unlock()
131+
sendError(conn, 30, "permission denied")
132+
return
133+
}
134+
deleted, err := st.NonTx().DeleteMessageInChannel(req.MessageID, req.ChannelID)
135+
s.remoteModerationMu.Unlock()
136+
if err != nil {
137+
slog.Error("delete chat message", "err", err)
138+
sendError(conn, 3, "could not delete message")
139+
return
140+
}
141+
if !deleted {
142+
sendError(conn, 3, "message not found")
143+
return
144+
}
145+
event := &pb.ControlMessage{ChatDeleteEvent: &pb.ChatDeleteEvent{ChannelID: req.ChannelID, MessageID: req.MessageID}}
146+
if err := writeControlMessage(conn, event); err != nil {
147+
slog.Warn("send chat deletion", "session", sessionID, "err", err)
148+
}
149+
// Old clients reject unknown envelope fields; only history-capable subscribers receive deletion events.
150+
handler.broadcastChat(req.ChannelID, event, false)
151+
}
152+
153+
func (handler *ControlHandler) broadcastChat(channelID int64, event *pb.ControlMessage, includeLegacy bool) {
154+
members := handler.server.channels.Members(channelID)
155+
legacy := make(map[uint32]bool, len(members))
156+
for _, sessionID := range members {
157+
legacy[sessionID] = true
158+
}
159+
handler.mu.RLock()
160+
clients := make(map[uint32]*controlClient)
161+
for sessionID, client := range handler.connMap {
162+
selected, explicit := handler.chatSelection[sessionID]
163+
if explicit && selected == channelID || includeLegacy && !explicit && legacy[sessionID] {
164+
// Membership and explicit selection both remain subject to the account's scope.
165+
if session, ok := handler.server.sessions.GetSnapshot(sessionID); ok &&
166+
(session.ChannelScope == 0 || session.ChannelScope == channelID) {
167+
clients[sessionID] = client
168+
}
169+
}
170+
}
171+
handler.mu.RUnlock()
172+
for sessionID, client := range clients {
173+
if err := client.send(event); err != nil {
174+
slog.Error("chat event write failed", "session", sessionID, "err", err)
175+
}
176+
}
177+
}
178+
179+
// ponytail: one bounded batch per minute; old imported backlogs drain over multiple ticks.
180+
func (s *Server) runChatJanitor(st datastore.DataProviderFactory) {
181+
ticker := time.NewTicker(time.Minute)
182+
defer ticker.Stop()
183+
for {
184+
select {
185+
case <-s.ctx.Done():
186+
return
187+
case <-ticker.C:
188+
if _, err := st.NonTx().PruneExpiredMessages(s.cfg.ChatMaxAge, 1000); err != nil {
189+
slog.Error("prune expired chat", "err", err)
190+
}
191+
}
192+
}
193+
}

0 commit comments

Comments
 (0)