Files
BBQ 7f9d6e4aba feat(acl): redesign ACL with conversation scope selector (#297)
Backend
- New subject kinds: all / channel_identity / channel_type
- Source scope fields on bot_acl_rules: source_channel,
  source_conversation_type, source_conversation_id, source_thread_id
- Fix source_scope_check constraint: resolve source_channel server-side
  (channel_type → subject_channel_type; channel_identity → DB lookup)
- Add GET /bots/:id/acl/channel-types/:type/conversations to list
  observed conversations by platform type
- ListObservedConversations: include private/DM chats, normalise
  conversation_type; COALESCE(name, handle) for display name
- enrichConversationAvatar: persist entry.Name → conversation_name
  (keeps Telegram group titles current on every message)
- Unify Priority type to int32 across Go types to match DB INTEGER;
  remove all int/int32 casts in service layer
- Fix duplicate nil guard in Evaluate; drop dead SourceScope.Channel field
- Migration 0048_acl_redesign

Frontend
- Drag-and-drop rule priority reordering (SortableJS/useSortable);
  fix reorder: compute new order from oldIndex/newIndex directly,
  not from the array (which useSortable syncs after onEnd)
- Conversation scope selector: searchable popover backed by observed
  conversations (by identity or platform type); collapsible manual-ID fallback
- Display: name as primary label, stable channel·type·id always shown
  as subtitle for verification
- bot-terminal: accessibility fix on close-tab button (keyboard events)
- i18n: drag-to-reorder, conversation source, manual IDs (en/zh)

Tests: update fakeChatACL to Evaluate interface; fix SourceScope literals.
SDK/spec regenerated.
2026-03-28 01:06:13 +08:00

154 lines
5.7 KiB
Go

package acl
import (
"strings"
"time"
"github.com/memohai/memoh/internal/channel"
)
const (
ActionChatTrigger = "chat.trigger"
EffectAllow = "allow"
EffectDeny = "deny"
SubjectKindAll = "all"
SubjectKindChannelIdentity = "channel_identity"
SubjectKindChannelType = "channel_type"
)
// Rule is the full ACL rule record returned to callers.
type Rule struct {
ID string `json:"id"`
BotID string `json:"bot_id"`
Priority int32 `json:"priority"`
Enabled bool `json:"enabled"`
Description string `json:"description,omitempty"`
Action string `json:"action"`
Effect string `json:"effect"`
SubjectKind string `json:"subject_kind"`
ChannelIdentityID string `json:"channel_identity_id,omitempty"`
SubjectChannelType string `json:"subject_channel_type,omitempty"`
SourceScope *SourceScope `json:"source_scope,omitempty"`
ChannelType string `json:"channel_type,omitempty"`
ChannelSubjectID string `json:"channel_subject_id,omitempty"`
ChannelIdentityDisplayName string `json:"channel_identity_display_name,omitempty"`
ChannelIdentityAvatarURL string `json:"channel_identity_avatar_url,omitempty"`
LinkedUserID string `json:"linked_user_id,omitempty"`
LinkedUserUsername string `json:"linked_user_username,omitempty"`
LinkedUserDisplayName string `json:"linked_user_display_name,omitempty"`
LinkedUserAvatarURL string `json:"linked_user_avatar_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListRulesResponse struct {
Items []Rule `json:"items"`
}
type DefaultEffectResponse struct {
DefaultEffect string `json:"default_effect"`
}
// SourceScope narrows a rule to a specific conversation / thread.
// Any zero-value field means "match any".
// Channel filtering is handled at the subject level (channel_type / channel_identity).
type SourceScope struct {
ConversationType string `json:"conversation_type,omitempty"`
ConversationID string `json:"conversation_id,omitempty"`
ThreadID string `json:"thread_id,omitempty"`
}
// CreateRuleRequest is used to create a new ACL rule.
type CreateRuleRequest struct {
Priority int32 `json:"priority"`
Enabled bool `json:"enabled"`
Description string `json:"description,omitempty"`
Effect string `json:"effect"`
SubjectKind string `json:"subject_kind"`
ChannelIdentityID string `json:"channel_identity_id,omitempty"`
SubjectChannelType string `json:"subject_channel_type,omitempty"`
SourceScope *SourceScope `json:"source_scope,omitempty"`
}
// UpdateRuleRequest is used to update an existing ACL rule.
type UpdateRuleRequest struct {
Priority int32 `json:"priority"`
Enabled bool `json:"enabled"`
Description string `json:"description,omitempty"`
Effect string `json:"effect"`
SubjectKind string `json:"subject_kind"`
ChannelIdentityID string `json:"channel_identity_id,omitempty"`
SubjectChannelType string `json:"subject_channel_type,omitempty"`
SourceScope *SourceScope `json:"source_scope,omitempty"`
}
// ReorderItem is a single priority update in a batch reorder request.
type ReorderItem struct {
ID string `json:"id"`
Priority int32 `json:"priority"`
}
type ReorderRequest struct {
Items []ReorderItem `json:"items"`
}
// EvaluateRequest carries all context needed to evaluate a chat.trigger.
type EvaluateRequest struct {
BotID string
ChannelIdentityID string
ChannelType string
SourceScope SourceScope
}
type ChannelIdentityCandidate struct {
ID string `json:"id"`
Channel string `json:"channel"`
ChannelSubjectID string `json:"channel_subject_id"`
DisplayName string `json:"display_name,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
LinkedUserID string `json:"linked_user_id,omitempty"`
LinkedUsername string `json:"linked_username,omitempty"`
LinkedDisplayName string `json:"linked_display_name,omitempty"`
LinkedAvatarURL string `json:"linked_avatar_url,omitempty"`
}
type ChannelIdentityCandidateListResponse struct {
Items []ChannelIdentityCandidate `json:"items"`
}
type ObservedConversationCandidate struct {
RouteID string `json:"route_id"`
Channel string `json:"channel"`
ConversationType string `json:"conversation_type,omitempty"`
ConversationID string `json:"conversation_id"`
ThreadID string `json:"thread_id,omitempty"`
ConversationName string `json:"conversation_name,omitempty"`
LastObservedAt time.Time `json:"last_observed_at"`
}
type ObservedConversationCandidateListResponse struct {
Items []ObservedConversationCandidate `json:"items"`
}
func (s SourceScope) Normalize() SourceScope {
scope := SourceScope{
ConversationID: strings.TrimSpace(s.ConversationID),
ThreadID: strings.TrimSpace(s.ThreadID),
}
if raw := strings.TrimSpace(s.ConversationType); raw != "" {
scope.ConversationType = channel.NormalizeConversationType(raw)
}
if scope.ThreadID != "" && scope.ConversationType == "" {
scope.ConversationType = channel.ConversationTypeThread
}
return scope
}
func (s SourceScope) IsZero() bool {
return strings.TrimSpace(s.ConversationType) == "" &&
strings.TrimSpace(s.ConversationID) == "" &&
strings.TrimSpace(s.ThreadID) == ""
}