feat: integrate AI gateway for free-form queries in discord-bot
Some checks failed
CI / test (push) Successful in 34s
CI / build-ai-gateway (push) Successful in 2m34s
CI / build-ha-gateway (push) Failing after 23s
CI / build-discord-bot (push) Failing after 25s

This commit is contained in:
Nik Afiq 2026-04-21 21:52:41 +09:00
parent 520f5d1ffb
commit 5d732405b8
9 changed files with 217 additions and 11 deletions

View File

@ -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

View File

@ -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")

View File

@ -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))
}

View File

@ -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 {

View 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
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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"),

View 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)
}