added features and fixes
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user