Full Code of pennersr/shove for AI

main 8a31c25c40e0 cached
39 files
138.3 KB
58.1k tokens
176 symbols
1 requests
Download .txt
Repository: pennersr/shove
Branch: main
Commit: 8a31c25c40e0
Files: 39
Total size: 138.3 KB

Directory structure:
gitextract_y77ellyt/

├── .github/
│   └── FUNDING.yml
├── .gitignore
├── .gitlab-ci.yml
├── .woodpecker.yaml
├── LICENSE
├── README.md
├── cmd/
│   └── shove/
│       └── main.go
├── go.mod
├── go.sum
├── internal/
│   ├── queue/
│   │   ├── memory/
│   │   │   ├── message.go
│   │   │   └── queue.go
│   │   ├── queue.go
│   │   └── redis/
│   │       └── queue.go
│   ├── server/
│   │   ├── feedback.go
│   │   ├── metrics.go
│   │   ├── push.go
│   │   ├── server.go
│   │   └── worker.go
│   └── services/
│       ├── apns/
│       │   ├── apns.go
│       │   └── message.go
│       ├── email/
│       │   ├── encode.go
│       │   ├── encode_test.go
│       │   ├── message.go
│       │   ├── send.go
│       │   └── service.go
│       ├── fcm/
│       │   ├── fcm.go
│       │   └── message.go
│       ├── pump.go
│       ├── services.go
│       ├── squasher.go
│       ├── telegram/
│       │   ├── message.go
│       │   └── telegram.go
│       ├── webhook/
│       │   ├── message.go
│       │   └── webhook.go
│       └── webpush/
│           ├── message.go
│           ├── message_test.go
│           └── webpush.go
├── pkg/
│   └── shove/
│       └── client.go
└── scripts/
    └── email.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms

github: pennersr
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']


================================================
FILE: .gitignore
================================================
*~
/cmd/shove/shove
/shove


================================================
FILE: .gitlab-ci.yml
================================================
image: golang:1.20-bullseye

variables:
  REPO_NAME: gitlab.com/pennersr/shove

# The problem is that to be able to use go get, one needs to put
# the repository in the $GOPATH. So for example if your gitlab domain
# is gitlab.com, and that your repository is namespace/project, and
# the default GOPATH being /go, then you'd need to have your
# repository in /go/src/gitlab.com/namespace/project
# Thus, making a symbolic link corrects this.
before_script:
  - mkdir -p $GOPATH/src/$(dirname $REPO_NAME)
  - ln -svf $CI_PROJECT_DIR $GOPATH/src/$REPO_NAME
  - cd $GOPATH/src/$REPO_NAME

stages:
  - test
  - build
  - deploy

format:
  stage: test
  script:
    - go fmt $(go list ./... | grep -v /vendor/)
    - go vet $(go list ./... | grep -v /vendor/)
    - go test -race $(go list ./... | grep -v /vendor/)

compile:
  stage: build
  script:
    - go build -race -ldflags "-extldflags '-static'" -o $CI_PROJECT_DIR/shove ./cmd/shove
  artifacts:
    paths:
      - shove


================================================
FILE: .woodpecker.yaml
================================================
when:
  - event: pull_request
  - event: [push, tag, manual]
    branch: main

steps:
  build:
    image: golang:1.21-bookworm
    commands:
      - go build -race -ldflags "-extldflags '-static'" -o $CI_PROJECT_DIR/shove ./cmd/shove

  lint:
    image: golang:1.21-bookworm
    commands:
      - go fmt $(go list ./... | grep -v /vendor/)
      - go vet $(go list ./... | grep -v /vendor/)

  test:
    image: golang:1.21-bookworm
    commands:
      - go test -race $(go list ./... | grep -v /vendor/)


================================================
FILE: LICENSE
================================================
The MIT License (MIT)

Copyright (c) 2019 Raymond Penners and contributors

Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

================================================
FILE: README.md
================================================
# When push comes to shove...

