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:
parent
a03d707904
commit
94ab7ea42b
45
README.md
45
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
|
- emits OpenTelemetry traces and metrics when configured
|
||||||
- keeps protobuf definitions and generated Go stubs in-repo
|
- keeps protobuf definitions and generated Go stubs in-repo
|
||||||
|
|
||||||
`EntityService` and `LightService` are implemented. `SwitchService` and
|
`EntityService` is implemented, `LightService` supports both discovery and
|
||||||
`EventService` are scaffolded but currently return `Unimplemented`.
|
control, `SwitchService` supports discovery, and `EventService` is still
|
||||||
|
scaffolded.
|
||||||
|
|
||||||
## Workspace Layout
|
## 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/domain`: pure domain types
|
||||||
- `internal/core/ports/driving`: interfaces exposed to primary adapters
|
- `internal/core/ports/driving`: interfaces exposed to primary adapters
|
||||||
- `internal/core/ports/driven`: interfaces the app layer depends on
|
- `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/primary/grpc`: gRPC handlers and proto/domain mapping
|
||||||
- `internal/adapters/secondary/ha`: Home Assistant REST client
|
- `internal/adapters/secondary/ha`: Home Assistant REST client
|
||||||
- `internal/telemetry`: OpenTelemetry setup
|
- `internal/telemetry`: OpenTelemetry setup
|
||||||
|
|
||||||
The runtime flow is:
|
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.
|
2. The gRPC adapter maps protobuf messages into domain parameters.
|
||||||
3. The app layer orchestrates the use case.
|
3. The app layer orchestrates the use case.
|
||||||
4. The HA adapter calls Home Assistant's REST API.
|
4. The HA adapter calls Home Assistant's REST API.
|
||||||
5. The response is mapped back into the protobuf response.
|
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
|
## Services
|
||||||
|
|
||||||
### Implemented
|
### Implemented
|
||||||
@ -53,13 +59,22 @@ The runtime flow is:
|
|||||||
- `GetState`
|
- `GetState`
|
||||||
- `ListStates`
|
- `ListStates`
|
||||||
- `ha.v1.LightService`
|
- `ha.v1.LightService`
|
||||||
|
- `ListLights`
|
||||||
|
- `TurnOn`
|
||||||
|
- `TurnOff`
|
||||||
|
- `Toggle`
|
||||||
|
- `ha.v1.SwitchService`
|
||||||
|
- `ListSwitches`
|
||||||
|
|
||||||
|
### Partially Stubbed
|
||||||
|
|
||||||
|
- `ha.v1.SwitchService`
|
||||||
- `TurnOn`
|
- `TurnOn`
|
||||||
- `TurnOff`
|
- `TurnOff`
|
||||||
- `Toggle`
|
- `Toggle`
|
||||||
|
|
||||||
### Stubbed
|
### Stubbed
|
||||||
|
|
||||||
- `ha.v1.SwitchService`
|
|
||||||
- `ha.v1.EventService`
|
- `ha.v1.EventService`
|
||||||
|
|
||||||
The event path is planned around Home Assistant WebSocket subscriptions, but the
|
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
|
## 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:
|
Examples against a locally running gateway:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List all light entities
|
# List states filtered by domain
|
||||||
grpcurl -plaintext -d '{"domain":"light"}' \
|
grpcurl -plaintext -d '{"domain":"light"}' \
|
||||||
localhost:50051 ha.v1.EntityService/ListStates
|
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
|
# Get one entity
|
||||||
grpcurl -plaintext -d '{"entity_id":"light.living_room"}' \
|
grpcurl -plaintext -d '{"entity_id":"light.living_room"}' \
|
||||||
localhost:50051 ha.v1.EntityService/GetState
|
localhost:50051 ha.v1.EntityService/GetState
|
||||||
@ -177,7 +207,8 @@ disabled for local development.
|
|||||||
## Current Limitations
|
## Current Limitations
|
||||||
|
|
||||||
- no authentication/authorization on inbound gRPC requests yet
|
- 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
|
- `EventService` is not implemented
|
||||||
- Home Assistant event streaming over WebSocket is not implemented
|
- Home Assistant event streaming over WebSocket is not implemented
|
||||||
- there are currently no unit tests in the repo
|
- there are currently no unit tests in the repo
|
||||||
|
|||||||
4
discord-bot/.env.example
Normal file
4
discord-bot/.env.example
Normal 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
19
discord-bot/Dockerfile
Normal 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
159
discord-bot/README.md
Normal 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
|
||||||
92
discord-bot/cmd/bot/main.go
Normal file
92
discord-bot/cmd/bot/main.go
Normal 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
40
discord-bot/go.mod
Normal 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
79
discord-bot/go.sum
Normal 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=
|
||||||
216
discord-bot/internal/adapters/primary/discord/handler.go
Normal file
216
discord-bot/internal/adapters/primary/discord/handler.go
Normal 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 ""
|
||||||
|
}
|
||||||
103
discord-bot/internal/adapters/primary/discord/register.go
Normal file
103
discord-bot/internal/adapters/primary/discord/register.go
Normal 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
|
||||||
|
}
|
||||||
116
discord-bot/internal/adapters/secondary/gateway/client.go
Normal file
116
discord-bot/internal/adapters/secondary/gateway/client.go
Normal 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
|
||||||
|
}
|
||||||
200
discord-bot/internal/app/command.go
Normal file
200
discord-bot/internal/app/command.go
Normal 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 "⚠️"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
discord-bot/internal/config/config.go
Normal file
32
discord-bot/internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
29
discord-bot/internal/core/ports/driven/ha.go
Normal file
29
discord-bot/internal/core/ports/driven/ha.go
Normal 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
|
||||||
|
}
|
||||||
72
discord-bot/internal/telemetry/telemetry.go
Normal file
72
discord-bot/internal/telemetry/telemetry.go
Normal 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
|
||||||
|
}
|
||||||
1
go.work
1
go.work
@ -1,6 +1,7 @@
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
use (
|
use (
|
||||||
|
./discord-bot
|
||||||
./gen
|
./gen
|
||||||
./ha-gateway
|
./ha-gateway
|
||||||
)
|
)
|
||||||
|
|||||||
11
go.work.sum
11
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.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/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/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-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/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=
|
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/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/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/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/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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=
|
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/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 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
|
||||||
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
|
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/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
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 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
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.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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/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 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
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/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user