feat: integrate AI gateway for free-form queries in discord-bot
This commit is contained in:
parent
520f5d1ffb
commit
5d732405b8
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
107
discord-bot/internal/adapters/secondary/aigateway/client.go
Normal file
107
discord-bot/internal/adapters/secondary/aigateway/client.go
Normal file
@ -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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
|
||||
8
discord-bot/internal/core/ports/driven/ai.go
Normal file
8
discord-bot/internal/core/ports/driven/ai.go
Normal file
@ -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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user