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 } // NewCommandApp constructs the Discord command application service. func NewCommandApp(ha driven.HAGateway, ai driven.AIGateway, models *modelstore.Store, validator *modelvalidator.Validator) *CommandApp { return &CommandApp{ha: ha, ai: ai, models: models, validator: validator} } // 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 } // 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 } // 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 } // 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 } // 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 "⚠️" } }