// Package directive is the orchestration layer between the Alexa protocol // edge (internal/alexa) and ha-gateway (internal/haclient): it routes a // decoded IntentRequest to a handler, resolves the Device slot via // driven.EntityResolver, and executes the resulting action via // driven.Controller. See alexa-bridge/plan.md's action table for the full // (intent, action) mapping this implements. package directive import ( "context" "fmt" "log/slog" "gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/alexa" "gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/core/ports/driven" ) // handlerFunc handles one already-type-dispatched IntentRequest. type handlerFunc func(ctx context.Context, r *Router, req alexa.Request) alexa.Response // paramsFunc extracts a Controller.ExecuteAction params map from an // intent's slots, or an error if a required slot is missing/empty. type paramsFunc func(slots map[string]alexa.Slot) (map[string]any, error) // Router implements alexa.Dispatcher. type Router struct { resolver driven.EntityResolver controller driven.Controller log *slog.Logger handlers map[string]handlerFunc } // NewRouter constructs a Router with the full set of built-in and custom // intent handlers registered. func NewRouter(resolver driven.EntityResolver, controller driven.Controller, log *slog.Logger) *Router { r := &Router{resolver: resolver, controller: controller, log: log} r.handlers = map[string]handlerFunc{ "AMAZON.HelpIntent": handleHelp, "AMAZON.StopIntent": handleStop, "AMAZON.CancelIntent": handleStop, "TurnOnIntent": handleAction("turn_on", nil), "TurnOffIntent": handleAction("turn_off", nil), "ToggleIntent": handleAction("toggle", nil), "SetBrightnessIntent": handleAction("set_brightness", brightnessParams), "SetColorTempIntent": handleAction("set_color_temp", colorTempParams), "SetColorIntent": handleAction("set_color", colorParams), "SetHVACModeIntent": handleAction("set_hvac_mode", hvacModeParams), "IncreaseTemperatureIntent": handleAction("increase_temperature", nil), "DecreaseTemperatureIntent": handleAction("decrease_temperature", nil), "SetTemperatureIntent": handleAction("set_temperature", temperatureParams), } return r } // Dispatch implements alexa.Dispatcher. func (r *Router) Dispatch(ctx context.Context, req alexa.Request) (alexa.Response, error) { switch req.Request.Type { case "LaunchRequest": return handleLaunch(), nil case "SessionEndedRequest": return alexa.Response{Version: "1.0"}, nil case "IntentRequest": return r.dispatchIntent(ctx, req), nil default: r.log.Warn("unrecognized request type", "type", req.Request.Type) return alexa.NewTellResponse("Sorry, I didn't understand that request."), nil } } func (r *Router) dispatchIntent(ctx context.Context, req alexa.Request) alexa.Response { if req.Request.Intent == nil { return alexa.NewTellResponse("Sorry, I didn't understand that request.") } h, ok := r.handlers[req.Request.Intent.Name] if !ok { r.log.Warn("unrecognized intent", "intent", req.Request.Intent.Name) return alexa.NewTellResponse("Sorry, I don't know how to do that yet.") } return h(ctx, r, req) } // handleAction builds a handlerFunc that resolves the Device slot, // optionally extracts action params, and executes the action via // driven.Controller. Controller/resolution failures are turned into a // spoken failure response rather than propagated as a Dispatch error — a // Dispatch error becomes an HTTP 500 and Alexa's own generic failure // speech, which is a worse experience than the skill explaining what went // wrong itself. func handleAction(action string, paramsFn paramsFunc) handlerFunc { return func(ctx context.Context, r *Router, req alexa.Request) alexa.Response { slots := req.Request.Intent.Slots deviceSlot, ok := slots["Device"] if !ok || deviceSlot.Value == "" { return alexa.NewTellResponse("Sorry, I didn't catch which device you meant.") } entity, ok := r.resolver.Resolve(deviceSlot.Value) if !ok { return alexa.NewTellResponse(fmt.Sprintf("Sorry, I couldn't find a device named %s.", deviceSlot.Value)) } var params map[string]any if paramsFn != nil { p, err := paramsFn(slots) if err != nil { r.log.Warn("invalid intent slots", "action", action, "entity_id", entity.EntityID, "err", err) return alexa.NewTellResponse("Sorry, I didn't understand that request.") } params = p } if err := r.controller.ExecuteAction(ctx, entity.EntityID, action, params); err != nil { r.log.Error("execute action failed", "entity_id", entity.EntityID, "action", action, "err", err) return alexa.NewTellResponse(fmt.Sprintf("Sorry, I couldn't do that to the %s.", entity.FriendlyName)) } return alexa.NewTellResponse(fmt.Sprintf("OK, done with the %s.", entity.FriendlyName)) } } func handleLaunch() alexa.Response { return alexa.Response{ Version: "1.0", Response: alexa.ResponseBody{ OutputSpeech: &alexa.OutputSpeech{Type: "PlainText", Text: "Home control ready. What would you like to do?"}, ShouldEndSession: false, }, } } func handleHelp(_ context.Context, _ *Router, _ alexa.Request) alexa.Response { return alexa.Response{ Version: "1.0", Response: alexa.ResponseBody{ OutputSpeech: &alexa.OutputSpeech{ Type: "PlainText", Text: "You can ask me to turn on or off lights, switches, and climate devices, or set brightness and temperature.", }, ShouldEndSession: false, }, } } func handleStop(_ context.Context, _ *Router, _ alexa.Request) alexa.Response { return alexa.NewTellResponse("Goodbye.") }