feat: add alert-bridge service for external cronjob -> Discord alerts
All checks were successful
CI / changes (push) Successful in 19s
CI / test (push) Successful in 31s
CI / build-ai-gateway (push) Successful in 1m8s
CI / build-ha-gateway (push) Successful in 1m19s
CI / build-discord-bot (push) Successful in 59s
CI / build-alexa-bridge (push) Successful in 1m8s
CI / build-alert-bridge (push) Successful in 1m17s
CI / build-tts-gateway (push) Successful in 1m10s
CI / build-tts-sidecar (push) Has been skipped
CI / build-tts-model (push) Has been skipped
All checks were successful
CI / changes (push) Successful in 19s
CI / test (push) Successful in 31s
CI / build-ai-gateway (push) Successful in 1m8s
CI / build-ha-gateway (push) Successful in 1m19s
CI / build-discord-bot (push) Successful in 59s
CI / build-alexa-bridge (push) Successful in 1m8s
CI / build-alert-bridge (push) Successful in 1m17s
CI / build-tts-gateway (push) Successful in 1m10s
CI / build-tts-sidecar (push) Has been skipped
CI / build-tts-model (push) Has been skipped
New standalone service that receives authenticated HTTP POSTs (e.g. from a cronjob elsewhere on the home network) and posts them to a Discord channel via an Incoming Webhook. Fire-and-forget, no dependency on discord-bot's bot session at all - mirrors alexa-bridge's precedent of a small bridge service with its own HTTP listener and auth rather than adding an inbound edge to an existing internal service. Wires alert-bridge into go.work, CI (changes filter, test job, new build-alert-bridge job), and the other five Dockerfiles' COPY lines. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
8f7024edfa
commit
39b7d6d4b4
@ -29,6 +29,7 @@ jobs:
|
||||
ha-gateway: ${{ steps.filter.outputs.ha-gateway }}
|
||||
discord-bot: ${{ steps.filter.outputs.discord-bot }}
|
||||
alexa-bridge: ${{ steps.filter.outputs.alexa-bridge }}
|
||||
alert-bridge: ${{ steps.filter.outputs.alert-bridge }}
|
||||
tts-gateway: ${{ steps.filter.outputs.tts-gateway }}
|
||||
tts-sidecar: ${{ steps.filter.outputs.tts-sidecar }}
|
||||
tts-model: ${{ steps.filter.outputs.tts-model }}
|
||||
@ -65,6 +66,11 @@ jobs:
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- 'alexa-bridge/**'
|
||||
alert-bridge:
|
||||
- 'gen/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- 'alert-bridge/**'
|
||||
tts-gateway:
|
||||
- 'gen/**'
|
||||
- 'go.work'
|
||||
@ -96,6 +102,7 @@ jobs:
|
||||
ha-gateway/go.sum
|
||||
discord-bot/go.sum
|
||||
alexa-bridge/go.sum
|
||||
alert-bridge/go.sum
|
||||
tts-gateway/go.sum
|
||||
gen/go.sum
|
||||
|
||||
@ -106,6 +113,7 @@ jobs:
|
||||
cd ../ha-gateway && go vet ./...
|
||||
cd ../discord-bot && go vet ./...
|
||||
cd ../alexa-bridge && go vet ./...
|
||||
cd ../alert-bridge && go vet ./...
|
||||
cd ../tts-gateway && go vet ./...
|
||||
|
||||
- name: go test
|
||||
@ -115,6 +123,7 @@ jobs:
|
||||
cd ../ha-gateway && go test ./...
|
||||
cd ../discord-bot && go test ./...
|
||||
cd ../alexa-bridge && go test ./...
|
||||
cd ../alert-bridge && go test ./...
|
||||
cd ../tts-gateway && go test ./...
|
||||
|
||||
build-ai-gateway:
|
||||
@ -225,6 +234,33 @@ jobs:
|
||||
${{ env.IMAGE_PREFIX }}/alexa-bridge:${{ github.sha }}
|
||||
${{ env.IMAGE_PREFIX }}/alexa-bridge:latest
|
||||
|
||||
build-alert-bridge:
|
||||
needs: [test, changes]
|
||||
if: github.ref == 'refs/heads/main' && needs.changes.outputs.alert-bridge == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: alert-bridge/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_PREFIX }}/alert-bridge:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_PREFIX }}/alert-bridge:buildcache,mode=max
|
||||
tags: |
|
||||
${{ env.IMAGE_PREFIX }}/alert-bridge:${{ github.sha }}
|
||||
${{ env.IMAGE_PREFIX }}/alert-bridge:latest
|
||||
|
||||
build-tts-gateway:
|
||||
needs: [test, changes]
|
||||
if: github.ref == 'refs/heads/main' && needs.changes.outputs.tts-gateway == 'true'
|
||||
|
||||
@ -10,6 +10,7 @@ COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/
|
||||
COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/
|
||||
COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/
|
||||
COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/
|
||||
COPY alert-bridge/go.mod alert-bridge/go.sum ./alert-bridge/
|
||||
COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/
|
||||
|
||||
WORKDIR /workspace/ai-gateway
|
||||
@ -21,6 +22,7 @@ COPY ha-gateway/ ./ha-gateway/
|
||||
COPY discord-bot/ ./discord-bot/
|
||||
COPY tts-gateway/ ./tts-gateway/
|
||||
COPY alexa-bridge/ ./alexa-bridge/
|
||||
COPY alert-bridge/ ./alert-bridge/
|
||||
COPY ai-gateway/ ./ai-gateway/
|
||||
|
||||
WORKDIR /workspace/ai-gateway
|
||||
|
||||
7
alert-bridge/.env.example
Normal file
7
alert-bridge/.env.example
Normal file
@ -0,0 +1,7 @@
|
||||
HTTP_PORT=8080
|
||||
API_KEY=change-me-to-a-random-shared-secret
|
||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your-webhook-id/your-webhook-token
|
||||
MENTION_USER_ID=
|
||||
OTEL_ENDPOINT=
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=text
|
||||
37
alert-bridge/Dockerfile
Normal file
37
alert-bridge/Dockerfile
Normal file
@ -0,0 +1,37 @@
|
||||
FROM golang:1.26-alpine AS builder
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY go.work go.work.sum ./
|
||||
|
||||
# Manifests only, copied first, so `go mod download` gets its own cache layer
|
||||
# invalidated only by dependency changes - not by every source edit below.
|
||||
COPY gen/go.mod gen/go.sum ./gen/
|
||||
COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/
|
||||
COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/
|
||||
COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/
|
||||
COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/
|
||||
COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/
|
||||
COPY alert-bridge/go.mod alert-bridge/go.sum ./alert-bridge/
|
||||
|
||||
WORKDIR /workspace/alert-bridge
|
||||
RUN go mod download
|
||||
|
||||
WORKDIR /workspace
|
||||
COPY gen/ ./gen/
|
||||
COPY ha-gateway/ ./ha-gateway/
|
||||
COPY ai-gateway/ ./ai-gateway/
|
||||
COPY discord-bot/ ./discord-bot/
|
||||
COPY tts-gateway/ ./tts-gateway/
|
||||
COPY alexa-bridge/ ./alexa-bridge/
|
||||
COPY alert-bridge/ ./alert-bridge/
|
||||
|
||||
WORKDIR /workspace/alert-bridge
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w -X main.version=${VERSION}" \
|
||||
-o /alert-bridge ./cmd/bridge
|
||||
|
||||
FROM gcr.io/distroless/static:nonroot
|
||||
COPY --from=builder /alert-bridge /alert-bridge
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/alert-bridge"]
|
||||
114
alert-bridge/README.md
Normal file
114
alert-bridge/README.md
Normal file
@ -0,0 +1,114 @@
|
||||
# alert-bridge
|
||||
|
||||
`alert-bridge` turns an authenticated HTTP POST from an external caller (e.g.
|
||||
a cronjob running elsewhere on the home network) into a message posted to a
|
||||
Discord channel. It is fire-and-forget: each request becomes one new Discord
|
||||
message, there is no editing or reacting to prior messages.
|
||||
|
||||
Unlike `ha-gateway`/`ai-gateway`/`discord-bot`/`tts-gateway`, it has no
|
||||
dependency on any other service in this repo — it posts directly to a Discord
|
||||
[Incoming Webhook](https://discord.com/developers/docs/resources/webhook), not
|
||||
through `discord-bot`'s bot session. That keeps the whole flow to one hop and
|
||||
means `discord-bot` needed zero changes to support this feature.
|
||||
|
||||
## Runtime Flow
|
||||
|
||||
1. The process loads `.env`, configures logging/telemetry, and starts serving
|
||||
HTTP on `HTTP_PORT`.
|
||||
2. A caller `POST`s to `/alerts` with a bearer token and a JSON body.
|
||||
3. The handler checks the bearer token (constant-time compare against
|
||||
`API_KEY`), validates the body, and defaults `level` to `info` if omitted.
|
||||
4. The alert is formatted as a Discord embed (color-coded by level) and POSTed
|
||||
to `DISCORD_WEBHOOK_URL`. `level=error` alerts additionally prefix the
|
||||
message with a mention of `MENTION_USER_ID`, since embeds alone never
|
||||
trigger a Discord ping.
|
||||
|
||||
## HTTP API
|
||||
|
||||
- `POST /alerts` — requires `Authorization: Bearer <API_KEY>`.
|
||||
|
||||
```json
|
||||
{"source": "ba-cronjob", "message": "Finished with 0 errors", "level": "info"}
|
||||
```
|
||||
|
||||
- `source`, `message`: required.
|
||||
- `level`: optional, one of `info` | `warn` | `error`, defaults to `info`.
|
||||
- Responses: `202` delivered, `400` bad request, `401` bad/missing token,
|
||||
`502` the Discord webhook call itself failed.
|
||||
|
||||
- `GET /healthz`: unauthenticated liveness/readiness check, always `200 OK`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `HTTP_PORT` | `8080` | HTTP listen port |
|
||||
| `API_KEY` | required | Shared secret callers must present as `Authorization: Bearer <API_KEY>` |
|
||||
| `DISCORD_WEBHOOK_URL` | required | Target channel's Discord Incoming Webhook URL |
|
||||
| `MENTION_USER_ID` | empty | Discord user ID mentioned on `level=error` alerts; leave empty to never mention |
|
||||
| `OTEL_ENDPOINT` | empty | OTLP gRPC collector endpoint; empty disables telemetry |
|
||||
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` |
|
||||
| `LOG_FORMAT` | `json` | `json` or `text` |
|
||||
|
||||
Example env file: [.env.example](.env.example)
|
||||
|
||||
## Local Run
|
||||
|
||||
```bash
|
||||
cd alert-bridge
|
||||
cp .env.example .env
|
||||
# edit .env: set API_KEY and DISCORD_WEBHOOK_URL
|
||||
go run ./cmd/bridge
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/alerts \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"source":"ba-cronjob","message":"Started","level":"info"}'
|
||||
```
|
||||
|
||||
## Test And Build
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go build ./...
|
||||
```
|
||||
|
||||
Build the container image from the workspace root:
|
||||
|
||||
```bash
|
||||
docker build -f alert-bridge/Dockerfile -t alert-bridge:dev .
|
||||
```
|
||||
|
||||
## Package Map
|
||||
|
||||
```text
|
||||
cmd/bridge/ # process entrypoint and wiring
|
||||
internal/adapters/primary/http/ # POST /alerts handler: auth, decode, validate
|
||||
internal/adapters/secondary/discordwebhook/ # builds the embed payload, posts to the Discord webhook
|
||||
internal/app/ # thin orchestration between the HTTP handler and the Notifier
|
||||
internal/core/domain/ # Alert{Source, Message, Level}
|
||||
internal/core/ports/driven/ # Notifier interface
|
||||
internal/config/ # environment loading
|
||||
internal/logger/ # slog setup
|
||||
internal/telemetry/ # OpenTelemetry setup
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Kubernetes manifests (Deployment/Service, and an internal-CA `IngressRoute` at
|
||||
a `*.home.arpa` hostname so LAN callers outside the cluster can reach it) live
|
||||
in the separate `homelab` repo, not here — not yet added as of this writing.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Single channel/webhook only — no per-source routing to multiple channels.
|
||||
- No rate limiting of its own; relies on Discord's own webhook rate limits
|
||||
(5 requests/2s per webhook), which is more than enough for cronjob-scale
|
||||
notification volume.
|
||||
- Fire-and-forget only: no message editing, threading, or reaction handling.
|
||||
That would require routing through `discord-bot`'s live bot session instead
|
||||
of a plain webhook — not needed for the current use case.
|
||||
93
alert-bridge/cmd/bridge/main.go
Normal file
93
alert-bridge/cmd/bridge/main.go
Normal file
@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
httpadapter "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/adapters/primary/http"
|
||||
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/adapters/secondary/discordwebhook"
|
||||
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/app"
|
||||
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/config"
|
||||
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/logger"
|
||||
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/telemetry"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
os.Stderr.WriteString("config error: " + err.Error() + "\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
log := logger.New(cfg.LogFormat, cfg.LogLevel)
|
||||
slog.SetDefault(log)
|
||||
log.Info("starting alert-bridge",
|
||||
"version", version,
|
||||
"http_port", cfg.HTTPPort,
|
||||
"log_level", cfg.LogLevel,
|
||||
"log_format", cfg.LogFormat,
|
||||
)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
ctx = logger.WithLogger(ctx, log)
|
||||
|
||||
shutdown, err := telemetry.Setup(ctx, "alert-bridge", version, cfg)
|
||||
if err != nil {
|
||||
log.Error("telemetry setup failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if cfg.OTELEndpoint != "" {
|
||||
log.Info("telemetry enabled", "endpoint", cfg.OTELEndpoint)
|
||||
} else {
|
||||
log.Debug("telemetry disabled")
|
||||
}
|
||||
|
||||
notifier := discordwebhook.New(cfg.DiscordWebhookURL, cfg.MentionUserID)
|
||||
alertApp := app.NewAlertApp(notifier)
|
||||
alertHandler := httpadapter.NewHandler(alertApp, cfg.APIKey)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("POST /alerts", alertHandler)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.HTTPPort,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Info("alert-bridge listening", "addr", srv.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Error("serve failed", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Info("shutdown signal received, draining")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Error("http server shutdown failed", "err", err)
|
||||
}
|
||||
log.Info("shutdown complete")
|
||||
|
||||
if err := shutdown(context.Background()); err != nil {
|
||||
log.Error("telemetry shutdown error", "err", err)
|
||||
}
|
||||
}
|
||||
33
alert-bridge/go.mod
Normal file
33
alert-bridge/go.mod
Normal file
@ -0,0 +1,33 @@
|
||||
module gitea.nik4nao.com/nik/home-services/alert-bridge
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
go.opentelemetry.io/otel v1.39.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0
|
||||
go.opentelemetry.io/otel/metric v1.39.0
|
||||
go.opentelemetry.io/otel/sdk v1.39.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0
|
||||
go.opentelemetry.io/otel/trace v1.39.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
|
||||
google.golang.org/grpc v1.71.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
)
|
||||
63
alert-bridge/go.sum
Normal file
63
alert-bridge/go.sum
Normal file
@ -0,0 +1,63 @@
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
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/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 h1:QcFwRrZLc82r8wODjvyCbP7Ifp3UANaBSmhDSFjnqSc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0/go.mod h1:CXIWhUomyWBG/oY2/r/kLp6K/cmx9e/7DLpBuuGdLCA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
|
||||
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
|
||||
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
|
||||
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
91
alert-bridge/internal/adapters/primary/http/handler.go
Normal file
91
alert-bridge/internal/adapters/primary/http/handler.go
Normal file
@ -0,0 +1,91 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/domain"
|
||||
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/logger"
|
||||
)
|
||||
|
||||
// AlertHandler processes one decoded, validated alert. internal/app.AlertApp
|
||||
// implements this.
|
||||
type AlertHandler interface {
|
||||
Handle(ctx context.Context, alert domain.Alert) error
|
||||
}
|
||||
|
||||
// alertRequest is the wire shape callers POST to /alerts.
|
||||
type alertRequest struct {
|
||||
Source string `json:"source"`
|
||||
Message string `json:"message"`
|
||||
Level string `json:"level"`
|
||||
}
|
||||
|
||||
// Handler is the HTTP entrypoint external callers (e.g. cronjobs) POST alerts to.
|
||||
type Handler struct {
|
||||
app AlertHandler
|
||||
apiKey string
|
||||
}
|
||||
|
||||
// NewHandler constructs the HTTP handler for the /alerts endpoint.
|
||||
func NewHandler(app AlertHandler, apiKey string) *Handler {
|
||||
return &Handler{app: app, apiKey: apiKey}
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
log := logger.FromContext(ctx)
|
||||
|
||||
if !h.authorized(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var body alertRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
log.Warn("decode request body failed", "err", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Source == "" || body.Message == "" {
|
||||
http.Error(w, "source and message are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
level := domain.Level(body.Level)
|
||||
if level == "" {
|
||||
level = domain.LevelInfo
|
||||
}
|
||||
switch level {
|
||||
case domain.LevelInfo, domain.LevelWarn, domain.LevelError:
|
||||
default:
|
||||
http.Error(w, "level must be one of: info, warn, error", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
alert := domain.Alert{Source: body.Source, Message: body.Message, Level: level}
|
||||
if err := h.app.Handle(ctx, alert); err != nil {
|
||||
log.Error("handle alert failed", "err", err, "source", body.Source)
|
||||
http.Error(w, "failed to deliver alert", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
// authorized checks the Authorization: Bearer <token> header against the
|
||||
// configured API key using a constant-time comparison so a mistyped key
|
||||
// doesn't leak how many leading bytes matched via response timing.
|
||||
func (h *Handler) authorized(r *http.Request) bool {
|
||||
const prefix = "Bearer "
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, prefix) {
|
||||
return false
|
||||
}
|
||||
token := strings.TrimPrefix(auth, prefix)
|
||||
return subtle.ConstantTimeCompare([]byte(token), []byte(h.apiKey)) == 1
|
||||
}
|
||||
120
alert-bridge/internal/adapters/primary/http/handler_test.go
Normal file
120
alert-bridge/internal/adapters/primary/http/handler_test.go
Normal file
@ -0,0 +1,120 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/domain"
|
||||
)
|
||||
|
||||
type fakeAlertHandler struct {
|
||||
handleFn func(ctx context.Context, alert domain.Alert) error
|
||||
got domain.Alert
|
||||
called bool
|
||||
}
|
||||
|
||||
func (f *fakeAlertHandler) Handle(ctx context.Context, alert domain.Alert) error {
|
||||
f.called = true
|
||||
f.got = alert
|
||||
if f.handleFn != nil {
|
||||
return f.handleFn(ctx, alert)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func doRequest(h *Handler, apiKey, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/alerts", strings.NewReader(body))
|
||||
if apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestHandler_Unauthorized(t *testing.T) {
|
||||
fake := &fakeAlertHandler{}
|
||||
h := NewHandler(fake, "secret")
|
||||
|
||||
rec := doRequest(h, "wrong-key", `{"source":"x","message":"y"}`)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
if fake.called {
|
||||
t.Fatal("app should not be called when unauthorized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_MissingAuthHeader(t *testing.T) {
|
||||
fake := &fakeAlertHandler{}
|
||||
h := NewHandler(fake, "secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/alerts", strings.NewReader(`{"source":"x","message":"y"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_BadJSON(t *testing.T) {
|
||||
fake := &fakeAlertHandler{}
|
||||
h := NewHandler(fake, "secret")
|
||||
|
||||
rec := doRequest(h, "secret", `not json`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_MissingFields(t *testing.T) {
|
||||
fake := &fakeAlertHandler{}
|
||||
h := NewHandler(fake, "secret")
|
||||
|
||||
rec := doRequest(h, "secret", `{"source":"","message":""}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_InvalidLevel(t *testing.T) {
|
||||
fake := &fakeAlertHandler{}
|
||||
h := NewHandler(fake, "secret")
|
||||
|
||||
rec := doRequest(h, "secret", `{"source":"x","message":"y","level":"critical"}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_DefaultsLevelToInfo(t *testing.T) {
|
||||
fake := &fakeAlertHandler{}
|
||||
h := NewHandler(fake, "secret")
|
||||
|
||||
rec := doRequest(h, "secret", `{"source":"ba-cronjob","message":"Started"}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected 202, got %d", rec.Code)
|
||||
}
|
||||
if fake.got.Level != domain.LevelInfo {
|
||||
t.Fatalf("expected default level info, got %q", fake.got.Level)
|
||||
}
|
||||
if fake.got.Source != "ba-cronjob" || fake.got.Message != "Started" {
|
||||
t.Fatalf("unexpected alert passed through: %+v", fake.got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_NotifierFailure(t *testing.T) {
|
||||
fake := &fakeAlertHandler{handleFn: func(ctx context.Context, alert domain.Alert) error {
|
||||
return errors.New("discord unreachable")
|
||||
}}
|
||||
h := NewHandler(fake, "secret")
|
||||
|
||||
rec := doRequest(h, "secret", `{"source":"x","message":"y","level":"error"}`)
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("expected 502, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,108 @@
|
||||
package discordwebhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/domain"
|
||||
)
|
||||
|
||||
// Discord embed colors (decimal RGB), matching Alert severity.
|
||||
const (
|
||||
colorInfo = 3447003 // blue
|
||||
colorWarn = 15844367 // yellow
|
||||
colorError = 15158332 // red
|
||||
)
|
||||
|
||||
// Client posts Alerts to a single Discord Incoming Webhook.
|
||||
type Client struct {
|
||||
webhookURL string
|
||||
mentionUserID string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New constructs a Discord webhook Notifier. mentionUserID may be empty, in
|
||||
// which case level=error alerts are posted without a mention.
|
||||
func New(webhookURL, mentionUserID string) *Client {
|
||||
return &Client{
|
||||
webhookURL: webhookURL,
|
||||
mentionUserID: mentionUserID,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type embed struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Color int `json:"color"`
|
||||
}
|
||||
|
||||
// allowedMentions is spelled out explicitly (rather than relying on Discord's
|
||||
// default) so a mention only ever pings the specific configured user, never
|
||||
// @everyone/@here/roles even if alert.Message text happens to contain them.
|
||||
type allowedMentions struct {
|
||||
Parse []string `json:"parse"`
|
||||
Users []string `json:"users"`
|
||||
}
|
||||
|
||||
type webhookPayload struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
Embeds []embed `json:"embeds"`
|
||||
AllowedMentions allowedMentions `json:"allowed_mentions"`
|
||||
}
|
||||
|
||||
// Notify posts alert to the configured Discord webhook. level=error alerts
|
||||
// mention mentionUserID (if configured) in the message content — Discord
|
||||
// embeds alone never trigger a ping, only plain message content can.
|
||||
func (c *Client) Notify(ctx context.Context, alert domain.Alert) error {
|
||||
payload := webhookPayload{
|
||||
Embeds: []embed{{
|
||||
Title: fmt.Sprintf("[%s]", alert.Source),
|
||||
Description: alert.Message,
|
||||
Color: colorFor(alert.Level),
|
||||
}},
|
||||
AllowedMentions: allowedMentions{Parse: []string{}, Users: []string{}},
|
||||
}
|
||||
|
||||
if alert.Level == domain.LevelError && c.mentionUserID != "" {
|
||||
payload.Content = fmt.Sprintf("<@%s>", c.mentionUserID)
|
||||
payload.AllowedMentions.Users = []string{c.mentionUserID}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal webhook payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.webhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build webhook request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send webhook request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("discord webhook returned status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func colorFor(level domain.Level) int {
|
||||
switch level {
|
||||
case domain.LevelWarn:
|
||||
return colorWarn
|
||||
case domain.LevelError:
|
||||
return colorError
|
||||
default:
|
||||
return colorInfo
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
package discordwebhook
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/domain"
|
||||
)
|
||||
|
||||
func TestClient_Notify_InfoNoMention(t *testing.T) {
|
||||
var got webhookPayload
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := New(server.URL, "182801655711006721")
|
||||
err := c.Notify(t.Context(), domain.Alert{Source: "ba-cronjob", Message: "Started", Level: domain.LevelInfo})
|
||||
if err != nil {
|
||||
t.Fatalf("Notify failed: %v", err)
|
||||
}
|
||||
|
||||
if got.Content != "" {
|
||||
t.Fatalf("expected no mention content for info level, got %q", got.Content)
|
||||
}
|
||||
if len(got.Embeds) != 1 || got.Embeds[0].Title != "[ba-cronjob]" || got.Embeds[0].Description != "Started" {
|
||||
t.Fatalf("unexpected embed: %+v", got.Embeds)
|
||||
}
|
||||
if got.Embeds[0].Color != colorInfo {
|
||||
t.Fatalf("expected info color, got %d", got.Embeds[0].Color)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Notify_ErrorMentionsConfiguredUser(t *testing.T) {
|
||||
var got webhookPayload
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := New(server.URL, "182801655711006721")
|
||||
err := c.Notify(t.Context(), domain.Alert{Source: "ba-cronjob", Message: "Finished with 3 errors", Level: domain.LevelError})
|
||||
if err != nil {
|
||||
t.Fatalf("Notify failed: %v", err)
|
||||
}
|
||||
|
||||
if got.Content != "<@182801655711006721>" {
|
||||
t.Fatalf("expected mention content, got %q", got.Content)
|
||||
}
|
||||
if len(got.AllowedMentions.Users) != 1 || got.AllowedMentions.Users[0] != "182801655711006721" {
|
||||
t.Fatalf("expected allowed_mentions scoped to configured user, got %+v", got.AllowedMentions)
|
||||
}
|
||||
if got.Embeds[0].Color != colorError {
|
||||
t.Fatalf("expected error color, got %d", got.Embeds[0].Color)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Notify_NoMentionUserConfigured(t *testing.T) {
|
||||
var got webhookPayload
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := New(server.URL, "")
|
||||
err := c.Notify(t.Context(), domain.Alert{Source: "x", Message: "y", Level: domain.LevelError})
|
||||
if err != nil {
|
||||
t.Fatalf("Notify failed: %v", err)
|
||||
}
|
||||
if got.Content != "" {
|
||||
t.Fatalf("expected no mention when no user configured, got %q", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Notify_NonSuccessStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := New(server.URL, "")
|
||||
err := c.Notify(t.Context(), domain.Alert{Source: "x", Message: "y", Level: domain.LevelInfo})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on non-2xx response, got nil")
|
||||
}
|
||||
}
|
||||
27
alert-bridge/internal/app/alert.go
Normal file
27
alert-bridge/internal/app/alert.go
Normal file
@ -0,0 +1,27 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/domain"
|
||||
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/ports/driven"
|
||||
)
|
||||
|
||||
// AlertApp orchestrates delivering an incoming alert to its Notifier.
|
||||
type AlertApp struct {
|
||||
notifier driven.Notifier
|
||||
}
|
||||
|
||||
// NewAlertApp constructs the alert application service.
|
||||
func NewAlertApp(notifier driven.Notifier) *AlertApp {
|
||||
return &AlertApp{notifier: notifier}
|
||||
}
|
||||
|
||||
// Handle delivers one alert, wrapping any delivery failure for the caller.
|
||||
func (a *AlertApp) Handle(ctx context.Context, alert domain.Alert) error {
|
||||
if err := a.notifier.Notify(ctx, alert); err != nil {
|
||||
return fmt.Errorf("notify: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
48
alert-bridge/internal/config/config.go
Normal file
48
alert-bridge/internal/config/config.go
Normal file
@ -0,0 +1,48 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config holds runtime configuration for the alert-bridge process.
|
||||
type Config struct {
|
||||
HTTPPort string // HTTP_PORT, default "8080"
|
||||
APIKey string // API_KEY — required bearer token callers must present in Authorization: Bearer <key>
|
||||
DiscordWebhookURL string // DISCORD_WEBHOOK_URL — required, the target channel's Discord Incoming Webhook URL
|
||||
MentionUserID string // MENTION_USER_ID — optional Discord user ID; when set, level=error alerts mention this user
|
||||
OTELEndpoint string
|
||||
LogLevel string
|
||||
LogFormat string
|
||||
}
|
||||
|
||||
// Load reads alert-bridge configuration from environment variables.
|
||||
func Load() (*Config, error) {
|
||||
apiKey := os.Getenv("API_KEY")
|
||||
if apiKey == "" {
|
||||
return nil, errors.New("API_KEY is required but not set")
|
||||
}
|
||||
|
||||
webhookURL := os.Getenv("DISCORD_WEBHOOK_URL")
|
||||
if webhookURL == "" {
|
||||
return nil, errors.New("DISCORD_WEBHOOK_URL is required but not set")
|
||||
}
|
||||
|
||||
return &Config{
|
||||
HTTPPort: getenvDefault("HTTP_PORT", "8080"),
|
||||
APIKey: apiKey,
|
||||
DiscordWebhookURL: webhookURL,
|
||||
MentionUserID: os.Getenv("MENTION_USER_ID"),
|
||||
OTELEndpoint: os.Getenv("OTEL_ENDPOINT"),
|
||||
LogLevel: getenvDefault("LOG_LEVEL", "info"),
|
||||
LogFormat: getenvDefault("LOG_FORMAT", "json"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getenvDefault keeps config loading concise for optional variables with defaults.
|
||||
func getenvDefault(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
18
alert-bridge/internal/core/domain/alert.go
Normal file
18
alert-bridge/internal/core/domain/alert.go
Normal file
@ -0,0 +1,18 @@
|
||||
package domain
|
||||
|
||||
// Level is the severity of an Alert. It controls the posted Discord embed's
|
||||
// color and whether the message mentions a user to raise attention.
|
||||
type Level string
|
||||
|
||||
const (
|
||||
LevelInfo Level = "info"
|
||||
LevelWarn Level = "warn"
|
||||
LevelError Level = "error"
|
||||
)
|
||||
|
||||
// Alert is one notification submitted by an external caller (e.g. a cronjob).
|
||||
type Alert struct {
|
||||
Source string
|
||||
Message string
|
||||
Level Level
|
||||
}
|
||||
13
alert-bridge/internal/core/ports/driven/notifier.go
Normal file
13
alert-bridge/internal/core/ports/driven/notifier.go
Normal file
@ -0,0 +1,13 @@
|
||||
package driven
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/domain"
|
||||
)
|
||||
|
||||
// Notifier delivers an Alert to its destination. internal/adapters/secondary/discordwebhook
|
||||
// is the only current implementation.
|
||||
type Notifier interface {
|
||||
Notify(ctx context.Context, alert domain.Alert) error
|
||||
}
|
||||
38
alert-bridge/internal/logger/logger.go
Normal file
38
alert-bridge/internal/logger/logger.go
Normal file
@ -0,0 +1,38 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
// New constructs a root logger for the configured format and level.
|
||||
func New(format, level string) *slog.Logger {
|
||||
var parsed slog.Level
|
||||
if err := parsed.UnmarshalText([]byte(level)); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "invalid log level %q, falling back to info\n", level)
|
||||
parsed = slog.LevelInfo
|
||||
}
|
||||
|
||||
opts := &slog.HandlerOptions{Level: parsed}
|
||||
if format == "json" {
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, opts))
|
||||
}
|
||||
|
||||
// WithLogger attaches a logger to the provided context.
|
||||
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, l)
|
||||
}
|
||||
|
||||
// FromContext retrieves a logger from context and falls back to slog.Default().
|
||||
func FromContext(ctx context.Context) *slog.Logger {
|
||||
if l, ok := ctx.Value(contextKey{}).(*slog.Logger); ok && l != nil {
|
||||
return l
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
82
alert-bridge/internal/telemetry/telemetry.go
Normal file
82
alert-bridge/internal/telemetry/telemetry.go
Normal file
@ -0,0 +1,82 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/config"
|
||||
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/logger"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
metricnoop "go.opentelemetry.io/otel/metric/noop"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||
)
|
||||
|
||||
// Setup initialises OTel trace and metric providers for one service.
|
||||
func Setup(ctx context.Context, serviceName, version string, cfg *config.Config) (shutdown func(context.Context) error, err error) {
|
||||
if cfg.OTELEndpoint == "" {
|
||||
otel.SetTracerProvider(tracenoop.NewTracerProvider())
|
||||
otel.SetMeterProvider(metricnoop.NewMeterProvider())
|
||||
logger.FromContext(ctx).Debug("otel disabled — OTEL_ENDPOINT not set")
|
||||
return func(context.Context) error { return nil }, nil
|
||||
}
|
||||
|
||||
res := resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceNameKey.String(serviceName),
|
||||
semconv.ServiceVersionKey.String(version),
|
||||
)
|
||||
|
||||
traceExp, err := otlptracegrpc.New(ctx,
|
||||
otlptracegrpc.WithEndpoint(cfg.OTELEndpoint),
|
||||
otlptracegrpc.WithInsecure(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithBatcher(traceExp),
|
||||
sdktrace.WithResource(res),
|
||||
sdktrace.WithSampler(sdktrace.AlwaysSample()),
|
||||
)
|
||||
otel.SetTracerProvider(tp)
|
||||
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
))
|
||||
|
||||
metricExp, err := otlpmetricgrpc.New(ctx,
|
||||
otlpmetricgrpc.WithEndpoint(cfg.OTELEndpoint),
|
||||
otlpmetricgrpc.WithInsecure(),
|
||||
)
|
||||
if err != nil {
|
||||
_ = tp.Shutdown(ctx)
|
||||
return nil, err
|
||||
}
|
||||
mp := sdkmetric.NewMeterProvider(
|
||||
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp,
|
||||
sdkmetric.WithInterval(30*time.Second))),
|
||||
sdkmetric.WithResource(res),
|
||||
)
|
||||
otel.SetMeterProvider(mp)
|
||||
|
||||
return func(ctx context.Context) error {
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
var shutdownErr error
|
||||
if err := tp.Shutdown(shutdownCtx); err != nil {
|
||||
shutdownErr = errors.Join(shutdownErr, err)
|
||||
}
|
||||
if err := mp.Shutdown(shutdownCtx); err != nil {
|
||||
shutdownErr = errors.Join(shutdownErr, err)
|
||||
}
|
||||
return shutdownErr
|
||||
}, nil
|
||||
}
|
||||
@ -11,6 +11,7 @@ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/
|
||||
COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/
|
||||
COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/
|
||||
COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/
|
||||
COPY alert-bridge/go.mod alert-bridge/go.sum ./alert-bridge/
|
||||
|
||||
WORKDIR /workspace/alexa-bridge
|
||||
RUN go mod download
|
||||
@ -22,6 +23,7 @@ COPY ai-gateway/ ./ai-gateway/
|
||||
COPY discord-bot/ ./discord-bot/
|
||||
COPY tts-gateway/ ./tts-gateway/
|
||||
COPY alexa-bridge/ ./alexa-bridge/
|
||||
COPY alert-bridge/ ./alert-bridge/
|
||||
|
||||
WORKDIR /workspace/alexa-bridge
|
||||
ARG VERSION=dev
|
||||
|
||||
@ -10,6 +10,7 @@ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/
|
||||
COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/
|
||||
COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/
|
||||
COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/
|
||||
COPY alert-bridge/go.mod alert-bridge/go.sum ./alert-bridge/
|
||||
COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/
|
||||
|
||||
WORKDIR /workspace/discord-bot
|
||||
@ -21,6 +22,7 @@ COPY ai-gateway/ ./ai-gateway/
|
||||
COPY ha-gateway/ ./ha-gateway/
|
||||
COPY tts-gateway/ ./tts-gateway/
|
||||
COPY alexa-bridge/ ./alexa-bridge/
|
||||
COPY alert-bridge/ ./alert-bridge/
|
||||
COPY discord-bot/ ./discord-bot/
|
||||
|
||||
WORKDIR /workspace/discord-bot
|
||||
|
||||
1
go.work
1
go.work
@ -2,6 +2,7 @@ go 1.26
|
||||
|
||||
use (
|
||||
./ai-gateway
|
||||
./alert-bridge
|
||||
./alexa-bridge
|
||||
./discord-bot
|
||||
./gen
|
||||
|
||||
32
go.work.sum
32
go.work.sum
@ -1,6 +1,7 @@
|
||||
cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
@ -39,6 +40,8 @@ github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I=
|
||||
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@ -88,12 +91,13 @@ golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
@ -103,6 +107,9 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
@ -110,32 +117,33 @@ golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
|
||||
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
||||
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@ -10,6 +10,7 @@ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/
|
||||
COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/
|
||||
COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/
|
||||
COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/
|
||||
COPY alert-bridge/go.mod alert-bridge/go.sum ./alert-bridge/
|
||||
COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/
|
||||
|
||||
WORKDIR /workspace/ha-gateway
|
||||
@ -21,6 +22,7 @@ COPY ai-gateway/ ./ai-gateway/
|
||||
COPY discord-bot/ ./discord-bot/
|
||||
COPY tts-gateway/ ./tts-gateway/
|
||||
COPY alexa-bridge/ ./alexa-bridge/
|
||||
COPY alert-bridge/ ./alert-bridge/
|
||||
COPY ha-gateway/ ./ha-gateway/
|
||||
|
||||
WORKDIR /workspace/ha-gateway
|
||||
|
||||
@ -16,6 +16,7 @@ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/
|
||||
COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/
|
||||
COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/
|
||||
COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/
|
||||
COPY alert-bridge/go.mod alert-bridge/go.sum ./alert-bridge/
|
||||
COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/
|
||||
|
||||
WORKDIR /workspace/tts-gateway
|
||||
@ -27,6 +28,7 @@ COPY ai-gateway/ ./ai-gateway/
|
||||
COPY ha-gateway/ ./ha-gateway/
|
||||
COPY discord-bot/ ./discord-bot/
|
||||
COPY alexa-bridge/ ./alexa-bridge/
|
||||
COPY alert-bridge/ ./alert-bridge/
|
||||
COPY tts-gateway/ ./tts-gateway/
|
||||
|
||||
WORKDIR /workspace/tts-gateway
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user