package message import ( "context" "database/sql" "encoding/json" "errors" "fmt" "log/slog" "net/http" "strconv" "strings" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/embed" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push" "github.com/go-chi/chi/v5" ) type Handler struct { db *sql.DB hub *gateway.Hub mentions *MentionHandler pushHandler *push.Handler logger *slog.Logger checker *permissions.Checker } func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger, checker *permissions.Checker) *Handler { return &Handler{ db: db, hub: hub, mentions: NewMentionHandler(db, pushHandler, logger), pushHandler: pushHandler, logger: logger, checker: checker, } } func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/", h.List) r.Post("/", h.Create) r.Get("/search", h.Search) 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 { Messages []string `json:"messages"` } type bulkDeleteResponse struct { Deleted int `json:"deleted"` } // BulkDelete deletes up to 100 messages in a channel. Requires MANAGE_MESSAGES // and messages must be within the last 14 days. func (h *Handler) BulkDelete(w http.ResponseWriter, r *http.Request) { channelID := chi.URLParam(r, "channelID") userID, serverID, ok := h.requireChannelAccess(w, r, channelID, 0) if !ok { return } allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_MESSAGES) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if !allowed { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return } var req bulkDeleteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if len(req.Messages) == 0 { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(bulkDeleteResponse{Deleted: 0}) return } if len(req.Messages) > 100 { http.Error(w, `{"error":"too many messages (max 100)"}`, http.StatusBadRequest) return } // ponytail: simple IN query with 14-day guard and channel_id filter in DB. placeholders := make([]string, len(req.Messages)) args := make([]interface{}, 0, len(req.Messages)+1) for i, id := range req.Messages { placeholders[i] = fmt.Sprintf("$%d", i+1) args = append(args, id) } args = append(args, channelID) query := fmt.Sprintf(` DELETE FROM messages WHERE id IN (%s) AND channel_id = $%d AND created_at > NOW() - INTERVAL '14 days' `, strings.Join(placeholders, ","), len(args)) res, err := h.db.ExecContext(r.Context(), query, args...) if err != nil { http.Error(w, `{"error":"failed to delete messages"}`, http.StatusInternalServerError) return } deleted, _ := res.RowsAffected() if h.hub != nil { for _, id := range req.Messages { h.hub.BroadcastToServer(serverID, gateway.Event{ Type: gateway.EventMessageDelete, Data: map[string]string{ "id": id, "channel_id": channelID, }, }) } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(bulkDeleteResponse{Deleted: int(deleted)}) } type embedResponse struct { ID string `json:"id"` URL string `json:"url"` Title string `json:"title"` Description string `json:"description"` ImageURL string `json:"image_url"` 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"` AuthorID string `json:"author_id"` AuthorName string `json:"author_username"` DisplayName *string `json:"author_display_name"` 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. func (h *Handler) isMember(ctx context.Context, userID, serverID string) (bool, error) { var exists bool err := h.db.QueryRowContext(ctx, ` SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2) `, userID, serverID).Scan(&exists) return exists, err } type createMessageRequest struct { Content string `json:"content"` ReplyTo *string `json:"reply_to,omitempty"` } // @Summary Create a message // @Description Send a message to a channel // @Tags messages // @Accept json // @Produce json // @Security SessionAuth // @Param channelID path string true "Channel ID" // @Param body body createMessageRequest true "Message data" // @Success 201 {object} messageResponse // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Router /channels/{channelID}/messages [post] func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { channelID := chi.URLParam(r, "channelID") userID, serverID, ok := h.requireChannelAccess(w, r, channelID, permissions.SEND_MESSAGES) if !ok { return } var req createMessageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if req.Content == "" { http.Error(w, `{"error":"content is required"}`, http.StatusBadRequest) return } if len(req.Content) > 4000 { http.Error(w, `{"error":"message too long (max 4000 chars)"}`, http.StatusBadRequest) return } // Check server mute. muted, err := moderation.IsMuted(r.Context(), h.db, serverID, userID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if muted { http.Error(w, `{"error":"you are muted in this server"}`, http.StatusForbidden) return } // Check slowmode. if retryAfter, active := h.checkSlowmode(r.Context(), channelID, userID); active { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusTooManyRequests) json.NewEncoder(w).Encode(map[string]interface{}{ "error": "slowmode", "retry_after": retryAfter, "retry_after_ms": retryAfter * 1000, }) return } var msg messageResponse var editedAt sql.NullString var createdAt sql.NullString var replyTo sql.NullString if req.ReplyTo != nil { replyTo = sql.NullString{String: *req.ReplyTo, Valid: true} } 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, pinned, created_at::text `, channelID, userID, req.Content, replyTo).Scan( &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) return } // Fetch author info err = h.db.QueryRowContext(r.Context(), ` SELECT username, display_name FROM users WHERE id = $1 `, userID).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 msg.Reactions = make([]emojiGroup, 0) // Broadcast MESSAGE_CREATE event via WebSocket (scoped to server) h.hub.BroadcastToServer(serverID, gateway.Event{ Type: gateway.EventMessageCreate, Data: msg, }) // Dispatch push notifications for @mentions go h.mentions.ParseAndNotify(r.Context(), channelID, userID, req.Content) // Dispatch push notifications to channel members go func() { var channelName string _ = h.db.QueryRowContext(context.Background(), `SELECT name FROM channels WHERE id = $1`, channelID).Scan(&channelName) if channelName == "" { channelName = channelID } if h.pushHandler != nil { h.pushHandler.SendChannelNotification(context.Background(), channelID, userID, channelName, req.Content) } }() // Fetch and store link embeds asynchronously. go embed.FetchAndStore(h.db, msg.ID, req.Content) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(msg) } func (h *Handler) checkSlowmode(ctx context.Context, channelID, userID string) (int, bool) { var slowmode int err := h.db.QueryRowContext(ctx, `SELECT slowmode_seconds FROM channels WHERE id = $1`, channelID).Scan(&slowmode) if err != nil || slowmode <= 0 { return 0, false } var elapsed int err = h.db.QueryRowContext(ctx, ` SELECT COALESCE(EXTRACT(EPOCH FROM (NOW() - created_at))::int, 0) FROM messages WHERE channel_id = $1 AND author_id = $2 ORDER BY created_at DESC LIMIT 1 `, channelID, userID).Scan(&elapsed) if err != nil { return 0, false } if elapsed < slowmode { return slowmode - elapsed, true } return 0, false } type updateMessageRequest struct { Content string `json:"content"` } // @Summary Update a message // @Description Edit a message (author only) // @Tags messages // @Accept json // @Produce json // @Security SessionAuth // @Param channelID path string true "Channel ID" // @Param messageID path string true "Message ID" // @Param body body updateMessageRequest true "Message update data" // @Success 200 {object} messageResponse // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /channels/{channelID}/messages/{messageID} [patch] func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { messageID := chi.URLParam(r, "messageID") userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } var req updateMessageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if req.Content == "" { http.Error(w, `{"error":"content is required"}`, http.StatusBadRequest) return } if len(req.Content) > 4000 { http.Error(w, `{"error":"message too long (max 4000 chars)"}`, http.StatusBadRequest) return } var msg messageResponse var editedAt sql.NullString var createdAt sql.NullString var replyTo sql.NullString 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, pinned, created_at::text `, req.Content, messageID, userID).Scan( &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, `{"error":"message not found or not authorized"}`, http.StatusNotFound) return } http.Error(w, `{"error":"failed to update message"}`, http.StatusInternalServerError) return } err = h.db.QueryRowContext(r.Context(), ` SELECT username, display_name FROM users WHERE id = $1 `, userID).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 // 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) if serverID != "" { h.hub.BroadcastToServer(serverID, gateway.Event{ Type: gateway.EventMessageUpdate, Data: msg, }) } go embed.FetchAndStore(h.db, msg.ID, req.Content) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(msg) } // @Summary Delete a message // @Description Delete a message (author only) // @Tags messages // @Security SessionAuth // @Param channelID path string true "Channel ID" // @Param messageID path string true "Message ID" // @Success 204 // @Failure 401 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /channels/{channelID}/messages/{messageID} [delete] func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { messageID := chi.URLParam(r, "messageID") userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } var channelID string err := h.db.QueryRowContext(r.Context(), ` DELETE FROM messages WHERE id = $1 AND author_id = $2 RETURNING channel_id `, messageID, userID).Scan(&channelID) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, `{"error":"message not found or not authorized"}`, http.StatusNotFound) return } http.Error(w, `{"error":"failed to delete message"}`, http.StatusInternalServerError) return } // Look up serverID for scoped broadcast serverID, _ := h.hub.ServerIDForChannel(r.Context(), channelID) if serverID != "" { h.hub.BroadcastToServer(serverID, gateway.Event{ Type: gateway.EventMessageDelete, Data: map[string]string{ "id": messageID, "channel_id": channelID, }, }) } w.WriteHeader(http.StatusNoContent) } // @Summary List messages // @Description List messages in a channel with pagination // @Tags messages // @Produce json // @Security SessionAuth // @Param channelID path string true "Channel ID" // @Param limit query int false "Max messages to return (1-100, default 50)" // @Param before query string false "Message ID to fetch messages before" // @Success 200 {array} messageResponse // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Router /channels/{channelID}/messages [get] func (h *Handler) List(w http.ResponseWriter, r *http.Request) { channelID := chi.URLParam(r, "channelID") userID, _, ok := h.requireChannelAccess(w, r, channelID, permissions.VIEW_CHANNEL) if !ok { return } limitStr := r.URL.Query().Get("limit") limit := 50 if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } } before := r.URL.Query().Get("before") var rows *sql.Rows 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.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) ORDER BY m.created_at DESC LIMIT $3 `, 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.pinned, m.created_at::text FROM messages m JOIN users u ON m.author_id = u.id WHERE m.channel_id = $1 ORDER BY m.created_at DESC LIMIT $2 `, channelID, limit) } if err != nil { http.Error(w, `{"error":"failed to list 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) // 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 { messages[i], messages[j] = messages[j], messages[i] } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(messages) _ = userID } // attachEmbeds loads embeds for the given messages and attaches them. func (h *Handler) attachEmbeds(ctx context.Context, messages []messageResponse) []messageResponse { if len(messages) == 0 { return messages } ids := make([]string, len(messages)) for i, m := range messages { ids[i] = m.ID } embedMap, err := embed.LoadForMessages(ctx, h.db, ids) if err != nil { return messages } for i := range messages { if embs, ok := embedMap[messages[i].ID]; ok { out := make([]embedResponse, len(embs)) for j, e := range embs { out[j] = embedResponse{ ID: e.ID, URL: e.URL, Title: e.Title, Description: e.Description, ImageURL: e.ImageURL, SiteName: e.SiteName, } } messages[i].Embeds = out } } 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") _, _, ok := h.requireChannelAccess(w, r, channelID, permissions.VIEW_CHANNEL) if !ok { return } q := strings.TrimSpace(r.URL.Query().Get("q")) if q == "" { http.Error(w, `{"error":"missing q parameter"}`, http.StatusBadRequest) return } limitStr := r.URL.Query().Get("limit") limit := 25 if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } } 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, ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank FROM messages m JOIN users u ON m.author_id = u.id WHERE m.channel_id = $1 AND m.search_vector @@ plainto_tsquery('english', $2) ORDER BY rank DESC, m.created_at DESC LIMIT $3 `, channelID, q, limit) if err != nil { http.Error(w, `{"error":"failed to search 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 var rank float64 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 } 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) } // requireChannelAccess checks if user is authenticated and is a member of the channel's server. // Returns (userID, serverID, ok). If required is non-zero, also checks the permission against // channel-level overrides. func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string, required int64) (string, string, bool) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return "", "", false } var serverID string err := h.db.QueryRowContext(r.Context(), `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID) if err != nil { http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) return "", "", false } isMember, err := h.isMember(r.Context(), userID, serverID) if err != nil || !isMember { http.Error(w, `{"error":"not a member"}`, http.StatusForbidden) return "", "", false } // Check channel-specific permission if a bitmap was provided. if required != 0 { allowed, checkErr := h.checker.CheckChannelPermission(r.Context(), serverID, userID, channelID, required) if checkErr != nil { h.logger.Error("permission check failed", "error", checkErr) http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return "", "", false } if !allowed { http.Error(w, `{"error":"missing permissions"}`, http.StatusForbidden) return "", "", false } } 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) }