feat(discord-bot): implement Discord command handler and register commands for light and switch control

- Added command handler for processing Discord interactions related to lights and switches.
- Implemented command registration for light control commands: list, on, off, and toggle.
- Created a gRPC client for communicating with the home automation gateway.
- Developed application logic for handling light and switch commands, including listing, turning on/off, and toggling lights.
- Introduced telemetry setup for OpenTelemetry integration.
- Added configuration loading for Discord token, gateway address, and OpenTelemetry endpoint.
- Defined core driven ports for interacting with the home automation gateway.
This commit is contained in:
Nik Afiq 2026-04-06 20:13:15 +09:00
parent a03d707904
commit 94ab7ea42b
16 changed files with 1211 additions and 7 deletions

View File

@ -10,8 +10,9 @@ The current implementation is centered on `ha-gateway`, a Go service that:
- emits OpenTelemetry traces and metrics when configured
- keeps protobuf definitions and generated Go stubs in-repo
`EntityService` and `LightService` are implemented. `SwitchService` and
`EventService` are scaffolded but currently return `Unimplemented`.
`EntityService` is implemented, `LightService` supports both discovery and
control, `SwitchService` supports discovery, and `EventService` is still
scaffolded.
## Workspace Layout
@ -32,19 +33,24 @@ The current implementation is centered on `ha-gateway`, a Go service that:
- `internal/core/domain`: pure domain types
- `internal/core/ports/driving`: interfaces exposed to primary adapters
- `internal/core/ports/driven`: interfaces the app layer depends on
- `internal/app`: application logic for entity and light operations
- `internal/app`: application logic for entity, light, and switch operations
- `internal/adapters/primary/grpc`: gRPC handlers and proto/domain mapping
- `internal/adapters/secondary/ha`: Home Assistant REST client
- `internal/telemetry`: OpenTelemetry setup
The runtime flow is:
1. A gRPC client calls `EntityService` or `LightService`.
1. A gRPC client calls `EntityService`, `LightService`, or `SwitchService`.
2. The gRPC adapter maps protobuf messages into domain parameters.
3. The app layer orchestrates the use case.
4. The HA adapter calls Home Assistant's REST API.
5. The response is mapped back into the protobuf response.
For discovery-style RPCs, the light and switch apps cache filtered entity lists
from Home Assistant state snapshots. The cache is primed at startup on a
best-effort basis and lazily refreshed on the first request if startup
discovery fails.
## Services
### Implemented
@ -53,13 +59,22 @@ The runtime flow is:
- `GetState`
- `ListStates`
- `ha.v1.LightService`
- `ListLights`
- `TurnOn`
- `TurnOff`
- `Toggle`
- `ha.v1.SwitchService`
- `ListSwitches`
### Partially Stubbed
- `ha.v1.SwitchService`
- `TurnOn`
- `TurnOff`
- `Toggle`
### Stubbed
- `ha.v1.SwitchService`
- `ha.v1.EventService`
The event path is planned around Home Assistant WebSocket subscriptions, but the
@ -130,13 +145,28 @@ The gateway listens on `:50051` by default.
## Smoke Test With grpcurl
The server registers gRPC reflection, so `grpcurl` can inspect it directly:
```bash
grpcurl -plaintext localhost:50051 list
grpcurl -plaintext localhost:50051 describe ha.v1.LightService
```
Examples against a locally running gateway:
```bash
# List all light entities
# List states filtered by domain
grpcurl -plaintext -d '{"domain":"light"}' \
localhost:50051 ha.v1.EntityService/ListStates
# List discovered lights
grpcurl -plaintext -d '{}' \
localhost:50051 ha.v1.LightService/ListLights
# List discovered switches
grpcurl -plaintext -d '{}' \
localhost:50051 ha.v1.SwitchService/ListSwitches
# Get one entity
grpcurl -plaintext -d '{"entity_id":"light.living_room"}' \
localhost:50051 ha.v1.EntityService/GetState
@ -177,7 +207,8 @@ disabled for local development.
## Current Limitations
- no authentication/authorization on inbound gRPC requests yet
- `SwitchService` is not implemented
- `SwitchService` control RPCs (`TurnOn`, `TurnOff`, `Toggle`) still return
`Unimplemented`
- `EventService` is not implemented
- Home Assistant event streaming over WebSocket is not implemented
- there are currently no unit tests in the repo

4
discord-bot/.env.example Normal file
View File

@ -0,0 +1,4 @@
DISCORD_TOKEN=your-bot-token-here
GUILD_ID=your-guild-id-here
HA_GATEWAY_ADDR=localhost:50051
OTEL_ENDPOINT=

19
discord-bot/Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM golang:1.26-alpine AS builder
WORKDIR /workspace
COPY go.work go.work.sum ./
COPY gen/ ./gen/
COPY ha-gateway/ ./ha-gateway/
COPY discord-bot/ ./discord-bot/
WORKDIR /workspace/discord-bot
RUN go mod download
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.version=${VERSION}" \
-o /discord-bot ./cmd/bot
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /discord-bot /discord-bot
ENTRYPOINT ["/discord-bot"]

159
discord-bot/README.md Normal file
View File

@ -0,0 +1,159 @@
# discord-bot
`discord-bot` is a Discord slash-command service that talks to
`ha-gateway` over plaintext gRPC and exposes common Home Assistant actions in
Discord.
## How It Works
Runtime flow:
1. The bot starts a Discord session and registers slash commands.
2. A user runs a slash command such as `/light list` or `/light on`.
3. The Discord adapter routes the interaction to the app layer.
4. The app layer calls `ha-gateway` through the secondary gRPC adapter.
5. `ha-gateway` talks to Home Assistant and returns the result.
6. The bot formats the response back into a Discord message.
The service follows the same hexagonal structure as `ha-gateway`:
- `cmd/bot`: process bootstrap
- `internal/adapters/primary/discord`: slash command registration and handlers
- `internal/app`: command orchestration and response formatting
- `internal/adapters/secondary/gateway`: gRPC client for `ha-gateway`
- `internal/core/ports/driven`: app-facing gateway interface
- `internal/config`: env loading
- `internal/telemetry`: OpenTelemetry setup
## Command Behavior
- List commands respond as regular channel messages.
- Action commands use deferred ephemeral replies, then send a follow-up result.
- Light autocomplete is backed by `ListLights` from `ha-gateway`.
- Switch autocomplete support exists in code, but there is currently no switch
action command that uses it.
## Common Commands
### List lights
```text
/light list
```
Shows all discovered lights in a fixed-width block. Each line includes:
- state emoji: `🟢` on, `🔴` off, `⚠️` unavailable or other state
- friendly name
- raw state
- supported color modes
- kelvin range when available
Example output shape:
```text
🟢 Desk Lamp on color_temp,xy 2000-6535K
🔴 Closet off hs,color_temp 2202-4000K
⚠️ Hall Group unavailable color_temp 2000-6535K hue-group
```
### Turn on a light
```text
/light on light:<entity> brightness:80 color_temp:3000
```
Options:
- `light`: required, autocomplete enabled
- `brightness`: optional, `1-100`
- `color_temp`: optional, `2000-6535`
The bot responds with a confirmation such as:
```text
Turned on `Desk Lamp` (brightness 80%, 3000K).
```
### Turn off a light
```text
/light off light:<entity> transition:3
```
Options:
- `light`: required, autocomplete enabled
- `transition`: optional, `0-30` seconds
### Toggle a light
```text
/light toggle light:<entity>
```
Option:
- `light`: required, autocomplete enabled
### List switches
```text
/switch list
```
Shows discovered switches in a fixed-width block with:
- state emoji
- friendly name
- raw state
- device class
## Configuration
Environment variables:
- `DISCORD_TOKEN`: required Discord bot token
- `GUILD_ID`: optional guild ID; if set, commands are registered to that guild
- `HA_GATEWAY_ADDR`: required gRPC address for `ha-gateway`
- `OTEL_ENDPOINT`: optional OTLP gRPC endpoint; empty disables telemetry
Example env file: [`.env.example`](/Users/nik-macbookair/repo/home-service/discord-bot/.env.example)
## Local Run
Create an env file and fill in real values:
```bash
cp discord-bot/.env.example discord-bot/.env
```
Then run:
```bash
cd discord-bot
go run ./cmd/bot
```
## Build
```bash
cd discord-bot
go build ./...
```
## Docker
Build from the repo root:
```bash
docker build -f discord-bot/Dockerfile -t discord-bot:dev .
```
## Current Limitations
- no switch on/off/toggle command is exposed yet
- the bot depends on `ha-gateway` being reachable and healthy
- list formatting is optimized for monospace code blocks, not rich embeds
- authentication/authorization is whatever Discord and the internal network
provide; the bot does not add another auth layer itself

View File

@ -0,0 +1,92 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/bwmarrin/discordgo"
"github.com/joho/godotenv"
discordadapter "gitea.nik4nao.com/nik/home-services/discord-bot/internal/adapters/primary/discord"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/adapters/secondary/gateway"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/app"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/config"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/telemetry"
)
var version = "dev"
func main() {
_ = godotenv.Load()
cfg, err := config.Load()
if err != nil {
slog.Error("config error", "err", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
shutdown, err := telemetry.Setup(ctx, cfg, version)
if err != nil {
slog.Error("telemetry setup failed", "err", err)
os.Exit(1)
}
haClient, err := gateway.New(ctx, cfg.HAGatewayAddr)
if err != nil {
slog.Error("ha-gateway client setup failed", "err", err)
os.Exit(1)
}
defer func() {
if err := haClient.Close(); err != nil {
slog.Error("ha-gateway client close failed", "err", err)
}
}()
commandApp := app.NewCommandApp(haClient)
session, err := discordgo.New("Bot " + cfg.DiscordToken)
if err != nil {
slog.Error("create discord session failed", "err", err)
os.Exit(1)
}
session.Identify.Intents = discordgo.IntentsGuilds
handler := discordadapter.NewHandler(commandApp)
handler.Register(session)
if err := session.Open(); err != nil {
slog.Error("open discord session failed", "err", err)
os.Exit(1)
}
if err := discordadapter.RegisterCommands(session, cfg.GuildID); err != nil {
slog.Error("register discord commands failed", "err", err)
os.Exit(1)
}
scope := "global"
if cfg.GuildID != "" {
scope = cfg.GuildID
}
slog.Info("discord bot started", "command_scope", scope, "ha_gateway_addr", cfg.HAGatewayAddr, "version", version)
<-ctx.Done()
slog.InfoContext(ctx, "shutting down discord bot")
if err := session.Close(); err != nil {
slog.ErrorContext(ctx, "close discord session failed", "err", err)
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := shutdown(shutdownCtx); err != nil {
slog.Error("telemetry shutdown error", "err", err)
}
}

40
discord-bot/go.mod Normal file
View File

@ -0,0 +1,40 @@
module gitea.nik4nao.com/nik/home-services/discord-bot
go 1.26
require (
gitea.nik4nao.com/nik/home-services/gen v0.0.0
github.com/bwmarrin/discordgo v0.29.0
github.com/joho/godotenv v1.5.1
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0
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/sdk v1.39.0
go.opentelemetry.io/otel/sdk/metric v1.39.0
google.golang.org/grpc v1.79.3
)
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/gorilla/websocket v1.4.2 // 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/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
replace gitea.nik4nao.com/nik/home-services/gen => ../gen

79
discord-bot/go.sum Normal file
View File

@ -0,0 +1,79 @@
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
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/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
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/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
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/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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,216 @@
package discord
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
apppkg "gitea.nik4nao.com/nik/home-services/discord-bot/internal/app"
"github.com/bwmarrin/discordgo"
)
type commandHandler interface {
HandleLightList(ctx context.Context) (string, error)
HandleLightOn(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) (string, error)
HandleLightOff(ctx context.Context, entityID string, transition *uint32) (string, error)
HandleLightToggle(ctx context.Context, entityID string) (string, error)
HandleSwitchList(ctx context.Context) (string, error)
AutocompleteLights(ctx context.Context) ([]apppkg.Choice, error)
AutocompleteSwitches(ctx context.Context) ([]apppkg.Choice, error)
}
type Handler struct {
app commandHandler
}
func NewHandler(app commandHandler) *Handler {
return &Handler{app: app}
}
func (h *Handler) Register(s *discordgo.Session) {
s.AddHandler(h.onInteractionCreate)
}
func (h *Handler) onInteractionCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
switch i.Type {
case discordgo.InteractionApplicationCommand:
h.handleApplicationCommand(ctx, s, i)
case discordgo.InteractionApplicationCommandAutocomplete:
h.handleAutocomplete(ctx, s, i)
}
}
func (h *Handler) handleApplicationCommand(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
h.respondError(ctx, s, i.Interaction, true, fmt.Errorf("missing subcommand"))
return
}
sub := data.Options[0]
switch data.Name + "." + sub.Name {
case "light.list":
msg, err := h.app.HandleLightList(ctx)
if err != nil {
h.respondError(ctx, s, i.Interaction, false, err)
return
}
h.respondMessage(ctx, s, i.Interaction, msg, false)
case "switch.list":
msg, err := h.app.HandleSwitchList(ctx)
if err != nil {
h.respondError(ctx, s, i.Interaction, false, err)
return
}
h.respondMessage(ctx, s, i.Interaction, msg, false)
case "light.on":
if err := h.deferResponse(s, i.Interaction, true); err != nil {
slog.ErrorContext(ctx, "defer discord response failed", "err", err)
return
}
msg, err := h.app.HandleLightOn(ctx, requiredStringOption(sub, "light"), optionalUint32Option(sub, "brightness"), optionalUint32Option(sub, "color_temp"))
h.followup(ctx, s, i.Interaction, msg, true, err)
case "light.off":
if err := h.deferResponse(s, i.Interaction, true); err != nil {
slog.ErrorContext(ctx, "defer discord response failed", "err", err)
return
}
msg, err := h.app.HandleLightOff(ctx, requiredStringOption(sub, "light"), optionalUint32Option(sub, "transition"))
h.followup(ctx, s, i.Interaction, msg, true, err)
case "light.toggle":
if err := h.deferResponse(s, i.Interaction, true); err != nil {
slog.ErrorContext(ctx, "defer discord response failed", "err", err)
return
}
msg, err := h.app.HandleLightToggle(ctx, requiredStringOption(sub, "light"))
h.followup(ctx, s, i.Interaction, msg, true, err)
default:
h.respondError(ctx, s, i.Interaction, true, fmt.Errorf("unsupported command: %s.%s", data.Name, sub.Name))
}
}
func (h *Handler) handleAutocomplete(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
data := i.ApplicationCommandData()
var (
choices []apppkg.Choice
err error
)
switch data.Name {
case "light":
choices, err = h.app.AutocompleteLights(ctx)
case "switch":
choices, err = h.app.AutocompleteSwitches(ctx)
default:
choices = nil
}
if err != nil {
slog.ErrorContext(ctx, "autocomplete failed", "command", data.Name, "err", err)
choices = nil
}
respChoices := make([]*discordgo.ApplicationCommandOptionChoice, 0, min(len(choices), 25))
focused := strings.ToLower(focusedOptionValue(data))
for _, choice := range choices {
if focused != "" && !strings.Contains(strings.ToLower(choice.Label), focused) && !strings.Contains(strings.ToLower(choice.Value), focused) {
continue
}
respChoices = append(respChoices, &discordgo.ApplicationCommandOptionChoice{
Name: choice.Label,
Value: choice.Value,
})
if len(respChoices) == 25 {
break
}
}
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionApplicationCommandAutocompleteResult,
Data: &discordgo.InteractionResponseData{
Choices: respChoices,
},
}); err != nil {
slog.ErrorContext(ctx, "discord autocomplete response failed", "err", err)
}
}
func (h *Handler) deferResponse(s *discordgo.Session, interaction *discordgo.Interaction, ephemeral bool) error {
data := &discordgo.InteractionResponseData{}
if ephemeral {
data.Flags = discordgo.MessageFlagsEphemeral
}
if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: data,
}); err != nil {
return fmt.Errorf("send deferred response: %w", err)
}
return nil
}
func (h *Handler) respondMessage(ctx context.Context, s *discordgo.Session, interaction *discordgo.Interaction, content string, ephemeral bool) {
data := &discordgo.InteractionResponseData{Content: content}
if ephemeral {
data.Flags = discordgo.MessageFlagsEphemeral
}
if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: data,
}); err != nil {
slog.ErrorContext(ctx, "discord response failed", "err", err)
}
}
func (h *Handler) respondError(ctx context.Context, s *discordgo.Session, interaction *discordgo.Interaction, ephemeral bool, err error) {
slog.ErrorContext(ctx, "discord command failed", "err", err)
h.respondMessage(ctx, s, interaction, fmt.Sprintf("Error: %v", err), ephemeral)
}
func (h *Handler) followup(ctx context.Context, s *discordgo.Session, interaction *discordgo.Interaction, content string, ephemeral bool, err error) {
if err != nil {
slog.ErrorContext(ctx, "discord action failed", "err", err)
content = fmt.Sprintf("Error: %v", err)
}
params := &discordgo.WebhookParams{Content: content}
if ephemeral {
params.Flags = discordgo.MessageFlagsEphemeral
}
if _, followErr := s.FollowupMessageCreate(interaction, true, params); followErr != nil {
slog.ErrorContext(ctx, "discord followup failed", "err", followErr)
}
}
func requiredStringOption(sub *discordgo.ApplicationCommandInteractionDataOption, name string) string {
for _, opt := range sub.Options {
if opt.Name == name {
return opt.StringValue()
}
}
return ""
}
func optionalUint32Option(sub *discordgo.ApplicationCommandInteractionDataOption, name string) *uint32 {
for _, opt := range sub.Options {
if opt.Name == name {
v := uint32(opt.IntValue())
return &v
}
}
return nil
}
func focusedOptionValue(data discordgo.ApplicationCommandInteractionData) string {
for _, sub := range data.Options {
for _, opt := range sub.Options {
if opt.Focused {
return opt.StringValue()
}
}
}
return ""
}

View File

@ -0,0 +1,103 @@
package discord
import (
"fmt"
"github.com/bwmarrin/discordgo"
)
func RegisterCommands(s *discordgo.Session, guildID string) error {
appID := s.State.User.ID
if appID == "" {
return fmt.Errorf("resolve application id: session user id is empty")
}
commands := []*discordgo.ApplicationCommand{
{
Name: "light",
Description: "Control and inspect lights",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "list",
Description: "List lights",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "on",
Description: "Turn on a light",
Options: []*discordgo.ApplicationCommandOption{
lightOption(),
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "brightness",
Description: "Brightness percentage",
MinValue: ptrFloat(1),
MaxValue: 100,
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "color_temp",
Description: "Color temperature in kelvin",
MinValue: ptrFloat(2000),
MaxValue: 6535,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "off",
Description: "Turn off a light",
Options: []*discordgo.ApplicationCommandOption{
lightOption(),
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "transition",
Description: "Transition in seconds",
MinValue: ptrFloat(0),
MaxValue: 30,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "toggle",
Description: "Toggle a light",
Options: []*discordgo.ApplicationCommandOption{
lightOption(),
},
},
},
},
{
Name: "switch",
Description: "Inspect switches",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "list",
Description: "List switches",
},
},
},
}
if _, err := s.ApplicationCommandBulkOverwrite(appID, guildID, commands); err != nil {
return fmt.Errorf("bulk overwrite application commands: %w", err)
}
return nil
}
func lightOption() *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionString,
Name: "light",
Description: "Light entity",
Required: true,
Autocomplete: true,
}
}
func ptrFloat(v float64) *float64 {
return &v
}

View File

@ -0,0 +1,116 @@
package gateway
import (
"context"
"fmt"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/core/ports/driven"
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type Client struct {
conn *grpc.ClientConn
lightClient hav1.LightServiceClient
switchClient hav1.SwitchServiceClient
}
func New(ctx context.Context, addr string) (*Client, error) {
conn, err := grpc.NewClient(
addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)
if err != nil {
return nil, fmt.Errorf("dial ha-gateway: %w", err)
}
return &Client{
conn: conn,
lightClient: hav1.NewLightServiceClient(conn),
switchClient: hav1.NewSwitchServiceClient(conn),
}, nil
}
func (c *Client) Close() error {
if err := c.conn.Close(); err != nil {
return fmt.Errorf("close ha-gateway client: %w", err)
}
return nil
}
func (c *Client) ListLights(ctx context.Context) ([]driven.Light, error) {
resp, err := c.lightClient.ListLights(ctx, &hav1.ListLightsRequest{})
if err != nil {
return nil, fmt.Errorf("list lights: %w", err)
}
lights := make([]driven.Light, 0, len(resp.GetLights()))
for _, light := range resp.GetLights() {
lights = append(lights, driven.Light{
EntityID: light.GetEntityId(),
FriendlyName: light.GetFriendlyName(),
State: light.GetState(),
SupportedColorModes: append([]string(nil), light.GetSupportedColorModes()...),
MinColorTempKelvin: light.GetMinColorTempKelvin(),
MaxColorTempKelvin: light.GetMaxColorTempKelvin(),
IsHueGroup: light.GetIsHueGroup(),
EffectList: append([]string(nil), light.GetEffectList()...),
})
}
return lights, nil
}
func (c *Client) ListSwitches(ctx context.Context) ([]driven.Switch, error) {
resp, err := c.switchClient.ListSwitches(ctx, &hav1.ListSwitchesRequest{})
if err != nil {
return nil, fmt.Errorf("list switches: %w", err)
}
switches := make([]driven.Switch, 0, len(resp.GetSwitches()))
for _, sw := range resp.GetSwitches() {
switches = append(switches, driven.Switch{
EntityID: sw.GetEntityId(),
FriendlyName: sw.GetFriendlyName(),
State: sw.GetState(),
DeviceClass: sw.GetDeviceClass(),
})
}
return switches, nil
}
func (c *Client) TurnOnLight(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error {
req := &hav1.TurnOnRequest{EntityId: entityID}
if brightnessPct != nil {
req.BrightnessPct = brightnessPct
}
if colorTempKelvin != nil {
req.ColorTempKelvin = colorTempKelvin
}
if _, err := c.lightClient.TurnOn(ctx, req); err != nil {
return fmt.Errorf("turn on light %s: %w", entityID, err)
}
return nil
}
func (c *Client) TurnOffLight(ctx context.Context, entityID string, transition *uint32) error {
req := &hav1.TurnOffRequest{EntityId: entityID}
if transition != nil {
req.Transition = transition
}
if _, err := c.lightClient.TurnOff(ctx, req); err != nil {
return fmt.Errorf("turn off light %s: %w", entityID, err)
}
return nil
}
func (c *Client) ToggleLight(ctx context.Context, entityID string) error {
if _, err := c.lightClient.Toggle(ctx, &hav1.ToggleRequest{EntityId: entityID}); err != nil {
return fmt.Errorf("toggle light %s: %w", entityID, err)
}
return nil
}

View File

@ -0,0 +1,200 @@
package app
import (
"context"
"fmt"
"slices"
"strings"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/core/ports/driven"
)
type Choice struct {
Label string
Value string
}
type CommandApp struct {
ha driven.HAGateway
}
func NewCommandApp(ha driven.HAGateway) *CommandApp {
return &CommandApp{ha: ha}
}
func (a *CommandApp) HandleLightList(ctx context.Context) (string, error) {
lights, err := a.ha.ListLights(ctx)
if err != nil {
return "", fmt.Errorf("handle light list: %w", err)
}
if len(lights) == 0 {
return "No lights found.", nil
}
lines := make([]string, 0, len(lights)+2)
lines = append(lines, "```text")
for _, light := range lights {
lines = append(lines, formatLightLine(light))
}
lines = append(lines, "```")
return strings.Join(lines, "\n"), nil
}
func (a *CommandApp) HandleLightOn(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) (string, error) {
name, err := a.lookupLightName(ctx, entityID)
if err != nil {
return "", fmt.Errorf("lookup light name: %w", err)
}
if err := a.ha.TurnOnLight(ctx, entityID, brightnessPct, colorTempKelvin); err != nil {
return "", fmt.Errorf("handle light on: %w", err)
}
details := make([]string, 0, 2)
if brightnessPct != nil {
details = append(details, fmt.Sprintf("brightness %d%%", *brightnessPct))
}
if colorTempKelvin != nil {
details = append(details, fmt.Sprintf("%dK", *colorTempKelvin))
}
if len(details) == 0 {
return fmt.Sprintf("Turned on `%s`.", name), nil
}
return fmt.Sprintf("Turned on `%s` (%s).", name, strings.Join(details, ", ")), nil
}
func (a *CommandApp) HandleLightOff(ctx context.Context, entityID string, transition *uint32) (string, error) {
name, err := a.lookupLightName(ctx, entityID)
if err != nil {
return "", fmt.Errorf("lookup light name: %w", err)
}
if err := a.ha.TurnOffLight(ctx, entityID, transition); err != nil {
return "", fmt.Errorf("handle light off: %w", err)
}
if transition == nil {
return fmt.Sprintf("Turned off `%s`.", name), nil
}
return fmt.Sprintf("Turned off `%s` with %ds transition.", name, *transition), nil
}
func (a *CommandApp) HandleLightToggle(ctx context.Context, entityID string) (string, error) {
name, err := a.lookupLightName(ctx, entityID)
if err != nil {
return "", fmt.Errorf("lookup light name: %w", err)
}
if err := a.ha.ToggleLight(ctx, entityID); err != nil {
return "", fmt.Errorf("handle light toggle: %w", err)
}
return fmt.Sprintf("Toggled `%s`.", name), nil
}
func (a *CommandApp) HandleSwitchList(ctx context.Context) (string, error) {
switches, err := a.ha.ListSwitches(ctx)
if err != nil {
return "", fmt.Errorf("handle switch list: %w", err)
}
if len(switches) == 0 {
return "No switches found.", nil
}
lines := make([]string, 0, len(switches)+2)
lines = append(lines, "```text")
for _, sw := range switches {
label := sw.FriendlyName
if label == "" {
label = sw.EntityID
}
details := sw.DeviceClass
if details == "" {
details = "switch"
}
lines = append(lines, fmt.Sprintf("%s %-15s %-12s %s", stateEmoji(sw.State), label, sw.State, details))
}
lines = append(lines, "```")
return strings.Join(lines, "\n"), nil
}
func (a *CommandApp) AutocompleteLights(ctx context.Context) ([]Choice, error) {
lights, err := a.ha.ListLights(ctx)
if err != nil {
return nil, fmt.Errorf("autocomplete lights: %w", err)
}
choices := make([]Choice, 0, len(lights))
for _, light := range lights {
label := light.FriendlyName
if label == "" {
label = light.EntityID
}
choices = append(choices, Choice{Label: label, Value: light.EntityID})
}
return choices, nil
}
func (a *CommandApp) AutocompleteSwitches(ctx context.Context) ([]Choice, error) {
switches, err := a.ha.ListSwitches(ctx)
if err != nil {
return nil, fmt.Errorf("autocomplete switches: %w", err)
}
choices := make([]Choice, 0, len(switches))
for _, sw := range switches {
label := sw.FriendlyName
if label == "" {
label = sw.EntityID
}
choices = append(choices, Choice{Label: label, Value: sw.EntityID})
}
return choices, nil
}
func (a *CommandApp) lookupLightName(ctx context.Context, entityID string) (string, error) {
lights, err := a.ha.ListLights(ctx)
if err != nil {
return "", fmt.Errorf("list lights: %w", err)
}
idx := slices.IndexFunc(lights, func(light driven.Light) bool {
return light.EntityID == entityID
})
if idx == -1 {
return entityID, nil
}
if lights[idx].FriendlyName == "" {
return entityID, nil
}
return lights[idx].FriendlyName, nil
}
func formatLightLine(light driven.Light) string {
label := light.FriendlyName
if label == "" {
label = light.EntityID
}
parts := make([]string, 0, 3)
if len(light.SupportedColorModes) > 0 {
parts = append(parts, strings.Join(light.SupportedColorModes, ","))
}
if light.MinColorTempKelvin > 0 && light.MaxColorTempKelvin > 0 {
parts = append(parts, fmt.Sprintf("%d-%dK", light.MinColorTempKelvin, light.MaxColorTempKelvin))
}
if light.IsHueGroup {
parts = append(parts, "hue-group")
}
details := strings.Join(parts, " ")
if details == "" {
details = "-"
}
return fmt.Sprintf("%s %-15s %-12s %s", stateEmoji(light.State), label, light.State, details)
}
func stateEmoji(state string) string {
switch state {
case "on":
return "🟢"
case "off":
return "🔴"
default:
return "⚠️"
}
}

