diff --git a/discord-bot/.env.example b/discord-bot/.env.example index 67e373d..bd58be6 100644 --- a/discord-bot/.env.example +++ b/discord-bot/.env.example @@ -1,6 +1,7 @@ DISCORD_TOKEN=your-bot-token-here GUILD_ID=your-guild-id-here HA_GATEWAY_ADDR=localhost:50051 +AI_GATEWAY_ADDR=localhost:50052 OTEL_ENDPOINT= LOG_LEVEL=info LOG_FORMAT=text diff --git a/discord-bot/cmd/bot/main.go b/discord-bot/cmd/bot/main.go index 5511088..b548d6e 100644 --- a/discord-bot/cmd/bot/main.go +++ b/discord-bot/cmd/bot/main.go @@ -12,6 +12,7 @@ import ( "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/aigateway" "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" @@ -38,6 +39,7 @@ func main() { log.Info("starting discord-bot", "version", version, "ha_gateway_addr", cfg.HAGatewayAddr, + "ai_gateway_addr", cfg.AIGatewayAddr, "discord_token", redactToken(cfg.DiscordToken), "tls_dir", cfg.TLSDir, "log_level", cfg.LogLevel, @@ -71,7 +73,18 @@ func main() { } }() - commandApp := app.NewCommandApp(haClient) + aiClient, err := aigateway.New(ctx, cfg.AIGatewayAddr, cfg.TLSDir, log) + if err != nil { + log.Error("ai-gateway client setup failed", "err", err) + os.Exit(1) + } + defer func() { + if err := aiClient.Close(); err != nil { + log.Error("ai-gateway client close failed", "err", err) + } + }() + + commandApp := app.NewCommandApp(haClient, aiClient) // Discord-specific wiring stays at the edge so the app layer remains transport-agnostic. session, err := discordgo.New("Bot " + cfg.DiscordToken) @@ -99,7 +112,12 @@ func main() { if cfg.GuildID != "" { scope = cfg.GuildID } - log.Info("discord bot started", "command_scope", scope, "ha_gateway_addr", cfg.HAGatewayAddr, "version", version) + log.Info("discord bot started", + "command_scope", scope, + "ha_gateway_addr", cfg.HAGatewayAddr, + "ai_gateway_addr", cfg.AIGatewayAddr, + "version", version, + ) <-ctx.Done() log.Info("shutdown signal received, closing session") diff --git a/discord-bot/internal/adapters/primary/discord/handler.go b/discord-bot/internal/adapters/primary/discord/handler.go index 8596043..f6180ce 100644 --- a/discord-bot/internal/adapters/primary/discord/handler.go +++ b/discord-bot/internal/adapters/primary/discord/handler.go @@ -23,6 +23,7 @@ type commandHandler interface { HandleLightOff(ctx context.Context, entityID string, transition *uint32) (string, error) HandleLightToggle(ctx context.Context, entityID string) (string, error) HandleSwitchList(ctx context.Context) (string, error) + HandleAIQuery(ctx context.Context, text string) (string, error) AutocompleteLights(ctx context.Context) ([]apppkg.Choice, error) AutocompleteSwitches(ctx context.Context) ([]apppkg.Choice, error) } @@ -136,6 +137,16 @@ func (h *Handler) handleApplicationCommand(ctx context.Context, s *discordgo.Ses } msg, err := h.app.HandleLightToggle(ctx, requiredStringOption(sub, "light")) h.followup(ctx, s, i.Interaction, msg, true, start, err) + case "ai.query": + if err := h.deferResponse(s, i.Interaction, true); err != nil { + log.Error("discord response failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return + } + msg, err := h.app.HandleAIQuery(ctx, requiredStringOption(sub, "text")) + h.followup(ctx, s, i.Interaction, msg, true, start, err) default: h.respondError(ctx, s, i.Interaction, true, start, fmt.Errorf("unsupported command: %s.%s", data.Name, sub.Name)) } diff --git a/discord-bot/internal/adapters/primary/discord/register.go b/discord-bot/internal/adapters/primary/discord/register.go index 4cbab8b..3cbe804 100644 --- a/discord-bot/internal/adapters/primary/discord/register.go +++ b/discord-bot/internal/adapters/primary/discord/register.go @@ -81,6 +81,25 @@ func RegisterCommands(s *discordgo.Session, guildID string) error { }, }, }, + { + Name: "ai", + Description: "Query the AI home assistant", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "query", + Description: "Send a free-form prompt", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "text", + Description: "Prompt text", + Required: true, + }, + }, + }, + }, + }, } if _, err := s.ApplicationCommandBulkOverwrite(appID, guildID, commands); err != nil { diff --git a/discord-bot/internal/adapters/secondary/aigateway/client.go b/discord-bot/internal/adapters/secondary/aigateway/client.go new file mode 100644 index 0000000..4e19516 --- /dev/null +++ b/discord-bot/internal/adapters/secondary/aigateway/client.go @@ -0,0 +1,107 @@ +package aigateway + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "gitea.nik4nao.com/nik/home-services/discord-bot/internal/logger" + aiv1 "gitea.nik4nao.com/nik/home-services/gen/ai/v1" + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" +) + +// Client implements the app's AI driven port over gRPC. +type Client struct { + conn *grpc.ClientConn + client aiv1.AIServiceClient + log *slog.Logger +} + +// New constructs a gRPC client for the internal ai-gateway service. +func New(ctx context.Context, addr, tlsDir string, log *slog.Logger) (*Client, error) { + transportCreds := insecure.NewCredentials() + if tlsDir != "" { + creds, err := loadTransportCredentials(tlsDir) + if err != nil { + return nil, fmt.Errorf("load mTLS credentials: %w", err) + } + transportCreds = creds + } + + conn, err := grpc.NewClient( + addr, + grpc.WithTransportCredentials(transportCreds), + grpc.WithStatsHandler(otelgrpc.NewClientHandler()), + ) + if err != nil { + return nil, fmt.Errorf("dial ai-gateway: %w", err) + } + + return &Client{ + conn: conn, + client: aiv1.NewAIServiceClient(conn), + log: log, + }, nil +} + +// Close closes the underlying gRPC connection. +func (c *Client) Close() error { + if err := c.conn.Close(); err != nil { + return fmt.Errorf("close ai-gateway client: %w", err) + } + return nil +} + +// Query forwards one free-form request to ai-gateway. +func (c *Client) Query(ctx context.Context, text string) (string, error) { + start := time.Now() + log := logger.FromContext(ctx).With("grpc.method", "AIService/Query") + resp, err := c.client.Query(ctx, &aiv1.QueryRequest{ + Text: text, + Source: "discord-bot", + }) + if err != nil { + log.Error("grpc call failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return "", fmt.Errorf("query ai-gateway: %w", err) + } + log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds()) + return resp.GetReply(), nil +} + +func loadTransportCredentials(tlsDir string) (credentials.TransportCredentials, error) { + cert, err := tls.LoadX509KeyPair( + filepath.Join(tlsDir, "tls.crt"), + filepath.Join(tlsDir, "tls.key"), + ) + if err != nil { + return nil, fmt.Errorf("load client key pair: %w", err) + } + + caPEM, err := os.ReadFile(filepath.Join(tlsDir, "ca.crt")) + if err != nil { + return nil, fmt.Errorf("read server CA: %w", err) + } + + rootCAs := x509.NewCertPool() + if !rootCAs.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("append server CA: invalid PEM") + } + + return credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{cert}, + RootCAs: rootCAs, + ServerName: "ai-gateway.home-services.svc.cluster.local", + MinVersion: tls.VersionTLS13, + }), nil +} diff --git a/discord-bot/internal/app/command.go b/discord-bot/internal/app/command.go index c7a488c..454f35b 100644 --- a/discord-bot/internal/app/command.go +++ b/discord-bot/internal/app/command.go @@ -18,11 +18,12 @@ type Choice struct { // CommandApp orchestrates Discord command use cases against ha-gateway. type CommandApp struct { ha driven.HAGateway + ai driven.AIGateway } // NewCommandApp constructs the Discord command application service. -func NewCommandApp(ha driven.HAGateway) *CommandApp { - return &CommandApp{ha: ha} +func NewCommandApp(ha driven.HAGateway, ai driven.AIGateway) *CommandApp { + return &CommandApp{ha: ha, ai: ai} } // HandleLightList formats discovered lights into a monospace-friendly response. @@ -121,6 +122,15 @@ func (a *CommandApp) HandleSwitchList(ctx context.Context) (string, error) { return strings.Join(lines, "\n"), nil } +// HandleAIQuery forwards a free-form request to ai-gateway. +func (a *CommandApp) HandleAIQuery(ctx context.Context, text string) (string, error) { + reply, err := a.ai.Query(ctx, text) + if err != nil { + return "", fmt.Errorf("handle ai query: %w", err) + } + return reply, nil +} + // AutocompleteLights maps discovered lights into Discord autocomplete choices. func (a *CommandApp) AutocompleteLights(ctx context.Context) ([]Choice, error) { lights, err := a.ha.ListLights(ctx) diff --git a/discord-bot/internal/app/command_test.go b/discord-bot/internal/app/command_test.go index 8b72a87..e839385 100644 --- a/discord-bot/internal/app/command_test.go +++ b/discord-bot/internal/app/command_test.go @@ -17,6 +17,10 @@ type mockHAGateway struct { toggleLightFunc func(ctx context.Context, entityID string) error } +type mockAIGateway struct { + queryFunc func(ctx context.Context, text string) (string, error) +} + func (m *mockHAGateway) ListLights(ctx context.Context) ([]driven.Light, error) { if m.listLightsFunc == nil { return nil, nil @@ -52,6 +56,13 @@ func (m *mockHAGateway) ToggleLight(ctx context.Context, entityID string) error return m.toggleLightFunc(ctx, entityID) } +func (m *mockAIGateway) Query(ctx context.Context, text string) (string, error) { + if m.queryFunc == nil { + return "", nil + } + return m.queryFunc(ctx, text) +} + func TestCommandAppHandleLightList(t *testing.T) { tests := []struct { name string @@ -95,7 +106,7 @@ func TestCommandAppHandleLightList(t *testing.T) { listLightsFunc: func(ctx context.Context) ([]driven.Light, error) { return tt.lights, nil }, - }) + }, &mockAIGateway{}) got, err := app.HandleLightList(context.Background()) if err != nil { @@ -185,7 +196,7 @@ func TestCommandAppHandleLightOn(t *testing.T) { gotColorTemp = colorTempKelvin return tt.turnOnErr }, - }) + }, &mockAIGateway{}) got, err := app.HandleLightOn(context.Background(), tt.entityID, tt.brightnessPct, tt.colorTempKelvin) if tt.wantErr != "" { @@ -255,7 +266,7 @@ func TestCommandAppHandleLightOff(t *testing.T) { gotTransition = transition return tt.turnOffErr }, - }) + }, &mockAIGateway{}) got, err := app.HandleLightOff(context.Background(), tt.entityID, tt.transition) if tt.wantErr != "" { @@ -316,7 +327,7 @@ func TestCommandAppHandleLightToggle(t *testing.T) { toggleLightFunc: func(ctx context.Context, entityID string) error { return tt.toggleErr }, - }) + }, &mockAIGateway{}) got, err := app.HandleLightToggle(context.Background(), "light.kitchen") if tt.wantErr != "" { @@ -361,7 +372,7 @@ func TestCommandAppHandleSwitchList(t *testing.T) { listSwitchesFunc: func(ctx context.Context) ([]driven.Switch, error) { return tt.switches, nil }, - }) + }, &mockAIGateway{}) got, err := app.HandleSwitchList(context.Background()) if err != nil { @@ -409,7 +420,7 @@ func TestCommandAppAutocompleteLights(t *testing.T) { } return tt.lights, nil }, - }) + }, &mockAIGateway{}) got, err := app.AutocompleteLights(context.Background()) if tt.wantErr != "" { @@ -463,7 +474,7 @@ func TestCommandAppAutocompleteSwitches(t *testing.T) { } return tt.switches, nil }, - }) + }, &mockAIGateway{}) got, err := app.AutocompleteSwitches(context.Background()) if tt.wantErr != "" { @@ -481,3 +492,22 @@ func TestCommandAppAutocompleteSwitches(t *testing.T) { }) } } + +func TestCommandAppHandleAIQuery(t *testing.T) { + app := NewCommandApp(&mockHAGateway{}, &mockAIGateway{ + queryFunc: func(ctx context.Context, text string) (string, error) { + if text != "turn on kitchen" { + t.Fatalf("Query() text = %q", text) + } + return "Turning on Kitchen.", nil + }, + }) + + got, err := app.HandleAIQuery(context.Background(), "turn on kitchen") + if err != nil { + t.Fatalf("HandleAIQuery() error = %v", err) + } + if got != "Turning on Kitchen." { + t.Fatalf("HandleAIQuery() = %q", got) + } +} diff --git a/discord-bot/internal/config/config.go b/discord-bot/internal/config/config.go index bfa676c..a3b9891 100644 --- a/discord-bot/internal/config/config.go +++ b/discord-bot/internal/config/config.go @@ -10,6 +10,7 @@ type Config struct { DiscordToken string GuildID string HAGatewayAddr string + AIGatewayAddr string TLSDir string OTELEndpoint string LogLevel string @@ -32,6 +33,7 @@ func Load() (*Config, error) { DiscordToken: token, GuildID: os.Getenv("GUILD_ID"), HAGatewayAddr: addr, + AIGatewayAddr: getenvDefault("AI_GATEWAY_ADDR", "ai-gateway.home-services.svc.cluster.local:50052"), TLSDir: os.Getenv("TLS_DIR"), OTELEndpoint: os.Getenv("OTEL_ENDPOINT"), LogLevel: getenvDefault("LOG_LEVEL", "info"), diff --git a/discord-bot/internal/core/ports/driven/ai.go b/discord-bot/internal/core/ports/driven/ai.go new file mode 100644 index 0000000..ce52270 --- /dev/null +++ b/discord-bot/internal/core/ports/driven/ai.go @@ -0,0 +1,8 @@ +package driven + +import "context" + +// AIGateway exposes the free-form AI query API used by the Discord bot. +type AIGateway interface { + Query(ctx context.Context, text string) (string, error) +}