Changeset 0.37.14 (#226)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-23 16:47:48 +00:00
committed by xcaliber
parent fcb998bff9
commit b7746c3004
164 changed files with 6972 additions and 3527 deletions

View File

@@ -1,17 +1,20 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"time"
"github.com/gin-gonic/gin"
capspkg "chat-switchboard/capabilities"
"chat-switchboard/crypto"
"chat-switchboard/database"
"chat-switchboard/events"
"chat-switchboard/models"
"chat-switchboard/providers"
@@ -153,7 +156,7 @@ func (h *MessageHandler) GetActivePath(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"messages": path})
c.JSON(http.StatusOK, gin.H{"data": path})
}
// ── Create Message (manual) ─────────────────
@@ -218,6 +221,11 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
return
}
// Broadcast via WS to channel participants
if h.hub != nil && msg.Role == "user" {
broadcastUserMessage(c.Request.Context(), h.hub, channelID, msg.ID, msg.Content, userID)
}
resp := messageResponse{
ID: msg.ID,
ChannelID: msg.ChannelID,
@@ -579,7 +587,7 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"messages": path, "active_leaf_id": leafID})
c.JSON(http.StatusOK, gin.H{"data": path, "active_leaf_id": leafID})
}
// ── List Siblings ───────────────────────────
@@ -638,3 +646,157 @@ func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
}
return true
}
// broadcastUserMessage publishes a message.created event to all user
// participants in the channel via WebSocket.
func broadcastUserMessage(ctx context.Context, hub *events.Hub, channelID, msgID, content, senderID string) {
// Look up sender display name
var displayName, username string
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT COALESCE(display_name, ''), COALESCE(username, '') FROM users WHERE id = $1
`), senderID).Scan(&displayName, &username)
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
"role": "user",
"content": content,
"user_id": senderID,
"display_name": displayName,
"username": username,
"created_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
evt := events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
// Send to all user participants in the channel
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
// Fallback: at least send to the sender
hub.PublishToUser(senderID, evt)
return
}
defer rows.Close()
for rows.Next() {
var pid string
if rows.Scan(&pid) == nil {
hub.PublishToUser(pid, evt)
}
}
}
// broadcastAssistantMessage publishes a message.created event for an assistant
// message to all user participants in the channel (except the requesting user,
// who already received the response via SSE).
func broadcastAssistantMessage(ctx context.Context, hub *events.Hub, channelID, msgID, content, model, senderUserID, personaID string) {
// Look up persona display name if available
var displayName, avatar string
if personaID != "" {
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT COALESCE(name, ''), COALESCE(avatar, '') FROM personas WHERE id = $1
`), personaID).Scan(&displayName, &avatar)
}
if displayName == "" {
displayName = model
}
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
"role": "assistant",
"content": content,
"model": model,
"participant_type": "persona",
"participant_id": personaID,
"display_name": displayName,
"avatar": avatar,
"created_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
evt := events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
// Send to all user participants except the sender (who got SSE)
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var pid string
if rows.Scan(&pid) == nil && pid != senderUserID {
hub.PublishToUser(pid, evt)
}
}
}
// ── Delete Message ──────────────────────────────────────────
// DeleteMessage soft-deletes a message.
// DELETE /channels/:id/messages/:msgId
func (h *MessageHandler) DeleteMessage(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
msgID := c.Param("msgId")
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
// Verify message belongs to this channel and is not already deleted
var exists bool
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL)
`), msgID, channelID).Scan(&exists)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
if err := h.stores.Messages.Delete(c.Request.Context(), msgID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete message"})
return
}
// Broadcast to channel participants (exclude sender — they already removed it)
broadcastMessageDeleted(c.Request.Context(), h.hub, channelID, msgID, userID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// broadcastMessageDeleted publishes a message.deleted event to all user
// participants in the channel except the sender.
func broadcastMessageDeleted(ctx context.Context, hub *events.Hub, channelID, msgID, senderID string) {
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
})
evt := events.Event{
Label: "message.deleted",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var pid string
if rows.Scan(&pid) == nil && pid != senderID {
hub.PublishToUser(pid, evt)
}
}
}