View File

@ -0,0 +1,32 @@
package config
import (
"errors"
"os"
)
type Config struct {
DiscordToken string
GuildID string
HAGatewayAddr string
OTELEndpoint string
}
func Load() (*Config, error) {
token := os.Getenv("DISCORD_TOKEN")
if token == "" {
return nil, errors.New("DISCORD_TOKEN is required but not set")
}
addr := os.Getenv("HA_GATEWAY_ADDR")
if addr == "" {
return nil, errors.New("HA_GATEWAY_ADDR is required but not set")
}
return &Config{
DiscordToken: token,
GuildID: os.Getenv("GUILD_ID"),
HAGatewayAddr: addr,
OTELEndpoint: os.Getenv("OTEL_ENDPOINT"),
}, nil
}

View File

@ -0,0 +1,29 @@
package driven
import "context"
type HAGateway interface {
ListLights(ctx context.Context) ([]Light, error)
ListSwitches(ctx context.Context) ([]Switch, error)
TurnOnLight(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error
TurnOffLight(ctx context.Context, entityID string, transition *uint32) error
ToggleLight(ctx context.Context, entityID string) error
}
type Light struct {
EntityID string
FriendlyName string
State string
SupportedColorModes []string
MinColorTempKelvin uint32
MaxColorTempKelvin uint32
IsHueGroup bool
EffectList []string
}
type Switch struct {
EntityID string
FriendlyName string
State string
DeviceClass string
}

View File

@ -0,0 +1,72 @@
package telemetry
import (
"context"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"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"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/config"
)
func Setup(ctx context.Context, cfg *config.Config, version string) (shutdown func(context.Context) error, err error) {
if cfg.OTELEndpoint == "" {
return func(context.Context) error { return nil }, nil
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName("discord-bot"),
semconv.ServiceVersion(version),
),
)
if err != nil {
return nil, err
}
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),
)
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 {
if err := tp.Shutdown(ctx); err != nil {
return err
}
return mp.Shutdown(ctx)
}, nil
}

View File

@ -1,6 +1,7 @@
go 1.26
use (
./discord-bot
./gen
./ha-gateway
)

View File

@ -5,6 +5,8 @@ cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCB
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@ -23,6 +25,8 @@ github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwm
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/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
@ -47,19 +51,26 @@ go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
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=