[![Go Report Card](https://goreportcard.com/badge/codeberg.org/pennersr/shove)](https://goreportcard.com/report/codeberg.org/pennersr/shove) [![Written in Emacs](https://pennersr.github.io/img/emacs-badge.svg)](https://www.gnu.org/software/emacs/) [![Pipeline Status](https://ci.codeberg.org/api/badges/13727/status.svg)](https://ci.codeberg.org/repos/13727)

## Background

This is the replacement for [Pulsus](https://github.com/pennersr/pulsus) which has been steadily serving up to 100M push notifications. But, given that it was still using the binary APNS protocol it was due for an upgrade.

## Overview

Design:
- Asynchronous: a push client can just fire & forget.
- Multiple workers per push service.
- Less moving parts: when using Redis, you can push directly to the queue, bypassing the need for the Shove server to be up and running.

Supported push services:
- APNS
- Email: supports automatic creation of email digests in case the rate limit
  is exceeded
- FCM
- Telegram: supports squashing multiple messages into one in case the rate limit
  is exceeded
- Webhook: issue arbitrary webhook posts
- Web Push

Features:
- Feedback: asynchronously receive information on invalid device tokens.
- Queueing: both in-memory and persistent via Redis.
- Exponential back-off in case of failure.
- Prometheus support.
- Squashing of messages in case rate limits are exceeded.


## Why?

- https://github.com/appleboy/gorush/issues/386#issuecomment-479191179

- https://github.com/mercari/gaurun/issues/115


## Usage

### Running

Usage:

    $ shove -h
    Usage of ./shove:
      -api-addr string
            API address to listen to (default ":8322")
      -apns-certificate-path string
            APNS certificate path
      -apns-sandbox-certificate-path string
            APNS sandbox certificate path
      -apns-workers int
            The number of workers pushing APNS messages (default 4)
      -email-host string
            Email host
      -email-port int
            Email port (default 25)
      -email-rate-amount int
            Email max. rate (amount)
      -email-rate-per int
            Email max. rate (per seconds)
      -email-tls
            Use TLS
      -email-tls-insecure
            Skip TLS verification
      -fcm-credentials-file string
            Path to FCM service account JSON file
      -fcm-workers int
            The number of workers pushing FCM messages (default 4)
      -queue-redis string
            Use Redis queue (Redis URL)
      -telegram-bot-token string
            Telegram bot token
      -telegram-rate-amount int
            Telegram max. rate (amount)
      -telegram-rate-per int
            Telegram max. rate (per seconds)
      -telegram-workers int
            The number of workers pushing Telegram messages (default 2)
      -webhook-workers int
            The number of workers pushing Webhook messages
      -webpush-vapid-private-key string
            VAPID public key
      -webpush-vapid-public-key string
            VAPID public key
      -webpush-workers int
            The number of workers pushing Web messages (default 8)


Start the server:

    $ shove \
        -api-addr localhost:8322 \
        -queue-redis redis://redis:6379 \
        -fcm-credentials-file /etc/shove/fcm/credentials.json \
        -apns-certificate-path /etc/shove/apns/production/bundle.pem -apns-sandbox-certificate-path /etc/shove/apns/sandbox/bundle.pem \
        -webpush-vapid-public-key=$VAPID_PUBLIC_KEY -webpush-vapid-private-key=$VAPID_PRIVATE_KEY \
        -telegram-bot-token $TELEGRAM_BOT_TOKEN


### APNS

Push an APNS notification:

    $ curl  -i  --data '{"service": "apns", "headers": {"apns-priority": 10, "apns-topic": "com.shove.app"}, "payload": {"aps": { "alert": "hi"}}, "token": "81b8ecff8cb6d22154404d43b9aeaaf6219dfbef2abb2fe313f3725f4505cb47"}' http://localhost:8322/api/push/apns


A successful push results in:

    HTTP/1.1 202 Accepted
    Date: Tue, 07 May 2019 19:00:15 GMT
    Content-Length: 2
    Content-Type: text/plain; charset=utf-8

    OK


### FCM

Push an FCM notification:

    $ curl  -i  --data '{"message": {"notification": {"body": "Hello world!", "title": "Test"}, "token": "c7VmdNNHQaGTLkmi....15CmMs"}}' http://localhost:8322/api/push/fcm

### Webhook

Push a Webhook call, containing arbitrary body content:

    $ curl  -i  --data '{"url": "http://localhost:8000/api/webhook", "headers": {"foo": "bar"}, "body": "Hello world!"}' http://localhost:8322/api/push/webhook

Or, post JSON:

    $ curl  -i  --data '{"url": "http://localhost:8000/api/webhook", "headers": {"foo": "bar"}, "data": {"hello": "world!"}}' http://localhost:8322/api/push/webhook


### WebPush

Push a WebPush notification:

    $ curl  -i  --data '{"subscription": {"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/gAAAAAc4BA....UrjGlg","keys":{"auth":"Hbj3ap...al9ew","p256dh":"BeKdTC3...KLGBJlgF"}}, "headers": {"ttl": 3600, "urgency": "high"}, "token": "use-this-for-feedback-instead-of-subscription", "payload": {"hello":"world"}}' http://localhost:8322/api/push/webpush

The subscription (serialized as a JSON string) is used for receiving
feedback. Alternatively, you can specify an optional `token` parameter as done
in the example above.


### Telegram

Push a Telegram notification:

    $ curl  -i  --data '{"method": "sendMessage", "payload": {"chat_id": "12345678", "text": "Hello!"}}' http://localhost:8322/api/push/telegram

Note that the Telegram Bot API documents `chat_id` as "Integer or String" --
Shove requires strings to be passed. For users that disconnected from your bot
the chat ID will be communicated back through the feedback mechanism. Here, the
token will equal the unreachable chat ID.


### Receive Feedback

Outdated/invalid tokens are communicated back. To receive those, you can periodically query the feedback channel to receive token feedback, and remove those from your database:


    $ curl -X POST 'http://localhost:8322/api/feedback'

    {
      "feedback": [
        {"service":"apns-sandbox",
         "token":"881becff86cbd221544044d3b9aeaaf6314dfbef2abb2fe313f3725f4505cb47",
         "reason":"invalid"}
      ]
    }


### Email

In order to keep your SMTP server safe from being blacklisted, the email service
supports rate limitting. When the rate is exceeded, multiple mails are
automatically digested.

    $ shove \
        -email-host localhost \
        -email-port 1025 \
        -api-addr localhost:8322 \
        -email-rate-amount 3 \
        -email-rate-per 10 \
        -queue-redis redis://localhost:6379

Push an email:

	$ curl -i -X POST --data @./scripts/email.json http://localhost:8322/api/push/email

If you send too many emails, you'll notice that they are digested, and at a
later time, one digest mail is being sent:

    2021/03/23 21:15:57 Using Redis queue at redis://localhost:6379
    2021/03/23 21:15:57 Initializing Email service
    2021/03/23 21:15:57 Serving on localhost:8322
    2021/03/23 21:15:57 Shove server started
    2021/03/23 21:15:57 email: Worker started
    2021/03/23 21:15:57 email: Digester started
    2021/03/23 21:15:58 email: Sending email
    2021/03/23 21:15:59 email: Sending email
    2021/03/23 21:15:59 email: Sending email
    2021/03/23 21:16:00 email: Rate to john@doe.org exceeded, email digested
    2021/03/23 21:16:12 email: Rate to john@doe.org exceeded, email digested
    2021/03/23 21:16:18 email: Sending digest email


### Redis Queues

Shove is being used to push a high volume of notifications in a production
environment, consisting of various microservices interacting together. In such a
scenario, it is important that the various services are not too tightly coupled
to one another.  For that purpose, Shove offers the ability to post
notifications directly to a Redis queue.

Posting directly to the Redis queue, instead of using the HTTP service
endpoints, has the advantage that you can take Shove offline without disturbing
the operation of the clients pushing the notifications.

Shove intentionally tries to make as little assumptions on the notification
payloads being pushed, as they are mostly handed over as is to the upstream
services. So, when using Shove this way, the client is responsible for handing
over a raw payload. Here's an example:


    package main

    import (
    	"encoding/json"
    	"codeberg.org/pennersr/shove/pkg/shove"
    	"log"
    	"os"
    )

    type FCMNotification struct {
    	To       string            `json:"to"`
    	Data     map[string]string `json:"data,omitempty"`
    }

    func main() {
    	redisURL := os.Getenv("REDIS_URL")
    	if redisURL == "" {
    		redis_URL = "redis://localhost:6379"
    	}
    	client := shove.NewRedisClient(redisURL)

    	notification := FCMNotification{
    		To:   "token....",
    		Data: map[string]string{},
    	}

    	raw, err := json.Marshal(notification)
    	if err != nil {
    		log.Fatal(err)
    	}
    	err = client.PushRaw("fcm", raw)
    	if err != nil {
    		log.Fatal(err)
    	}
    }


## Status

Used in production, over at:

- [Drakdoo: Indicator based signals & alerts](https://www.drakdoo.com): 365.251.428 alerts fired and counting.


================================================
FILE: cmd/shove/main.go
================================================
package main

import (
	"context"
	"flag"
	"os"
	"os/signal"
	"syscall"
	"time"

	"codeberg.org/pennersr/shove/internal/queue"
	"codeberg.org/pennersr/shove/internal/queue/memory"
	"codeberg.org/pennersr/shove/internal/queue/redis"
	"codeberg.org/pennersr/shove/internal/server"
	"codeberg.org/pennersr/shove/internal/services"
	"codeberg.org/pennersr/shove/internal/services/apns"
	"codeberg.org/pennersr/shove/internal/services/email"
	"codeberg.org/pennersr/shove/internal/services/fcm"
	"codeberg.org/pennersr/shove/internal/services/telegram"
	"codeberg.org/pennersr/shove/internal/services/webhook"
	"codeberg.org/pennersr/shove/internal/services/webpush"
	"golang.org/x/exp/slog"
)

var debug = flag.Bool("debug", false, "Enable debug logging")
var apiAddr = flag.String("api-addr", ":8322", "API address to listen to")

var apnsCertificate = flag.String("apns-certificate-path", "", "APNS certificate path")
var apnsSandboxCertificate = flag.String("apns-sandbox-certificate-path", "", "APNS sandbox certificate path")
var apnsWorkers = flag.Int("apns-workers", 4, "The number of workers pushing APNS messages")

var fcmCredentialsFile = flag.String("fcm-credentials-file", "", "FCM credentials file")
var fcmWorkers = flag.Int("fcm-workers", 4, "The number of workers pushing FCM messages")

var redisURL = flag.String("queue-redis", "", "Use Redis queue (Redis URL)")

var webhookWorkers = flag.Int("webhook-workers", 0, "The number of workers pushing Webhook messages")

var webPushVAPIDPublicKey = flag.String("webpush-vapid-public-key", "", "VAPID public key")
var webPushVAPIDPrivateKey = flag.String("webpush-vapid-private-key", "", "VAPID public key")
var webPushWorkers = flag.Int("webpush-workers", 8, "The number of workers pushing Web messages")

var telegramBotToken = flag.String("telegram-bot-token", "", "Telegram bot token")
var telegramWorkers = flag.Int("telegram-workers", 2, "The number of workers pushing Telegram messages")
var telegramRateAmount = flag.Int("telegram-rate-amount", 0, "Telegram max. rate (amount)")
var telegramRatePer = flag.Int("telegram-rate-per", 0, "Telegram max. rate (per seconds)")

var emailHost = flag.String("email-host", "", "Email host")
var emailPort = flag.Int("email-port", 25, "Email port")
var emailPlainAuth = flag.Bool("email-plain-auth", false, "Email plain auth(username and password)")
var emailUsername = flag.String("email-username", "", "Email username")
var emailPassword = flag.String("email-password", "", "Email password")
var emailTLS = flag.Bool("email-tls", false, "Use TLS")
var emailTLSInsecure = flag.Bool("email-tls-insecure", false, "Skip TLS verification")
var emailRateAmount = flag.Int("email-rate-amount", 0, "Email max. rate (amount)")
var emailRatePer = flag.Int("email-rate-per", 0, "Email max. rate (per seconds)")

func newLogger() *slog.Logger {
	var opts *slog.HandlerOptions
	if *debug {
		opts = &slog.HandlerOptions{
			Level: slog.LevelDebug,
		}
	}
	logger := slog.New(slog.NewTextHandler(os.Stderr, opts))
	return logger
}

func newServiceLogger(service string) *slog.Logger {
	logger := newLogger()
	return logger.With(
		slog.String("service", service),
	)
}

func main() {
	flag.Parse()

	logger := newLogger()
	slog.SetDefault(logger)

	stop := make(chan os.Signal, 1)
	signal.Notify(stop, os.Interrupt, syscall.SIGTERM)

	var qf queue.QueueFactory
	if *redisURL == "" {
		slog.Info("Using non-persistent in-memory queue")
		qf = memory.MemoryQueueFactory{}
	} else {
		slog.Info("Using Redis queue at", "address", *redisURL)
		qf = redis.NewQueueFactory(*redisURL)
	}
	s := server.NewServer(*apiAddr, qf)

	if *apnsCertificate != "" {
		apns, err := apns.NewAPNS(*apnsCertificate, true, newServiceLogger("apns"))
		if err != nil {
			slog.Error("Failed to setup APNS service", "error", err)
			os.Exit(1)
		}
		if err := s.AddService(apns, *apnsWorkers, services.SquashConfig{}); err != nil {
			slog.Error("Failed to add APNS service", "error", err)
			os.Exit(1)
		}
	}

	if *apnsSandboxCertificate != "" {
		apns, err := apns.NewAPNS(*apnsSandboxCertificate, false, newServiceLogger("apns-sandbox"))
		if err != nil {
			slog.Error("Failed to setup APNS sandbox service", "error", err)
			os.Exit(1)
		}
		if err := s.AddService(apns, *apnsWorkers, services.SquashConfig{}); err != nil {
			slog.Error("Failed to add APNS sandbox service", "error", err)
			os.Exit(1)
		}
	}

	if *fcmCredentialsFile != "" {
		fcm, err := fcm.NewFCM(*fcmCredentialsFile, newServiceLogger("fcm"))
		if err != nil {
			slog.Error("Failed to setup FCM service", "error", err)
			os.Exit(1)
		}
		if err := s.AddService(fcm, *fcmWorkers, services.SquashConfig{}); err != nil {
			slog.Error("Failed to add FCM service", "error", err)
			os.Exit(1)
		}
	}

	if *webhookWorkers > 0 {
		wh, err := webhook.NewWebhook(newServiceLogger("webhook"))
		if err != nil {
			slog.Error("Failed to setup Webhook service", "error", err)
			os.Exit(1)
		}
		if err := s.AddService(wh, *webhookWorkers, services.SquashConfig{}); err != nil {
			slog.Error("Failed to add Webhook service", "error", err)
			os.Exit(1)
		}
	}

	if *webPushVAPIDPrivateKey != "" {
		web, err := webpush.NewWebPush(*webPushVAPIDPublicKey, *webPushVAPIDPrivateKey, newServiceLogger("webpush"))
		if err != nil {
			slog.Error("Failed to setup WebPush service", "error", err)
			os.Exit(1)
		}
		if err := s.AddService(web, *webPushWorkers, services.SquashConfig{}); err != nil {
			slog.Error("Failed to add WebPush service", "error", err)
			os.Exit(1)
		}
	}

	if *telegramBotToken != "" {
		tg, err := telegram.NewTelegramService(*telegramBotToken, newServiceLogger("telegram"))
		if err != nil {
			slog.Error("Failed to setup Telegram service", "error", err)
			os.Exit(1)
		}
		if err := s.AddService(tg, *telegramWorkers, services.SquashConfig{
			RateMax: *telegramRateAmount,
			RatePer: time.Second * time.Duration(*telegramRatePer),
		}); err != nil {
			slog.Error("Failed to add Telegram service", "error", err)
			os.Exit(1)
		}
	}

	if *emailHost != "" {
		config := email.EmailConfig{
			EmailHost:     *emailHost,
			EmailPort:     *emailPort,
			TLS:           *emailTLS,
			TLSInsecure:   *emailTLSInsecure,
			Log:           newServiceLogger("email"),
			PlainAuth:     *emailPlainAuth,
			EmailUsername: *emailUsername,
			EmailPassword: *emailPassword,
		}
		email, err := email.NewEmailService(config)
		if err != nil {
			slog.Error("Failed to setup email service", "error", err)
			os.Exit(1)
		}
		if err := s.AddService(email, 1, services.SquashConfig{
			RateMax: *emailRateAmount,
			RatePer: time.Second * time.Duration(*emailRatePer),
		}); err != nil {
			slog.Error("Failed to add email service", "error", err)
			os.Exit(1)
		}
	}

	go func() {
		slog.Info("Serving", "address", *apiAddr)
		err := s.Serve()
		if err != nil {
			slog.Error("Serve failed", "error", err)
			os.Exit(1)
		}
	}()
	<-stop
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	s.Shutdown(ctx)
	slog.Info("Exiting")
}


================================================
FILE: go.mod
================================================
module codeberg.org/pennersr/shove

go 1.21

toolchain go1.22.6

require (
	codeberg.org/pennersr/redq v0.0.0-20240908181154-b13bb619b69d
	firebase.google.com/go v3.13.0+incompatible
	github.com/SherClockHolmes/webpush-go v1.2.0
	github.com/gomodule/redigo v2.0.0+incompatible
	github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
	github.com/prometheus/client_golang v1.14.0
	github.com/sideshow/apns2 v0.23.0
	golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa
	google.golang.org/api v0.189.0
)

require (
	cloud.google.com/go v0.115.0 // indirect
	cloud.google.com/go/auth v0.7.2 // indirect
	cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect
	cloud.google.com/go/compute/metadata v0.5.0 // indirect
	cloud.google.com/go/firestore v1.16.0 // indirect
	cloud.google.com/go/iam v1.1.10 // indirect
	cloud.google.com/go/longrunning v0.5.9 // indirect
	cloud.google.com/go/storage v1.41.0 // indirect
	github.com/beorn7/perks v1.0.1 // indirect
	github.com/cespare/xxhash/v2 v2.2.0 // indirect
	github.com/felixge/httpsnoop v1.0.4 // indirect
	github.com/go-logr/logr v1.4.2 // indirect
	github.com/go-logr/stdr v1.2.2 // indirect
	github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
	github.com/golang-jwt/jwt/v4 v4.4.1 // indirect
	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
	github.com/golang/protobuf v1.5.4 // indirect
	github.com/google/s2a-go v0.1.7 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
	github.com/googleapis/gax-go/v2 v2.13.0 // indirect
	github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
	github.com/prometheus/client_model v0.3.0 // indirect
	github.com/prometheus/common v0.37.0 // indirect
	github.com/prometheus/procfs v0.8.0 // indirect
	go.opencensus.io v0.24.0 // indirect
	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
	go.opentelemetry.io/otel v1.24.0 // indirect
	go.opentelemetry.io/otel/metric v1.24.0 // indirect
	go.opentelemetry.io/otel/trace v1.24.0 // indirect
	golang.org/x/crypto v0.25.0 // indirect
	golang.org/x/net v0.27.0 // indirect
	golang.org/x/oauth2 v0.21.0 // indirect
	golang.org/x/sync v0.7.0 // indirect
	golang.org/x/sys v0.22.0 // indirect
	golang.org/x/text v0.16.0 // indirect
	golang.org/x/time v0.5.0 // indirect
	google.golang.org/appengine v1.6.8 // indirect
	google.golang.org/genproto v0.0.0-20240722135656-d784300faade // indirect
	google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade // indirect
	google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade // indirect
	google.golang.org/grpc v1.64.1 // indirect
	google.golang.org/protobuf v1.34.2 // indirect
)


================================================
FILE: go.sum
================================================
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/auth v0.7.2 h1:uiha352VrCDMXg+yoBtaD0tUF4Kv9vrtrWPYXwutnDE=
cloud.google.com/go/auth v0.7.2/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs=
cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI=
cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.16.0 h1:YwmDHcyrxVRErWcgxunzEaZxtNbc8QoFYA/JOEwDPgc=
cloud.google.com/go/firestore v1.16.0/go.mod h1:+22v/7p+WNBSQwdSwP57vz47aZiY+HrDkrOsJNhk7rg=
cloud.google.com/go/iam v1.1.10 h1:ZSAr64oEhQSClwBL670MsJAW5/RLiC6kfw3Bqmd5ZDI=
cloud.google.com/go/iam v1.1.10/go.mod h1:iEgMq62sg8zx446GCaijmA2Miwg5o3UbO+nI47WHJps=
cloud.google.com/go/longrunning v0.5.9 h1:haH9pAuXdPAMqHvzX0zlWQigXT7B0+CL4/2nXXdBo5k=
cloud.google.com/go/longrunning v0.5.9/go.mod h1:HD+0l9/OOW0za6UWdKJtXoFAX/BGg/3Wj8p10NeWF7c=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.41.0 h1:RusiwatSu6lHeEXe3kglxakAmAbfV+rhtPqA6i8RBx0=
cloud.google.com/go/storage v1.41.0/go.mod h1:J1WCa/Z2FcgdEDuPUY8DxT5I+d9mFKsCepp5vR6Sq80=
codeberg.org/pennersr/redq v0.0.0-20240908181154-b13bb619b69d h1:ltVUgmkwSzpb/l/MvUOj2UgHRwMVtnxh6XKipVMglUo=
codeberg.org/pennersr/redq v0.0.0-20240908181154-b13bb619b69d/go.mod h1:hE2GlgcqKql1wcqp9wjN+2mCsECP8EQS/YbiCrWDxdQ=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/SherClockHolmes/webpush-go v1.2.0 h1:sGv0/ZWCvb1HUH+izLqrb2i68HuqD/0Y+AmGQfyqKJA=
github.com/SherClockHolmes/webpush-go v1.2.0/go.mod h1:w6X47YApe/B9wUz2Wh8xukxlyupaxSSEbu6yKJcHN2w=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ=
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
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.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
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.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/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.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/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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 v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/sideshow/apns2 v0.23.0 h1:lpkikaZ995GIcKk6AFsYzHyezCrsrfEDvUWcWkEGErY=
github.com/sideshow/apns2 v0.23.0/go.mod h1:7Fceu+sL0XscxrfLSkAoH6UtvKefq3Kq1n4W3ayQZqE=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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.1/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/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/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-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/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-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
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=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.189.0 h1:equMo30LypAkdkLMBqfeIqtyAnlyig1JSZArl4XPwdI=
google.golang.org/api v0.189.0/go.mod h1:FLWGJKb0hb+pU2j+rJqwbnsF+ym+fQs73rbJ+KAUgy8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20240722135656-d784300faade h1:lKFsS7wpngDgSCeFn7MoLy+wBDQZ1UQIJD4UNM1Qvkg=
google.golang.org/genproto v0.0.0-20240722135656-d784300faade/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY=
google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade h1:WxZOF2yayUHpHSbUE6NMzumUzBxYc3YGwo0YHnbzsJY=
google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade h1:oCRSWfwGXQsqlVdErcyTt4A93Y8fo0/9D4b1gnI++qo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
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.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
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/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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.2.5/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/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=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=


================================================
FILE: internal/queue/memory/message.go
================================================
package memory

type memoryQueuedMessage struct {
	msg     []byte
	pending bool
	idx     int
}

func (qm *memoryQueuedMessage) Message() []byte {
	return qm.msg
}


================================================
FILE: internal/queue/memory/queue.go
================================================
package memory

import (
	"codeberg.org/pennersr/shove/internal/queue"
	"context"
	"errors"
	"sync"
)

// MemoryQueueFactory ...
type MemoryQueueFactory struct{}

type memoryQueue struct {
	buf          []*memoryQueuedMessage
	lock         sync.Mutex
	cond         *sync.Cond
	shuttingDown bool
}

func (mq *memoryQueue) Queue(msg []byte) (err error) {
	mq.lock.Lock()
	qm := &memoryQueuedMessage{
		msg: msg,
		idx: -1,
	}
	for i := 0; i < len(mq.buf); i++ {
		if mq.buf[i] == nil {
			qm.idx = i
			mq.buf[i] = qm
			break
		}
	}
	if qm.idx < 0 {
		qm.idx = len(mq.buf)
		mq.buf = append(mq.buf, qm)
	}
	mq.lock.Unlock()
	mq.cond.Signal()
	return nil
}

func (mq *memoryQueue) Shutdown() (err error) {
	mq.lock.Lock()
	mq.shuttingDown = true
	mq.cond.Broadcast()
	mq.lock.Unlock()
	return
}

func (mq *memoryQueue) Remove(qm queue.QueuedMessage) (err error) {
	mq.lock.Lock()
	mqm := qm.(*memoryQueuedMessage)
	mq.buf[mqm.idx] = nil
	mq.lock.Unlock()
	return nil
}

func (mq *memoryQueue) Requeue(qm queue.QueuedMessage) (err error) {
	mq.lock.Lock()
	mqm := qm.(*memoryQueuedMessage)
	mqm.pending = false
	mq.lock.Unlock()
	mq.cond.Signal()
	return
}

func (mq *memoryQueue) getNextMessage() *memoryQueuedMessage {
	for i := 0; i < len(mq.buf); i++ {
		m := mq.buf[i]
		if m != nil && !m.pending {
			m.pending = true
			return m
		}
	}
	return nil
}

func (mq *memoryQueue) Get(ctx context.Context) (queue.QueuedMessage, error) {
	mq.cond.L.Lock()
	defer mq.cond.L.Unlock()
	for ctx.Err() == nil {
		if mq.shuttingDown {
			break
		}
		msg := mq.getNextMessage()
		if msg == nil {
			mq.cond.Wait()
			continue
		}
		return msg, nil
	}
	return nil, errors.New("queue shut down")
}

// NewQueue ...
func (mqf MemoryQueueFactory) NewQueue(id string) (q queue.Queue, err error) {
	mq := &memoryQueue{}
	mq.cond = sync.NewCond(&mq.lock)
	q = mq
	return
}


================================================
FILE: internal/queue/queue.go
================================================
package queue

import (
	"context"
)

// Queue ...
type Queue interface {
	Queue([]byte) error
	Get(ctx context.Context) (QueuedMessage, error)
	Remove(QueuedMessage) error
	Requeue(QueuedMessage) error
	Shutdown() error
}

// QueuedMessage ...
type QueuedMessage interface {
	Message() []byte
}

// QueueFactory ...
type QueueFactory interface {
	NewQueue(id string) (Queue, error)
}


================================================
FILE: internal/queue/redis/queue.go
================================================
package redis

import (
	"context"
	"time"

	"codeberg.org/pennersr/redq"
	"codeberg.org/pennersr/shove/internal/queue"
	"github.com/gomodule/redigo/redis"
)

type redisQueueFactory struct {
	pool *redis.Pool
}

type redisQueue struct {
	q *redq.RedQueue
}

// NewQueueFactory ...
func NewQueueFactory(url string) queue.QueueFactory {
	qf := &redisQueueFactory{
		pool: &redis.Pool{
			MaxIdle:     3,
			IdleTimeout: 240 * time.Second,
			Dial: func() (redis.Conn, error) {
				return redis.DialURL(url)
			},
		},
	}
	return qf
}

func (rq redisQueue) Queue(msg []byte) (err error) {
	return rq.q.Queue(msg)
}

func (rq redisQueue) Get(ctx context.Context) (qm queue.QueuedMessage, err error) {
	qm, err = rq.q.Get(ctx)
	return
}

func (rq redisQueue) Remove(qm queue.QueuedMessage) (err error) {
	return rq.q.Remove(qm.(redq.QueuedMessage))
}

func (rq redisQueue) Requeue(qm queue.QueuedMessage) (err error) {
	return rq.q.Requeue(qm.(redq.QueuedMessage))
}

func (rq redisQueue) Shutdown() (err error) {
	return rq.q.Close()
}

func (rqf *redisQueueFactory) NewQueue(id string) (q queue.Queue, err error) {
	waitingList := ListName(id)
	rq, err := redq.NewQueue(rqf.pool, waitingList)
	if err != nil {
		return
	}
	q = redisQueue{q: rq}
	return
}

// ListName returns the Redis list name used for queueing.
func ListName(serviceID string) string {
	return "shove:" + serviceID
}


================================================
FILE: internal/server/feedback.go
================================================
package server

import (
	"encoding/json"
	"golang.org/x/exp/slog"
	"net/http"
)

type tokenFeedback struct {
	Service     string `json:"service"`
	Token       string `json:"token"`
	Replacement string `json:"replacement_token,omitempty"`
	Reason      string `json:"reason"`
}

func (s *Server) handleFeedback(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" {
		http.Error(w, "Invalid request method.", 405)
		return
	}
	s.feedbackLock.Lock()
	j, err := json.Marshal(struct {
		Feedback []tokenFeedback `json:"feedback"`
	}{Feedback: s.feedback})
	if err != nil {
		s.feedbackLock.Unlock()
		http.Error(w, err.Error(), 500)
		return
	}
	s.feedback = make([]tokenFeedback, 0)
	s.feedbackLock.Unlock()
	w.Header().Set("Content-Type", "application/json")
	w.Write(j)
}

// TokenInvalid ...
func (s *Server) TokenInvalid(serviceID, token string) {
	s.feedbackLock.Lock()
	s.feedback = append(s.feedback, tokenFeedback{serviceID, token, "", "invalid"})
	s.feedbackLock.Unlock()
	slog.Info("Invalid token", "service", serviceID, "token", token)
}

// ReplaceToken ...
func (s *Server) ReplaceToken(serviceID, token, replacement string) {
	s.feedbackLock.Lock()
	s.feedback = append(s.feedback, tokenFeedback{serviceID, token, replacement, "replaced"})
	s.feedbackLock.Unlock()
	slog.Info("Token replaced", "service", serviceID)
}


================================================
FILE: internal/server/metrics.go
================================================
package server

import (
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promauto"
	"time"
)

var (
	pushSuccessCounter = promauto.NewCounterVec(prometheus.CounterOpts{
		Name: "shove_push_success_total",
		Help: "The total number of successful push notifications sent",
	}, []string{
		"service",
	})

	pushErrorCounter = promauto.NewCounterVec(prometheus.CounterOpts{
		Name: "shove_push_error_total",
		Help: "The total number of push notifications errored",
	}, []string{
		"service",
	})
)

// CountPush ...
func (s *Server) CountPush(serviceID string, success bool, duration time.Duration) {
	if success {
		pushSuccessCounter.WithLabelValues(serviceID).Inc()
	} else {
		pushErrorCounter.WithLabelValues(serviceID).Inc()
	}

}


================================================
FILE: internal/server/push.go
================================================
package server

import (
	"fmt"
	"io/ioutil"
	"net/http"
	"strings"
)

func (s *Server) handlePush(w http.ResponseWriter, r *http.Request) {
	service := strings.TrimPrefix(r.URL.Path, "/api/push/")
	wrk, ok := s.workers[service]
	if !ok {
		http.NotFound(w, r)
		return
	}
	if r.Method != "POST" {
		http.Error(w, "Invalid request method.", 405)
		return
	}

	body, err := ioutil.ReadAll(r.Body)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	err = wrk.push(body)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	w.WriteHeader(http.StatusAccepted)
	fmt.Fprintf(w, "OK")
}


================================================
FILE: internal/server/server.go
================================================
package server

import (
	"codeberg.org/pennersr/shove/internal/queue"
	"codeberg.org/pennersr/shove/internal/services"
	"context"
	"github.com/prometheus/client_golang/prometheus/promhttp"
	"golang.org/x/exp/slog"
	"net/http"
	"sync"
)

// Server ...
type Server struct {
	server       *http.Server
	shuttingDown bool
	queueFactory queue.QueueFactory
	workers      map[string]*worker
	feedbackLock sync.Mutex
	feedback     []tokenFeedback
}

// NewServer ...
func NewServer(addr string, qf queue.QueueFactory) (s *Server) {
	mux := http.NewServeMux()

	h := &http.Server{
		Addr:    addr,
		Handler: mux,
	}
	s = &Server{
		server:       h,
		queueFactory: qf,
		workers:      make(map[string]*worker),
		feedback:     make([]tokenFeedback, 0),
	}
	mux.HandleFunc("/api/push/", s.handlePush)
	mux.HandleFunc("/api/feedback", s.handleFeedback)
	mux.Handle("/metrics", promhttp.Handler())
	return s
}

// Serve ...
func (s *Server) Serve() (err error) {
	slog.Info("Shove server started")
	err = s.server.ListenAndServe()
	if s.shuttingDown {
		err = nil
	}
	return
}

// Shutdown ...
func (s *Server) Shutdown(ctx context.Context) (err error) {
	s.shuttingDown = true
	s.server.Shutdown(ctx)
	if err = s.server.Shutdown(ctx); err != nil {
		slog.Error("Shutting down Shove server", "error", err)
		return
	}
	slog.Info("Shove server stopped")
	for _, w := range s.workers {
		err = w.shutdown()
		if err != nil {
			return
		}
	}
	return
}

// AddService ...
func (s *Server) AddService(pp services.PushService, workers int, squash services.SquashConfig) (err error) {
	slog.Info("Initializing service", "service", pp)
	q, err := s.queueFactory.NewQueue(pp.ID())
	if err != nil {
		return
	}
	w, err := newWorker(pp, q)
	if err != nil {
		return
	}
	go w.serve(workers, squash, s)
	s.workers[pp.ID()] = w
	return
}


================================================
FILE: internal/server/worker.go
================================================
package server

import (
	"codeberg.org/pennersr/shove/internal/queue"
	"codeberg.org/pennersr/shove/internal/services"
	"context"
	"golang.org/x/exp/slog"
)

type worker struct {
	queue    queue.Queue
	service  services.PushService
	ctx      context.Context
	cancel   context.CancelFunc
	finished chan (bool)
}

func newWorker(pp services.PushService, queue queue.Queue) (w *worker, err error) {
	w = &worker{
		queue:    queue,
		service:  pp,
		finished: make(chan bool),
	}
	w.ctx, w.cancel = context.WithCancel(context.Background())
	return
}

func (w *worker) push(msg []byte) (err error) {
	if err = w.service.Validate(msg); err != nil {
		return
	}
	err = w.queue.Queue(msg)
	return
}

func (w *worker) serve(workers int, squash services.SquashConfig, fc services.FeedbackCollector) {
	pump := services.NewPump(workers, squash, w.service)
	err := pump.Serve(w.ctx, w.queue, fc)
	if err != nil {
		slog.Error("Serve failed", "error", err)
	}
	w.finished <- true
}

func (w *worker) shutdown() (err error) {
	if err = w.queue.Shutdown(); err != nil {
		return
	}
	w.cancel()
	<-w.finished
	return
}


================================================
FILE: internal/services/apns/apns.go
================================================
package apns

import (
	"codeberg.org/pennersr/shove/internal/services"
	"crypto/tls"
	"github.com/sideshow/apns2"
	"github.com/sideshow/apns2/certificate"
	"golang.org/x/exp/slog"
	"time"
)

// APNS ...
type APNS struct {
	production bool
	log        *slog.Logger
	cert       tls.Certificate
}

// NewAPNS ...
func NewAPNS(pemFile string, production bool, log *slog.Logger) (apns *APNS, err error) {
	cert, err := certificate.FromPemFile(pemFile, "")
	if err != nil {
		return
	}
	apns = &APNS{
		cert:       cert,
		production: production,
		log:        log,
	}
	return
}

func (apns *APNS) Logger() *slog.Logger {
	return apns.log
}

func (apns *APNS) NewClient() (pclient services.PumpClient, err error) {
	client := apns2.NewClient(apns.cert)
	if apns.production {
		client.Production()
	} else {
		client.Development()
	}
	pclient = client
	return
}

// ID ...
func (apns *APNS) ID() string {
	if apns.production {
		return "apns"
	}
	return "apns-sandbox"

}

// String ...
func (apns *APNS) String() string {
	if apns.production {
		return "APNS"
	}
	return "APNS-sandbox"
}

func (apns *APNS) SquashAndPushMessage(client services.PumpClient, smsgs []services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {
	panic("not implemented")
}

func (apns *APNS) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) (status services.PushStatus) {
	client := pclient.(*apns2.Client)
	notif := smsg.(apnsNotification)
	t := time.Now()
	resp, err := client.Push(notif.notification)
	duration := time.Now().Sub(t)
	sent := false
	if err != nil {
		apns.log.Error("Push message failed", "error", err)
		status = services.PushStatusTempFail
	} else {
		reason := resp.Reason
		if reason == "" {
			reason = "OK"
		}
		apns.log.Info("Pushed", "reason", reason, "duration", duration)
		sent = resp.Sent()
		if resp.Reason == apns2.ReasonBadDeviceToken || resp.Reason == apns2.ReasonUnregistered {
			fc.TokenInvalid(apns.ID(), notif.notification.DeviceToken)
		}
		retry := resp.StatusCode >= 500
		if sent {
			status = services.PushStatusSuccess
		} else if retry {
			status = services.PushStatusTempFail
		} else {
			status = services.PushStatusHardFail
		}
	}
	fc.CountPush(apns.ID(), sent, duration)
	return
}


================================================
FILE: internal/services/apns/message.go
================================================
package apns

import (
	"codeberg.org/pennersr/shove/internal/services"
	"encoding/json"
	"errors"
	"github.com/sideshow/apns2"
	"time"
)

type apnsMessage struct {
	Token   string                     `json:"token"`
	Headers map[string]json.RawMessage `json:"headers,omitempty"`
	Payload json.RawMessage            `json:"payload,omitempty"`
}

type apnsNotification struct {
	notification *apns2.Notification
}

func (notif apnsNotification) GetSquashKey() string {
	panic("not implemented")
}

func (apns *APNS) ConvertMessage(data []byte) (smsg services.ServiceMessage, err error) {
	var msg apnsMessage
	if err = json.Unmarshal(data, &msg); err != nil {
		return
	}
	if msg.Token == "" {
		err = errors.New("token required")
		return
	}

	notif := new(apns2.Notification)
	notif.DeviceToken = msg.Token
	topic, ok := msg.Headers["apns-topic"]
	if !ok {
		err = errors.New("APNS requires a topic")
		return
	}
	err = json.Unmarshal(topic, &notif.Topic)
	if err != nil {
		return
	}
	prio, ok := msg.Headers["apns-priority"]
	if ok {
		err = json.Unmarshal(prio, &notif.Priority)
		if err != nil {
			return
		}
	}
	collapse, ok := msg.Headers["apns-collapse-id"]
	if ok {
		err = json.Unmarshal(collapse, &notif.CollapseID)
		if err != nil {
			return
		}
	}
	exp, ok := msg.Headers["apns-expiration"]
	if ok {
		var epoch int64
		err = json.Unmarshal(exp, &epoch)
		if err != nil {
			return
		}
		notif.Expiration = time.Unix(epoch, 0)
	}
	notif.Payload = msg.Payload
	smsg = apnsNotification{notification: notif}
	return
}

// Validate ...
func (apns *APNS) Validate(data []byte) (err error) {
	_, err = apns.ConvertMessage(data)
	return
}


================================================
FILE: internal/services/email/encode.go
================================================
package email

import (
	"bytes"
	"crypto/rand"
	"errors"
	"fmt"
	"io"
	"mime"
	"mime/multipart"
	"net/mail"
	"net/textproto"

	jwemail "github.com/jordan-wright/email"
)

func encodeSMTPAddress(s string) (string, error) {
	addr, err := mail.ParseAddress(s)
	if err != nil {
		return "", err
	}
	return addr.Address, nil
}

func encodeSMTPAddresses(from string, to []string) (encFrom string, encTo []string, err error) {
	encFrom, err = encodeSMTPAddress(from)
	if err != nil {
		return
	}
	encTo = make([]string, 0, len(to))
	for _, t := range to {
		t, err = encodeSMTPAddress(t)
		if err != nil {
			return
		}
		encTo = append(encTo, t)
	}
	return
}

func encodeAddress(s string) (string, error) {
	addr, err := mail.ParseAddress(s)
	if err != nil {
		return "", err
	}
	return addr.String(), nil
}

func encodeRFC2047(s string) string {
	return mime.QEncoding.Encode("UTF-8", s)
}

func encodeEmail(em email) ([]byte, error) {
	e := jwemail.NewEmail()
	e.From = em.From
	e.To = em.To
	e.Subject = em.Subject
	e.Text = []byte(em.Text)
	e.HTML = []byte(em.HTML)
	for _, atm := range em.Attachments {
		e.Attachments = append(e.Attachments, &jwemail.Attachment{
			Filename:    atm.Filename,
			Content:     atm.Content,
			ContentType: atm.ContentType,
			Header:      textproto.MIMEHeader{},
		})
	}
	return e.Bytes()
}

func encodeEmailDigest(emails []email) ([]byte, error) {
	if len(emails) == 0 {
		return nil, errors.New("no emails specified")
	}
	if len(emails) == 1 {
		return encodeEmail(emails[0])
	}
	mixedContent := &bytes.Buffer{}
	mixedWriter := multipart.NewWriter(mixedContent)

	digWriter, err := nestedMultipart(mixedWriter, "multipart/digest")
	if err != nil {
		return nil, err
	}

	// Actual content alternatives (finally!)
	for i, em := range emails {
		filename := fmt.Sprintf("message-%d.eml", i+1)
		s, _ := encodeEmail(em)
		var childContent io.Writer
		childContent, _ = digWriter.CreatePart(textproto.MIMEHeader{
			"Content-Type":        {fmt.Sprintf("message/rfc822; name=\"%s\"", filename)},
			"Content-Disposition": {"inline; filename=" + filename},
		})
		childContent.Write([]byte(s))
	}
	digWriter.Close()
	mixedWriter.Close()

	headers := make(map[string]string)
	headers["To"] = emails[0].To[0]
	headers["From"] = emails[0].From
	subject := emails[0].Digest.Subject
	if subject == "" {
		subject = emails[0].Subject
	}
	headers["Subject"] = subject
	headers["Content-Type"] = "multipart/mixed; boundary=" + mixedWriter.Boundary()
	headers["MIME-Version"] = "1.0"

	var out bytes.Buffer
	err = encodeHeaders(&out, headers)
	if err != nil {
		return nil, err
	}
	out.WriteString(mixedContent.String())
	return out.Bytes(), nil

}

func encodeHeaders(out *bytes.Buffer, headers map[string]string) error {
	for k, v := range headers {
		out.WriteString(k)
		out.WriteString(": ")
		if k == "To" || k == "From" {
			var err error
			v, err = encodeAddress(v)
			if err != nil {
				return err
			}

		} else if k == "Subject" {
			v = encodeRFC2047(v)
		}
		out.WriteString(v)
		out.WriteString("\r\n")
	}
	out.WriteString("\r\n")
	return nil
}

func nestedMultipart(enclosingWriter *multipart.Writer, contentType string) (nestedWriter *multipart.Writer, err error) {
	boundary, err := randomBoundary()
	if err != nil {
		return
	}
	contentWithBoundary := contentType + "; boundary=\"" + boundary + "\""
	contentBuffer, err := enclosingWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {contentWithBoundary}})
	if err != nil {
		return
	}

	nestedWriter = multipart.NewWriter(contentBuffer)
	nestedWriter.SetBoundary(boundary)
	return
}

func randomBoundary() (string, error) {
	var buf [30]byte
	_, err := io.ReadFull(rand.Reader, buf[:])
	if err != nil {
		return "", err
	}
	return fmt.Sprintf("%x", buf[:]), nil
}


================================================
FILE: internal/services/email/encode_test.go
================================================
package email

import (
	"testing"
)

var png1Pixel = []byte{
	0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
	0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x37, 0x6e, 0xf9,
	0x24, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x60, 0x01, 0x00, 0x00,
	0x00, 0xff, 0xff, 0x03, 0x00, 0x00, 0x06, 0x00, 0x05, 0x57, 0xbf, 0xab, 0xd4, 0x00, 0x00, 0x00,
	0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
}

func TestEncodeEmailDigest(t *testing.T) {
	emails := []email{
		{
			Subject: "Hello Jane!",
			From:    "john@doe.org",
			To:      []string{"jane@doe.org"},
			Text:    "Hello Jane!\n\nBye\n",
			HTML:    "<p>Hello <b>Jane!</b></p>",
			Attachments: []attachment{
				{
					Filename:    "px1.png",
					ContentType: "image/png",
					Content:     png1Pixel,
				},
			},
		},
		{
			Subject: "One last thing...!",
			From:    "john@doe.org",
			To:      []string{"jane@doe.org"},
			Text:    "Don't forget the milk...",
			HTML:    "<p>Don't forget the <b>milk</b></p>",
			Attachments: []attachment{
				{
					Filename:    "px1.png",
					ContentType: "image/png",
					Content:     png1Pixel,
				},
			},
		},
	}

	out, err := encodeEmailDigest(emails)
	if err != nil {
		t.Fatal(err)
	}
	t.Log(string(out))
}

func TestEncodeEmail(t *testing.T) {
	e := email{

		Subject: "Hello!",
		From:    "john@doe.org",
		To:      []string{"jane@doe.org"},
		Text:    "Hello\n\nBye\n",
		HTML:    "<p>Hello <b>world</b></p>",
		Attachments: []attachment{
			{
				Filename:    "px1.png",
				ContentType: "image/png",
				Content:     png1Pixel,
			},
		},
	}
	out, err := encodeEmail(e)
	if err != nil {
		t.Fatal(err)
	}
	t.Log(string(out))
}

func TestEncodeSMTPAddresses(t *testing.T) {
	from, to, err := encodeSMTPAddresses("John <john@doe.org>", []string{"jane@doe.org", "Nobody <noreply@mail.org>"})
	if err != nil {
		t.Fatal(err)
	}
	if from != "john@doe.org" {
		t.Fatal(from)
	}
	if to[0] != "jane@doe.org" {
		t.Fatal(to[0])
	}
	if to[1] != "noreply@mail.org" {
		t.Fatal(to[0])
	}
}

func TestEncodeSMTPAddressesErrors(t *testing.T) {
	_, _, err := encodeSMTPAddresses("John <john@doe.org", []string{"jane@doe.org", "Nobody <noreply@mail.org>"})
	if err == nil {
		t.Fail()
	}
	_, _, err = encodeSMTPAddresses("John <john@doe.org>", []string{"<jane@doe.org", "Nobody <noreply@mail.org>"})
	if err == nil {
		t.Fail()
	}
}


================================================
FILE: internal/services/email/message.go
================================================
package email

import (
	"codeberg.org/pennersr/shove/internal/services"
	"encoding/json"
	"errors"
)

type attachment struct {
	Filename    string `json:"filename"`
	ContentType string `json:"content-type"`
	Content     []byte `json:"content"`
}
type email struct {
	Subject     string       `json:"subject"`
	To          []string     `json:"to"`
	From        string       `json:"from"`
	Text        string       `json:"text"`
	HTML        string       `json:"html"`
	Attachments []attachment `json:"attachments"`
	Digest      struct {
		Subject string `json:"subject"`
	} `json:"digest"`
}

func (em email) GetSquashKey() string {
	return em.To[0]
}

func (es *EmailService) ConvertMessage(data []byte) (services.ServiceMessage, error) {
	var em email
	if err := json.Unmarshal(data, &em); err != nil {
		return em, err
	}
	if len(em.To) == 0 {
		return em, errors.New("missing: `to`")
	}
	if len(em.To) != 1 {
		return em, errors.New("only one `to` is supported")
	}
	if len(em.From) == 0 {
		return em, errors.New("missing: `from`")
	}
	if len(em.Subject) == 0 {
		return em, errors.New("missing: `subject`")
	}
	return em, nil
}

func (es *EmailService) Validate(data []byte) error {
	_, err := es.ConvertMessage(data)
	return err
}


================================================
FILE: internal/services/email/send.go
================================================
package email

import (
	"crypto/tls"
	"fmt"
	"net/smtp"
	"time"

	"codeberg.org/pennersr/shove/internal/services"
)

func (ec EmailConfig) send(from string, to []string, body []byte, fc services.FeedbackCollector) error {
	t := time.Now()
	addr := fmt.Sprintf("%s:%d", ec.EmailHost, ec.EmailPort)
	var auth smtp.Auth

	// Only use it with TLS because net/smtp throws an error when tls is not enabled when using plain auth
	if ec.PlainAuth && ec.TLS {
		auth = smtp.PlainAuth("", ec.EmailUsername, ec.EmailPassword, ec.EmailHost)
	}

	var err error
	from, to, err = encodeSMTPAddresses(from, to)
	if err == nil {
		if !ec.TLS {
			err = smtp.SendMail(addr, auth, from, to, body)
		} else {
			err = ec.sendMailTLS(addr, auth, from, to, body)
		}
	}
	duration := time.Since(t)
	fc.CountPush(serviceID, err == nil, duration)

	if err != nil {
		ec.Log.Error("Send failed", "error", err)
		return err
	}
	return nil
}

func (ec EmailConfig) sendMailTLS(addr string, auth smtp.Auth, from string, to []string, body []byte) error {
	var t *tls.Config
	if ec.TLSInsecure {
		t = &tls.Config{InsecureSkipVerify: true}
	}
	c, err := smtp.Dial(addr)
	if err != nil {
		return err
	}
	defer c.Close()
	if err = c.Hello("localhost"); err != nil {
		return err
	}
	// Use TLS if available
	if ok, _ := c.Extension("STARTTLS"); ok {
		if err = c.StartTLS(t); err != nil {
			return err
		}
	}

	if auth != nil {
		if ok, _ := c.Extension("AUTH"); ok {
			if err = c.Auth(auth); err != nil {
				return err
			}
		}
	}
	if err = c.Mail(from); err != nil {
		return err
	}
	for _, addr := range to {
		if err = c.Rcpt(addr); err != nil {
			return err
		}
	}
	w, err := c.Data()
	if err != nil {
		return err
	}
	_, err = w.Write(body)
	if err != nil {
		return err
	}
	err = w.Close()
	if err != nil {
		return err
	}
	return c.Quit()
}


================================================
FILE: internal/services/email/service.go
================================================
package email

import (
	"golang.org/x/exp/slog"

	"codeberg.org/pennersr/shove/internal/services"
)

const serviceID = "email"

type EmailConfig struct {
	EmailHost     string
	EmailPort     int
	Log           *slog.Logger
	TLS           bool
	TLSInsecure   bool
	PlainAuth     bool
	EmailUsername string
	EmailPassword string
}

type EmailService struct {
	config EmailConfig
}

func NewEmailService(config EmailConfig) (es *EmailService, err error) {
	es = &EmailService{
		config: config,
	}
	return
}

func (es *EmailService) Logger() *slog.Logger {
	return es.config.Log
}

func (es *EmailService) ID() string {
	return serviceID
}

func (es *EmailService) String() string {
	return "Email"
}

func (es *EmailService) NewClient() (services.PumpClient, error) {
	return nil, nil
}

func (es *EmailService) SquashAndPushMessage(client services.PumpClient, smsgs []services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {
	emails := make([]email, len(smsgs))
	for i, smsg := range smsgs {
		emails[i] = smsg.(email)
	}
	body, err := encodeEmailDigest(emails)
	if err != nil {
		return services.PushStatusHardFail
	}
	return es.push(emails[0].From, emails[0].To, body, fc)
}

func (es *EmailService) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) (status services.PushStatus) {
	email := smsg.(email)
	es.config.Log.Info("Sending email")
	body, err := encodeEmail(email)
	if err != nil {
		return services.PushStatusHardFail
	}
	return es.push(email.From, email.To, body, fc)
}
func (es *EmailService) push(from string, to []string, body []byte, fc services.FeedbackCollector) services.PushStatus {
	err := es.config.send(from, to, body, fc)
	if err != nil {
		es.config.Log.Error("Failed to send email", "error", err)
		return services.PushStatusHardFail // TODO: smtp down is not a hard failure
	}
	return services.PushStatusSuccess
}


================================================
FILE: internal/services/fcm/fcm.go
================================================
package fcm

import (
	"codeberg.org/pennersr/shove/internal/services"
	"context"
	firebase "firebase.google.com/go"
	"firebase.google.com/go/messaging"
	"golang.org/x/exp/slog"
	"google.golang.org/api/option"
	"strings"
	"time"
)

// FCM ...
type FCM struct {
	credentialsFile string
	log             *slog.Logger
}

// NewFCM ...
func NewFCM(credentialsFile string, log *slog.Logger) (fcm *FCM, err error) {
	fcm = &FCM{
		credentialsFile: credentialsFile,
		log:             log,
	}
	return
}

func (fcm *FCM) Logger() *slog.Logger {
	return fcm.log
}

// ID ...
func (fcm *FCM) ID() string {
	return "fcm"
}

// String ...
func (fcm *FCM) String() string {
	return "FCM"
}

func (fcm *FCM) NewClient() (services.PumpClient, error) {
	opt := option.WithCredentialsFile(fcm.credentialsFile)
	ctx := context.Background()
	app, err := firebase.NewApp(ctx, nil, opt)
	if err != nil {
		return nil, err
	}
	client, err := app.Messaging(ctx)
	if err != nil {
		return nil, err
	}
	return client, nil
}

func (fcm *FCM) SquashAndPushMessage(services.PumpClient, []services.ServiceMessage, services.FeedbackCollector) services.PushStatus {
	panic("not implemented")
}

func (fcm *FCM) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {
	msg := smsg.(fcmMessage)
	startedAt := time.Now()
	var success bool

	client := pclient.(*messaging.Client)
	_, err := client.Send(context.Background(), msg.Message)
	duration := time.Now().Sub(startedAt)
	defer func() {
		fc.CountPush(fcm.ID(), success, duration)
	}()
	fcm.log.Info("Pushed", "duration", duration)
	if err != nil {
		// TODO: Isn't there a better way?
		if strings.Contains(err.Error(), "registration-token-not-registered") {
			fc.TokenInvalid(fcm.ID(), msg.Message.Token)
		} else {
			fcm.log.Error("Posting failed", "error", err)
		}
		return services.PushStatusHardFail
	}
	success = true
	return services.PushStatusSuccess
}


================================================
FILE: internal/services/fcm/message.go
================================================
package fcm

import (
	"codeberg.org/pennersr/shove/internal/services"
	"encoding/json"
	"errors"
	"firebase.google.com/go/messaging"
)

type fcmMessage struct {
	Message *messaging.Message `json:"message"`
}

func (fcmMessage) GetSquashKey() string {
	panic("not implemented")
}

func (fcm *FCM) ConvertMessage(data []byte) (smsg services.ServiceMessage, err error) {
	var msg fcmMessage
	if err := json.Unmarshal(data, &msg); err != nil {
		return nil, err
	}
	if msg.Message == nil {
		return nil, errors.New("message key missing")
	}
	if msg.Message.Token == "" {
		return nil, errors.New("no token specified")
	}
	return msg, nil
}

// Validate ...
func (fcm *FCM) Validate(data []byte) error {
	_, err := fcm.ConvertMessage(data)
	return err
}


================================================
FILE: internal/services/pump.go
================================================
package services

import (
	"context"
	"golang.org/x/exp/slog"
	"math"
	"sync"
	"time"

	"codeberg.org/pennersr/shove/internal/queue"
)

type Pump struct {
	wg       sync.WaitGroup
	adapter  PumpAdapter
	workers  int
	squasher *squasher
}

type ServiceMessage interface {
	GetSquashKey() string
}

type PushStatus int

const (
	// PushStatusSuccess ...
	PushStatusSuccess PushStatus = iota
	// PushStatusTempFail signals a failure that may be resolved by retrying
	PushStatusTempFail
	// PushStatusHardFail signals a failure for which a retry would not hekp
	PushStatusHardFail
)

type PumpClient interface {
}

type PumpAdapter interface {
	ConvertMessage([]byte) (ServiceMessage, error)
	NewClient() (PumpClient, error)
	PushMessage(client PumpClient, smsg ServiceMessage, fc FeedbackCollector) PushStatus
	SquashAndPushMessage(client PumpClient, smsgs []ServiceMessage, fc FeedbackCollector) PushStatus
	Logger() *slog.Logger
}

// NewPump
func NewPump(workers int, squash SquashConfig, adapter PumpAdapter) (p *Pump) {
	p = &Pump{
		workers: workers,
		adapter: adapter,
	}
	if squash.RateMax > 0 {
		p.squasher = newSquasher(squash, adapter)
	}
	return p
}

func (p *Pump) push(q queue.Queue, qm queue.QueuedMessage, client PumpClient, smsg ServiceMessage, fc FeedbackCollector) (status PushStatus, squashed bool) {
	if p.squasher != nil {
		squashed = p.squasher.prepareToPush(q, qm, client, smsg)
		if squashed {
			return
		}
	}
	status = p.adapter.PushMessage(client, smsg, fc)
	return
}

func (p *Pump) serveClient(ctx context.Context, q queue.Queue, client PumpClient, fc FeedbackCollector) {
	defer func() {
		p.wg.Done()
	}()
	failureCount := 0
	log := p.adapter.Logger()
	for ctx.Err() == nil {
		qm, err := q.Get(ctx)
		if err != nil {
			slog.Error("Unable to read from queue", "error", err)
			return
		}
		msg := qm.Message()
		smsg, err := p.adapter.ConvertMessage(msg)
		if err != nil {
			slog.Error("Bad message", "error", err)
			removeFromQueue(q, qm, log)
			continue
		}
		status, squashed := p.push(q, qm, client, smsg, fc)
		if squashed {
			// Message should remain in pending queue
			continue
		}
		if status == PushStatusSuccess || status == PushStatusHardFail {
			removeFromQueue(q, qm, log)
		} else {
			if err = q.Requeue(qm); err != nil {
				slog.Error("Unable to requeue", "error", err)
			}
		}
		if status == PushStatusTempFail {
			p.backoff(ctx, failureCount)
			failureCount++

		} else {
			failureCount = 0
		}
	}
}

func removeFromQueue(q queue.Queue, qm queue.QueuedMessage, log *slog.Logger) {
	if err := q.Remove(qm); err != nil {
		slog.Error("Unable to remove from the queue", "error", err)
	}
}

func (p *Pump) backoff(ctx context.Context, failureCount int) {
	sleep := time.Duration(float64(time.Second) * math.Min(30, math.Pow(2., float64(failureCount))))
	p.adapter.Logger().Info("Backing off", "duration", sleep)
	ctx, cancel := context.WithTimeout(ctx, sleep)
	defer cancel()
	<-ctx.Done()
}

func (p *Pump) Serve(ctx context.Context, q queue.Queue, fc FeedbackCollector) (err error) {
	log := p.adapter.Logger()
	if p.squasher != nil {
		p.wg.Add(1)
		go func() {
			log.Info("Squasher started")
			p.squasher.serve(fc)
			log.Info("Squasher stopped")
			p.wg.Add(-1)
		}()
	}
	clients := make([]PumpClient, p.workers)
	for i := 0; i < p.workers; i++ {
		clients[i], err = p.adapter.NewClient()
		if err != nil {
			return
		}
	}

	for i := 0; i < p.workers; i++ {
		go func(client PumpClient) {
			p.serveClient(ctx, q, client, fc)
			if p.squasher != nil {
				p.squasher.requestShutdown()
			}
		}(clients[i])
		p.wg.Add(1)
	}
	slog.Info("Workers started", "worker_count", p.workers)
	p.wg.Wait()
	slog.Info("Workers stopped")

	return
}


================================================
FILE: internal/services/services.go
================================================
package services

import (
	"fmt"
	"time"
)

// FeedbackCollector ...
type FeedbackCollector interface {
	TokenInvalid(serviceID, token string)
	ReplaceToken(serviceID, token, replacement string)
	CountPush(serviceID string, success bool, duration time.Duration)
}

// PushService ...
type PushService interface {
	PumpAdapter
	fmt.Stringer
	ID() string
	Validate([]byte) error
}


================================================
FILE: internal/services/squasher.go
================================================
package services

import (
	"sync"
	"time"

	"codeberg.org/pennersr/shove/internal/queue"
)

type batch struct {
	key         string
	serviceMsgs []ServiceMessage
	due         time.Time
	queuedMsgs  []queue.QueuedMessage
	q           queue.Queue
	client      PumpClient
}

type SquashConfig struct {
	RateMax int
	RatePer time.Duration
}

type squasher struct {
	pushedAt     map[string][]time.Time
	batches      map[string]batch
	config       SquashConfig
	cond         *sync.Cond
	lock         sync.Mutex
	shuttingDown bool
	adapter      PumpAdapter
}

func newSquasher(config SquashConfig, adapter PumpAdapter) (d *squasher) {
	d = new(squasher)
	d.adapter = adapter
	d.config = config
	d.pushedAt = make(map[string][]time.Time)
	d.batches = make(map[string]batch)
	d.cond = sync.NewCond(&d.lock)
	return d
}

func (d *squasher) flushAndGetRate(key string) (sendCount int, sentAt time.Time) {
	var flushedTimes []time.Time
	times := d.pushedAt[key]
	var didFlush = false
	for _, t := range times {
		if time.Since(t) > d.config.RatePer {
			didFlush = true
			continue
		}
		flushedTimes = append(flushedTimes, t)
		sendCount++
	}
	if didFlush {
		d.pushedAt[key] = flushedTimes
	}
	if len(flushedTimes) > 0 {
		sentAt = flushedTimes[0]
	}
	return
}

func (d *squasher) recordPush(key string) {
	times := d.pushedAt[key]
	times = append(times, time.Now())
	d.pushedAt[key] = times
}

func (d *squasher) prepareToPush(q queue.Queue, qm queue.QueuedMessage, client PumpClient, smsg ServiceMessage) (squashed bool) {
	d.cond.L.Lock()
	defer d.cond.L.Unlock()

	key := smsg.GetSquashKey()
	sendCount, firstSendAt := d.flushAndGetRate(key)
	if sendCount < d.config.RateMax {
		d.recordPush(key)
		return false
	}
	d.adapter.Logger().Info("Rate exceeded, squashed", "destination", key)

	batch, ok := d.batches[key]
	if ok {
		if batch.q != q {
			panic("squasher cannot handle mixed queues")
		}
	} else {
		batch.q = q
		batch.client = client
	}
	batch.key = key
	batch.serviceMsgs = append(batch.serviceMsgs, smsg)
	batch.queuedMsgs = append(batch.queuedMsgs, qm)
	batch.due = firstSendAt.Add(d.config.RatePer)
	d.batches[key] = batch
	d.cond.Signal()
	return true
}

func (d *squasher) getNextBatch() (b batch, stopped bool) {
	for {
		d.cond.L.Lock()
		if len(d.batches) == 0 {
			d.cond.Wait()
		}
		if d.shuttingDown {
			d.cond.L.Unlock()
			stopped = true
			return
		}
		var minDueBatch batch
		var minDueBatchKey string
		for key, batch := range d.batches {
			if minDueBatch.due.IsZero() || minDueBatch.due.After(batch.due) {
				minDueBatch = batch
				minDueBatchKey = key
			}
		}
		now := time.Now()
		if now.After(minDueBatch.due) {
			delete(d.batches, minDueBatchKey)
			d.cond.L.Unlock()
			return minDueBatch, false
		}
		d.cond.L.Unlock()

		zzz := minDueBatch.due.Sub(now)
		maxZzz := time.Millisecond * 500
		if zzz > maxZzz {
			zzz = maxZzz
		}
		time.Sleep(zzz)
	}
}

func (d *squasher) requestShutdown() {
	d.cond.L.Lock()
	d.shuttingDown = true
	d.cond.Signal()
	d.cond.L.Unlock()
}

func (d *squasher) shutdown() {
	d.cond.L.Lock()
	defer d.cond.L.Unlock()

	d.adapter.Logger().Info("Shutting down squasher", "unsent_batch_count", len(d.batches))
}

func (d *squasher) serve(fc FeedbackCollector) {
	for {
		batch, stopped := d.getNextBatch()
		if stopped {
			d.shutdown()
			return
		}
		d.sendBatch(batch, fc)

	}
}

func (d *squasher) sendBatch(b batch, fc FeedbackCollector) {
	d.adapter.Logger().Info("Sending batch", "batch_size", len(b.serviceMsgs))
	d.cond.L.Lock()
	d.recordPush(b.key)
	d.cond.L.Unlock()

	status := d.adapter.SquashAndPushMessage(b.client, b.serviceMsgs, fc)
	switch status {
	case PushStatusTempFail:
		// TODO: We should actually attempt to retry this with a backoff
		fallthrough
	case PushStatusHardFail:
		d.adapter.Logger().Error("Failed to send batch")
		fallthrough
	case PushStatusSuccess:
		for _, qm := range b.queuedMsgs {
			removeFromQueue(b.q, qm, d.adapter.Logger())
		}
	}
}


================================================
FILE: internal/services/telegram/message.go
================================================
package telegram

import (
	"encoding/json"
	"errors"
	"fmt"
	"strings"

	"codeberg.org/pennersr/shove/internal/services"
)

type telegramMessage struct {
	Method string `json:"method"`
	// This is intentionally kept as a raw message so that this can be fed 1:1
	// to the API.
	Payload       json.RawMessage `json:"payload"`
	parsedPayload telegramPayload
}

type telegramPayload struct {
	ChatID  string `json:"chat_id"`
	Text    string `json:"text,omitempty"`
	Caption string `json:"caption,omitempty"`
	Photo   string `json:"photo,omitempty"`
}

func (msg telegramMessage) GetSquashKey() string {
	// TODO: This should include method (`sendMessage`)
	return msg.parsedPayload.ChatID
}

func (tg *TelegramService) ConvertMessage(data []byte) (services.ServiceMessage, error) {
	var msg telegramMessage
	if err := json.Unmarshal(data, &msg); err != nil {
		return nil, err
	}
	if !strings.HasPrefix(msg.Method, "send") {
		return nil, fmt.Errorf("invalid method: %s", msg.Method)
	}
	// Telegram documents the chat_id as: "Integer or String", we're assuming string.
	if err := json.Unmarshal(msg.Payload, &msg.parsedPayload); err != nil {
		return nil, err
	}
	if msg.parsedPayload.ChatID == "" {
		return nil, errors.New("missing `chat_id`")
	}
	return msg, nil
}

// Validate ...
func (tg *TelegramService) Validate(data []byte) error {
	_, err := tg.ConvertMessage(data)
	return err
}

func concatText(builder *strings.Builder, text string) {
	text = strings.TrimSpace(text)
	if len(text) == 0 {
		return
	}
	texts := builder.String()
	if len(texts) == 0 || strings.HasSuffix(texts, "\n\n") {
		// No newlines needed
	} else if strings.HasSuffix(texts, "\n") {
		builder.WriteString("\n")
	} else {
		builder.WriteString("\n\n")
	}
	builder.WriteString(text)
}

func trimString(input string, maxLength int) string {
	if len(input) <= maxLength {
		return input
	}
	trimLength := maxLength - 3
	if trimLength < 0 {
		trimLength = 0
	}
	return input[:trimLength] + "..."
}

func squashMessages(msgs []telegramMessage) (dmsg telegramMessage, err error) {
	if len(msgs) == 0 {
		err = errors.New("need at least one message to digest")
		return
	}
	dmsg = msgs[0]
	var texts strings.Builder
	var captions strings.Builder
	for _, msg := range msgs {
		if msg.Method != dmsg.Method {
			err = errors.New("cannot digest mix of methods")
			return
		}
		if msg.parsedPayload.ChatID != dmsg.parsedPayload.ChatID {
			err = errors.New("different `chat_id` seen while digesting")
			return
		}
		concatText(&texts, msg.parsedPayload.Text)
		concatText(&captions, msg.parsedPayload.Caption)
	}
	dmsg.parsedPayload.Text = trimString(texts.String(), 4095)
	dmsg.parsedPayload.Caption = trimString(captions.String(), 1023)
	dmsg.Payload, err = json.Marshal(&dmsg.parsedPayload)
	return
}


================================================
FILE: internal/services/telegram/telegram.go
================================================
package telegram

import (
	"bytes"
	"encoding/json"
	"fmt"
	"golang.org/x/exp/slog"
	"net/http"
	"strings"
	"time"

	"codeberg.org/pennersr/shove/internal/services"
)

// TelegramService ...
type TelegramService struct {
	botToken string
	log      *slog.Logger
}

// NewTelegramService ...
func NewTelegramService(botToken string, log *slog.Logger) (tg *TelegramService, err error) {
	tg = &TelegramService{
		botToken: botToken,
		log:      log,
	}
	return
}

func (tg *TelegramService) Logger() *slog.Logger {
	return tg.log
}

// ID ...
func (tg *TelegramService) ID() string {
	return "telegram"

}

// String ...
func (tg *TelegramService) String() string {
	return "Telegram"
}

func (tg *TelegramService) NewClient() (services.PumpClient, error) {
	client := &http.Client{
		Timeout: time.Duration(15 * time.Second),
	}
	return client, nil
}

func (tg *TelegramService) SquashAndPushMessage(pclient services.PumpClient, smsgs []services.ServiceMessage, fc services.FeedbackCollector) (status services.PushStatus) {
	client := pclient.(*http.Client)
	msgs := make([]telegramMessage, len(smsgs))
	for i, smsg := range smsgs {
		msgs[i] = smsg.(telegramMessage)
	}
	dmsg, err := squashMessages(msgs)
	if err != nil {
		tg.log.Error("Squashing failed", "error", err)
		return services.PushStatusHardFail
	}
	return tg.pushMessage(client, dmsg.Method, dmsg.parsedPayload.ChatID, dmsg.Payload, fc)
}

func (tg *TelegramService) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) (status services.PushStatus) {
	client := pclient.(*http.Client)
	msg := smsg.(telegramMessage)
	return tg.pushMessage(client, msg.Method, msg.parsedPayload.ChatID, msg.Payload, fc)
}

func (tg *TelegramService) pushMessage(client *http.Client, method string, chatID string, payload json.RawMessage, fc services.FeedbackCollector) (status services.PushStatus) {
	startedAt := time.Now()
	var success bool

	url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", tg.botToken, method)
	req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload))
	if err != nil {
		tg.log.Error("Failure creating request", "error", err)
		return services.PushStatusHardFail
	}
	req.Header.Set("Content-Type", "application/json")
	resp, err := client.Do(req)
	if err != nil {
		tg.log.Error("Posting failed", "error", err)
		return services.PushStatusTempFail
	}
	duration := time.Now().Sub(startedAt)

	defer func() {
		fc.CountPush(tg.ID(), success, duration)
	}()

	defer resp.Body.Close()

	if resp.StatusCode == 429 {
		tg.log.Error("Throttled, too many requests", "status", 429)
		return services.PushStatusTempFail
	}

	var respData struct {
		OK          bool   `json:"ok"`
		ErrorCode   int    `json:"error_code"`
		Description string `json:"description"`
	}

	err = json.NewDecoder(resp.Body).Decode(&respData)
	if err != nil {
		tg.log.Error("Unable to decode response", "error", err)
		return services.PushStatusTempFail
	}

	// It's a bit odd that an invalid chat ID results in a 400 instead of a
	// special response code {"ok":false,"error_code":400,"description":"Bad
	// Request: chat not found"}
	if respData.ErrorCode == 400 && strings.Contains(respData.Description, "chat not found") {
		fc.TokenInvalid(tg.ID(), chatID)
	}
	if resp.StatusCode >= 400 && resp.StatusCode < 500 {
		tg.log.Error("Rejected", "description", respData.Description, "error_code", respData.ErrorCode, "status", resp.StatusCode)
		return services.PushStatusHardFail
	}
	if resp.StatusCode >= 500 && resp.StatusCode < 600 {
		tg.log.Error("Upstream failure", "status", resp.StatusCode)
		return services.PushStatusTempFail
	}
	tg.log.Info("Pushed", "duration", duration)
	return services.PushStatusSuccess
}


