it works
This commit is contained in:
parent
363efa1cca
commit
f25524791b
4 changed files with 370 additions and 86 deletions
217
README.md
217
README.md
|
|
@ -21,110 +21,193 @@ zig build
|
||||||
# Release build (arm64)
|
# Release build (arm64)
|
||||||
zig build -Doptimize=ReleaseFast
|
zig build -Doptimize=ReleaseFast
|
||||||
|
|
||||||
# Create Lambda deployment package
|
|
||||||
zig build -Doptimize=ReleaseFast package
|
|
||||||
|
|
||||||
# Build for native target (e.g., for local testing)
|
# Build for native target (e.g., for local testing)
|
||||||
zig build -Dtarget=native
|
zig build -Dtarget=native
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
zig build test -Dtarget=native
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- [lambda-zig](../../lambda-zig) - AWS Lambda runtime for Zig
|
- [lambda-zig](https://git.lerch.org/lobo/lambda-zig) - AWS Lambda runtime for Zig
|
||||||
- [controlr](../../controlr) - Rinnai API client (provides `rinnai` module)
|
- [controlr](https://git.lerch.org/lobo/controlr) - Rinnai API client (provides `rinnai` module)
|
||||||
|
|
||||||
|
## Deployment Setup
|
||||||
|
|
||||||
|
Before deploying, you need to configure both AWS credentials and ASK CLI authentication.
|
||||||
|
|
||||||
|
### 1. AWS Credentials
|
||||||
|
|
||||||
|
AWS credentials are required for Lambda deployment. You can configure them in several ways:
|
||||||
|
|
||||||
|
#### Option A: AWS CLI Configuration (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Configure default profile
|
||||||
|
aws configure
|
||||||
|
|
||||||
|
# Or configure a named profile
|
||||||
|
aws configure --profile personal
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `~/.aws/credentials` and `~/.aws/config` files.
|
||||||
|
|
||||||
|
#### Option B: Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AWS_ACCESS_KEY_ID=AKIA...
|
||||||
|
export AWS_SECRET_ACCESS_KEY=...
|
||||||
|
export AWS_DEFAULT_REGION=us-west-2
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using a Non-Default AWS Profile
|
||||||
|
|
||||||
|
If your default AWS profile is your work account but you want to deploy to your personal account:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set the profile for the current shell session
|
||||||
|
export AWS_PROFILE=personal
|
||||||
|
|
||||||
|
# Or specify it per-command
|
||||||
|
zig build awslambda_deploy -Dprofile=personal
|
||||||
|
zig build deploy -Dprofile=personal
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also set the region:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zig build awslambda_deploy -Dprofile=personal -Dregion=us-west-2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. ASK CLI Authentication
|
||||||
|
|
||||||
|
The ASK CLI requires authentication with your Amazon Developer account to deploy Alexa skills.
|
||||||
|
|
||||||
|
#### First-Time Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies (if not already installed)
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Configure ASK CLI (opens browser for Amazon login). Note this takes an ungodly
|
||||||
|
# amount of time to do anything and it will look like everything is hung
|
||||||
|
bun x ask configure
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Open your browser to sign in with your Amazon Developer account
|
||||||
|
2. Store credentials in `~/.ask/cli_config`
|
||||||
|
|
||||||
|
#### Verify Authentication
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun x ask smapi list-skills-for-vendor
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Rinnai Credentials
|
||||||
|
|
||||||
|
The Lambda function needs your Rinnai account credentials to authenticate with the water heater API.
|
||||||
|
|
||||||
|
Create a `.env` file in the project root (this file is gitignored):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
COGNITO_USERNAME=your@email.com
|
||||||
|
COGNITO_PASSWORD=your_password
|
||||||
|
```
|
||||||
|
|
||||||
|
These credentials will be automatically deployed to Lambda when you use the `-Denv-file=.env` option.
|
||||||
|
|
||||||
|
## Build Steps
|
||||||
|
|
||||||
|
| Step | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `zig build` | Build the bootstrap executable |
|
||||||
|
| `zig build test` | Run unit tests |
|
||||||
|
| `zig build awslambda_package` | Package Lambda function into zip |
|
||||||
|
| `zig build awslambda_iam` | Create/verify IAM role |
|
||||||
|
| `zig build awslambda_deploy` | Deploy Lambda function to AWS |
|
||||||
|
| `zig build awslambda_run` | Invoke the deployed Lambda function |
|
||||||
|
| `zig build ask_deploy` | Deploy Alexa skill metadata |
|
||||||
|
| `zig build deploy` | Deploy both Lambda and Alexa skill |
|
||||||
|
|
||||||
|
### Build Options
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `-Doptimize=ReleaseFast` | Build with optimizations | Debug |
|
||||||
|
| `-Dtarget=native` | Build for local machine | aarch64-linux |
|
||||||
|
| `-Dfunction-name=NAME` | Lambda function name | zig-fn |
|
||||||
|
| `-Dprofile=PROFILE` | AWS profile to use | default |
|
||||||
|
| `-Dregion=REGION` | AWS region | from profile |
|
||||||
|
| `-Drole-name=ROLE` | IAM role name | lambda_basic_execution |
|
||||||
|
| `-Dpayload=JSON` | Payload for `awslambda_run` | {} |
|
||||||
|
| `-Denv-file=PATH` | Environment variables file | none |
|
||||||
|
| `-Dallow-principal=PRINCIPAL` | AWS service principal to grant invoke permission | none |
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- AWS CLI configured with appropriate credentials
|
Before deploying, ensure you have:
|
||||||
- mise (for zig and bun version management)
|
|
||||||
|
|
||||||
### 1. Build the Package
|
1. **AWS Account** with credentials configured (see [Deployment Setup](#deployment-setup))
|
||||||
|
2. **Amazon Developer Account** with ASK CLI authenticated (`bun x ask configure`)
|
||||||
|
3. **Rinnai credentials** in `.env` file (see [Rinnai Credentials](#3-rinnai-credentials))
|
||||||
|
|
||||||
|
### Full Deployment (Lambda + Alexa Skill)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mise exec -- zig build -Doptimize=ReleaseFast package
|
zig build deploy -Doptimize=ReleaseFast \
|
||||||
|
-Dfunction-name=water-recirculation \
|
||||||
|
-Dprofile=personal \
|
||||||
|
-Dregion=us-west-2 \
|
||||||
|
-Denv-file=.env \
|
||||||
|
-Dallow-principal=alexa-appkit.amazon.com
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates `function.zip` containing the arm64 bootstrap executable.
|
This command will:
|
||||||
|
1. Build the Lambda function for arm64
|
||||||
|
2. Package it into a zip file
|
||||||
|
3. Create/update the Lambda function in AWS
|
||||||
|
4. Set environment variables from `.env`
|
||||||
|
5. Grant Alexa Skills Kit permission to invoke the function
|
||||||
|
6. Deploy the Alexa skill metadata via ASK CLI
|
||||||
|
|
||||||
### 2. Create Lambda Function (first time only)
|
### Lambda Only
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
aws lambda create-function \
|
zig build awslambda_deploy -Doptimize=ReleaseFast \
|
||||||
--function-name water-recirculation \
|
-Dfunction-name=water-recirculation \
|
||||||
--runtime provided.al2023 \
|
-Dprofile=personal \
|
||||||
--handler bootstrap \
|
-Dregion=us-west-2 \
|
||||||
--architectures arm64 \
|
-Denv-file=.env \
|
||||||
--role arn:aws:iam::ACCOUNT_ID:role/lambda_basic_execution \
|
-Dallow-principal=alexa-appkit.amazon.com
|
||||||
--zip-file fileb://function.zip \
|
|
||||||
--timeout 30 \
|
|
||||||
--memory-size 128
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Set Environment Variables
|
### Alexa Skill Only
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
aws lambda update-function-configuration \
|
zig build ask_deploy
|
||||||
--function-name water-recirculation \
|
|
||||||
--environment "Variables={COGNITO_USERNAME=your@email.com,COGNITO_PASSWORD=your_password}"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Update Function Code (subsequent deploys)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mise exec -- zig build -Doptimize=ReleaseFast package
|
|
||||||
|
|
||||||
aws lambda update-function-code \
|
|
||||||
--function-name water-recirculation \
|
|
||||||
--zip-file fileb://function.zip
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Deploy Alexa Skill
|
|
||||||
|
|
||||||
First time setup - configure ASK CLI (opens browser for Amazon login):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mise exec -- bunx ask-cli configure
|
|
||||||
```
|
|
||||||
|
|
||||||
Deploy the skill:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mise exec -- bunx ask-cli deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
- Create the Alexa skill in your developer account
|
|
||||||
- Upload the interaction model
|
|
||||||
- Link to the Lambda endpoint
|
|
||||||
|
|
||||||
After deployment, add the Alexa Skills Kit trigger permission to Lambda:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
aws lambda add-permission \
|
|
||||||
--function-name water-recirculation \
|
|
||||||
--statement-id alexa-skill \
|
|
||||||
--action lambda:InvokeFunction \
|
|
||||||
--principal alexa-appkit.amazon.com \
|
|
||||||
--event-source-token amzn1.ask.skill.YOUR_SKILL_ID
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
water_recirculation/
|
water_recirculation/
|
||||||
├── build.zig # Build configuration (defaults to arm64-linux)
|
├── build.zig # Build configuration
|
||||||
├── build.zig.zon # Dependencies (lambda-zig, controlr)
|
├── build.zig.zon # Dependencies (lambda-zig, controlr)
|
||||||
|
├── .env # Rinnai credentials (gitignored, create locally)
|
||||||
├── ask-resources.json # ASK CLI deployment config
|
├── ask-resources.json # ASK CLI deployment config
|
||||||
|
├── package.json # Node.js deps for ASK CLI
|
||||||
├── src/
|
├── src/
|
||||||
│ └── main.zig # Alexa request handler
|
│ └── main.zig # Alexa request handler + tests
|
||||||
├── skill-package/
|
├── skill-package/
|
||||||
│ ├── skill.json # Alexa skill manifest
|
│ ├── skill.json # Alexa skill manifest
|
||||||
│ └── interactionModels/
|
│ └── interactionModels/
|
||||||
│ └── custom/
|
│ └── custom/
|
||||||
│ └── en-US.json # Interaction model
|
│ └── en-US.json # Interaction model
|
||||||
└── function.zip # Lambda deployment package (after build)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sample Utterances
|
## Sample Utterances
|
||||||
|
|
|
||||||
27
build.zig
27
build.zig
|
|
@ -1,4 +1,5 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
const lambda_zig = @import("lambda_zig");
|
||||||
|
|
||||||
pub fn build(b: *std.Build) !void {
|
pub fn build(b: *std.Build) !void {
|
||||||
// Default to aarch64-linux for Lambda Graviton deployment
|
// Default to aarch64-linux for Lambda Graviton deployment
|
||||||
|
|
@ -42,18 +43,24 @@ pub fn build(b: *std.Build) !void {
|
||||||
|
|
||||||
b.installArtifact(exe);
|
b.installArtifact(exe);
|
||||||
|
|
||||||
// Create a step to package for Lambda
|
// Configure Lambda build steps (awslambda_package, awslambda_deploy, etc.)
|
||||||
const package_step = b.step("package", "Package for AWS Lambda (arm64) deployment");
|
try lambda_zig.configureBuild(b, lambda_zig_dep, exe);
|
||||||
|
|
||||||
// After installing, create a zip
|
// ASK CLI deploy step for Alexa skill metadata
|
||||||
const install_step = b.getInstallStep();
|
const ask_deploy_cmd = b.addSystemCommand(&.{
|
||||||
|
"bun", "x", "ask", "deploy", "--target", "skill-metadata",
|
||||||
// Add a system command to create zip (requires zip to be installed)
|
|
||||||
const zip_cmd = b.addSystemCommand(&.{
|
|
||||||
"zip", "-j", "function.zip", "zig-out/bin/bootstrap",
|
|
||||||
});
|
});
|
||||||
zip_cmd.step.dependOn(install_step);
|
const ask_deploy_step = b.step("ask_deploy", "Deploy Alexa skill metadata via ASK CLI");
|
||||||
package_step.dependOn(&zip_cmd.step);
|
ask_deploy_step.dependOn(&ask_deploy_cmd.step);
|
||||||
|
|
||||||
|
// Full deploy step - deploys both Lambda function and Alexa skill
|
||||||
|
const full_deploy_step = b.step("deploy", "Deploy Lambda function and Alexa skill");
|
||||||
|
// Lambda deploy (awslambda_deploy) is added by lambda_zig.configureBuild
|
||||||
|
// We need to get a reference to it - it's registered as "awslambda_deploy"
|
||||||
|
if (b.top_level_steps.get("awslambda_deploy")) |lambda_deploy| {
|
||||||
|
full_deploy_step.dependOn(&lambda_deploy.step);
|
||||||
|
}
|
||||||
|
full_deploy_step.dependOn(&ask_deploy_cmd.step);
|
||||||
|
|
||||||
// Test step - reuses the same target query, tests run via emulation or on native arm64
|
// Test step - reuses the same target query, tests run via emulation or on native arm64
|
||||||
const test_module = b.createModule(.{
|
const test_module = b.createModule(.{
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@
|
||||||
.hash = "controlr-0.1.0-upFm0LtgAAAo85RiRDa0WmSrORIhAa5bCF8UdT9rDyUk",
|
.hash = "controlr-0.1.0-upFm0LtgAAAo85RiRDa0WmSrORIhAa5bCF8UdT9rDyUk",
|
||||||
},
|
},
|
||||||
.lambda_zig = .{
|
.lambda_zig = .{
|
||||||
.url = "git+https://git.lerch.org/lobo/lambda-zig#183d2d912c41ca721c8d18e5c258e4472d38db70",
|
.url = "git+https://git.lerch.org/lobo/lambda-zig#b420abb0a145e8c9bb151606c124ed380cb744e9",
|
||||||
.hash = "lambda_zig-0.1.0-_G43_6YQAQD-ahqtf3DQpJroP__spvt4U_uI5TtMZ4Xv",
|
.hash = "lambda_zig-0.1.0-_G43_zRAAQA7RdBlHSxhmfSRrlvOOk3DJhfDJfimhneA",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
.paths = .{
|
.paths = .{
|
||||||
|
|
|
||||||
208
src/main.zig
208
src/main.zig
|
|
@ -2,6 +2,7 @@ const std = @import("std");
|
||||||
const json = std.json;
|
const json = std.json;
|
||||||
const lambda = @import("lambda_runtime");
|
const lambda = @import("lambda_runtime");
|
||||||
const rinnai = @import("rinnai");
|
const rinnai = @import("rinnai");
|
||||||
|
const builtin = @import("builtin");
|
||||||
|
|
||||||
const log = std.log.scoped(.alexa);
|
const log = std.log.scoped(.alexa);
|
||||||
|
|
||||||
|
|
@ -19,24 +20,24 @@ fn handler(allocator: std.mem.Allocator, event_data: []const u8) anyerror![]cons
|
||||||
|
|
||||||
// Parse the Alexa request
|
// Parse the Alexa request
|
||||||
const parsed = json.parseFromSlice(json.Value, allocator, event_data, .{}) catch |err| {
|
const parsed = json.parseFromSlice(json.Value, allocator, event_data, .{}) catch |err| {
|
||||||
log.err("Failed to parse Alexa request: {}", .{err});
|
if (!builtin.is_test) log.err("Failed to parse Alexa request: {}", .{err});
|
||||||
return buildAlexaResponse(allocator, "I couldn't understand that request.", true);
|
return buildAlexaResponse(allocator, "I couldn't understand that request.", true);
|
||||||
};
|
};
|
||||||
defer parsed.deinit();
|
defer parsed.deinit();
|
||||||
|
|
||||||
// Get request type
|
// Get request type
|
||||||
const request_obj = parsed.value.object.get("request") orelse {
|
const request_obj = parsed.value.object.get("request") orelse {
|
||||||
log.err("No 'request' field in Alexa event", .{});
|
if (!builtin.is_test) log.err("No 'request' field in Alexa event", .{});
|
||||||
return buildAlexaResponse(allocator, "Invalid request format.", true);
|
return buildAlexaResponse(allocator, "Invalid request format.", true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const request_type = request_obj.object.get("type") orelse {
|
const request_type = request_obj.object.get("type") orelse {
|
||||||
log.err("No 'type' field in request", .{});
|
if (!builtin.is_test) log.err("No 'type' field in request", .{});
|
||||||
return buildAlexaResponse(allocator, "Invalid request format.", true);
|
return buildAlexaResponse(allocator, "Invalid request format.", true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const request_type_str = if (request_type == .string) request_type.string else {
|
const request_type_str = if (request_type == .string) request_type.string else {
|
||||||
log.err("Request type is not a string", .{});
|
if (!builtin.is_test) log.err("Request type is not a string", .{});
|
||||||
return buildAlexaResponse(allocator, "Invalid request format.", true);
|
return buildAlexaResponse(allocator, "Invalid request format.", true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -57,17 +58,17 @@ fn handler(allocator: std.mem.Allocator, event_data: []const u8) anyerror![]cons
|
||||||
/// Handle Alexa intent requests
|
/// Handle Alexa intent requests
|
||||||
fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value) ![]const u8 {
|
fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value) ![]const u8 {
|
||||||
const intent_obj = request_obj.object.get("intent") orelse {
|
const intent_obj = request_obj.object.get("intent") orelse {
|
||||||
log.err("No 'intent' field in IntentRequest", .{});
|
if (!builtin.is_test) log.err("No 'intent' field in IntentRequest", .{});
|
||||||
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true);
|
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const intent_name_val = intent_obj.object.get("name") orelse {
|
const intent_name_val = intent_obj.object.get("name") orelse {
|
||||||
log.err("No 'name' field in intent", .{});
|
if (!builtin.is_test) log.err("No 'name' field in intent", .{});
|
||||||
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true);
|
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const intent_name = if (intent_name_val == .string) intent_name_val.string else {
|
const intent_name = if (intent_name_val == .string) intent_name_val.string else {
|
||||||
log.err("Intent name is not a string", .{});
|
if (!builtin.is_test) log.err("Intent name is not a string", .{});
|
||||||
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true);
|
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -163,3 +164,196 @@ fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_sess
|
||||||
\\{{"version":"1.0","response":{{"outputSpeech":{{"type":"PlainText","text":"{s}"}},"shouldEndSession":{s}}}}}
|
\\{{"version":"1.0","response":{{"outputSpeech":{{"type":"PlainText","text":"{s}"}},"shouldEndSession":{s}}}}}
|
||||||
, .{ escaped_speech.items, end_session_str });
|
, .{ escaped_speech.items, end_session_str });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
test "buildAlexaResponse with speech and end session" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const response = try buildAlexaResponse(allocator, "Hello world", true);
|
||||||
|
defer allocator.free(response);
|
||||||
|
|
||||||
|
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
|
||||||
|
defer parsed.deinit();
|
||||||
|
|
||||||
|
try std.testing.expectEqualStrings("1.0", parsed.value.object.get("version").?.string);
|
||||||
|
const resp = parsed.value.object.get("response").?.object;
|
||||||
|
try std.testing.expect(resp.get("shouldEndSession").?.bool == true);
|
||||||
|
try std.testing.expectEqualStrings("PlainText", resp.get("outputSpeech").?.object.get("type").?.string);
|
||||||
|
try std.testing.expectEqualStrings("Hello world", resp.get("outputSpeech").?.object.get("text").?.string);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildAlexaResponse with speech and keep session open" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const response = try buildAlexaResponse(allocator, "What can I help with?", false);
|
||||||
|
defer allocator.free(response);
|
||||||
|
|
||||||
|
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
|
||||||
|
defer parsed.deinit();
|
||||||
|
|
||||||
|
const resp = parsed.value.object.get("response").?.object;
|
||||||
|
try std.testing.expect(resp.get("shouldEndSession").?.bool == false);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildAlexaResponse empty speech (SessionEndedRequest)" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const response = try buildAlexaResponse(allocator, "", true);
|
||||||
|
defer allocator.free(response);
|
||||||
|
|
||||||
|
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
|
||||||
|
defer parsed.deinit();
|
||||||
|
|
||||||
|
const resp = parsed.value.object.get("response").?.object;
|
||||||
|
try std.testing.expect(resp.get("shouldEndSession").?.bool == true);
|
||||||
|
try std.testing.expect(resp.get("outputSpeech") == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildAlexaResponse escapes special characters" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const response = try buildAlexaResponse(allocator, "Say \"hello\"\nNew line", true);
|
||||||
|
defer allocator.free(response);
|
||||||
|
|
||||||
|
// Should parse as valid JSON (escaping worked)
|
||||||
|
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
|
||||||
|
defer parsed.deinit();
|
||||||
|
|
||||||
|
const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string;
|
||||||
|
try std.testing.expectEqualStrings("Say \"hello\"\nNew line", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "handler returns error response for invalid JSON" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const response = try handler(allocator, "not valid json");
|
||||||
|
defer allocator.free(response);
|
||||||
|
|
||||||
|
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
|
||||||
|
defer parsed.deinit();
|
||||||
|
|
||||||
|
const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string;
|
||||||
|
try std.testing.expectEqualStrings("I couldn't understand that request.", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "handler returns error for missing request field" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const response = try handler(allocator, "{}");
|
||||||
|
defer allocator.free(response);
|
||||||
|
|
||||||
|
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
|
||||||
|
defer parsed.deinit();
|
||||||
|
|
||||||
|
const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string;
|
||||||
|
try std.testing.expectEqualStrings("Invalid request format.", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "handler handles LaunchRequest" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const launch_request =
|
||||||
|
\\{"request":{"type":"LaunchRequest"}}
|
||||||
|
;
|
||||||
|
const response = try handler(allocator, launch_request);
|
||||||
|
defer allocator.free(response);
|
||||||
|
|
||||||
|
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
|
||||||
|
defer parsed.deinit();
|
||||||
|
|
||||||
|
const resp = parsed.value.object.get("response").?.object;
|
||||||
|
try std.testing.expect(resp.get("shouldEndSession").?.bool == false);
|
||||||
|
const text = resp.get("outputSpeech").?.object.get("text").?.string;
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, text, "hot water") != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "handler handles SessionEndedRequest" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const session_ended =
|
||||||
|
\\{"request":{"type":"SessionEndedRequest"}}
|
||||||
|
;
|
||||||
|
const response = try handler(allocator, session_ended);
|
||||||
|
defer allocator.free(response);
|
||||||
|
|
||||||
|
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
|
||||||
|
defer parsed.deinit();
|
||||||
|
|
||||||
|
const resp = parsed.value.object.get("response").?.object;
|
||||||
|
try std.testing.expect(resp.get("shouldEndSession").?.bool == true);
|
||||||
|
try std.testing.expect(resp.get("outputSpeech") == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "handler handles AMAZON.HelpIntent" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const help_request =
|
||||||
|
\\{"request":{"type":"IntentRequest","intent":{"name":"AMAZON.HelpIntent"}}}
|
||||||
|
;
|
||||||
|
const response = try handler(allocator, help_request);
|
||||||
|
defer allocator.free(response);
|
||||||
|
|
||||||
|
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
|
||||||
|
defer parsed.deinit();
|
||||||
|
|
||||||
|
const resp = parsed.value.object.get("response").?.object;
|
||||||
|
try std.testing.expect(resp.get("shouldEndSession").?.bool == false);
|
||||||
|
const text = resp.get("outputSpeech").?.object.get("text").?.string;
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, text, "recirculation") != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "handler handles AMAZON.StopIntent" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const stop_request =
|
||||||
|
\\{"request":{"type":"IntentRequest","intent":{"name":"AMAZON.StopIntent"}}}
|
||||||
|
;
|
||||||
|
const response = try handler(allocator, stop_request);
|
||||||
|
defer allocator.free(response);
|
||||||
|
|
||||||
|
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
|
||||||
|
defer parsed.deinit();
|
||||||
|
|
||||||
|
const resp = parsed.value.object.get("response").?.object;
|
||||||
|
try std.testing.expect(resp.get("shouldEndSession").?.bool == true);
|
||||||
|
const text = resp.get("outputSpeech").?.object.get("text").?.string;
|
||||||
|
try std.testing.expectEqualStrings("Okay, goodbye.", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "handler handles AMAZON.CancelIntent" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const cancel_request =
|
||||||
|
\\{"request":{"type":"IntentRequest","intent":{"name":"AMAZON.CancelIntent"}}}
|
||||||
|
;
|
||||||
|
const response = try handler(allocator, cancel_request);
|
||||||
|
defer allocator.free(response);
|
||||||
|
|
||||||
|
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
|
||||||
|
defer parsed.deinit();
|
||||||
|
|
||||||
|
const resp = parsed.value.object.get("response").?.object;
|
||||||
|
try std.testing.expect(resp.get("shouldEndSession").?.bool == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "handler handles unknown intent" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const unknown_intent =
|
||||||
|
\\{"request":{"type":"IntentRequest","intent":{"name":"SomeRandomIntent"}}}
|
||||||
|
;
|
||||||
|
const response = try handler(allocator, unknown_intent);
|
||||||
|
defer allocator.free(response);
|
||||||
|
|
||||||
|
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
|
||||||
|
defer parsed.deinit();
|
||||||
|
|
||||||
|
const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string;
|
||||||
|
try std.testing.expectEqualStrings("I don't know how to do that.", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "handler handles unknown request type" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const unknown_request =
|
||||||
|
\\{"request":{"type":"SomeOtherRequest"}}
|
||||||
|
;
|
||||||
|
const response = try handler(allocator, unknown_request);
|
||||||
|
defer allocator.free(response);
|
||||||
|
|
||||||
|
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
|
||||||
|
defer parsed.deinit();
|
||||||
|
|
||||||
|
const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string;
|
||||||
|
try std.testing.expectEqualStrings("I didn't understand that.", text);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue