mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
7f9d6e4aba
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.
354 lines
12 KiB
Go
354 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
|
|
"github.com/memohai/memoh/internal/accounts"
|
|
"github.com/memohai/memoh/internal/acl"
|
|
"github.com/memohai/memoh/internal/bots"
|
|
"github.com/memohai/memoh/internal/channel/identities"
|
|
identitypkg "github.com/memohai/memoh/internal/identity"
|
|
)
|
|
|
|
type ACLHandler struct {
|
|
service *acl.Service
|
|
botService *bots.Service
|
|
accountService *accounts.Service
|
|
identityService *identities.Service
|
|
}
|
|
|
|
func NewACLHandler(service *acl.Service, botService *bots.Service, accountService *accounts.Service, identityService *identities.Service) *ACLHandler {
|
|
return &ACLHandler{
|
|
service: service,
|
|
botService: botService,
|
|
accountService: accountService,
|
|
identityService: identityService,
|
|
}
|
|
}
|
|
|
|
func (h *ACLHandler) Register(e *echo.Echo) {
|
|
group := e.Group("/bots/:bot_id/acl")
|
|
group.GET("/rules", h.ListRules)
|
|
group.POST("/rules", h.CreateRule)
|
|
group.PUT("/rules/reorder", h.ReorderRules)
|
|
group.PUT("/rules/:rule_id", h.UpdateRule)
|
|
group.DELETE("/rules/:rule_id", h.DeleteRule)
|
|
group.GET("/default-effect", h.GetDefaultEffect)
|
|
group.PUT("/default-effect", h.SetDefaultEffect)
|
|
group.GET("/channel-identities", h.SearchChannelIdentities)
|
|
group.GET("/channel-identities/:channel_identity_id/conversations", h.ListObservedConversations)
|
|
group.GET("/channel-types/:channel_type/conversations", h.ListObservedConversationsByChannelType)
|
|
}
|
|
|
|
// ListRules godoc
|
|
// @Summary List bot ACL rules
|
|
// @Description List all ACL rules for a bot ordered by priority
|
|
// @Tags bots
|
|
// @Param bot_id path string true "Bot ID"
|
|
// @Success 200 {object} acl.ListRulesResponse
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/acl/rules [get].
|
|
func (h *ACLHandler) ListRules(c echo.Context) error {
|
|
botID, _, err := h.requireManageAccess(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items, err := h.service.ListRules(c.Request().Context(), botID)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
return c.JSON(http.StatusOK, acl.ListRulesResponse{Items: items})
|
|
}
|
|
|
|
// CreateRule godoc
|
|
// @Summary Create ACL rule
|
|
// @Description Create a new priority-ordered ACL rule for chat.trigger
|
|
// @Tags bots
|
|
// @Param bot_id path string true "Bot ID"
|
|
// @Param payload body acl.CreateRuleRequest true "Rule payload"
|
|
// @Success 201 {object} acl.Rule
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/acl/rules [post].
|
|
func (h *ACLHandler) CreateRule(c echo.Context) error {
|
|
botID, actorID, err := h.requireManageAccess(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var req acl.CreateRuleRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
item, err := h.service.CreateRule(c.Request().Context(), botID, actorID, req)
|
|
if err != nil {
|
|
return h.mapRuleError(err)
|
|
}
|
|
return c.JSON(http.StatusCreated, item)
|
|
}
|
|
|
|
// UpdateRule godoc
|
|
// @Summary Update ACL rule
|
|
// @Description Update an existing ACL rule
|
|
// @Tags bots
|
|
// @Param bot_id path string true "Bot ID"
|
|
// @Param rule_id path string true "Rule ID"
|
|
// @Param payload body acl.UpdateRuleRequest true "Rule payload"
|
|
// @Success 200 {object} acl.Rule
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/acl/rules/{rule_id} [put].
|
|
func (h *ACLHandler) UpdateRule(c echo.Context) error {
|
|
if _, _, err := h.requireManageAccess(c); err != nil {
|
|
return err
|
|
}
|
|
ruleID := strings.TrimSpace(c.Param("rule_id"))
|
|
if ruleID == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "rule_id is required")
|
|
}
|
|
var req acl.UpdateRuleRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
item, err := h.service.UpdateRule(c.Request().Context(), ruleID, req)
|
|
if err != nil {
|
|
return h.mapRuleError(err)
|
|
}
|
|
return c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
// DeleteRule godoc
|
|
// @Summary Delete ACL rule
|
|
// @Description Delete an ACL rule by ID
|
|
// @Tags bots
|
|
// @Param bot_id path string true "Bot ID"
|
|
// @Param rule_id path string true "Rule ID"
|
|
// @Success 204 "No Content"
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/acl/rules/{rule_id} [delete].
|
|
func (h *ACLHandler) DeleteRule(c echo.Context) error {
|
|
if _, _, err := h.requireManageAccess(c); err != nil {
|
|
return err
|
|
}
|
|
ruleID := strings.TrimSpace(c.Param("rule_id"))
|
|
if ruleID == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "rule_id is required")
|
|
}
|
|
if err := h.service.DeleteRule(c.Request().Context(), ruleID); err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// ReorderRules godoc
|
|
// @Summary Reorder ACL rules
|
|
// @Description Batch-update priorities for multiple ACL rules
|
|
// @Tags bots
|
|
// @Param bot_id path string true "Bot ID"
|
|
// @Param payload body acl.ReorderRequest true "Reorder payload"
|
|
// @Success 204 "No Content"
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/acl/rules/reorder [put].
|
|
func (h *ACLHandler) ReorderRules(c echo.Context) error {
|
|
if _, _, err := h.requireManageAccess(c); err != nil {
|
|
return err
|
|
}
|
|
var req acl.ReorderRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
if err := h.service.ReorderRules(c.Request().Context(), req.Items); err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// GetDefaultEffect godoc
|
|
// @Summary Get bot ACL default effect
|
|
// @Description Get the fallback effect when no rule matches
|
|
// @Tags bots
|
|
// @Param bot_id path string true "Bot ID"
|
|
// @Success 200 {object} acl.DefaultEffectResponse
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/acl/default-effect [get].
|
|
func (h *ACLHandler) GetDefaultEffect(c echo.Context) error {
|
|
botID, _, err := h.requireManageAccess(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
effect, err := h.service.GetDefaultEffect(c.Request().Context(), botID)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
return c.JSON(http.StatusOK, acl.DefaultEffectResponse{DefaultEffect: effect})
|
|
}
|
|
|
|
// SetDefaultEffect godoc
|
|
// @Summary Set bot ACL default effect
|
|
// @Description Set the fallback effect when no rule matches (allow or deny)
|
|
// @Tags bots
|
|
// @Param bot_id path string true "Bot ID"
|
|
// @Param payload body acl.DefaultEffectResponse true "Default effect payload"
|
|
// @Success 204 "No Content"
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/acl/default-effect [put].
|
|
func (h *ACLHandler) SetDefaultEffect(c echo.Context) error {
|
|
botID, _, err := h.requireManageAccess(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var req acl.DefaultEffectResponse
|
|
if err := c.Bind(&req); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
if err := h.service.SetDefaultEffect(c.Request().Context(), botID, req.DefaultEffect); err != nil {
|
|
if errors.Is(err, acl.ErrInvalidEffect) {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// SearchChannelIdentities godoc
|
|
// @Summary Search ACL channel identity candidates
|
|
// @Description Search locally observed channel identities for building ACL rules
|
|
// @Tags bots
|
|
// @Param bot_id path string true "Bot ID"
|
|
// @Param q query string false "Search query"
|
|
// @Param limit query int false "Max results"
|
|
// @Success 200 {object} acl.ChannelIdentityCandidateListResponse
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/acl/channel-identities [get].
|
|
func (h *ACLHandler) SearchChannelIdentities(c echo.Context) error {
|
|
if _, _, err := h.requireManageAccess(c); err != nil {
|
|
return err
|
|
}
|
|
items, err := h.identityService.Search(c.Request().Context(), strings.TrimSpace(c.QueryParam("q")), parseLimit(c.QueryParam("limit")))
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
result := make([]acl.ChannelIdentityCandidate, 0, len(items))
|
|
for _, item := range items {
|
|
result = append(result, acl.ChannelIdentityCandidate{
|
|
ID: item.ID,
|
|
Channel: item.Channel,
|
|
ChannelSubjectID: item.ChannelSubjectID,
|
|
DisplayName: item.DisplayName,
|
|
AvatarURL: item.AvatarURL,
|
|
LinkedUserID: item.UserID,
|
|
LinkedUsername: item.LinkedUsername,
|
|
LinkedDisplayName: item.LinkedDisplayName,
|
|
LinkedAvatarURL: item.LinkedAvatarURL,
|
|
})
|
|
}
|
|
return c.JSON(http.StatusOK, acl.ChannelIdentityCandidateListResponse{Items: result})
|
|
}
|
|
|
|
// ListObservedConversations godoc
|
|
// @Summary List observed conversations for a channel identity
|
|
// @Description List previously observed conversation candidates for a channel identity, for scoped rule building
|
|
// @Tags bots
|
|
// @Param bot_id path string true "Bot ID"
|
|
// @Param channel_identity_id path string true "Channel Identity ID"
|
|
// @Success 200 {object} acl.ObservedConversationCandidateListResponse
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/acl/channel-identities/{channel_identity_id}/conversations [get].
|
|
func (h *ACLHandler) ListObservedConversations(c echo.Context) error {
|
|
botID, _, err := h.requireManageAccess(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
channelIdentityID := strings.TrimSpace(c.Param("channel_identity_id"))
|
|
if err := identitypkg.ValidateChannelIdentityID(channelIdentityID); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
items, err := h.service.ListObservedConversationsByChannelIdentity(c.Request().Context(), botID, channelIdentityID)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
return c.JSON(http.StatusOK, acl.ObservedConversationCandidateListResponse{Items: items})
|
|
}
|
|
|
|
// ListObservedConversationsByChannelType godoc
|
|
// @Summary List observed conversations for a platform type
|
|
// @Description List previously observed group/thread conversation candidates for a channel type under this bot
|
|
// @Tags bots
|
|
// @Param bot_id path string true "Bot ID"
|
|
// @Param channel_type path string true "Channel type (e.g. telegram, discord)"
|
|
// @Success 200 {object} acl.ObservedConversationCandidateListResponse
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/acl/channel-types/{channel_type}/conversations [get].
|
|
func (h *ACLHandler) ListObservedConversationsByChannelType(c echo.Context) error {
|
|
botID, _, err := h.requireManageAccess(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
channelType := strings.TrimSpace(c.Param("channel_type"))
|
|
if channelType == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "channel_type is required")
|
|
}
|
|
items, err := h.service.ListObservedConversationsByChannelType(c.Request().Context(), botID, channelType)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
return c.JSON(http.StatusOK, acl.ObservedConversationCandidateListResponse{Items: items})
|
|
}
|
|
|
|
func (h *ACLHandler) requireManageAccess(c echo.Context) (string, string, error) {
|
|
actorID, err := RequireChannelIdentityID(c)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
botID := strings.TrimSpace(c.Param("bot_id"))
|
|
if botID == "" {
|
|
return "", "", echo.NewHTTPError(http.StatusBadRequest, "bot_id is required")
|
|
}
|
|
if _, err := AuthorizeBotAccess(c.Request().Context(), h.botService, h.accountService, actorID, botID); err != nil {
|
|
return "", "", err
|
|
}
|
|
return botID, actorID, nil
|
|
}
|
|
|
|
func (*ACLHandler) mapRuleError(err error) error {
|
|
if errors.Is(err, acl.ErrInvalidRuleSubject) ||
|
|
errors.Is(err, acl.ErrInvalidSourceScope) ||
|
|
errors.Is(err, acl.ErrInvalidEffect) {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
func parseLimit(raw string) int {
|
|
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
|
if err != nil || value <= 0 {
|
|
return 50
|
|
}
|
|
if value > 200 {
|
|
return 200
|
|
}
|
|
return value
|
|
}
|