================================================
FILE: internal/services/webhook/message.go
================================================
package webhook

import (
	"encoding/json"
	"errors"
	"net/url"

	"codeberg.org/pennersr/shove/internal/services"
)

type webhookMessage struct {
	URL      string            `json:"url"`
	Headers  map[string]string `json:"headers"`
	Body     string            `json:"body"`
	Data     json.RawMessage   `json:"data"`
	postData []byte
	rawData  []byte
}

func (webhookMessage) GetSquashKey() string {
	panic("not implemented")
}

func (wh *Webhook) ConvertMessage(data []byte) (smsg services.ServiceMessage, err error) {
	var msg webhookMessage
	if err := json.Unmarshal(data, &msg); err != nil {
		return nil, err
	}
	if _, err := url.ParseRequestURI(msg.URL); err != nil {
		return nil, err
	}
	if len(msg.Body) > 0 && len(msg.Data) > 0 {
		return nil, errors.New("either body or data expected")
	}
	if len(msg.Data) > 0 {
		msg.postData = []byte(msg.Data)
		if msg.Headers == nil {
			msg.Headers = make(map[string]string)
		}
		msg.Headers["content-type"] = "application/json"
	} else if len(msg.Body) > 0 {
		msg.postData = []byte(msg.Body)
	}
	msg.rawData = data
	return msg, nil
}

