All checks were successful
CI / changes (push) Successful in 1s
CI / test (push) Successful in 6s
CI / build-ai-gateway (push) Has been skipped
CI / build-ha-gateway (push) Has been skipped
CI / build-discord-bot (push) Successful in 1m33s
CI / build-tts-gateway (push) Successful in 37s
CI / build-tts-sidecar (push) Has been skipped
- Implemented the /speak command in Discord bot to synthesize speech using the TTS gateway. - Added voice handling logic to join voice channels and play synthesized audio. - Created tests for the new command and voice functionalities. - Introduced TTSGateway interface for TTS service communication. - Updated configuration to include TTS gateway address. - Documented the TTS gateway integration and model artifact distribution process.
471 lines
16 KiB
Go
471 lines
16 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
|
|
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/core/ports/driven"
|
|
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/modelstore"
|
|
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/modelvalidator"
|
|
)
|
|
|
|
// Choice is one Discord autocomplete entry.
|
|
type Choice struct {
|
|
Label string
|
|
Value string
|
|
}
|
|
|
|
// CommandApp orchestrates Discord command use cases against ha-gateway.
|
|
type CommandApp struct {
|
|
ha driven.HAGateway
|
|
ai driven.AIGateway
|
|
models *modelstore.Store
|
|
validator *modelvalidator.Validator
|
|
tts driven.TTSGateway
|
|
}
|
|
|
|
// NewCommandApp constructs the Discord command application service.
|
|
func NewCommandApp(ha driven.HAGateway, ai driven.AIGateway, models *modelstore.Store, validator *modelvalidator.Validator, tts driven.TTSGateway) *CommandApp {
|
|
return &CommandApp{ha: ha, ai: ai, models: models, validator: validator, tts: tts}
|
|
}
|
|
|
|
// HandleLightList formats discovered lights into a monospace-friendly response.
|
|
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
|
|
}
|
|
|
|
// HandleLightOn issues a turn-on request and returns a user-facing confirmation.
|
|
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
|
|
}
|
|
|
|
// HandleLightOff issues a turn-off request and returns a user-facing confirmation.
|
|
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
|
|
}
|
|
|
|
// HandleLightToggle issues a toggle request and returns a user-facing confirmation.
|
|
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
|
|
}
|
|
|
|
// HandleSwitchList formats discovered switches into a monospace-friendly response.
|
|
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
|
|
}
|
|
|
|
// HandleSwitchOn issues a turn-on request and returns a user-facing confirmation.
|
|
func (a *CommandApp) HandleSwitchOn(ctx context.Context, entityID string) (string, error) {
|
|
name, err := a.lookupSwitchName(ctx, entityID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("lookup switch name: %w", err)
|
|
}
|
|
if err := a.ha.TurnOnSwitch(ctx, entityID); err != nil {
|
|
return "", fmt.Errorf("handle switch on: %w", err)
|
|
}
|
|
return fmt.Sprintf("Turned on `%s`.", name), nil
|
|
}
|
|
|
|
// HandleSwitchOff issues a turn-off request and returns a user-facing confirmation.
|
|
func (a *CommandApp) HandleSwitchOff(ctx context.Context, entityID string) (string, error) {
|
|
name, err := a.lookupSwitchName(ctx, entityID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("lookup switch name: %w", err)
|
|
}
|
|
if err := a.ha.TurnOffSwitch(ctx, entityID); err != nil {
|
|
return "", fmt.Errorf("handle switch off: %w", err)
|
|
}
|
|
return fmt.Sprintf("Turned off `%s`.", name), nil
|
|
}
|
|
|
|
// HandleSwitchToggle issues a toggle request and returns a user-facing confirmation.
|
|
func (a *CommandApp) HandleSwitchToggle(ctx context.Context, entityID string) (string, error) {
|
|
name, err := a.lookupSwitchName(ctx, entityID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("lookup switch name: %w", err)
|
|
}
|
|
if err := a.ha.ToggleSwitch(ctx, entityID); err != nil {
|
|
return "", fmt.Errorf("handle switch toggle: %w", err)
|
|
}
|
|
return fmt.Sprintf("Toggled `%s`.", name), nil
|
|
}
|
|
|
|
// HandleACOn issues a turn-on request and returns a user-facing confirmation.
|
|
func (a *CommandApp) HandleACOn(ctx context.Context, entityID string) (string, error) {
|
|
name, err := a.lookupClimateName(ctx, entityID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("lookup climate name: %w", err)
|
|
}
|
|
if err := a.ha.TurnOnClimate(ctx, entityID); err != nil {
|
|
return "", fmt.Errorf("handle ac on: %w", err)
|
|
}
|
|
return fmt.Sprintf("Turned on `%s`.", name), nil
|
|
}
|
|
|
|
// HandleACOff issues a turn-off request and returns a user-facing confirmation.
|
|
func (a *CommandApp) HandleACOff(ctx context.Context, entityID string) (string, error) {
|
|
name, err := a.lookupClimateName(ctx, entityID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("lookup climate name: %w", err)
|
|
}
|
|
if err := a.ha.TurnOffClimate(ctx, entityID); err != nil {
|
|
return "", fmt.Errorf("handle ac off: %w", err)
|
|
}
|
|
return fmt.Sprintf("Turned off `%s`.", name), nil
|
|
}
|
|
|
|
// HandleACMode issues an HVAC mode change request and returns a user-facing confirmation.
|
|
func (a *CommandApp) HandleACMode(ctx context.Context, entityID, hvacMode string) (string, error) {
|
|
name, err := a.lookupClimateName(ctx, entityID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("lookup climate name: %w", err)
|
|
}
|
|
if err := a.ha.SetClimateHVACMode(ctx, entityID, hvacMode); err != nil {
|
|
return "", fmt.Errorf("handle ac mode: %w", err)
|
|
}
|
|
return fmt.Sprintf("Set `%s` mode to `%s`.", name, hvacMode), nil
|
|
}
|
|
|
|
// HandleACTempUp issues a temperature step-up request and returns a user-facing confirmation.
|
|
func (a *CommandApp) HandleACTempUp(ctx context.Context, entityID string) (string, error) {
|
|
name, err := a.lookupClimateName(ctx, entityID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("lookup climate name: %w", err)
|
|
}
|
|
if err := a.ha.IncreaseClimateTemperature(ctx, entityID); err != nil {
|
|
return "", fmt.Errorf("handle ac temp up: %w", err)
|
|
}
|
|
return fmt.Sprintf("Increased `%s` target temperature.", name), nil
|
|
}
|
|
|
|
// HandleACTempDown issues a temperature step-down request and returns a user-facing confirmation.
|
|
func (a *CommandApp) HandleACTempDown(ctx context.Context, entityID string) (string, error) {
|
|
name, err := a.lookupClimateName(ctx, entityID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("lookup climate name: %w", err)
|
|
}
|
|
if err := a.ha.DecreaseClimateTemperature(ctx, entityID); err != nil {
|
|
return "", fmt.Errorf("handle ac temp down: %w", err)
|
|
}
|
|
return fmt.Sprintf("Decreased `%s` target temperature.", name), nil
|
|
}
|
|
|
|
// HandleAIQuery forwards a free-form request to ai-gateway.
|
|
func (a *CommandApp) HandleAIQuery(ctx context.Context, text string) (string, error) {
|
|
reply, modelUsed, err := a.ai.Query(ctx, text, a.models.Get())
|
|
if err != nil {
|
|
return "", fmt.Errorf("handle ai query: %w", err)
|
|
}
|
|
return fmt.Sprintf("%s\n\n_(via %s)_", reply, modelUsed), nil
|
|
}
|
|
|
|
// HandleAIModelSet validates and stores the selected model globally.
|
|
func (a *CommandApp) HandleAIModelSet(ctx context.Context, name string) (string, error) {
|
|
canonical, err := a.validator.Normalize(ctx, name)
|
|
if err != nil {
|
|
if err.Error() == "ambiguous model name" {
|
|
return "", fmt.Errorf("unknown model: %s. Be more specific.", name)
|
|
}
|
|
if err.Error() == "unknown model" {
|
|
return "", fmt.Errorf("unknown model: %s. Try /ai model list.", name)
|
|
}
|
|
return "", fmt.Errorf("validate model: %w", err)
|
|
}
|
|
a.models.Set(canonical)
|
|
return fmt.Sprintf("Active model set to `%s`.", canonical), nil
|
|
}
|
|
|
|
// HandleAIModelGet reports the current selected model or default state.
|
|
func (a *CommandApp) HandleAIModelGet(ctx context.Context) (string, error) {
|
|
cur := a.models.Get()
|
|
if cur == "" {
|
|
return "No model override set. Using ai-gateway default.", nil
|
|
}
|
|
return fmt.Sprintf("Active model: `%s`", cur), nil
|
|
}
|
|
|
|
// HandleAIModelList shows the installed models and marks the active selection.
|
|
func (a *CommandApp) HandleAIModelList(ctx context.Context) (string, error) {
|
|
models, err := a.validator.Known(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("list models: %w", err)
|
|
}
|
|
if len(models) == 0 {
|
|
return "No models installed on the Ollama host.", nil
|
|
}
|
|
|
|
active := a.models.Get()
|
|
lines := make([]string, 0, len(models)+1)
|
|
lines = append(lines, "Available models:")
|
|
for _, model := range models {
|
|
marker := ""
|
|
if model == active {
|
|
marker = " <- active"
|
|
}
|
|
lines = append(lines, fmt.Sprintf("- `%s`%s", model, marker))
|
|
}
|
|
return strings.Join(lines, "\n"), nil
|
|
}
|
|
|
|
// HandleSpeak synthesizes speech via tts-gateway for the invoking Discord adapter
|
|
// to play back in a voice channel; the app layer stays transport-agnostic and
|
|
// leaves voice-channel playback itself to the Discord adapter.
|
|
func (a *CommandApp) HandleSpeak(ctx context.Context, speakerName, text string) ([]byte, string, error) {
|
|
audio, mimeType, err := a.tts.Synthesize(ctx, speakerName, text)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("handle speak: %w", err)
|
|
}
|
|
return audio, mimeType, nil
|
|
}
|
|
|
|
// AutocompleteSpeakers returns the full tts-gateway speaker roster for the /speak
|
|
// command; handleAutocomplete's existing substring filter narrows it client-side,
|
|
// same as the light/switch/ac autocompletes above.
|
|
func (a *CommandApp) AutocompleteSpeakers(ctx context.Context) ([]Choice, error) {
|
|
speakers, err := a.tts.ListSpeakers(ctx, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("autocomplete speakers: %w", err)
|
|
}
|
|
|
|
choices := make([]Choice, 0, len(speakers))
|
|
for _, speaker := range speakers {
|
|
choices = append(choices, Choice{Label: speaker, Value: speaker})
|
|
}
|
|
return choices, nil
|
|
}
|
|
|
|
// AutocompleteAIModels returns model names for the /ai model set command.
|
|
func (a *CommandApp) AutocompleteAIModels(ctx context.Context) ([]Choice, error) {
|
|
models, err := a.validator.Known(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("autocomplete ai models: %w", err)
|
|
}
|
|
choices := make([]Choice, 0, len(models))
|
|
for _, model := range models {
|
|
choices = append(choices, Choice{Label: model, Value: model})
|
|
}
|
|
return choices, nil
|
|
}
|
|
|
|
// AutocompleteLights maps discovered lights into Discord autocomplete choices.
|
|
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
|
|
}
|
|
|
|
// AutocompleteSwitches maps discovered switches into Discord autocomplete choices.
|
|
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
|
|
}
|
|
|
|
// AutocompleteClimates maps discovered climates into Discord autocomplete choices.
|
|
func (a *CommandApp) AutocompleteClimates(ctx context.Context) ([]Choice, error) {
|
|
climates, err := a.ha.ListClimates(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("autocomplete climates: %w", err)
|
|
}
|
|
|
|
choices := make([]Choice, 0, len(climates))
|
|
for _, c := range climates {
|
|
label := c.FriendlyName
|
|
if label == "" {
|
|
label = c.EntityID
|
|
}
|
|
choices = append(choices, Choice{Label: label, Value: c.EntityID})
|
|
}
|
|
return choices, nil
|
|
}
|
|
|
|
// lookupLightName falls back to the entity ID so confirmations remain useful
|
|
// even when Home Assistant does not expose a friendly name.
|
|
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
|
|
}
|
|
|
|
// lookupSwitchName falls back to the entity ID so confirmations remain useful
|
|
// even when Home Assistant does not expose a friendly name.
|
|
func (a *CommandApp) lookupSwitchName(ctx context.Context, entityID string) (string, error) {
|
|
switches, err := a.ha.ListSwitches(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("list switches: %w", err)
|
|
}
|
|
idx := slices.IndexFunc(switches, func(sw driven.Switch) bool {
|
|
return sw.EntityID == entityID
|
|
})
|
|
if idx == -1 {
|
|
return entityID, nil
|
|
}
|
|
if switches[idx].FriendlyName == "" {
|
|
return entityID, nil
|
|
}
|
|
return switches[idx].FriendlyName, nil
|
|
}
|
|
|
|
// lookupClimateName falls back to the entity ID so confirmations remain useful
|
|
// even when Home Assistant does not expose a friendly name.
|
|
func (a *CommandApp) lookupClimateName(ctx context.Context, entityID string) (string, error) {
|
|
climates, err := a.ha.ListClimates(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("list climates: %w", err)
|
|
}
|
|
idx := slices.IndexFunc(climates, func(c driven.Climate) bool {
|
|
return c.EntityID == entityID
|
|
})
|
|
if idx == -1 {
|
|
return entityID, nil
|
|
}
|
|
if climates[idx].FriendlyName == "" {
|
|
return entityID, nil
|
|
}
|
|
return climates[idx].FriendlyName, nil
|
|
}
|
|
|
|
// formatLightLine keeps list output compact because Discord code blocks are
|
|
// easier to scan than rich embeds for dense discovery data.
|
|
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)
|
|
}
|
|
|
|
// stateEmoji compresses common Home Assistant states into visually scannable output.
|
|
func stateEmoji(state string) string {
|
|
switch state {
|
|
case "on":
|
|
return "🟢"
|
|
case "off":
|
|
return "🔴"
|
|
default:
|
|
return "⚠️"
|
|
}
|
|
}
|