diff --git a/Makefile b/Makefile index 8fe6b44..8f8a831 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ LDFLAGS := -s -w CGO_ENABLED := 0 -build: +build: web CGO_ENABLED=$(CGO_ENABLED) go build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY) ./cmd/synapbus test: @@ -17,7 +17,7 @@ dev: CGO_ENABLED=$(CGO_ENABLED) go run ./cmd/synapbus serve web: - cd web && npm install && npm run build + cd web && npm install --legacy-peer-deps && npm run build rm -rf internal/web/dist cp -r web/build internal/web/dist diff --git a/internal/api/channels_handler.go b/internal/api/channels_handler.go index 47fea6c..9534ef8 100644 --- a/internal/api/channels_handler.go +++ b/internal/api/channels_handler.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log/slog" "net/http" + "strconv" "github.com/go-chi/chi/v5" @@ -198,6 +199,41 @@ func (h *ChannelsHandler) JoinChannel(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "joined"}) } +// ChannelMessages handles GET /api/channels/{name}/messages. +func (h *ChannelsHandler) ChannelMessages(w http.ResponseWriter, r *http.Request) { + _, ok := OwnerIDFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required")) + return + } + + name := chi.URLParam(r, "name") + ch, err := h.channelService.GetChannelByName(r.Context(), name) + if err != nil { + writeJSON(w, http.StatusNotFound, errorBody("not_found", "Channel not found")) + return + } + + limit := 100 + if l := r.URL.Query().Get("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + limit = parsed + } + } + + msgs, err := h.msgService.GetChannelMessages(r.Context(), ch.ID, limit) + if err != nil { + h.logger.Error("get channel messages failed", "error", err) + writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get messages")) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "messages": msgs, + "total": len(msgs), + }) +} + // LeaveChannel handles POST /api/channels/{name}/leave. func (h *ChannelsHandler) LeaveChannel(w http.ResponseWriter, r *http.Request) { ownerID, ok := OwnerIDFromContext(r.Context()) diff --git a/internal/api/messages_handler.go b/internal/api/messages_handler.go index 8f6dfa7..bf51fca 100644 --- a/internal/api/messages_handler.go +++ b/internal/api/messages_handler.go @@ -425,6 +425,51 @@ func (h *MessagesHandler) GetReplies(w http.ResponseWriter, r *http.Request) { }) } +// DMMessages handles GET /api/agents/{name}/messages — returns DM messages with a specific agent. +func (h *MessagesHandler) DMMessages(w http.ResponseWriter, r *http.Request) { + ownerID, ok := OwnerIDFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required")) + return + } + + peerAgent := chi.URLParam(r, "name") + + ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID) + if err != nil { + h.logger.Error("list agents failed", "error", err) + writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents")) + return + } + + if len(ownedAgents) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"messages": []*messaging.Message{}, "total": 0}) + return + } + + agentNames := make([]string, len(ownedAgents)) + for i, a := range ownedAgents { + agentNames[i] = a.Name + } + + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 100 + } + + msgs, err := h.msgService.GetDMMessages(r.Context(), agentNames, peerAgent, limit) + if err != nil { + h.logger.Error("get dm messages failed", "error", err) + writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get messages")) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "messages": msgs, + "total": len(msgs), + }) +} + func (h *MessagesHandler) isAgentOwnedBy(r *http.Request, agentName string, ownerID int64) bool { if agentName == "" { return false diff --git a/internal/api/router.go b/internal/api/router.go index f3868b4..a4fc98b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -100,6 +100,7 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router { r.Post("/api/agents", agentsHandler.RegisterAgent) r.Delete("/api/agents/{name}", agentsHandler.DeleteAgent) r.Post("/api/agents/{name}/revoke-key", agentsHandler.RevokeKey) + r.Get("/api/agents/{name}/messages", messagesHandler.DMMessages) }) // API Keys @@ -124,6 +125,7 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router { r.Get("/api/channels", channelsHandler.ListChannels) r.Get("/api/channels/{name}", channelsHandler.GetChannel) r.Post("/api/channels", channelsHandler.CreateChannel) + r.Get("/api/channels/{name}/messages", channelsHandler.ChannelMessages) r.Post("/api/channels/{name}/join", channelsHandler.JoinChannel) r.Post("/api/channels/{name}/leave", channelsHandler.LeaveChannel) }) diff --git a/internal/messaging/service.go b/internal/messaging/service.go index 5c0981b..88ca6d5 100644 --- a/internal/messaging/service.go +++ b/internal/messaging/service.go @@ -336,6 +336,24 @@ func (s *MessagingService) GetReplies(ctx context.Context, messageID int64) ([]* return replies, nil } +// GetChannelMessages returns messages posted to a channel. +func (s *MessagingService) GetChannelMessages(ctx context.Context, channelID int64, limit int) ([]*Message, error) { + messages, err := s.store.GetChannelMessages(ctx, channelID, limit) + if err != nil { + return nil, fmt.Errorf("get channel messages: %w", err) + } + return messages, nil +} + +// GetDMMessages returns direct messages between owned agents and a peer agent. +func (s *MessagingService) GetDMMessages(ctx context.Context, ownedAgents []string, peerAgent string, limit int) ([]*Message, error) { + messages, err := s.store.GetDMMessages(ctx, ownedAgents, peerAgent, limit) + if err != nil { + return nil, fmt.Errorf("get dm messages: %w", err) + } + return messages, nil +} + // GetConversation returns a conversation and its messages. func (s *MessagingService) GetConversation(ctx context.Context, id int64) (*Conversation, []*Message, error) { conv, err := s.store.GetConversation(ctx, id) diff --git a/internal/messaging/store.go b/internal/messaging/store.go index 492204f..4cb43d4 100644 --- a/internal/messaging/store.go +++ b/internal/messaging/store.go @@ -23,6 +23,8 @@ type MessageStore interface { GetConversation(ctx context.Context, id int64) (*Conversation, error) GetConversationMessages(ctx context.Context, conversationID int64) ([]*Message, error) GetReplies(ctx context.Context, messageID int64) ([]*Message, error) + GetChannelMessages(ctx context.Context, channelID int64, limit int) ([]*Message, error) + GetDMMessages(ctx context.Context, agents []string, peerAgent string, limit int) ([]*Message, error) AgentExists(ctx context.Context, agentName string) (bool, error) } @@ -443,6 +445,69 @@ func (s *SQLiteMessageStore) GetReplies(ctx context.Context, messageID int64) ([ return scanMessages(rows) } +func (s *SQLiteMessageStore) GetChannelMessages(ctx context.Context, channelID int64, limit int) ([]*Message, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, conversation_id, from_agent, to_agent, channel_id, + body, priority, status, metadata, claimed_by, claimed_at, + created_at, updated_at, reply_to + FROM messages WHERE channel_id = ? + ORDER BY created_at ASC + LIMIT ?`, channelID, limit, + ) + if err != nil { + return nil, fmt.Errorf("get channel messages: %w", err) + } + defer rows.Close() + return scanMessages(rows) +} + +func (s *SQLiteMessageStore) GetDMMessages(ctx context.Context, agents []string, peerAgent string, limit int) ([]*Message, error) { + if limit <= 0 { + limit = 50 + } + if len(agents) == 0 { + return []*Message{}, nil + } + + // Build placeholders for owned agents + placeholders := make([]string, len(agents)) + args := make([]any, 0, len(agents)*2+2) + for i, a := range agents { + placeholders[i] = "?" + args = append(args, a) + } + inClause := strings.Join(placeholders, ",") + + // Messages where (from_agent IN owned AND to_agent = peer) OR (from_agent = peer AND to_agent IN owned) + // and channel_id IS NULL (DMs only) + query := fmt.Sprintf( + `SELECT id, conversation_id, from_agent, to_agent, channel_id, + body, priority, status, metadata, claimed_by, claimed_at, + created_at, updated_at, reply_to + FROM messages + WHERE channel_id IS NULL + AND ((from_agent IN (%s) AND to_agent = ?) OR (from_agent = ? AND to_agent IN (%s))) + ORDER BY created_at ASC + LIMIT ?`, + inClause, inClause, + ) + args = append(args, peerAgent, peerAgent) + for _, a := range agents { + args = append(args, a) + } + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("get dm messages: %w", err) + } + defer rows.Close() + return scanMessages(rows) +} + func (s *SQLiteMessageStore) AgentExists(ctx context.Context, agentName string) (bool, error) { var count int err := s.db.QueryRowContext(ctx, diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index 60ac56f..0895219 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -8,29 +8,29 @@ - - - - - - - - + + + + + + + +