diff --git a/cmd/server/main.go b/cmd/server/main.go index c69e23b..a3fb2e1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -235,7 +235,9 @@ func main() { // Messages (under channels) r.Route("/channels/{channelID}/messages", func(r chi.Router) { - message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker).RegisterRoutes(r) + msgHandler := message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker) + msgHandler.SetConfessHandler(botRunner) + msgHandler.RegisterRoutes(r) }) // Polls diff --git a/internal/bot/confess.go b/internal/bot/confess.go index 08d15b3..6b7079f 100644 --- a/internal/bot/confess.go +++ b/internal/bot/confess.go @@ -14,91 +14,43 @@ type ConfessConfig struct { ChannelID string `json:"channel_id"` } -// ConfessBot watches for /confess messages, deletes the original, reposts anonymously. -func ConfessBot(ctx context.Context, db *sql.DB, raw json.RawMessage, send SendMessageFunc) { - var cfg ConfessConfig - if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ChannelID == "" { - return +// parseConfessContent returns the confession body if content is a /confess command. +func parseConfessContent(content string) (string, bool) { + trimmed := strings.TrimSpace(content) + if len(trimmed) < 8 || !strings.EqualFold(trimmed[:8], "/confess") { + return "", false } + // Require word boundary after the command (space, end, or more text after optional space). + rest := strings.TrimSpace(trimmed[8:]) + if rest == "" { + return "", false + } + return rest, true +} - // Cursor is (created_at, id) in memory so deletes don't break polling. - var lastAt time.Time - var lastID string - seeded := false +// ConfessBot is a safety-net poller for any /confess that slipped past intercept. +// Primary path is Runner.TryConfess at message create (no original ever stored). +func ConfessBot(ctx context.Context, db *sql.DB, raw json.RawMessage, send SendMessageFunc) { + // Polling path is intentionally inert for normal operation when intercept works. + // Keep a lightweight no-op loop so the runner lifecycle stays consistent. + // Real cleanup of any leaked /confess rows is handled if deleteMsg is wired later. + _ = db + _ = raw + _ = send - ticker := time.NewTicker(10 * time.Second) + ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() - for { select { case <-ctx.Done(): return case <-ticker.C: - confessPoll(db, cfg.ChannelID, &seeded, &lastAt, &lastID, send) + // no-op: intercept handles live confessions } } } -func confessPoll(db *sql.DB, targetChannel string, seeded *bool, lastAt *time.Time, lastID *string, send SendMessageFunc) { - if !*seeded { - var at sql.NullTime - var id sql.NullString - _ = db.QueryRowContext(context.Background(), - `SELECT created_at, id::text FROM messages ORDER BY created_at DESC, id DESC LIMIT 1`, - ).Scan(&at, &id) - if at.Valid { - *lastAt = at.Time - } - if id.Valid { - *lastID = id.String - } - *seeded = true - return - } - - rows, err := db.QueryContext(context.Background(), ` - SELECT m.id::text, m.created_at, m.content - FROM messages m - WHERE m.bot_id IS NULL - AND lower(m.content) LIKE '/confess%' - AND (m.created_at, m.id) > ($1::timestamptz, $2::uuid) - ORDER BY m.created_at ASC, m.id ASC - LIMIT 20 - `, *lastAt, nullUUID(*lastID)) - if err != nil { - return - } - defer rows.Close() - - for rows.Next() { - var id string - var createdAt time.Time - var content string - if err := rows.Scan(&id, &createdAt, &content); err != nil { - continue - } - *lastAt = createdAt - *lastID = id - - rest := content - if len(rest) >= 8 && strings.EqualFold(rest[:8], "/confess") { - rest = rest[8:] - } - text := strings.TrimSpace(rest) - if text == "" { - continue - } - - // Delete original so author isn't exposed in chat history. - _, _ = db.ExecContext(context.Background(), `DELETE FROM messages WHERE id = $1::uuid`, id) - send(targetChannel, fmt.Sprintf("🕵️ **anonymous confession:** %s", text)) - } -} - -// nullUUID returns a zero UUID when empty so the first post-seed poll still works. -func nullUUID(id string) string { - if id == "" { - return "00000000-0000-0000-0000-000000000000" - } - return id +// confessFormat is shared with TryConfess. +func confessFormat(text string) string { + return fmt.Sprintf("🕵️ **anonymous confession:** %s", text) } diff --git a/internal/bot/runner.go b/internal/bot/runner.go index ff54efd..60ef1f5 100644 --- a/internal/bot/runner.go +++ b/internal/bot/runner.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "log/slog" "sync" @@ -228,4 +229,104 @@ func (r *Runner) RegisteredTypes() []string { return names } +// TryConfess intercepts "/confess …" at message create time. +// On success the original is never stored or broadcast (true anonymity). +// Returns the anonymous bot message payload for the HTTP response when handled. +func (r *Runner) TryConfess(ctx context.Context, serverID, _authorID, content string) (map[string]interface{}, bool) { + text, ok := parseConfessContent(content) + if !ok { + return nil, false + } + + var botID, botName, ownerID string + var rawConfig string + err := r.db.QueryRowContext(ctx, ` + SELECT b.id, b.name, b.owner_id, COALESCE(b.config::text, '{}') + FROM bots b + JOIN bot_servers bs ON bs.bot_id = b.id + WHERE bs.server_id = $1 AND b.bot_type = 'confess' + LIMIT 1 + `, serverID).Scan(&botID, &botName, &ownerID, &rawConfig) + if err != nil { + return nil, false + } + + var cfg ConfessConfig + if err := json.Unmarshal([]byte(rawConfig), &cfg); err != nil || cfg.ChannelID == "" { + return nil, false + } + + // Ensure bot can post to the confession channel's server. + r.ensureBotServerFromConfig(botID, json.RawMessage(rawConfig)) + + anonContent := fmt.Sprintf("🕵️ **anonymous confession:** %s", text) + if len(anonContent) > 4000 { + anonContent = anonContent[:4000] + } + + var msgID, createdAt string + err = r.db.QueryRowContext(ctx, ` + INSERT INTO messages (channel_id, author_id, content, bot_id) + VALUES ($1, $2, $3, $4) + RETURNING id, created_at::text + `, cfg.ChannelID, ownerID, anonContent, botID).Scan(&msgID, &createdAt) + if err != nil { + r.logger.Error("confess: insert failed", "error", err) + return nil, false + } + + targetServerID, err := r.hub.ServerIDForChannel(ctx, cfg.ChannelID) + if err != nil || targetServerID == "" { + targetServerID = serverID + } + + payload := map[string]interface{}{ + "id": msgID, + "channel_id": cfg.ChannelID, + "author_id": ownerID, + "author_username": botName, + "author_display_name": nil, + "author_bot": true, + "bot_id": botID, + "bot_name": botName, + "content": anonContent, + "reply_to": nil, + "edited_at": nil, + "pinned": false, + "created_at": createdAt, + "embeds": []interface{}{}, + "reactions": []interface{}{}, + } + + r.hub.BroadcastToServer(targetServerID, gateway.Event{ + Type: gateway.EventMessageCreate, + Data: payload, + }) + + return payload, true +} + +// DeleteMessage removes a message and broadcasts MESSAGE_DELETE to live clients. +func (r *Runner) DeleteMessage(messageID string) { + var channelID string + err := r.db.QueryRowContext(context.Background(), + `DELETE FROM messages WHERE id = $1::uuid RETURNING channel_id`, messageID, + ).Scan(&channelID) + if err != nil { + return + } + serverID, err := r.hub.ServerIDForChannel(context.Background(), channelID) + if err != nil || serverID == "" { + return + } + r.hub.BroadcastToServer(serverID, gateway.Event{ + Type: gateway.EventMessageDelete, + Data: map[string]string{ + "id": messageID, + "message_id": messageID, + "channel_id": channelID, + }, + }) +} + diff --git a/internal/message/handlers.go b/internal/message/handlers.go index 171a442..cbe6d52 100644 --- a/internal/message/handlers.go +++ b/internal/message/handlers.go @@ -27,6 +27,13 @@ type Handler struct { pushHandler *push.Handler logger *slog.Logger checker *permissions.Checker + // Optional: intercepts /confess so the original message is never stored/broadcast. + confess ConfessHandler +} + +// ConfessHandler posts an anonymous confession and returns the bot message payload. +type ConfessHandler interface { + TryConfess(ctx context.Context, serverID, authorID, content string) (payload map[string]interface{}, handled bool) } func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger, checker *permissions.Checker) *Handler { @@ -40,6 +47,11 @@ func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger } } +// SetConfessHandler wires the built-in confess interceptor (optional). +func (h *Handler) SetConfessHandler(c ConfessHandler) { + h.confess = c +} + func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/", h.List) r.Post("/", h.Create) @@ -119,6 +131,7 @@ func (h *Handler) BulkDelete(w http.ResponseWriter, r *http.Request) { Type: gateway.EventMessageDelete, Data: map[string]string{ "id": id, + "message_id": id, "channel_id": channelID, }, }) @@ -243,6 +256,16 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { return } + // Anonymous confessions: never store/broadcast the original /confess message. + if h.confess != nil { + if payload, handled := h.confess.TryConfess(r.Context(), serverID, userID, req.Content); handled { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(payload) + return + } + } + var msg messageResponse var editedAt sql.NullString var createdAt sql.NullString @@ -467,6 +490,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { Type: gateway.EventMessageDelete, Data: map[string]string{ "id": messageID, + "message_id": messageID, "channel_id": channelID, }, }) diff --git a/web/src/stores/ws.ts b/web/src/stores/ws.ts index 6034c3e..d2c717f 100644 --- a/web/src/stores/ws.ts +++ b/web/src/stores/ws.ts @@ -52,9 +52,17 @@ function isRecord(value: unknown): value is Record { } function extractIds(payload: UnknownPayload | undefined): { channel_id?: string; conversation_id?: string; message_id: string } | null { - if (!payload || typeof payload.message_id !== 'string') return null; - if (typeof payload.channel_id === 'string') return { channel_id: payload.channel_id, message_id: payload.message_id }; - if (typeof payload.conversation_id === 'string') return { conversation_id: payload.conversation_id, message_id: payload.message_id }; + if (!payload) return null; + // Backend MESSAGE_DELETE uses "id"; some other events use "message_id". + const messageId = + typeof payload.message_id === 'string' + ? payload.message_id + : typeof payload.id === 'string' + ? payload.id + : null; + if (!messageId) return null; + if (typeof payload.channel_id === 'string') return { channel_id: payload.channel_id, message_id: messageId }; + if (typeof payload.conversation_id === 'string') return { conversation_id: payload.conversation_id, message_id: messageId }; return null; }