// Validate ...
func (wh *Webhook) Validate(data []byte) error {
	_, err := wh.ConvertMessage(data)
	return err
}


================================================
FILE: internal/services/webhook/webhook.go
================================================
package webhook

import (
	"bytes"
	"io/ioutil"
	"net/http"
	"time"

	"codeberg.org/pennersr/shove/internal/services"
	"golang.org/x/exp/slog"
)

type Webhook struct {
	log *slog.Logger
}

func NewWebhook(log *slog.Logger) (fcm *Webhook, err error) {
	fcm = &Webhook{
		log: log,
	}
	return
}

func (fcm *Webhook) Logger() *slog.Logger {
	return fcm.log
}

// ID ...
func (fcm *Webhook) ID() string {
	return "webhook"
}

// String ...
func (fcm *Webhook) String() string {
	return "Webhook"
}

func (fcm *Webhook) NewClient() (services.PumpClient, error) {
	client := &http.Client{
		Timeout: time.Duration(5 * time.Second),
		Transport: &http.Transport{
			MaxIdleConns:    5,
			IdleConnTimeout: 30 * time.Second,
		},
	}
	return client, nil
}

func (wh *Webhook) SquashAndPushMessage(services.PumpClient, []services.ServiceMessage, services.FeedbackCollector) services.PushStatus {
	panic("not implemented")
}

