173 lines
5.5 KiB
Go
173 lines
5.5 KiB
Go
package moderation
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/tinode/chat/server/logs"
|
|
"github.com/tinode/chat/server/moderation/aliyun"
|
|
"github.com/tinode/chat/server/store"
|
|
"github.com/tinode/chat/server/store/types"
|
|
)
|
|
|
|
type circuitState struct {
|
|
sync.Mutex
|
|
failures int
|
|
openUntil time.Time
|
|
}
|
|
|
|
var aliyunCircuit circuitState
|
|
|
|
func Review(ctx context.Context, req Request) (*Result, error) {
|
|
text := strings.TrimSpace(req.Text)
|
|
result := &Result{RequestID: moderationRequestID(), Action: ActionAllow, RiskLevel: "none"}
|
|
if text == "" || strings.TrimSpace(req.TenantCode) == "" {
|
|
return result, nil
|
|
}
|
|
policy, err := store.ModerationConfig.Policy(req.TenantCode)
|
|
if err != nil {
|
|
return failureResult(result, nil, err)
|
|
}
|
|
if policy == nil || !policy.Enabled || !policy.Scenes[req.Scene] {
|
|
return result, nil
|
|
}
|
|
defer func() { recordDecision(req, text, result) }()
|
|
result.BlockMessage = policy.BlockMessage
|
|
if policy.LocalWordsEnabled {
|
|
words, wordsErr := store.ModerationConfig.Words(req.TenantCode)
|
|
if wordsErr != nil {
|
|
if logs.Warn != nil {
|
|
logs.Warn.Printf("moderation local words unavailable: tenant=%s scene=%s err=%v", req.TenantCode, req.Scene, wordsErr)
|
|
}
|
|
} else if localResult := matchLocalWords(text, req.Scene, words); localResult != nil {
|
|
result.Provider, result.RiskLevel, result.RiskLabels = "local", localResult.RiskLevel, localResult.RiskLabels
|
|
result.Action = actionForRisk(policy, localResult.RiskLevel)
|
|
if localResult.Summary == ActionBlock {
|
|
result.Action = ActionBlock
|
|
}
|
|
if result.Action == ActionBlock {
|
|
result.Err = ErrRejected
|
|
return result, ErrRejected
|
|
}
|
|
}
|
|
}
|
|
if !policy.AliyunEnabled {
|
|
return result, nil
|
|
}
|
|
providerConfig, err := store.ModerationConfig.Provider()
|
|
if err != nil || providerConfig == nil || !providerConfig.Enabled {
|
|
return failureResult(result, policy, firstError(err, ErrUnavailable))
|
|
}
|
|
if circuitOpen() {
|
|
return failureResult(result, policy, ErrUnavailable)
|
|
}
|
|
provider, err := aliyun.NewTextClient(providerConfig)
|
|
if err != nil {
|
|
recordFailure()
|
|
return failureResult(result, policy, err)
|
|
}
|
|
service := providerConfig.ChatService
|
|
if req.Scene == SceneUserNickname || req.Scene == SceneUserSignature || req.Scene == SceneGroupName || req.Scene == SceneGroupNickname {
|
|
service = providerConfig.NicknameService
|
|
}
|
|
providerResult, err := provider.Review(ctx, service, text, result.RequestID)
|
|
if err != nil {
|
|
recordFailure()
|
|
return failureResult(result, policy, err)
|
|
}
|
|
recordSuccess()
|
|
result.Provider, result.ProviderRequestID, result.RiskLevel, result.RiskLabels, result.Latency = "aliyun_green", providerResult.ProviderRequestID, providerResult.RiskLevel, providerResult.RiskLabels, providerResult.Latency
|
|
result.Action = actionForRisk(policy, result.RiskLevel)
|
|
if result.Action == ActionBlock {
|
|
result.Err = ErrRejected
|
|
return result, ErrRejected
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func failureResult(result *Result, policy *types.ModerationPolicy, err error) (*Result, error) {
|
|
result.Err = err
|
|
failureMode := "allow"
|
|
if policy != nil && policy.FailureMode == "block" {
|
|
failureMode = "block"
|
|
result.BlockMessage = policy.BlockMessage
|
|
}
|
|
if failureMode == "block" {
|
|
result.Action = ActionBlock
|
|
return result, ErrUnavailable
|
|
}
|
|
result.Action = ActionAuditOnly
|
|
return result, nil
|
|
}
|
|
|
|
func actionForRisk(policy *types.ModerationPolicy, risk string) string {
|
|
if policy != nil {
|
|
if action := normalizedAction(policy.RiskActions[normalizedRisk(risk)]); action != "" {
|
|
return action
|
|
}
|
|
}
|
|
return ActionAllow
|
|
}
|
|
func firstError(err, fallback error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return fallback
|
|
}
|
|
func moderationRequestID() string {
|
|
var raw [12]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
|
|
}
|
|
return hex.EncodeToString(raw[:])
|
|
}
|
|
func circuitOpen() bool {
|
|
aliyunCircuit.Lock()
|
|
defer aliyunCircuit.Unlock()
|
|
return time.Now().Before(aliyunCircuit.openUntil)
|
|
}
|
|
func recordFailure() {
|
|
aliyunCircuit.Lock()
|
|
defer aliyunCircuit.Unlock()
|
|
aliyunCircuit.failures++
|
|
if aliyunCircuit.failures >= 5 {
|
|
aliyunCircuit.openUntil = time.Now().Add(30 * time.Second)
|
|
aliyunCircuit.failures = 0
|
|
}
|
|
}
|
|
func recordSuccess() {
|
|
aliyunCircuit.Lock()
|
|
aliyunCircuit.failures = 0
|
|
aliyunCircuit.openUntil = time.Time{}
|
|
aliyunCircuit.Unlock()
|
|
}
|
|
func logDecision(req Request, result *Result) {
|
|
if logs.Info != nil {
|
|
logs.Info.Printf("content moderation: request=%s tenant=%s scene=%s actor=%s provider=%s risk=%s action=%s latency_ms=%d", result.RequestID, req.TenantCode, req.Scene, req.ActorID.UserId(), result.Provider, result.RiskLevel, result.Action, result.Latency.Milliseconds())
|
|
}
|
|
}
|
|
|
|
func recordDecision(req Request, text string, result *Result) {
|
|
logDecision(req, result)
|
|
sum := sha256.Sum256([]byte(text))
|
|
errorCode := ""
|
|
if result.Err != nil {
|
|
errorCode = "PROVIDER_ERROR"
|
|
}
|
|
if err := store.SecurityAudit.RecordContentModeration(req.TenantID, &types.ContentModerationLog{
|
|
RequestID: result.RequestID, Scene: req.Scene, Topic: req.Topic, ActorID: req.ActorID,
|
|
Provider: result.Provider, ProviderRequestID: result.ProviderRequestID, RiskLevel: result.RiskLevel,
|
|
RiskLabels: result.RiskLabels, Action: result.Action, ContentHash: hex.EncodeToString(sum[:]),
|
|
LatencyMS: result.Latency.Milliseconds(), ErrorCode: errorCode, CreatedAt: time.Now().UTC().Round(time.Millisecond),
|
|
}); err != nil {
|
|
if logs.Warn != nil {
|
|
logs.Warn.Printf("content moderation audit write failed: request=%s tenant=%s err=%v", result.RequestID, req.TenantCode, err)
|
|
}
|
|
}
|
|
}
|