From 29f3eb3874791d36bead6e58bd4f268439169f2f Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 29 May 2026 19:11:17 +0700 Subject: [PATCH] feat(channels): expose read-only channel contexts --- internal/http/channel_instance_contexts.go | 215 ++++++++++++++++++ .../http/channel_instance_contexts_test.go | 189 +++++++++++++++ internal/http/channel_instances.go | 8 + 3 files changed, 412 insertions(+) create mode 100644 internal/http/channel_instance_contexts.go create mode 100644 internal/http/channel_instance_contexts_test.go diff --git a/internal/http/channel_instance_contexts.go b/internal/http/channel_instance_contexts.go new file mode 100644 index 00000000..a122bf69 --- /dev/null +++ b/internal/http/channel_instance_contexts.go @@ -0,0 +1,215 @@ +package http + +import ( + "log/slog" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +type channelContextDTO struct { + ChannelInstanceID string `json:"channel_instance_id"` + ChannelType string `json:"channel_type"` + ScopeType string `json:"scope_type"` + ScopeKey string `json:"scope_key"` + DisplayName string `json:"display_name"` + Source string `json:"source"` + PeerKind string `json:"peer_kind,omitempty"` + ContactType string `json:"contact_type,omitempty"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + MemberCount int `json:"member_count,omitempty"` + LiveMembers bool `json:"live_members_supported"` +} + +type channelContextMemberDTO struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name,omitempty"` + Username string `json:"username,omitempty"` + PlatformID string `json:"platform_id"` + Source string `json:"source"` + Status string `json:"membership_status"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` +} + +func (h *ChannelInstancesHandler) handleListContexts(w http.ResponseWriter, r *http.Request) { + inst, ok := h.resolveInstance(w, r) + if !ok { + return + } + if h.contactStore == nil { + writeJSON(w, http.StatusOK, map[string]any{ + "contexts": []channelContextDTO{baseChannelContextDTO(*inst)}, + }) + return + } + + opts := store.ContactListOpts{ + ChannelType: inst.ChannelType, + ChannelInstance: inst.Name, + ContactType: "group", + Limit: 200, + } + contacts, err := h.contactStore.ListContacts(r.Context(), opts) + if err != nil { + slog.Error("channel_instances.contexts", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "channel contexts")) + return + } + + contexts := []channelContextDTO{baseChannelContextDTO(*inst)} + for _, c := range contacts { + ctxDTO := contextDTOFromContact(inst.ID, c) + contexts = append(contexts, ctxDTO) + } + + writeJSON(w, http.StatusOK, map[string]any{"contexts": contexts}) +} + +func (h *ChannelInstancesHandler) handleListContextMembers(w http.ResponseWriter, r *http.Request) { + inst, ok := h.resolveInstance(w, r) + if !ok { + return + } + + scopeType := strings.TrimSpace(r.PathValue("scopeType")) + scopeKey := strings.TrimSpace(r.PathValue("scopeKey")) + if scopeType == "" || scopeKey == "" { + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "scopeType and scopeKey")) + return + } + + var liveErr string + members := make([]channelContextMemberDTO, 0) + if h.channelMgr != nil && scopeType == store.ChannelScopeTypeGroup { + if live, err := h.channelMgr.ListGroupMembers(r.Context(), inst.Name, scopeKey); err == nil { + for _, m := range live { + members = append(members, channelContextMemberDTO{ + UserID: m.MemberID, + DisplayName: m.Name, + PlatformID: m.MemberID, + Source: "live_provider", + Status: "visible", + }) + } + writeJSON(w, http.StatusOK, map[string]any{ + "members": members, + "live_members_supported": true, + "source": "live_provider", + }) + return + } else { + liveErr = err.Error() + } + } + + if h.contactStore != nil { + contacts, err := h.contactStore.ListContacts(r.Context(), store.ContactListOpts{ + ChannelType: inst.ChannelType, + ChannelInstance: inst.Name, + ContactType: "user", + PeerKind: peerKindForScope(scopeType), + Limit: 200, + }) + if err != nil { + slog.Error("channel_instances.context_members", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "channel members")) + return + } + for _, c := range contacts { + members = append(members, memberDTOFromContact(c)) + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "members": members, + "live_members_supported": false, + "source": "stored_contacts", + "unsupported_reason": liveErr, + }) +} + +func baseChannelContextDTO(inst store.ChannelInstanceData) channelContextDTO { + name := inst.DisplayName + if name == "" { + name = inst.Name + } + return channelContextDTO{ + ChannelInstanceID: inst.ID.String(), + ChannelType: inst.ChannelType, + ScopeType: store.ChannelScopeTypeChannel, + ScopeKey: inst.Name, + DisplayName: name, + Source: "channel_instance", + PeerKind: "direct", + ContactType: "channel", + LiveMembers: false, + } +} + +func contextDTOFromContact(instanceID uuid.UUID, c store.ChannelContact) channelContextDTO { + scopeType := store.ChannelScopeTypeGroup + if c.ContactType == "topic" { + scopeType = "topic" + } + displayName := c.SenderID + if c.DisplayName != nil && *c.DisplayName != "" { + displayName = *c.DisplayName + } + peerKind := "" + if c.PeerKind != nil { + peerKind = *c.PeerKind + } + lastSeen := c.LastSeenAt + return channelContextDTO{ + ChannelInstanceID: instanceID.String(), + ChannelType: c.ChannelType, + ScopeType: scopeType, + ScopeKey: c.SenderID, + DisplayName: displayName, + Source: "stored_contact", + PeerKind: peerKind, + ContactType: c.ContactType, + LastSeenAt: &lastSeen, + } +} + +func memberDTOFromContact(c store.ChannelContact) channelContextMemberDTO { + displayName := "" + if c.DisplayName != nil { + displayName = *c.DisplayName + } + username := "" + if c.Username != nil { + username = *c.Username + } + userID := c.SenderID + if c.UserID != nil && *c.UserID != "" { + userID = *c.UserID + } + lastSeen := c.LastSeenAt + return channelContextMemberDTO{ + UserID: userID, + DisplayName: displayName, + Username: username, + PlatformID: c.SenderID, + Source: "stored_contact", + Status: "visible", + LastSeenAt: &lastSeen, + } +} + +func peerKindForScope(scopeType string) string { + if scopeType == store.ChannelScopeTypeGroup || scopeType == "topic" { + return "group" + } + return "direct" +} diff --git a/internal/http/channel_instance_contexts_test.go b/internal/http/channel_instance_contexts_test.go new file mode 100644 index 00000000..6a9ae77b --- /dev/null +++ b/internal/http/channel_instance_contexts_test.go @@ -0,0 +1,189 @@ +package http + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type fakeChannelContextContactStore struct { + contacts []store.ChannelContact + seenOpts []store.ContactListOpts +} + +func (s *fakeChannelContextContactStore) UpsertContact(context.Context, string, string, string, string, string, string, string, string, string, string) error { + return nil +} + +func (s *fakeChannelContextContactStore) ListContacts(_ context.Context, opts store.ContactListOpts) ([]store.ChannelContact, error) { + s.seenOpts = append(s.seenOpts, opts) + var out []store.ChannelContact + for _, c := range s.contacts { + if opts.ChannelType != "" && c.ChannelType != opts.ChannelType { + continue + } + if opts.ChannelInstance != "" && (c.ChannelInstance == nil || *c.ChannelInstance != opts.ChannelInstance) { + continue + } + if opts.ContactType != "" && c.ContactType != opts.ContactType { + continue + } + if opts.PeerKind != "" && (c.PeerKind == nil || *c.PeerKind != opts.PeerKind) { + continue + } + out = append(out, c) + } + return out, nil +} + +func (s *fakeChannelContextContactStore) CountContacts(context.Context, store.ContactListOpts) (int, error) { + return len(s.contacts), nil +} + +func (s *fakeChannelContextContactStore) GetContactsBySenderIDs(context.Context, []string) (map[string]store.ChannelContact, error) { + return map[string]store.ChannelContact{}, nil +} + +func (s *fakeChannelContextContactStore) GetContactByID(context.Context, uuid.UUID) (*store.ChannelContact, error) { + return nil, nil +} + +func (s *fakeChannelContextContactStore) GetSenderIDsByContactIDs(context.Context, []uuid.UUID) ([]string, error) { + return nil, nil +} + +func (s *fakeChannelContextContactStore) MergeContacts(context.Context, []uuid.UUID, uuid.UUID) error { + return nil +} + +func (s *fakeChannelContextContactStore) UnmergeContacts(context.Context, []uuid.UUID) error { + return nil +} + +func (s *fakeChannelContextContactStore) GetContactsByMergedID(context.Context, uuid.UUID) ([]store.ChannelContact, error) { + return nil, nil +} + +func (s *fakeChannelContextContactStore) ResolveTenantUserID(context.Context, string, string) (string, error) { + return "", nil +} + +func TestChannelContextsListUsesStoredGroupsAndMasksToContextShape(t *testing.T) { + token := "channel-contexts-key" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "caller"}, + }) + + instID := uuid.New() + channelName := "discord-admin" + displayName := "Ops Discord" + peerKind := "group" + lastSeen := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC) + contacts := &fakeChannelContextContactStore{contacts: []store.ChannelContact{{ + ID: uuid.New(), + ChannelType: "discord", + ChannelInstance: &channelName, + SenderID: "guild-123/channel-456", + DisplayName: &displayName, + PeerKind: &peerKind, + ContactType: "group", + LastSeenAt: lastSeen, + }}} + handler := NewChannelInstancesHandler( + &stubChannelInstanceStore{inst: &store.ChannelInstanceData{BaseModel: store.BaseModel{ID: instID}, Name: channelName, DisplayName: "Discord Admin", ChannelType: "discord", AgentID: uuid.New()}}, + nil, nil, contacts, nil, nil, + ) + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Contexts []channelContextDTO `json:"contexts"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body.Contexts) != 2 { + t.Fatalf("contexts len = %d, want 2: %+v", len(body.Contexts), body.Contexts) + } + if body.Contexts[0].ScopeType != store.ChannelScopeTypeChannel || body.Contexts[0].ScopeKey != channelName { + t.Fatalf("base context = %+v", body.Contexts[0]) + } + gotGroup := body.Contexts[1] + if gotGroup.ScopeType != store.ChannelScopeTypeGroup || gotGroup.ScopeKey != "guild-123/channel-456" || gotGroup.Source != "stored_contact" { + t.Fatalf("group context = %+v", gotGroup) + } + if len(contacts.seenOpts) == 0 || contacts.seenOpts[0].ChannelInstance != channelName { + t.Fatalf("expected channel_instance filter %q, got %+v", channelName, contacts.seenOpts) + } +} + +func TestChannelContextMembersUseStoredContactsWhenLiveProviderUnavailable(t *testing.T) { + token := "channel-context-members-key" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "caller"}, + }) + + instID := uuid.New() + channelName := "telegram-main" + peerKind := "group" + displayName := "Duy" + username := "duy" + userID := "tenant-user-1" + contacts := &fakeChannelContextContactStore{contacts: []store.ChannelContact{{ + ID: uuid.New(), + ChannelType: "telegram", + ChannelInstance: &channelName, + SenderID: "386246614", + UserID: &userID, + DisplayName: &displayName, + Username: &username, + PeerKind: &peerKind, + ContactType: "user", + LastSeenAt: time.Date(2026, 5, 29, 10, 5, 0, 0, time.UTC), + }}} + handler := NewChannelInstancesHandler( + &stubChannelInstanceStore{inst: &store.ChannelInstanceData{BaseModel: store.BaseModel{ID: instID}, Name: channelName, ChannelType: "telegram", AgentID: uuid.New()}}, + nil, nil, contacts, nil, nil, + ) + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts/group/group:telegram:-100123/members", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Members []channelContextMemberDTO `json:"members"` + LiveMembersSupported bool `json:"live_members_supported"` + Source string `json:"source"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.LiveMembersSupported || body.Source != "stored_contacts" { + t.Fatalf("expected stored contact fallback, got %+v", body) + } + if len(body.Members) != 1 || body.Members[0].UserID != userID || body.Members[0].PlatformID != "386246614" { + t.Fatalf("members = %+v", body.Members) + } +} diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index ac88145a..f16b29d8 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -109,6 +109,11 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /v1/channels/instances/{id}/writers", h.adminAuth(h.handleAddWriter)) mux.HandleFunc("DELETE /v1/channels/instances/{id}/writers/{userId}", h.adminAuth(h.handleRemoveWriter)) } + + if h.contactStore != nil { + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts", h.auth(h.handleListContexts)) + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/members", h.auth(h.handleListContextMembers)) + } } func (h *ChannelInstancesHandler) auth(next http.HandlerFunc) http.HandlerFunc { @@ -631,6 +636,9 @@ func (h *ChannelInstancesHandler) handleListContacts(w http.ResponseWriter, r *h if v := r.URL.Query().Get("channel_type"); v != "" { opts.ChannelType = v } + if v := r.URL.Query().Get("channel_instance"); v != "" { + opts.ChannelInstance = v + } if v := r.URL.Query().Get("peer_kind"); v != "" { opts.PeerKind = v }