mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 14:16:30 +00:00
feat(channels): expose read-only channel contexts
This commit is contained in:
1 parent
d230228217
commit
29f3eb3874
3 files changed
+412
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user