func (wh *Webhook) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {
	msg := smsg.(webhookMessage)
	startedAt := time.Now()
	var success bool

	wh.log.Debug("POST", "url", msg.URL, "data", string(msg.postData))
	req, err := http.NewRequest("POST", msg.URL, bytes.NewBuffer(msg.postData))
	if err != nil {
		wh.log.Error("Failed to create request", "error", err)
		return services.PushStatusHardFail
	}
	for k, v := range msg.Headers {
		req.Header.Set(k, v)
	}

	client := pclient.(*http.Client)
	resp, err := client.Do(req)
	if err != nil {
		wh.log.Error("Failed to post", "error", err)
		return services.PushStatusHardFail
	}
	duration := time.Now().Sub(startedAt)

	defer func() {
		fc.CountPush(wh.ID(), success, duration)
	}()

	body, error := ioutil.ReadAll(resp.Body)
	if error != nil {
		wh.log.Error("Failed to read POST response", "error", error)
	} else {
		wh.log.Debug("POST response", "response", string(body))
	}

	defer resp.Body.Close()
	if resp.StatusCode >= 400 && resp.StatusCode < 500 {
		wh.log.Error("Rejected", "status", resp.StatusCode)
		return services.PushStatusHardFail
	}
	if resp.StatusCode >= 500 && resp.StatusCode < 600 {
		wh.log.Error("Upstream failure", "status", resp.StatusCode)
		// A retry might help, but currently retries are not limitted to
		// a certain number of attempts, meaning, we would keep trying
		// indefinitely.
		return services.PushStatusHardFail
	}
	success = true
	return services.PushStatusSuccess
}


================================================
FILE: internal/services/webpush/message.go
================================================
package webpush

import (
	"codeberg.org/pennersr/shove/internal/services"
	"encoding/json"
	wpg "github.com/SherClockHolmes/webpush-go"
)

type webPushMessage struct {
	Subscription json.RawMessage `json:"subscription"`
	Payload      json.RawMessage `json:"payload"`
	Token        string          `json:"token"`
	Headers      struct {
		TTL     int    `json:"ttl"`
		Topic   string `json:"topic"`
		Urgency string `json:"urgency"`
	} `json:"headers"`

	options      wpg.Options
	subscription wpg.Subscription
}

func (msg webPushMessage) GetSquashKey() string {
	panic("not implemented")
}

func (wp *WebPush) ConvertMessage(data []byte) (services.ServiceMessage, error) {
	var msg webPushMessage
	if err := json.Unmarshal(data, &msg); err != nil {
		return nil, err
	}
	if err := json.Unmarshal(msg.Subscription, &msg.subscription); err != nil {
		return nil, err
	}
	if msg.Token == "" {
		msg.Token = string(msg.Subscription)
	}
	msg.options = wpg.Options{
		VAPIDPublicKey:  wp.vapidPublicKey,
		VAPIDPrivateKey: wp.vapidPrivateKey,
	}
	msg.options.Topic = msg.Headers.Topic
	if msg.Headers.Urgency != "" {
		msg.options.Urgency = wpg.Urgency(msg.Headers.Urgency)
	}
	if msg.Headers.TTL > 0 {
		msg.options.TTL = msg.Headers.TTL
	}
	return msg, nil
}

// Validate ...
func (wp *WebPush) Validate(data []byte) error {
	_, err := wp.ConvertMessage(data)
	return err
}


================================================
FILE: internal/services/webpush/message_test.go
================================================
package webpush

import (
	"fmt"
	"golang.org/x/exp/slog"
	"os"
	"testing"
)

const subscription = `{
	"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/gAAAAA",
	"keys": {
		"auth":"bHmp2U5UKnWaL-31nal7ew",
		"p256dh":"BKedT"
	}
}`

func TestConvert(t *testing.T) {
	logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
	wp, err := NewWebPush("pub", "pvt", logger)
	if err != nil {
		t.Fatal(err)
	}
	smsg, err := wp.ConvertMessage([]byte(fmt.Sprintf(`
{
	"subscription": %s,
	"headers": {
		"ttl": 10,
		"urgency": "low"
	},
	"payload": {"xxx":"z"}
}
`, subscription)))
	if err != nil {
		t.Fatal(err)
	}
	msg := smsg.(webPushMessage)
	if msg.options.TTL != 10 {
		t.Fatal("TTL wrong")
	}
	if msg.Token != subscription {
		t.Fatal("Token not derived from subscription")
	}
}

func TestConvertWithToken(t *testing.T) {
	logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
	wp, err := NewWebPush("pub", "pvt", logger)
	if err != nil {
		t.Fatal(err)
	}
	smsg, err := wp.ConvertMessage([]byte(fmt.Sprintf(`
{
	"subscription": %s,
	"token": "my-token",
	"headers": {
		"ttl": 10,
		"urgency": "low"
	},
	"payload": {"xxx":"z"}
}
`, subscription)))
	if err != nil {
		t.Fatal(err)
	}
	msg := smsg.(webPushMessage)
	if msg.Token != "my-token" {
		t.Fatal(msg.Token)
	}
}


================================================
FILE: internal/services/webpush/webpush.go
================================================
package webpush

import (
	"codeberg.org/pennersr/shove/internal/queue"
	"codeberg.org/pennersr/shove/internal/services"
	wpg "github.com/SherClockHolmes/webpush-go"
	"golang.org/x/exp/slog"
	"net/http"
	"time"
)

// WebPush ...
type WebPush struct {
	vapidPublicKey  string
	vapidPrivateKey string
	log             *slog.Logger
}

// NewWebPush ...
func NewWebPush(vapidPub, vapidPvt string, log *slog.Logger) (wp *WebPush, err error) {
	wp = &WebPush{
		vapidPrivateKey: vapidPvt,
		vapidPublicKey:  vapidPub,
		log:             log,
	}
	return
}

func (wp *WebPush) Logger() *slog.Logger {
	return wp.log
}

func (wp *WebPush) NewClient() (services.PumpClient, error) {
	client := &http.Client{
		Timeout: time.Duration(15 * time.Second),
		Transport: &http.Transport{
			MaxIdleConns:    5,
			IdleConnTimeout: 30 * time.Second,
		},
	}
	return client, nil
}

// ID ...
func (wp *WebPush) ID() string {
	return "webpush"
}

// String ...
func (wp *WebPush) String() string {
	return "WebPush"
}

