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