added features and fixes

This commit is contained in:
2026-07-02 15:34:00 +00:00
parent eb5b7d55a4
commit 89f8381e2d
30 changed files with 1718 additions and 206 deletions
+327 -9
View File
@@ -47,6 +47,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/bulk-delete", h.BulkDelete)
r.Patch("/{messageID}", h.Update)
r.Delete("/{messageID}", h.Delete)
r.Get("/pinned", h.ListPinned)
r.Put("/{messageID}/pin", h.Pin)
r.Delete("/{messageID}/pin", h.Unpin)
}
type bulkDeleteRequest struct {
@@ -135,6 +138,21 @@ type embedResponse struct {
SiteName string `json:"site_name"`
}
type reactionResponse struct {
ID string `json:"id"`
MessageID string `json:"message_id"`
UserID string `json:"user_id"`
Emoji string `json:"emoji"`
CreatedAt string `json:"created_at"`
}
type emojiGroup struct {
Emoji string `json:"emoji"`
Count int `json:"count"`
Users []string `json:"users"`
Details []reactionResponse `json:"details"`
}
type messageResponse struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
@@ -144,8 +162,10 @@ type messageResponse struct {
Content string `json:"content"`
ReplyTo *string `json:"reply_to,omitempty"`
EditedAt *string `json:"edited_at"`
Pinned bool `json:"pinned"`
CreatedAt string `json:"created_at"`
Embeds []embedResponse `json:"embeds"`
Reactions []emojiGroup `json:"reactions"`
}
// isMember checks whether the given user is a member of the given server.
@@ -229,9 +249,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
err = h.db.QueryRowContext(r.Context(), `
INSERT INTO messages (channel_id, author_id, content, reply_to)
VALUES ($1, $2, $3, $4)
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
`, channelID, userID, req.Content, replyTo).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt,
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
)
if err != nil {
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError)
@@ -254,6 +274,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
msg.Reactions = make([]emojiGroup, 0)
// Broadcast MESSAGE_CREATE event via WebSocket (scoped to server)
h.hub.BroadcastToServer(serverID, gateway.Event{
@@ -355,9 +376,9 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
err := h.db.QueryRowContext(r.Context(), `
UPDATE messages SET content = $1, edited_at = NOW()
WHERE id = $2 AND author_id = $3
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
`, req.Content, messageID, userID).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt,
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -386,6 +407,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
// Re-fetch embeds after edit.
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
// Look up serverID for scoped broadcast
serverID, _ := h.hub.ServerIDForChannel(r.Context(), msg.ChannelID)
@@ -482,7 +504,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
var err error
if before != "" {
rows, err = h.db.QueryContext(r.Context(), `
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
FROM messages m
JOIN users u ON m.author_id = u.id
WHERE m.channel_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2)
@@ -491,7 +513,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
`, channelID, before, limit)
} else {
rows, err = h.db.QueryContext(r.Context(), `
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
FROM messages m
JOIN users u ON m.author_id = u.id
WHERE m.channel_id = $1
@@ -511,7 +533,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
var editedAt sql.NullString
var createdAt sql.NullString
var replyTo sql.NullString
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt)
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt)
if err != nil {
continue
}
@@ -526,6 +548,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
}
messages = h.attachEmbeds(r.Context(), messages)
messages = h.attachReactions(r.Context(), messages)
// Reverse: query returns DESC (newest first), client expects ASC (oldest first).
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
@@ -570,6 +593,85 @@ func (h *Handler) attachEmbeds(ctx context.Context, messages []messageResponse)
return messages
}
func (h *Handler) attachReactions(ctx context.Context, messages []messageResponse) []messageResponse {
if len(messages) == 0 {
return messages
}
msgIDs := make([]string, len(messages))
msgMap := make(map[string]int)
for i, msg := range messages {
msgIDs[i] = msg.ID
msgMap[msg.ID] = i
messages[i].Reactions = make([]emojiGroup, 0)
}
placeholders := make([]string, len(msgIDs))
args := make([]interface{}, len(msgIDs))
for i, id := range msgIDs {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
query := fmt.Sprintf(`
SELECT id, message_id, user_id, emoji, created_at::text
FROM reactions
WHERE message_id IN (%s)
ORDER BY created_at ASC
`, strings.Join(placeholders, ", "))
rows, err := h.db.QueryContext(ctx, query, args...)
if err != nil {
return messages
}
defer rows.Close()
reactionsMap := make(map[string]map[string]*emojiGroup)
emojiOrderMap := make(map[string][]string)
for rows.Next() {
var reaction reactionResponse
var createdAt sql.NullString
if err := rows.Scan(&reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt); err != nil {
continue
}
reaction.CreatedAt = createdAt.String
msgID := reaction.MessageID
if _, ok := reactionsMap[msgID]; !ok {
reactionsMap[msgID] = make(map[string]*emojiGroup)
emojiOrderMap[msgID] = make([]string, 0)
}
group, ok := reactionsMap[msgID][reaction.Emoji]
if !ok {
group = &emojiGroup{
Emoji: reaction.Emoji,
Users: []string{},
Details: []reactionResponse{},
}
reactionsMap[msgID][reaction.Emoji] = group
emojiOrderMap[msgID] = append(emojiOrderMap[msgID], reaction.Emoji)
}
group.Count++
group.Users = append(group.Users, reaction.UserID)
group.Details = append(group.Details, reaction)
}
for msgID, emojisMap := range reactionsMap {
idx, ok := msgMap[msgID]
if !ok {
continue
}
order := emojiOrderMap[msgID]
for _, emoji := range order {
messages[idx].Reactions = append(messages[idx].Reactions, *emojisMap[emoji])
}
}
return messages
}
// Search searches messages in a channel using PostgreSQL full-text search.
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
@@ -593,7 +695,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text,
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text,
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank
FROM messages m
JOIN users u ON m.author_id = u.id
@@ -614,7 +716,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
var createdAt sql.NullString
var replyTo sql.NullString
var rank float64
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt, &rank)
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &rank)
if err != nil {
continue
}
@@ -629,6 +731,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
}
messages = h.attachEmbeds(r.Context(), messages)
messages = h.attachReactions(r.Context(), messages)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages)
@@ -673,3 +776,218 @@ func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, c
return userID, serverID, true
}
func (h *Handler) Pin(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
messageID := chi.URLParam(r, "messageID")
_, serverID, ok := h.requireChannelAccess(w, r, channelID, permissions.SEND_MESSAGES)
if !ok {
return
}
// Verify the message exists and belongs to this channel
var msgChannelID string
err := h.db.QueryRowContext(r.Context(), "SELECT channel_id FROM messages WHERE id = $1", messageID).Scan(&msgChannelID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if msgChannelID != channelID {
http.Error(w, `{"error":"message does not belong to this channel"}`, http.StatusBadRequest)
return
}
// Check if already pinned
var isPinned bool
err = h.db.QueryRowContext(r.Context(), "SELECT pinned FROM messages WHERE id = $1", messageID).Scan(&isPinned)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if isPinned {
http.Error(w, `{"error":"message is already pinned"}`, http.StatusBadRequest)
return
}
// Count pinned messages in this channel
var count int
err = h.db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND pinned = TRUE", channelID).Scan(&count)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if count >= 5 {
http.Error(w, `{"error":"limit reached (max 5 pinned messages per channel)"}`, http.StatusBadRequest)
return
}
// Update message
var msg messageResponse
var editedAt sql.NullString
var createdAt sql.NullString
var replyTo sql.NullString
err = h.db.QueryRowContext(r.Context(), `
UPDATE messages SET pinned = TRUE
WHERE id = $1
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
`, messageID).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
)
if err != nil {
http.Error(w, `{"error":"failed to pin message"}`, http.StatusInternalServerError)
return
}
// Fetch author details
err = h.db.QueryRowContext(r.Context(), `
SELECT username, display_name FROM users WHERE id = $1
`, msg.AuthorID).Scan(&msg.AuthorName, &msg.DisplayName)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.String
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
// Attach embeds
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
// Broadcast MESSAGE_UPDATE
h.hub.BroadcastToServer(serverID, gateway.Event{
Type: gateway.EventMessageUpdate,
Data: msg,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(msg)
}
func (h *Handler) Unpin(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
messageID := chi.URLParam(r, "messageID")
_, serverID, ok := h.requireChannelAccess(w, r, channelID, permissions.SEND_MESSAGES)
if !ok {
return
}
// Verify the message exists and belongs to this channel
var msgChannelID string
err := h.db.QueryRowContext(r.Context(), "SELECT channel_id FROM messages WHERE id = $1", messageID).Scan(&msgChannelID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if msgChannelID != channelID {
http.Error(w, `{"error":"message does not belong to this channel"}`, http.StatusBadRequest)
return
}
// Update message
var msg messageResponse
var editedAt sql.NullString
var createdAt sql.NullString
var replyTo sql.NullString
err = h.db.QueryRowContext(r.Context(), `
UPDATE messages SET pinned = FALSE
WHERE id = $1
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
`, messageID).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
)
if err != nil {
http.Error(w, `{"error":"failed to unpin message"}`, http.StatusInternalServerError)
return
}
// Fetch author details
err = h.db.QueryRowContext(r.Context(), `
SELECT username, display_name FROM users WHERE id = $1
`, msg.AuthorID).Scan(&msg.AuthorName, &msg.DisplayName)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.String
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
// Attach embeds
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
// Broadcast MESSAGE_UPDATE
h.hub.BroadcastToServer(serverID, gateway.Event{
Type: gateway.EventMessageUpdate,
Data: msg,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(msg)
}
func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
_, _, ok := h.requireChannelAccess(w, r, channelID, permissions.VIEW_CHANNEL)
if !ok {
return
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
FROM messages m
JOIN users u ON m.author_id = u.id
WHERE m.channel_id = $1 AND m.pinned = TRUE
ORDER BY m.created_at DESC
`, channelID)
if err != nil {
http.Error(w, `{"error":"failed to list pinned messages"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
messages := make([]messageResponse, 0)
for rows.Next() {
var msg messageResponse
var editedAt sql.NullString
var createdAt sql.NullString
var replyTo sql.NullString
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt)
if err != nil {
continue
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.String
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
messages = append(messages, msg)
}
messages = h.attachEmbeds(r.Context(), messages)
messages = h.attachReactions(r.Context(), messages)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages)
}