func (wp *WebPush) SquashAndPushMessage(client services.PumpClient, smsgs []services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {
	panic("not implemented")
}

func (wp *WebPush) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {
	success := false
	msg := smsg.(webPushMessage)
	msg.options.HTTPClient = pclient.(*http.Client)
	startedAt := time.Now()
	// Send Notification
	resp, err := wpg.SendNotification(msg.Payload, &msg.subscription, &msg.options)
	if err != nil {
		wp.log.Error("Failed to send", "error", err)
		return services.PushStatusHardFail
	}
	defer resp.Body.Close()
	duration := time.Now().Sub(startedAt)
	wp.log.Info("Pushed", "status", resp.StatusCode, "duration", duration)
	defer func() {
		fc.CountPush(wp.ID(), success, duration)
	}()
	switch resp.StatusCode {
	case 201:
		//  201 Created. The request to send a push message was received and accepted.
		success = true
		return services.PushStatusSuccess

	case 429:
		// 429 Too many requests. Meaning your application server has
		// reached a rate limit with a push service. The push service
		// should include a 'Retry-After' header to indicate how long
		// before another request can be made.
		return services.PushStatusTempFail

	case 400:
		// 400 Invalid request. This generally means one of your headers is invalid or improperly formatted.
		return services.PushStatusHardFail

	case 404:
		// 404 Not Found. This is an indication that the subscription is
		// expired and can't be used. In this case you should delete the
		// `PushSubscription` and wait for the client to resubscribe the
		// user.
		fallthrough
	case 410:
		// 410 Gone. The subscription is no longer valid and should be
		// removed from application server. This can be reproduced by
		// calling `unsubscribe()` on a `PushSubscription`.
		fc.TokenInvalid(wp.ID(), msg.Token)
		return services.PushStatusHardFail

	default:
		// 413 Payload size too large. The minimum size payload a push service must support is 4096 bytes (or 4kb).
		return services.PushStatusHardFail
	}
}

func (wp *WebPush) remove(q queue.Queue, qm queue.QueuedMessage) {
	if err := q.Remove(qm); err != nil {
		wp.log.Error("Failed to remove from the queue", "error", err)
	}
}


================================================
FILE: pkg/shove/client.go
================================================
package shove

import (
	shvredis "codeberg.org/pennersr/shove/internal/queue/redis"
	"github.com/gomodule/redigo/redis"
	"time"
)

// Client ...
type Client interface {
	PushRaw(serviceID string, data []byte) (err error)
}

type redisClient struct {
	pool *redis.Pool
}

// NewRedisClient ...
func NewRedisClient(redisURL string) Client {
	rc := &redisClient{
		pool: &redis.Pool{
			MaxIdle:     3,
			IdleTimeout: 240 * time.Second,
			Dial: func() (redis.Conn, error) {
				return redis.DialURL(redisURL)
			},
		},
	}
	return rc
}

// PushRaw ...
func (rc *redisClient) PushRaw(id string, data []byte) (err error) {
	waitingList := shvredis.ListName(id)
	conn := rc.pool.Get()
	defer conn.Close()
	_, err = conn.Do("RPUSH", waitingList, data)
	return
}


================================================
FILE: scripts/email.json
================================================
{
    "digest": {
        "subject": "Hello Digest"
    },
    "subject": "Hello world!",
    "from": "jane@doe.org",
    "to": ["john@doe.org"],
    "text": "Hello World!\n\nGreetings.\n",
    "html": "<!DOCTYPE html>\n<html=\"en\">\n<head>\n<title>Hello World</title>\n</head>\n<body>\n<p>Hello <b>world</b>!\n</body>",
    "attachments": [
        {
            "filename": "document.png",
            "content-type": "image/png",
            "content": "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAA+LaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/Pgo8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJYTVAgQ29yZSA0LjQuMC1FeGl2MiI+CiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICB4bWxuczppcHRjRXh0PSJodHRwOi8vaXB0Yy5vcmcvc3RkL0lwdGM0eG1wRXh0LzIwMDgtMDItMjkvIgogICAgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iCiAgICB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIgogICAgeG1sbnM6cGx1cz0iaHR0cDovL25zLnVzZXBsdXMub3JnL2xkZi94bXAvMS4wLyIKICAgIHhtbG5zOkdJTVA9Imh0dHA6Ly93d3cuZ2ltcC5vcmcveG1wLyIKICAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIKICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICB4bXBNTTpEb2N1bWVudElEPSJnaW1wOmRvY2lkOmdpbXA6YTYxMWNhYzYtZWUzNy00MWM5LTljMTItZDJiMWVhMjIyZWMwIgogICB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOmVlY2NlNzk4LWE5MzctNDBmYS04NzliLWNkMjgyODg5MDA4ZCIKICAgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOmM0YTcwMDY0LTkxMjMtNDdiYS1hMDJkLTg3MmYzZWViYjY1ZCIKICAgR0lNUDpBUEk9IjIuMCIKICAgR0lNUDpQbGF0Zm9ybT0iTGludXgiCiAgIEdJTVA6VGltZVN0YW1wPSIxNjE2NDA4NjE2MzAxMDQ0IgogICBHSU1QOlZlcnNpb249IjIuMTAuMjIiCiAgIGRjOkZvcm1hdD0iaW1hZ2UvcG5nIgogICB0aWZmOk9yaWVudGF0aW9uPSIxIgogICB4bXA6Q3JlYXRvclRvb2w9IkdJTVAgMi4xMCI+CiAgIDxpcHRjRXh0OkxvY2F0aW9uQ3JlYXRlZD4KICAgIDxyZGY6QmFnLz4KICAgPC9pcHRjRXh0OkxvY2F0aW9uQ3JlYXRlZD4KICAgPGlwdGNFeHQ6TG9jYXRpb25TaG93bj4KICAgIDxyZGY6QmFnLz4KICAgPC9pcHRjRXh0OkxvY2F0aW9uU2hvd24+CiAgIDxpcHRjRXh0OkFydHdvcmtPck9iamVjdD4KICAgIDxyZGY6QmFnLz4KICAgPC9pcHRjRXh0OkFydHdvcmtPck9iamVjdD4KICAgPGlwdGNFeHQ6UmVnaXN0cnlJZD4KICAgIDxyZGY6QmFnLz4KICAgPC9pcHRjRXh0OlJlZ2lzdHJ5SWQ+CiAgIDx4bXBNTTpIaXN0b3J5PgogICAgPHJkZjpTZXE+CiAgICAgPHJkZjpsaQogICAgICBzdEV2dDphY3Rpb249InNhdmVkIgogICAgICBzdEV2dDpjaGFuZ2VkPSIvIgogICAgICBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOmRjN2Y5Zjg4LTU2MzUtNGFiMC1hMzE2LTQ4ZDNjNTA0ZjkzYSIKICAgICAgc3RFdnQ6c29mdHdhcmVBZ2VudD0iR2ltcCAyLjEwIChMaW51eCkiCiAgICAgIHN0RXZ0OndoZW49IiswMTowMCIvPgogICAgPC9yZGY6U2VxPgogICA8L3htcE1NOkhpc3Rvcnk+CiAgIDxwbHVzOkltYWdlU3VwcGxpZXI+CiAgICA8cmRmOlNlcS8+CiAgIDwvcGx1czpJbWFnZVN1cHBsaWVyPgogICA8cGx1czpJbWFnZUNyZWF0b3I+CiAgICA8cmRmOlNlcS8+CiAgIDwvcGx1czpJbWFnZUNyZWF0b3I+CiAgIDxwbHVzOkNvcHlyaWdodE93bmVyPgogICAgPHJkZjpTZXEvPgogICA8L3BsdXM6Q29weXJpZ2h0T3duZXI+CiAgIDxwbHVzOkxpY2Vuc29yPgogICAgPHJkZjpTZXEvPgogICA8L3BsdXM6TGljZW5zb3I+CiAgPC9yZGY6RGVzY3JpcHRpb24+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgCjw/eHBhY2tldCBlbmQ9InciPz76UHWrAAADFHpUWHRSYXcgcHJvZmlsZSB0eXBlIGV4aWYAAHja7ZVJshshDIb3nCJHQANIOg4NUJUb5PgRNN1+dobKtMjCUA0gwy+hD8qhf/k8wicvGBkDJ9FsOUcvbGxYfKDxLMdqIfJq9yRegyd7uH9AN5H3dE4Nt7273cew57adwLX+Ero9FR+lxw+lbPvxbD+2IOqr0I6A4PQc296whQh3RHzO644om8rT0Vrdnnmb9PExCeaUQdhbxiiSzceKkYWN2wyUZLmPAbany3DNr6UYCbETUPQWCc8oaX5AxXvy1u3BF8Iy4TIR5ZX46CgxigvbKTxKvJP5MTePHP2g/Mqx9jVZ1yA8LsYevdyPe/RyPaRsO532h1C++yeslx3Si51uN/gUkT4848eIhG4X8ZXqGE3H6Gtx4MKZjfM+1HWUNfKFx8zW2pa9in/Jx7KqedVYYnXSLcQaj3j4xACd8QCGBgUG9NVXqBiRsaN4j1iRYNqUBA0rTeY8a4CBQkaNlJAqdiI34x0LLL+23FXQ2GIDJURwMSAkvGr4OPmb+o3QGPPNAES9c0URcN5oBJjkZuurHAiMnVMnGlaK4U70xzLBkhNMK80aLZZ4nBJHgsfdosl5vv7kleP5xkDaFoiR3XcCRCBgiBkoQYYoiALAhAoGBaIiMR4QKkBK2DxKZKKMgorRffsegbUWE55mw0JMiTIJKRkVJmZOnFlYAxuXRIlTSjlJ0mSpZMrzheUsWbPlIiQsSbKIqJgUJWVNmlVU1bQYGgVjS5ZNTM2sFHdaXLn47uIrSjnwoIOPdORDDj3sKBUrVa6p5ipVq9XSsFFo3FLLTZo2a6VDx06de+q5S9duvQwYOGjwSCMPGTpslJvapvpM7ZXcz6nBpoYLFAVv5KbmZpFLAiByTJOZE0MGJy6TABDiZBYVmHGSO6BCiIZElNCjTBNOg0kMmbgDpgE3uwe573ILrH/EDV/JhYnuX5ALE90LuW+5fYdaKzHGuoitVxhWUiONGH1B14JaYkT97T786ca30FvoLfQWegu9hd5C/6/QGKNZjOEr2O6+4EF2ypYAAAAGUExURUdwTAAAAJ8qhFEAAAABdFJOUwBA5thmAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAB5JREFUCNdjsH/AoGDAoKDBoKDCoGDDoMCCD9n/AQB+kwT702H/GwAAAABJRU5ErkJggg=="
        }
    ]
}
Download .txt
gitextract_y77ellyt/

├── .github/
│   └── FUNDING.yml
├── .gitignore
├── .gitlab-ci.yml
├── .woodpecker.yaml
├── LICENSE
├── README.md
├── cmd/
│   └── shove/
│       └── main.go
├── go.mod
├── go.sum
├── internal/
│   ├── queue/
│   │   ├── memory/
│   │   │   ├── message.go
│   │   │   └── queue.go
│   │   ├── queue.go
│   │   └── redis/
│   │       └── queue.go
│   ├── server/
│   │   ├── feedback.go
│   │   ├── metrics.go
│   │   ├── push.go
│   │   ├── server.go
│   │   └── worker.go
│   └── services/
│       ├── apns/
│       │   ├── apns.go
│       │   └── message.go
│       ├── email/
│       │   ├── encode.go
│       │   ├── encode_test.go
│       │   ├── message.go
│       │   ├── send.go
│       │   └── service.go
│       ├── fcm/
│       │   ├── fcm.go
│       │   └── message.go
│       ├── pump.go
│       ├── services.go
│       ├── squasher.go
│       ├── telegram/
│       │   ├── message.go
│       │   └── telegram.go
│       ├── webhook/
│       │   ├── message.go
│       │   └── webhook.go
│       └── webpush/
│           ├── message.go
│           ├── message_test.go
│           └── webpush.go
├── pkg/
│   └── shove/
│       └── client.go
└── scripts/
    └── email.json
Download .txt
SYMBOL INDEX (176 symbols across 30 files)

FILE: cmd/shove/main.go
  function newLogger (line 58) | func newLogger() *slog.Logger {
  function newServiceLogger (line 69) | func newServiceLogger(service string) *slog.Logger {
  function main (line 76) | func main() {

FILE: internal/queue/memory/message.go
  type memoryQueuedMessage (line 3) | type memoryQueuedMessage struct
    method Message (line 9) | func (qm *memoryQueuedMessage) Message() []byte {

FILE: internal/queue/memory/queue.go
  type MemoryQueueFactory (line 11) | type MemoryQueueFactory struct
    method NewQueue (line 96) | func (mqf MemoryQueueFactory) NewQueue(id string) (q queue.Queue, err ...
  type memoryQueue (line 13) | type memoryQueue struct
    method Queue (line 20) | func (mq *memoryQueue) Queue(msg []byte) (err error) {
    method Shutdown (line 42) | func (mq *memoryQueue) Shutdown() (err error) {
    method Remove (line 50) | func (mq *memoryQueue) Remove(qm queue.QueuedMessage) (err error) {
    method Requeue (line 58) | func (mq *memoryQueue) Requeue(qm queue.QueuedMessage) (err error) {
    method getNextMessage (line 67) | func (mq *memoryQueue) getNextMessage() *memoryQueuedMessage {
    method Get (line 78) | func (mq *memoryQueue) Get(ctx context.Context) (queue.QueuedMessage, ...

FILE: internal/queue/queue.go
  type Queue (line 8) | type Queue interface
  type QueuedMessage (line 17) | type QueuedMessage interface
  type QueueFactory (line 22) | type QueueFactory interface

FILE: internal/queue/redis/queue.go
  type redisQueueFactory (line 12) | type redisQueueFactory struct
    method NewQueue (line 55) | func (rqf *redisQueueFactory) NewQueue(id string) (q queue.Queue, err ...
  type redisQueue (line 16) | type redisQueue struct
    method Queue (line 34) | func (rq redisQueue) Queue(msg []byte) (err error) {
    method Get (line 38) | func (rq redisQueue) Get(ctx context.Context) (qm queue.QueuedMessage,...
    method Remove (line 43) | func (rq redisQueue) Remove(qm queue.QueuedMessage) (err error) {
    method Requeue (line 47) | func (rq redisQueue) Requeue(qm queue.QueuedMessage) (err error) {
    method Shutdown (line 51) | func (rq redisQueue) Shutdown() (err error) {
  function NewQueueFactory (line 21) | func NewQueueFactory(url string) queue.QueueFactory {
  function ListName (line 66) | func ListName(serviceID string) string {

FILE: internal/server/feedback.go
  type tokenFeedback (line 9) | type tokenFeedback struct
  method handleFeedback (line 16) | func (s *Server) handleFeedback(w http.ResponseWriter, r *http.Request) {
  method TokenInvalid (line 37) | func (s *Server) TokenInvalid(serviceID, token string) {
  method ReplaceToken (line 45) | func (s *Server) ReplaceToken(serviceID, token, replacement string) {

FILE: internal/server/metrics.go
  method CountPush (line 26) | func (s *Server) CountPush(serviceID string, success bool, duration time...

FILE: internal/server/push.go
  method handlePush (line 10) | func (s *Server) handlePush(w http.ResponseWriter, r *http.Request) {

FILE: internal/server/server.go
  type Server (line 14) | type Server struct
    method Serve (line 44) | func (s *Server) Serve() (err error) {
    method Shutdown (line 54) | func (s *Server) Shutdown(ctx context.Context) (err error) {
    method AddService (line 72) | func (s *Server) AddService(pp services.PushService, workers int, squa...
  function NewServer (line 24) | func NewServer(addr string, qf queue.QueueFactory) (s *Server) {

FILE: internal/server/worker.go
  type worker (line 10) | type worker struct
    method push (line 28) | func (w *worker) push(msg []byte) (err error) {
    method serve (line 36) | func (w *worker) serve(workers int, squash services.SquashConfig, fc s...
    method shutdown (line 45) | func (w *worker) shutdown() (err error) {
  function newWorker (line 18) | func newWorker(pp services.PushService, queue queue.Queue) (w *worker, e...

FILE: internal/services/apns/apns.go
  type APNS (line 13) | type APNS struct
    method Logger (line 33) | func (apns *APNS) Logger() *slog.Logger {
    method NewClient (line 37) | func (apns *APNS) NewClient() (pclient services.PumpClient, err error) {
    method ID (line 49) | func (apns *APNS) ID() string {
    method String (line 58) | func (apns *APNS) String() string {
    method SquashAndPushMessage (line 65) | func (apns *APNS) SquashAndPushMessage(client services.PumpClient, sms...
    method PushMessage (line 69) | func (apns *APNS) PushMessage(pclient services.PumpClient, smsg servic...
  function NewAPNS (line 20) | func NewAPNS(pemFile string, production bool, log *slog.Logger) (apns *A...

FILE: internal/services/apns/message.go
  type apnsMessage (line 11) | type apnsMessage struct
  type apnsNotification (line 17) | type apnsNotification struct
    method GetSquashKey (line 21) | func (notif apnsNotification) GetSquashKey() string {
  method ConvertMessage (line 25) | func (apns *APNS) ConvertMessage(data []byte) (smsg services.ServiceMess...
  method Validate (line 75) | func (apns *APNS) Validate(data []byte) (err error) {

FILE: internal/services/email/encode.go
  function encodeSMTPAddress (line 17) | func encodeSMTPAddress(s string) (string, error) {
  function encodeSMTPAddresses (line 25) | func encodeSMTPAddresses(from string, to []string) (encFrom string, encT...
  function encodeAddress (line 41) | func encodeAddress(s string) (string, error) {
  function encodeRFC2047 (line 49) | func encodeRFC2047(s string) string {
  function encodeEmail (line 53) | func encodeEmail(em email) ([]byte, error) {
  function encodeEmailDigest (line 71) | func encodeEmailDigest(emails []email) ([]byte, error) {
  function encodeHeaders (line 121) | func encodeHeaders(out *bytes.Buffer, headers map[string]string) error {
  function nestedMultipart (line 142) | func nestedMultipart(enclosingWriter *multipart.Writer, contentType stri...
  function randomBoundary (line 158) | func randomBoundary() (string, error) {

FILE: internal/services/email/encode_test.go
  function TestEncodeEmailDigest (line 15) | func TestEncodeEmailDigest(t *testing.T) {
  function TestEncodeEmail (line 54) | func TestEncodeEmail(t *testing.T) {
  function TestEncodeSMTPAddresses (line 77) | func TestEncodeSMTPAddresses(t *testing.T) {
  function TestEncodeSMTPAddressesErrors (line 93) | func TestEncodeSMTPAddressesErrors(t *testing.T) {

FILE: internal/services/email/message.go
  type attachment (line 9) | type attachment struct
  type email (line 14) | type email struct
    method GetSquashKey (line 26) | func (em email) GetSquashKey() string {
  method ConvertMessage (line 30) | func (es *EmailService) ConvertMessage(data []byte) (services.ServiceMes...
  method Validate (line 50) | func (es *EmailService) Validate(data []byte) error {

FILE: internal/services/email/send.go
  method send (line 12) | func (ec EmailConfig) send(from string, to []string, body []byte, fc ser...
  method sendMailTLS (line 41) | func (ec EmailConfig) sendMailTLS(addr string, auth smtp.Auth, from stri...

FILE: internal/services/email/service.go
  constant serviceID (line 9) | serviceID = "email"
  type EmailConfig (line 11) | type EmailConfig struct
  type EmailService (line 22) | type EmailService struct
    method Logger (line 33) | func (es *EmailService) Logger() *slog.Logger {
    method ID (line 37) | func (es *EmailService) ID() string {
    method String (line 41) | func (es *EmailService) String() string {
    method NewClient (line 45) | func (es *EmailService) NewClient() (services.PumpClient, error) {
    method SquashAndPushMessage (line 49) | func (es *EmailService) SquashAndPushMessage(client services.PumpClien...
    method PushMessage (line 61) | func (es *EmailService) PushMessage(pclient services.PumpClient, smsg ...
    method push (line 70) | func (es *EmailService) push(from string, to []string, body []byte, fc...
  function NewEmailService (line 26) | func NewEmailService(config EmailConfig) (es *EmailService, err error) {

FILE: internal/services/fcm/fcm.go
  type FCM (line 15) | type FCM struct
    method Logger (line 29) | func (fcm *FCM) Logger() *slog.Logger {
    method ID (line 34) | func (fcm *FCM) ID() string {
    method String (line 39) | func (fcm *FCM) String() string {
    method NewClient (line 43) | func (fcm *FCM) NewClient() (services.PumpClient, error) {
    method SquashAndPushMessage (line 57) | func (fcm *FCM) SquashAndPushMessage(services.PumpClient, []services.S...
    method PushMessage (line 61) | func (fcm *FCM) PushMessage(pclient services.PumpClient, smsg services...
  function NewFCM (line 21) | func NewFCM(credentialsFile string, log *slog.Logger) (fcm *FCM, err err...

FILE: internal/services/fcm/message.go
  type fcmMessage (line 10) | type fcmMessage struct
    method GetSquashKey (line 14) | func (fcmMessage) GetSquashKey() string {
  method ConvertMessage (line 18) | func (fcm *FCM) ConvertMessage(data []byte) (smsg services.ServiceMessag...
  method Validate (line 33) | func (fcm *FCM) Validate(data []byte) error {

FILE: internal/services/pump.go
  type Pump (line 13) | type Pump struct
    method push (line 58) | func (p *Pump) push(q queue.Queue, qm queue.QueuedMessage, client Pump...
    method serveClient (line 69) | func (p *Pump) serveClient(ctx context.Context, q queue.Queue, client ...
    method backoff (line 116) | func (p *Pump) backoff(ctx context.Context, failureCount int) {
    method Serve (line 124) | func (p *Pump) Serve(ctx context.Context, q queue.Queue, fc FeedbackCo...
  type ServiceMessage (line 20) | type ServiceMessage interface
  type PushStatus (line 24) | type PushStatus
  constant PushStatusSuccess (line 28) | PushStatusSuccess PushStatus = iota
  constant PushStatusTempFail (line 30) | PushStatusTempFail
  constant PushStatusHardFail (line 32) | PushStatusHardFail
  type PumpClient (line 35) | type PumpClient interface
  type PumpAdapter (line 38) | type PumpAdapter interface
  function NewPump (line 47) | func NewPump(workers int, squash SquashConfig, adapter PumpAdapter) (p *...
  function removeFromQueue (line 110) | func removeFromQueue(q queue.Queue, qm queue.QueuedMessage, log *slog.Lo...

FILE: internal/services/services.go
  type FeedbackCollector (line 9) | type FeedbackCollector interface
  type PushService (line 16) | type PushService interface

FILE: internal/services/squasher.go
  type batch (line 10) | type batch struct
  type SquashConfig (line 19) | type SquashConfig struct
  type squasher (line 24) | type squasher struct
    method flushAndGetRate (line 44) | func (d *squasher) flushAndGetRate(key string) (sendCount int, sentAt ...
    method recordPush (line 65) | func (d *squasher) recordPush(key string) {
    method prepareToPush (line 71) | func (d *squasher) prepareToPush(q queue.Queue, qm queue.QueuedMessage...
    method getNextBatch (line 101) | func (d *squasher) getNextBatch() (b batch, stopped bool) {
    method requestShutdown (line 137) | func (d *squasher) requestShutdown() {
    method shutdown (line 144) | func (d *squasher) shutdown() {
    method serve (line 151) | func (d *squasher) serve(fc FeedbackCollector) {
    method sendBatch (line 163) | func (d *squasher) sendBatch(b batch, fc FeedbackCollector) {
  function newSquasher (line 34) | func newSquasher(config SquashConfig, adapter PumpAdapter) (d *squasher) {

FILE: internal/services/telegram/message.go
  type telegramMessage (line 12) | type telegramMessage struct
    method GetSquashKey (line 27) | func (msg telegramMessage) GetSquashKey() string {
  type telegramPayload (line 20) | type telegramPayload struct
  method ConvertMessage (line 32) | func (tg *TelegramService) ConvertMessage(data []byte) (services.Service...
  method Validate (line 51) | func (tg *TelegramService) Validate(data []byte) error {
  function concatText (line 56) | func concatText(builder *strings.Builder, text string) {
  function trimString (line 72) | func trimString(input string, maxLength int) string {
  function squashMessages (line 83) | func squashMessages(msgs []telegramMessage) (dmsg telegramMessage, err e...

FILE: internal/services/telegram/telegram.go
  type TelegramService (line 16) | type TelegramService struct
    method Logger (line 30) | func (tg *TelegramService) Logger() *slog.Logger {
    method ID (line 35) | func (tg *TelegramService) ID() string {
    method String (line 41) | func (tg *TelegramService) String() string {
    method NewClient (line 45) | func (tg *TelegramService) NewClient() (services.PumpClient, error) {
    method SquashAndPushMessage (line 52) | func (tg *TelegramService) SquashAndPushMessage(pclient services.PumpC...
    method PushMessage (line 66) | func (tg *TelegramService) PushMessage(pclient services.PumpClient, sm...
    method pushMessage (line 72) | func (tg *TelegramService) pushMessage(client *http.Client, method str...
  function NewTelegramService (line 22) | func NewTelegramService(botToken string, log *slog.Logger) (tg *Telegram...

FILE: internal/services/webhook/message.go
  type webhookMessage (line 11) | type webhookMessage struct
    method GetSquashKey (line 20) | func (webhookMessage) GetSquashKey() string {
  method ConvertMessage (line 24) | func (wh *Webhook) ConvertMessage(data []byte) (smsg services.ServiceMes...
  method Validate (line 49) | func (wh *Webhook) Validate(data []byte) error {

FILE: internal/services/webhook/webhook.go
  type Webhook (line 13) | type Webhook struct
    method Logger (line 24) | func (fcm *Webhook) Logger() *slog.Logger {
    method ID (line 29) | func (fcm *Webhook) ID() string {
    method String (line 34) | func (fcm *Webhook) String() string {
    method NewClient (line 38) | func (fcm *Webhook) NewClient() (services.PumpClient, error) {
    method SquashAndPushMessage (line 49) | func (wh *Webhook) SquashAndPushMessage(services.PumpClient, []service...
    method PushMessage (line 53) | func (wh *Webhook) PushMessage(pclient services.PumpClient, smsg servi...
  function NewWebhook (line 17) | func NewWebhook(log *slog.Logger) (fcm *Webhook, err error) {

FILE: internal/services/webpush/message.go
  type webPushMessage (line 9) | type webPushMessage struct
    method GetSquashKey (line 23) | func (msg webPushMessage) GetSquashKey() string {
  method ConvertMessage (line 27) | func (wp *WebPush) ConvertMessage(data []byte) (services.ServiceMessage,...
  method Validate (line 53) | func (wp *WebPush) Validate(data []byte) error {

FILE: internal/services/webpush/message_test.go
  constant subscription (line 10) | subscription = `{
  function TestConvert (line 18) | func TestConvert(t *testing.T) {
  function TestConvertWithToken (line 46) | func TestConvertWithToken(t *testing.T) {

FILE: internal/services/webpush/webpush.go
  type WebPush (line 13) | type WebPush struct
    method Logger (line 29) | func (wp *WebPush) Logger() *slog.Logger {
    method NewClient (line 33) | func (wp *WebPush) NewClient() (services.PumpClient, error) {
    method ID (line 45) | func (wp *WebPush) ID() string {
    method String (line 50) | func (wp *WebPush) String() string {
    method SquashAndPushMessage (line 54) | func (wp *WebPush) SquashAndPushMessage(client services.PumpClient, sm...
    method PushMessage (line 58) | func (wp *WebPush) PushMessage(pclient services.PumpClient, smsg servi...
    method remove (line 111) | func (wp *WebPush) remove(q queue.Queue, qm queue.QueuedMessage) {
  function NewWebPush (line 20) | func NewWebPush(vapidPub, vapidPvt string, log *slog.Logger) (wp *WebPus...

FILE: pkg/shove/client.go
  type Client (line 10) | type Client interface
  type redisClient (line 14) | type redisClient struct
    method PushRaw (line 33) | func (rc *redisClient) PushRaw(id string, data []byte) (err error) {
  function NewRedisClient (line 19) | func NewRedisClient(redisURL string) Client {
Condensed preview — 39 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (151K chars).
[
  {
    "path": ".github/FUNDING.yml",
    "chars": 642,
    "preview": "# These are supported funding model platforms\n\ngithub: pennersr\npatreon: # Replace with a single Patreon username\nopen_c"
  },
  {
    "path": ".gitignore",
    "chars": 27,
    "preview": "*~\n/cmd/shove/shove\n/shove\n"
  },
  {
    "path": ".gitlab-ci.yml",
    "chars": 976,
    "preview": "image: golang:1.20-bullseye\n\nvariables:\n  REPO_NAME: gitlab.com/pennersr/shove\n\n# The problem is that to be able to use "
  },
  {
    "path": ".woodpecker.yaml",
    "chars": 504,
    "preview": "when:\n  - event: pull_request\n  - event: [push, tag, manual]\n    branch: main\n\nsteps:\n  build:\n    image: golang:1.21-bo"
  },
  {
    "path": "LICENSE",
    "chars": 1098,
    "preview": "The MIT License (MIT)\n\nCopyright (c) 2019 Raymond Penners and contributors\n\nPermission is hereby granted, free of charge"
  },
  {
    "path": "README.md",
    "chars": 9216,
    "preview": "# When push comes to shove...\n\n[![Go Report Card](https://goreportcard.com/badge/codeberg.org/pennersr/shove)](https://g"
  },
  {
    "path": "cmd/shove/main.go",
    "chars": 7018,
    "preview": "package main\n\nimport (\n\t\"context\"\n\t\"flag\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"codeberg.org/pennersr/shove/internal/"
  },
  {
    "path": "go.mod",
    "chars": 2871,
    "preview": "module codeberg.org/pennersr/shove\n\ngo 1.21\n\ntoolchain go1.22.6\n\nrequire (\n\tcodeberg.org/pennersr/redq v0.0.0-2024090818"
  },
  {
    "path": "go.sum",
    "chars": 59929,
    "preview": "cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ncloud.google.com/go v0.34.0/go.mod h1"
  },
  {
    "path": "internal/queue/memory/message.go",
    "chars": 163,
    "preview": "package memory\n\ntype memoryQueuedMessage struct {\n\tmsg     []byte\n\tpending bool\n\tidx     int\n}\n\nfunc (qm *memoryQueuedMe"
  },
  {
    "path": "internal/queue/memory/queue.go",
    "chars": 1855,
    "preview": "package memory\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n\t\"context\"\n\t\"errors\"\n\t\"sync\"\n)\n\n// MemoryQueueFac"
  },
  {
    "path": "internal/queue/queue.go",
    "chars": 385,
    "preview": "package queue\n\nimport (\n\t\"context\"\n)\n\n// Queue ...\ntype Queue interface {\n\tQueue([]byte) error\n\tGet(ctx context.Context)"
  },
  {
    "path": "internal/queue/redis/queue.go",
    "chars": 1384,
    "preview": "package redis\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"codeberg.org/pennersr/redq\"\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n"
  },
  {
    "path": "internal/server/feedback.go",
    "chars": 1341,
    "preview": "package server\n\nimport (\n\t\"encoding/json\"\n\t\"golang.org/x/exp/slog\"\n\t\"net/http\"\n)\n\ntype tokenFeedback struct {\n\tService  "
  },
  {
    "path": "internal/server/metrics.go",
    "chars": 785,
    "preview": "package server\n\nimport (\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometh"
  },
  {
    "path": "internal/server/push.go",
    "chars": 623,
    "preview": "package server\n\nimport (\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"strings\"\n)\n\nfunc (s *Server) handlePush(w http.ResponseWriter"
  },
  {
    "path": "internal/server/server.go",
    "chars": 1815,
    "preview": "package server\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n\t\"codeberg.org/pennersr/shove/internal/services\"\n"
  },
  {
    "path": "internal/server/worker.go",
    "chars": 1105,
    "preview": "package server\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n\t\"codeberg.org/pennersr/shove/internal/services\"\n"
  },
  {
    "path": "internal/services/apns/apns.go",
    "chars": 2279,
    "preview": "package apns\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"crypto/tls\"\n\t\"github.com/sideshow/apns2\"\n\t\"git"
  },
  {
    "path": "internal/services/apns/message.go",
    "chars": 1646,
    "preview": "package apns\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"github.com/sideshow"
  },
  {
    "path": "internal/services/email/encode.go",
    "chars": 3759,
    "preview": "package email\n\nimport (\n\t\"bytes\"\n\t\"crypto/rand\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime\"\n\t\"mime/multipart\"\n\t\"net/mail\"\n\t\"net/textp"
  },
  {
    "path": "internal/services/email/encode_test.go",
    "chars": 2441,
    "preview": "package email\n\nimport (\n\t\"testing\"\n)\n\nvar png1Pixel = []byte{\n\t0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x0"
  },
  {
    "path": "internal/services/email/message.go",
    "chars": 1238,
    "preview": "package email\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"encoding/json\"\n\t\"errors\"\n)\n\ntype attachment s"
  },
  {
    "path": "internal/services/email/send.go",
    "chars": 1822,
    "preview": "package email\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"net/smtp\"\n\t\"time\"\n\n\t\"codeberg.org/pennersr/shove/internal/services\"\n)\n\nfu"
  },
  {
    "path": "internal/services/email/service.go",
    "chars": 1915,
    "preview": "package email\n\nimport (\n\t\"golang.org/x/exp/slog\"\n\n\t\"codeberg.org/pennersr/shove/internal/services\"\n)\n\nconst serviceID = "
  },
  {
    "path": "internal/services/fcm/fcm.go",
    "chars": 1954,
    "preview": "package fcm\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"context\"\n\tfirebase \"firebase.google.com/go\"\n\t\"f"
  },
  {
    "path": "internal/services/fcm/message.go",
    "chars": 750,
    "preview": "package fcm\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"firebase.google.com/"
  },
  {
    "path": "internal/services/pump.go",
    "chars": 3701,
    "preview": "package services\n\nimport (\n\t\"context\"\n\t\"golang.org/x/exp/slog\"\n\t\"math\"\n\t\"sync\"\n\t\"time\"\n\n\t\"codeberg.org/pennersr/shove/in"
  },
  {
    "path": "internal/services/services.go",
    "chars": 380,
    "preview": "package services\n\nimport (\n\t\"fmt\"\n\t\"time\"\n)\n\n// FeedbackCollector ...\ntype FeedbackCollector interface {\n\tTokenInvalid(s"
  },
  {
    "path": "internal/services/squasher.go",
    "chars": 3947,
    "preview": "package services\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n)\n\ntype batch struct {\n\tkey   "
  },
  {
    "path": "internal/services/telegram/message.go",
    "chars": 2777,
    "preview": "package telegram\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"codeberg.org/pennersr/shove/internal/services"
  },
  {
    "path": "internal/services/telegram/telegram.go",
    "chars": 3721,
    "preview": "package telegram\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"golang.org/x/exp/slog\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"c"
  },
  {
    "path": "internal/services/webhook/message.go",
    "chars": 1201,
    "preview": "package webhook\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/url\"\n\n\t\"codeberg.org/pennersr/shove/internal/services\"\n)\n\ntyp"
  },
  {
    "path": "internal/services/webhook/webhook.go",
    "chars": 2452,
    "preview": "package webhook\n\nimport (\n\t\"bytes\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\""
  },
  {
    "path": "internal/services/webpush/message.go",
    "chars": 1371,
    "preview": "package webpush\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"encoding/json\"\n\twpg \"github.com/SherClockHo"
  },
  {
    "path": "internal/services/webpush/message_test.go",
    "chars": 1287,
    "preview": "package webpush\n\nimport (\n\t\"fmt\"\n\t\"golang.org/x/exp/slog\"\n\t\"os\"\n\t\"testing\"\n)\n\nconst subscription = `{\n\t\"endpoint\":\"https"
  },
  {
    "path": "internal/services/webpush/webpush.go",
    "chars": 3305,
    "preview": "package webpush\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n\t\"codeberg.org/pennersr/shove/internal/services\""
  },
  {
    "path": "pkg/shove/client.go",
    "chars": 758,
    "preview": "package shove\n\nimport (\n\tshvredis \"codeberg.org/pennersr/shove/internal/queue/redis\"\n\t\"github.com/gomodule/redigo/redis\""
  },
  {
    "path": "scripts/email.json",
    "chars": 7142,
    "preview": "{\n    \"digest\": {\n        \"subject\": \"Hello Digest\"\n    },\n    \"subject\": \"Hello world!\",\n    \"from\": \"jane@doe.org\",\n  "
  }
]

About this extraction

This page contains the full source code of the pennersr/shove GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 39 files (138.3 KB), approximately 58.1k tokens, and a symbol index with 176 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!