feat(channels): expose read-only channel contexts

This commit is contained in:
Goon committed 2026-05-29 19:11:17 +07:00
1 parent d230228217
commit 29f3eb3874
3 files changed
+412

No files matched your search

+215
View File
@@ -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"
}
@@ -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)
}
}
+8
View File
@@ -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
}