From 94ab7ea42b412052498b840750e34a11102cec57 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Mon, 6 Apr 2026 20:13:15 +0900 Subject: [PATCH] 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. --- README.md | 45 +++- discord-bot/.env.example | 4 + discord-bot/Dockerfile | 19 ++ discord-bot/README.md | 159 +++++++++++++ discord-bot/cmd/bot/main.go | 92 ++++++++ discord-bot/go.mod | 40 ++++ discord-bot/go.sum | 79 +++++++ .../adapters/primary/discord/handler.go | 216 ++++++++++++++++++ .../adapters/primary/discord/register.go | 103 +++++++++ .../adapters/secondary/gateway/client.go | 116 ++++++++++ discord-bot/internal/app/command.go | 200 ++++++++++++++++ discord-bot/internal/config/config.go | 32 +++ discord-bot/internal/core/ports/driven/ha.go | 29 +++ discord-bot/internal/telemetry/telemetry.go | 72 ++++++ go.work | 1 + go.work.sum | 11 + 16 files changed, 1211 insertions(+), 7 deletions(-) create mode 100644 discord-bot/.env.example create mode 100644 discord-bot/Dockerfile create mode 100644 discord-bot/README.md create mode 100644 discord-bot/cmd/bot/main.go create mode 100644 discord-bot/go.mod create mode 100644 discord-bot/go.sum create mode 100644 discord-bot/internal/adapters/primary/discord/handler.go create mode 100644 discord-bot/internal/adapters/primary/discord/register.go create mode 100644 discord-bot/internal/adapters/secondary/gateway/client.go create mode 100644 discord-bot/internal/app/command.go create mode 100644 discord-bot/internal/config/config.go create mode 100644 discord-bot/internal/core/ports/driven/ha.go create mode 100644 discord-bot/internal/telemetry/telemetry.go diff --git a/README.md b/README.md index 90fa31c..2cbb644 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/discord-bot/.env.example b/discord-bot/.env.example new file mode 100644 index 0000000..b6e1302 --- /dev/null +++ b/discord-bot/.env.example @@ -0,0 +1,4 @@ +DISCORD_TOKEN=your-bot-token-here +GUILD_ID=your-guild-id-here +HA_GATEWAY_ADDR=localhost:50051 +OTEL_ENDPOINT= diff --git a/discord-bot/Dockerfile b/discord-bot/Dockerfile new file mode 100644 index 0000000..d63c9a6 --- /dev/null +++ b/discord-bot/Dockerfile @@ -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"] diff --git a/discord-bot/README.md b/discord-bot/README.md new file mode 100644 index 0000000..9a3abf5 --- /dev/null +++ b/discord-bot/README.md @@ -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: 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: transition:3 +``` + +Options: + +- `light`: required, autocomplete enabled +- `transition`: optional, `0-30` seconds + +### Toggle a light + +```text +/light toggle light: +``` + +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 diff --git a/discord-bot/cmd/bot/main.go b/discord-bot/cmd/bot/main.go new file mode 100644 index 0000000..fc876b7 --- /dev/null +++ b/discord-bot/cmd/bot/main.go @@ -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) + } +} diff --git a/discord-bot/go.mod b/discord-bot/go.mod new file mode 100644 index 0000000..7e27e6d --- /dev/null +++ b/discord-bot/go.mod @@ -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 diff --git a/discord-bot/go.sum b/discord-bot/go.sum new file mode 100644 index 0000000..b5765dd --- /dev/null +++ b/discord-bot/go.sum @@ -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= diff --git a/discord-bot/internal/adapters/primary/discord/handler.go b/discord-bot/internal/adapters/primary/discord/handler.go new file mode 100644 index 0000000..f3271e8 --- /dev/null +++ b/discord-bot/internal/adapters/primary/discord/handler.go @@ -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 "" +} diff --git a/discord-bot/internal/adapters/primary/discord/register.go b/discord-bot/internal/adapters/primary/discord/register.go new file mode 100644 index 0000000..32bc6ae --- /dev/null +++ b/discord-bot/internal/adapters/primary/discord/register.go @@ -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 +} diff --git a/discord-bot/internal/adapters/secondary/gateway/client.go b/discord-bot/internal/adapters/secondary/gateway/client.go new file mode 100644 index 0000000..b56fd9e --- /dev/null +++ b/discord-bot/internal/adapters/secondary/gateway/client.go @@ -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 +} diff --git a/discord-bot/internal/app/command.go b/discord-bot/internal/app/command.go new file mode 100644 index 0000000..3ac6b0c --- /dev/null +++ b/discord-bot/internal/app/command.go @@ -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 "⚠️" + } +} diff --git a/discord-bot/internal/config/config.go b/discord-bot/internal/config/config.go new file mode 100644 index 0000000..44d3cdc --- /dev/null +++ b/discord-bot/internal/config/config.go @@ -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 +} diff --git a/discord-bot/internal/core/ports/driven/ha.go b/discord-bot/internal/core/ports/driven/ha.go new file mode 100644 index 0000000..6255237 --- /dev/null +++ b/discord-bot/internal/core/ports/driven/ha.go @@ -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 +} diff --git a/discord-bot/internal/telemetry/telemetry.go b/discord-bot/internal/telemetry/telemetry.go new file mode 100644 index 0000000..8589c71 --- /dev/null +++ b/discord-bot/internal/telemetry/telemetry.go @@ -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 +} diff --git a/go.work b/go.work index 721af23..426ea0c 100644 --- a/go.work +++ b/go.work @@ -1,6 +1,7 @@ go 1.26 use ( + ./discord-bot ./gen ./ha-gateway ) diff --git a/go.work.sum b/go.work.sum index 6eed3c9..83791ff 100644 --- a/go.work.sum +++ b/go.work.sum @@ -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=