diff --git a/.github/workflows/build-and-publish.yaml b/.github/workflows/build-and-publish.yaml new file mode 100644 index 0000000..c96cdb4 --- /dev/null +++ b/.github/workflows/build-and-publish.yaml @@ -0,0 +1,54 @@ +name: Build and Publish + +env: + REGISTRY: ghcr.io + PACKAGE_NAME: ${{ github.repository }} + GITHUB_SHA: ${{ github.sha }} + +on: + push: + branches: [ "main" ] + +jobs: + publish-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Print Go version + run: | + go version + + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set image name + run: | + echo "IMAGE_NAME=${REGISTRY}/${PACKAGE_NAME,,}:${GITHUB_SHA}" >> ${GITHUB_ENV} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 + with: + context: . + push: true + tags: ${{ env.IMAGE_NAME }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Log image name + run: | + echo "Image URI: ${IMAGE_NAME}" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89dbe21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +.env +main \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..30054a9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# Build container +FROM golang:1.22 AS builder + +RUN go version + +RUN apt-get update && apt-get upgrade -y && apt-get install -y ca-certificates git zlib1g-dev + +COPY . /go/src/github.com/TicketsBot/misconduct-detector +WORKDIR /go/src/github.com/TicketsBot/misconduct-detector + +RUN git submodule update --init --recursive --remote + +RUN set -Eeux && \ + go mod download && \ + go mod verify + +RUN GOOS=linux GOARCH=amd64 \ + go build \ + -trimpath \ + -o main cmd/misconduct-detector/main.go + +# Prod container +FROM ubuntu:latest + +RUN apt-get update && apt-get upgrade -y && apt-get install -y ca-certificates curl + +COPY --from=builder /go/src/github.com/TicketsBot/misconduct-detector/main /srv/misconduct-detector/main + +RUN chmod +x /srv/misconduct-detector/main + +RUN useradd -m container +USER container +WORKDIR /srv/misconduct-detector + +CMD ["/srv/misconduct-detector/main"] \ No newline at end of file diff --git a/cmd/misconduct-detector/main.go b/cmd/misconduct-detector/main.go new file mode 100644 index 0000000..36abff8 --- /dev/null +++ b/cmd/misconduct-detector/main.go @@ -0,0 +1,161 @@ +package main + +import ( + "context" + "fmt" + "github.com/TicketsBot/common/observability" + "github.com/TicketsBot/common/rpc" + "github.com/TicketsBot/misconduct-detector/internal/config" + "github.com/TicketsBot/misconduct-detector/internal/processor" + "github.com/TicketsBot/misconduct-detector/internal/processor/rules" + "github.com/TicketsBot/misconduct-detector/internal/queue" + "github.com/getsentry/sentry-go" + "github.com/jackc/pgx/v4/pgxpool" + "github.com/rxdn/gdl/cache" + "github.com/rxdn/gdl/objects/guild" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +func main() { + config := must(config.LoadFromEnv()) + + // Build logger + if config.SentryDsn != nil { + if err := sentry.Init(sentry.ClientOptions{ + Dsn: *config.SentryDsn, + }); err != nil { + panic(fmt.Errorf("sentry.Init: %w", err)) + } + } + + var logger *zap.Logger + var err error + if config.JsonLogs { + loggerConfig := zap.NewProductionConfig() + loggerConfig.Level.SetLevel(config.LogLevel) + + logger, err = loggerConfig.Build( + zap.AddCaller(), + zap.AddStacktrace(zap.ErrorLevel), + zap.WrapCore(observability.ZapSentryAdapter(observability.EnvironmentProduction)), + ) + } else { + loggerConfig := zap.NewDevelopmentConfig() + loggerConfig.Level.SetLevel(config.LogLevel) + loggerConfig.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + + logger, err = loggerConfig.Build(zap.AddCaller(), zap.AddStacktrace(zap.ErrorLevel)) + } + + if err != nil { + panic(fmt.Errorf("failed to initialise zap logger: %w", err)) + } + + // Build app context + appContext := must(buildAppContext(config, logger)) + + // Connect to Kafka + guildCh := make(chan guild.Guild, 10) + consumer := queue.NewConsumer(config, logger.With(zap.String("module", "consumer")), guildCh) + + logger.Info("Starting RPC client") + rpcClient, err := rpc.NewClient( + logger.With(zap.String("service", "rpc-client")), + rpc.Config{ + Brokers: config.Kafka.Brokers, + ConsumerConcurrency: 1, + }, + map[string]rpc.Listener{ + config.Kafka.EventsTopic: consumer, + }, + ) + if err != nil { + logger.Fatal("Failed to start RPC client", zap.Error(err)) + return + } + + logger.Info("RPC client started") + + wg := &sync.WaitGroup{} + delegators := make([]*processor.Delegator, config.ConcurrentTasks) + for i := 0; i < config.ConcurrentTasks; i++ { + wg.Add(1) + + delegator := processor.NewDelegator( + config, + logger.With(zap.String("module", "delegator")), + appContext, + rules.Ruleset, + queue.NewKafkaProducer(config, rpcClient), + guildCh, + ) + + go func() { + defer wg.Done() + delegator.Run() + }() + + delegators[i] = delegator + } + + awaitShutdown(logger, wg, delegators) +} + +func awaitShutdown(logger *zap.Logger, wg *sync.WaitGroup, delegators []*processor.Delegator) { + done := make(chan os.Signal, 1) + signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) + <-done + + logger.Info("Shutting down") + for _, delegator := range delegators { + delegator.Shutdown() + } + + wgCh := make(chan struct{}) + go func() { + wg.Wait() + close(wgCh) + }() + + select { + case <-wgCh: + logger.Info("All delegators have shut down") + case <-time.After(10 * time.Second): + logger.Warn("Some delegators have not shut down, but timeout has expired") + } +} + +func buildAppContext(config config.Config, logger *zap.Logger) (*rules.AppContext, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + logger.Info("Connecting to cache") + pool, err := pgxpool.Connect(ctx, config.Cache.Uri) + if err != nil { + return nil, err + } + + cache := cache.NewPgCache(pool, cache.CacheOptions{ + Guilds: true, + Users: true, + Members: true, + Channels: true, + }) + logger.Info("Connected to cache") + + return rules.NewAppContext(config, &cache), nil +} + +func must[T any](t T, err error) T { + if err != nil { + panic(err) + } + + return t +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9894b4c --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +module github.com/TicketsBot/misconduct-detector + +go 1.22 + +require ( + github.com/TicketsBot/common v0.0.0-20241104184641-e39c64bdcf3e + github.com/caarlos0/env/v11 v11.2.2 + github.com/getsentry/sentry-go v0.21.0 + github.com/prometheus/client_golang v1.20.5 + github.com/rxdn/gdl v0.0.0-20241027214923-02dff700595b + github.com/stretchr/testify v1.9.0 + go.uber.org/zap v1.27.0 + golang.org/x/sync v0.8.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-redis/redis/v8 v8.11.3 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/panjf2000/ants/v2 v2.10.0 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/twmb/franz-go v1.18.0 // indirect + github.com/twmb/franz-go/pkg/kmsg v1.9.0 // indirect + go.uber.org/atomic v1.4.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/TicketsBot/common => ../common diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..75e2991 --- /dev/null +++ b/go.sum @@ -0,0 +1,181 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/caarlos0/env/v11 v11.2.2 h1:95fApNrUyueipoZN/EhA8mMxiNxrBwDa+oAZrMWl3Kg= +github.com/caarlos0/env/v11 v11.2.2/go.mod h1:JBfcdeQiBoI3Zh1QRAWfe+tpiNTmDtcCj/hHHHMx0vc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/getsentry/sentry-go v0.21.0 h1:c9l5F1nPF30JIppulk4veau90PK6Smu3abgVtVQWon4= +github.com/getsentry/sentry-go v0.21.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-redis/redis/v8 v8.11.3 h1:GCjoYp8c+yQTJfc0n69iwSiHjvuAdruxl7elnZCxgt8= +github.com/go-redis/redis/v8 v8.11.3/go.mod h1:xNJ9xDG09FsIPwh3bWdk+0oDWHbtF9rPN0F/oD9XeKc= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.15.0 h1:WjP/FQ/sk43MRmnEcT+MlDw2TFvkrXlprrPST/IudjU= +github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0= +github.com/panjf2000/ants/v2 v2.10.0 h1:zhRg1pQUtkyRiOFo2Sbqwjp0GfBNo9cUY2/Grpx1p+8= +github.com/panjf2000/ants/v2 v2.10.0/go.mod h1:7ZxyxsqE4vvW0M7LSD8aI3cKwgFhBHbxnlN8mDqHa1I= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rxdn/gdl v0.0.0-20241027214923-02dff700595b h1:vSQ8iR4vrrDNchF24oxKMYbdO2D/2HKNqQkx8+v2ZMY= +github.com/rxdn/gdl v0.0.0-20241027214923-02dff700595b/go.mod h1:hDxVWVHzvsO3Mt9d5KIjMLbm3K91Qgqw3LS0FIUxGVo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twmb/franz-go v1.18.0 h1:25FjMZfdozBywVX+5xrWC2W+W76i0xykKjTdEeD2ejw= +github.com/twmb/franz-go v1.18.0/go.mod h1:zXCGy74M0p5FbXsLeASdyvfLFsBvTubVqctIaa5wQ+I= +github.com/twmb/franz-go/pkg/kmsg v1.9.0 h1:JojYUph2TKAau6SBtErXpXGC7E3gg4vGZMv9xFU/B6M= +github.com/twmb/franz-go/pkg/kmsg v1.9.0/go.mod h1:CMbfazviCyY6HM0SXuG5t9vOwYDHRCSrJJyBAe5paqg= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..da6b0d4 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,34 @@ +package config + +import ( + "github.com/caarlos0/env/v11" + "go.uber.org/zap/zapcore" + "time" +) + +type Config struct { + SentryDsn *string `env:"SENTRY_DSN"` + JsonLogs bool `env:"JSON_LOGS"` + LogLevel zapcore.Level `env:"LOG_LEVEL" envDefault:"info"` + TaskTimeout time.Duration `env:"TASK_TIMEOUT" envDefault:"10s"` + ConcurrentTasks int `env:"CONCURRENT_TASKS" envDefault:"3"` + + Kafka struct { + Brokers []string `env:"BROKERS,required" envSeparator:","` + EventsTopic string `env:"EVENTS_TOPIC,required"` + DetectionTopic string `env:"DETECTION_TOPIC,required"` + } `envPrefix:"KAFKA_"` + + Discord struct { + ProxyUrl *string `env:"PROXY_URL"` + Token string `env:"TOKEN,required"` + } `envPrefix:"DISCORD_"` + + Cache struct { + Uri string `env:"URI,required"` + } `envPrefix:"CACHE_"` +} + +func LoadFromEnv() (Config, error) { + return env.ParseAs[Config]() +} diff --git a/internal/processor/delegator.go b/internal/processor/delegator.go new file mode 100644 index 0000000..5427c96 --- /dev/null +++ b/internal/processor/delegator.go @@ -0,0 +1,143 @@ +package processor + +import ( + "context" + "github.com/TicketsBot/common/rpc/model" + "github.com/TicketsBot/misconduct-detector/internal/config" + "github.com/TicketsBot/misconduct-detector/internal/processor/rules" + "github.com/TicketsBot/misconduct-detector/internal/queue" + "github.com/rxdn/gdl/objects/guild" + "go.uber.org/zap" + "golang.org/x/sync/errgroup" + "sync" + "time" +) + +type Delegator struct { + config config.Config + logger *zap.Logger + appContext *rules.AppContext + + evaluators []rules.Evaluator + + producer queue.Producer + ch <-chan guild.Guild + shutdownCh chan struct{} +} + +const ActionThreshold = 25 + +func NewDelegator( + config config.Config, + logger *zap.Logger, + appContext *rules.AppContext, + evaluators []rules.Evaluator, + producer queue.Producer, + ch <-chan guild.Guild, +) *Delegator { + return &Delegator{ + config: config, + logger: logger, + appContext: appContext, + evaluators: evaluators, + producer: producer, + ch: ch, + shutdownCh: make(chan struct{}), + } +} + +func (d *Delegator) Run() { + for { + select { + case guild := <-d.ch: + d.handleGuild(&guild) + case <-d.shutdownCh: + d.logger.Info("Shutting down delegator") + return + } + } +} + +func (d *Delegator) Shutdown() { + close(d.shutdownCh) +} + +func (d *Delegator) handleGuild(guild *guild.Guild) { + logger := d.logger.With(zap.Uint64("guild_id", guild.Id)) + logger.Info("Evaluating guild") + + ctx, cancel := context.WithTimeout(context.Background(), d.config.TaskTimeout) + defer cancel() + + scores := make(map[string]int) + mu := &sync.Mutex{} + + var group errgroup.Group + for _, evaluator := range d.evaluators { + properties := evaluator.Properties() + + ruleEvaluationCounter.WithLabelValues(properties.RuleName).Inc() + + if properties.ShouldSpawnGoroutine { + evaluator := evaluator + group.Go(func() error { + now := time.Now() + defer func() { + ruleExecutionTimeHistogram.WithLabelValues(properties.RuleName).Observe(time.Since(now).Seconds()) + }() + + s, err := evaluator.Evaluate(ctx, d.appContext, guild) + if err != nil { + return err + } + + mu.Lock() + scores[properties.RuleName] = s + mu.Unlock() + + ruleScoreCounter.WithLabelValues(properties.RuleName).Add(float64(s)) + return nil + }) + } else { + now := time.Now() + s, err := evaluator.Evaluate(ctx, d.appContext, guild) + ruleExecutionTimeHistogram.WithLabelValues(properties.RuleName).Observe(time.Since(now).Seconds()) + if err != nil { + d.logger.Error("Failed to evaluate guild", zap.Error(err)) + continue + } + + mu.Lock() + scores[properties.RuleName] = s + mu.Unlock() + } + } + + if err := group.Wait(); err != nil { + logger.Error("Failed to evaluate guild", zap.Error(err)) + // Don't return! If the score still exceeds the threshold, we should still take action + } + + var totalScore int + for _, score := range scores { + totalScore += score + } + + if totalScore > 100 { + totalScore = 100 + } + + d.logger.Debug("Evaluated guild", zap.Int("total_score", totalScore), zap.Any("scores", scores)) + + if totalScore > ActionThreshold { + d.logger.Info("Guild is above alert threshold, publishing", zap.Int("total_score", totalScore), zap.Any("scores", scores)) + + if err := d.producer.PublishAlert(ctx, model.MisconductAlert{ + Guild: guild, + Score: totalScore, + RuleScores: scores, + }); err != nil { + logger.Error("Failed to publish alert", zap.Error(err)) + } + } +} diff --git a/internal/processor/metrics.go b/internal/processor/metrics.go new file mode 100644 index 0000000..07f69ef --- /dev/null +++ b/internal/processor/metrics.go @@ -0,0 +1,31 @@ +package processor + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +const ( + MetricNamespace = "tickets" + MetricSubsystem = "misconduct_detector" +) + +var ( + ruleEvaluationCounter = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: MetricNamespace, + Subsystem: MetricSubsystem, + Name: "rule_evaluations", + }, []string{"rule"}) + + ruleExecutionTimeHistogram = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: MetricNamespace, + Subsystem: MetricSubsystem, + Name: "rule_execution_time", + }, []string{"rule"}) + + ruleScoreCounter = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: MetricNamespace, + Subsystem: MetricSubsystem, + Name: "rule_score", + }, []string{"rule"}) +) diff --git a/internal/processor/rules/appcontext.go b/internal/processor/rules/appcontext.go new file mode 100644 index 0000000..38e1741 --- /dev/null +++ b/internal/processor/rules/appcontext.go @@ -0,0 +1,51 @@ +package rules + +import ( + "context" + "errors" + "fmt" + "github.com/TicketsBot/misconduct-detector/internal/config" + "github.com/rxdn/gdl/cache" + "github.com/rxdn/gdl/objects/user" + "github.com/rxdn/gdl/rest" + "github.com/rxdn/gdl/rest/request" +) + +type AppContext struct { + config config.Config + cache cache.Cache +} + +func NewAppContext(config config.Config, cache cache.Cache) *AppContext { + return &AppContext{ + config: config, + cache: cache, + } +} + +func (a *AppContext) FetchUser(ctx context.Context, userId uint64) (user.User, bool, error) { + cached, err := a.cache.GetUser(ctx, userId) + if err == nil { + return cached, true, nil + } else if !errors.Is(err, cache.ErrNotFound) { + return user.User{}, false, fmt.Errorf("failed to fetch user from cache: %w", err) + } + + // Fetch from API + fetched, err := rest.GetUser(ctx, a.config.Discord.Token, nil, userId) + if err != nil { + var restError request.RestError + if errors.As(err, &restError) && restError.StatusCode == 404 { + return user.User{}, false, nil + } + + return user.User{}, false, fmt.Errorf("failed to fetch user from Discord API: %w", err) + } + + // Cache + if err := a.cache.StoreUser(ctx, fetched); err != nil { + return user.User{}, false, fmt.Errorf("failed to cache user: %w", err) + } + + return fetched, true, nil +} diff --git a/internal/processor/rules/cryptoscam_name.go b/internal/processor/rules/cryptoscam_name.go new file mode 100644 index 0000000..80dde0d --- /dev/null +++ b/internal/processor/rules/cryptoscam_name.go @@ -0,0 +1,40 @@ +package rules + +import ( + "context" + "github.com/rxdn/gdl/objects/guild" + "strings" +) + +type CryptoScamNameEvaluator struct { +} + +var _ Evaluator = (*CryptoScamNameEvaluator)(nil) + +var cryptoScamWords = map[string]int{ + "ticket": 50, + "support ticket": 70, +} + +func (c *CryptoScamNameEvaluator) Evaluate(_ context.Context, _ *AppContext, guild *guild.Guild) (int, error) { + lower := strings.ToLower(guild.Name) + + var maxScore int + for word, score := range cryptoScamWords { + if strings.Contains(lower, word) { + if score > maxScore { + maxScore = score + } + } + } + + return maxScore, nil +} + +func (c *CryptoScamNameEvaluator) Properties() EvaluatorProperties { + return EvaluatorProperties{ + RuleName: "Guild name contains \"ticket\"", + RuleType: RuleTypeCryptoScam, + ShouldSpawnGoroutine: false, + } +} diff --git a/internal/processor/rules/general_accountage.go b/internal/processor/rules/general_accountage.go new file mode 100644 index 0000000..f258f01 --- /dev/null +++ b/internal/processor/rules/general_accountage.go @@ -0,0 +1,36 @@ +package rules + +import ( + "context" + "github.com/TicketsBot/common/utils" + "github.com/rxdn/gdl/objects/guild" + "time" +) + +type GeneralAccountAgeEvaluator struct { +} + +var _ Evaluator = (*GeneralAccountAgeEvaluator)(nil) + +func (e *GeneralAccountAgeEvaluator) Evaluate(_ context.Context, _ *AppContext, guild *guild.Guild) (int, error) { + createdAt := utils.SnowflakeToTimestamp(guild.OwnerId) + accountAge := time.Now().Sub(createdAt) + + if accountAge.Hours() < 24 { + return 80, nil + } else if accountAge.Hours() < 24*7 { + return 50, nil + } else if accountAge.Hours() < 24*30 { + return 20, nil + } else { + return 0, nil + } +} + +func (e *GeneralAccountAgeEvaluator) Properties() EvaluatorProperties { + return EvaluatorProperties{ + RuleName: "Account age", + RuleType: RuleTypeGeneral, + ShouldSpawnGoroutine: false, + } +} diff --git a/internal/processor/rules/rule.go b/internal/processor/rules/rule.go new file mode 100644 index 0000000..29b1d78 --- /dev/null +++ b/internal/processor/rules/rule.go @@ -0,0 +1,25 @@ +package rules + +import ( + "context" + "github.com/rxdn/gdl/objects/guild" +) + +type Evaluator interface { + Evaluate(ctx context.Context, appContext *AppContext, guild *guild.Guild) (int, error) + Properties() EvaluatorProperties +} + +type RuleType string + +type EvaluatorProperties struct { + RuleName string + RuleType RuleType + ShouldSpawnGoroutine bool +} + +const ( + RuleTypeGeneral RuleType = "GENERAL" + RuleTypeCryptoScam RuleType = "CRYPTO_SCAM" + RuleTypeGameCheats RuleType = "GAME_CHEATS" +) diff --git a/internal/processor/rules/ruleset.go b/internal/processor/rules/ruleset.go new file mode 100644 index 0000000..53827e4 --- /dev/null +++ b/internal/processor/rules/ruleset.go @@ -0,0 +1,6 @@ +package rules + +var Ruleset = []Evaluator{ + &CryptoScamNameEvaluator{}, + &GeneralAccountAgeEvaluator{}, +} diff --git a/internal/queue/consumer.go b/internal/queue/consumer.go new file mode 100644 index 0000000..78825d2 --- /dev/null +++ b/internal/queue/consumer.go @@ -0,0 +1,71 @@ +package queue + +import ( + "context" + "encoding/json" + "github.com/TicketsBot/common/eventforwarding" + "github.com/TicketsBot/common/rpc" + "github.com/TicketsBot/misconduct-detector/internal/config" + "github.com/rxdn/gdl/gateway/payloads" + "github.com/rxdn/gdl/gateway/payloads/events" + "github.com/rxdn/gdl/objects/guild" + "go.uber.org/zap" + "time" +) + +type Consumer struct { + config config.Config + logger *zap.Logger + + ch chan<- guild.Guild +} + +// Still scan guilds that have joined if there is a Kafka backlog while booting +const joinDetectionThreshold = time.Hour + +var _ rpc.Listener = (*Consumer)(nil) + +func NewConsumer(config config.Config, logger *zap.Logger, ch chan<- guild.Guild) *Consumer { + return &Consumer{ + config: config, + logger: logger, + ch: ch, + } +} + +func (c *Consumer) BuildContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), c.config.TaskTimeout) +} + +func (c *Consumer) HandleMessage(ctx context.Context, message []byte) { + var wrapped eventforwarding.Event + if err := json.Unmarshal(message, &wrapped); err != nil { + c.logger.Error("Failed to unmarshal guild", zap.Error(err)) + return + } + + var payload payloads.Payload + if err := json.Unmarshal(wrapped.Event, &payload); err != nil { + c.logger.Error("Failed to unmarshal payload", zap.Error(err)) + return + } + + if payload.EventName != string(events.GUILD_CREATE) { + return + } + + var guild events.GuildCreate + if err := json.Unmarshal(wrapped.Event, &guild); err != nil { + c.logger.Error("Failed to unmarshal guild", zap.Error(err)) + return + } + + if guild.JoinedAt.Before(time.Now().Add(-joinDetectionThreshold)) { + c.logger.Debug("Ignoring guild, as we joined it too long ago", zap.Uint64("guild_id", guild.Id)) + return + } + + c.logger.Debug("Received guild applicable for scanning", zap.Uint64("guild_id", guild.Id)) + + c.ch <- guild.Guild +} diff --git a/internal/queue/kafkaproducer.go b/internal/queue/kafkaproducer.go new file mode 100644 index 0000000..4bc9622 --- /dev/null +++ b/internal/queue/kafkaproducer.go @@ -0,0 +1,26 @@ +package queue + +import ( + "context" + "github.com/TicketsBot/common/rpc" + "github.com/TicketsBot/common/rpc/model" + "github.com/TicketsBot/misconduct-detector/internal/config" +) + +type KafkaProducer struct { + config config.Config + client *rpc.Client +} + +var _ Producer = (*KafkaProducer)(nil) + +func NewKafkaProducer(config config.Config, client *rpc.Client) *KafkaProducer { + return &KafkaProducer{ + config: config, + client: client, + } +} + +func (k *KafkaProducer) PublishAlert(ctx context.Context, alert model.MisconductAlert) error { + return k.client.ProduceSyncJson(ctx, k.config.Kafka.DetectionTopic, alert) +} diff --git a/internal/queue/producer.go b/internal/queue/producer.go new file mode 100644 index 0000000..343570e --- /dev/null +++ b/internal/queue/producer.go @@ -0,0 +1,10 @@ +package queue + +import ( + "context" + "github.com/TicketsBot/common/rpc/model" +) + +type Producer interface { + PublishAlert(ctx context.Context, alert model.MisconductAlert) error +}