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

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:
Nik Afiq 2026-08-01 22:45:18 +09:00
parent 8f7024edfa
commit 39b7d6d4b4
24 changed files with 1056 additions and 12 deletions

View File

@ -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'

View File

@ -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

View 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
View 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
View 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.

View 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
View 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
View 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=

View 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
}

View 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)
}
}

View File

@ -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
}
}

View File

@ -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")
}
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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()
}

View 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
}

View File

@ -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

View File

@ -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

View File

@ -2,6 +2,7 @@ go 1.26
use (
./ai-gateway
./alert-bridge
./alexa-bridge
./discord-bot
./gen

View File

@ -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=

View File

@ -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

View File

@ -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