├── .ruby-version ├── .gitignore ├── Gemfile ├── lib ├── google_assistant │ ├── version.rb │ ├── errors.rb │ ├── permission.rb │ ├── user.rb │ ├── device.rb │ ├── conversation.rb │ ├── response │ │ ├── speech_response.rb │ │ ├── ask_for_permission.rb │ │ ├── input_prompt.rb │ │ └── base.rb │ ├── argument.rb │ ├── intent.rb │ ├── dialog_state.rb │ └── assistant.rb └── google_assistant.rb ├── test ├── fake_response.rb ├── test_helper.rb ├── fixtures │ ├── main_intent_request.json │ ├── empty_arguments_request.json │ ├── permission_denied.json │ ├── text_intent_request.json │ ├── single_argument_request.json │ ├── coarse_location_granted.json │ ├── user_name_granted.json │ └── precise_location_granted.json ├── test_google_assistant.rb └── google_assistant │ ├── test_user.rb │ ├── test_conversation.rb │ ├── test_device.rb │ ├── test_argument.rb │ ├── test_permission.rb │ ├── test_dialog_state.rb │ ├── test_intent.rb │ └── test_assistant.rb ├── Gemfile.lock ├── Rakefile ├── .editorconfig ├── google_assistant.gemspec ├── LICENSE.md └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.3.1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.gem -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | ruby "2.3.1" 3 | -------------------------------------------------------------------------------- /lib/google_assistant/version.rb: -------------------------------------------------------------------------------- 1 | module GoogleAssistant 2 | VERSION = "1.0.0" 3 | end 4 | -------------------------------------------------------------------------------- /test/fake_response.rb: -------------------------------------------------------------------------------- 1 | class FakeResponse 2 | attr_reader :headers 3 | 4 | def initialize 5 | @headers = {} 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | 5 | PLATFORMS 6 | ruby 7 | 8 | DEPENDENCIES 9 | 10 | RUBY VERSION 11 | ruby 2.3.1p112 12 | 13 | BUNDLED WITH 14 | 1.13.6 15 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rake/testtask" 4 | 5 | Rake::TestTask.new do |t| 6 | t.libs << "test" 7 | t.test_files = FileList["test/**/test_*.rb"] 8 | end 9 | 10 | desc "Run tests" 11 | task default: :test 12 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "fake_response" 3 | 4 | module TestHelper 5 | 6 | def load_json_fixture(fixture_name) 7 | file = File.read("test/fixtures/#{fixture_name}.json") 8 | JSON.parse(file) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /lib/google_assistant.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Dir["#{File.dirname(__FILE__)}/google_assistant/**/*.rb"].each { |file| require file } 4 | 5 | module GoogleAssistant 6 | 7 | def self.respond_to(params, response, &block) 8 | Assistant.new(params, response).respond_to(&block) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/google_assistant/errors.rb: -------------------------------------------------------------------------------- 1 | module GoogleAssistant 2 | InvalidIntent = Class.new(StandardError) 3 | InvalidMessage = Class.new(StandardError) 4 | InvalidInputPrompt = Class.new(StandardError) 5 | InvalidPermission = Class.new(StandardError) 6 | InvalidPermissionContext = Class.new(StandardError) 7 | MissingRequestInputs = Class.new(StandardError) 8 | MissingRequestIntent = Class.new(StandardError) 9 | end 10 | -------------------------------------------------------------------------------- /lib/google_assistant/permission.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GoogleAssistant 4 | module Permission 5 | NAME = "NAME" 6 | DEVICE_PRECISE_LOCATION = "DEVICE_PRECISE_LOCATION" 7 | DEVICE_COARSE_LOCATION = "DEVICE_COARSE_LOCATION" 8 | 9 | def self.valid?(permissions) 10 | permissions = [*permissions] 11 | permissions.all? do |permission| 12 | [NAME, DEVICE_PRECISE_LOCATION, DEVICE_COARSE_LOCATION].include?(permission) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/google_assistant/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GoogleAssistant 4 | class User 5 | attr_reader :id, :profile, :access_token 6 | 7 | def initialize(opts) 8 | @id = opts["user_id"] 9 | @profile = opts["profile"] || {} 10 | @access_token = opts["access_token"] 11 | end 12 | 13 | def display_name 14 | profile["display_name"] 15 | end 16 | 17 | def given_name 18 | profile["given_name"] 19 | end 20 | 21 | def family_name 22 | profile["family_name"] 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/fixtures/main_intent_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "conversation": { 3 | "conversation_id": "1234567890", 4 | "type": 1 5 | }, 6 | "inputs": [ 7 | { 8 | "arguments": [], 9 | "intent": "assistant.intent.action.MAIN", 10 | "raw_inputs": [ 11 | { 12 | "input_type": 2, 13 | "query": "Google Assistant Ruby Test" 14 | } 15 | ] 16 | } 17 | ], 18 | "user": { 19 | "user_id": "qwERtyUiopaSdfGhJklzXCVBNm/tF=" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/fixtures/empty_arguments_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "conversation": { 3 | "conversation_id": "1234567890", 4 | "type": 1 5 | }, 6 | "inputs": [ 7 | { 8 | "arguments": [], 9 | "intent": "assistant.intent.action.MAIN", 10 | "raw_inputs": [ 11 | { 12 | "input_type": 2, 13 | "query": "Google Assistant Ruby Test" 14 | } 15 | ] 16 | } 17 | ], 18 | "user": { 19 | "user_id": "qwERtyUiopaSdfGhJklzXCVBNm/tF=" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/google_assistant/device.rb: -------------------------------------------------------------------------------- 1 | module GoogleAssistant 2 | class Device 3 | attr_reader :location, :coordinates 4 | 5 | def initialize(opts) 6 | @location = opts["location"] || {} 7 | @coordinates = @location["coordinates"] || {} 8 | end 9 | 10 | def city 11 | location["city"] 12 | end 13 | 14 | def zip_code 15 | location["zip_code"] 16 | end 17 | 18 | def formatted_address 19 | location["formatted_address"] 20 | end 21 | 22 | def latitude 23 | coordinates["latitude"] 24 | end 25 | 26 | def longitude 27 | coordinates["longitude"] 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/google_assistant/conversation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GoogleAssistant 4 | class Conversation 5 | class Type 6 | TYPE_UNSPECIFIED = 0 7 | NEW = 1 8 | ACTIVE = 2 9 | EXPIRED = 3 10 | ARCHIVED = 4 11 | end 12 | 13 | attr_reader :id, :type, :dialog_state 14 | 15 | def initialize(opts) 16 | @id = opts["conversation_id"] 17 | @type = opts["type"] 18 | @dialog_state = DialogState.new(opts["conversation_token"]) 19 | end 20 | 21 | def state 22 | dialog_state.state 23 | end 24 | 25 | def state=(state) 26 | dialog_state.state = state 27 | end 28 | 29 | def data 30 | dialog_state.data 31 | end 32 | 33 | def data=(data) 34 | dialog_state.data = data 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/google_assistant/response/speech_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "google_assistant/response/base" 4 | 5 | module GoogleAssistant 6 | module Response 7 | class SpeechResponse < Base 8 | 9 | attr_accessor :message 10 | 11 | def initialize(message) 12 | @message = message 13 | super() 14 | end 15 | 16 | def to_json 17 | raise GoogleAssistant::InvalidMessage if message.nil? || message.empty? 18 | 19 | response = super(false) 20 | 21 | speech_response = if is_ssml?(message) 22 | { ssml: message } 23 | else 24 | { text_to_speech: message } 25 | end 26 | response[:final_response] = { speech_response: speech_response } 27 | 28 | response 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/fixtures/permission_denied.json: -------------------------------------------------------------------------------- 1 | { 2 | "conversation": { 3 | "conversation_id": "1234567890", 4 | "conversation_token": "{\"state\":null,\"data\":{}}", 5 | "type": 2 6 | }, 7 | "inputs": [ 8 | { 9 | "intent": "assistant.intent.action.PERMISSION", 10 | "raw_inputs": [ 11 | { 12 | "input_type": 2, 13 | "query": "nope" 14 | } 15 | ], 16 | "arguments": [ 17 | { 18 | "name": "permission_granted", 19 | "raw_text": "nope", 20 | "text_value": "false" 21 | } 22 | ] 23 | } 24 | ], 25 | "user": { 26 | "user_id": "qwERtyUiopaSdfGhJklzXCVBNm/tF=" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/google_assistant/argument.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GoogleAssistant 4 | class Argument 5 | attr_reader :name, :raw_text, :text_value 6 | 7 | def self.from(opts) 8 | case opts["name"] 9 | when "permission_granted" 10 | PermissionArgument.new(opts) 11 | when "text" 12 | TextArgument.new(opts) 13 | else 14 | Argument.new(opts) 15 | end 16 | end 17 | 18 | def initialize(opts) 19 | @name = opts["name"] 20 | @raw_text = opts["raw_text"] 21 | @text_value = opts["text_value"] 22 | end 23 | end 24 | 25 | class TextArgument < Argument 26 | alias_method :value, :text_value 27 | end 28 | 29 | class PermissionArgument < Argument 30 | 31 | def permission_granted? 32 | text_value == "true" 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/google_assistant/response/ask_for_permission.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "google_assistant/response/base" 4 | 5 | module GoogleAssistant 6 | module Response 7 | class AskForPermission < Base 8 | 9 | attr_accessor :context, :permissions 10 | 11 | def initialize(context, permissions, conversation) 12 | @context = context 13 | @permissions = [*permissions].compact 14 | super(conversation) 15 | end 16 | 17 | def to_json 18 | response = super(true) 19 | 20 | expected_intent = build_expected_intent(StandardIntents::PERMISSION, permissions, context) 21 | expected_inputs = build_expected_inputs(expected_intent: expected_intent) 22 | response[:expected_inputs] = expected_inputs 23 | 24 | response 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/fixtures/text_intent_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "conversation": { 3 | "conversation_id": "1234567890", 4 | "conversation_token": "{\"state\":null,\"data\":{}}", 5 | "type": 2 6 | }, 7 | "inputs": [ 8 | { 9 | "arguments": [ 10 | { 11 | "name": "text", 12 | "raw_text": "this is some input", 13 | "text_value": "this is some input" 14 | } 15 | ], 16 | "intent": "assistant.intent.action.TEXT", 17 | "raw_inputs": [ 18 | { 19 | "input_type": 2, 20 | "query": "this is some input" 21 | } 22 | ] 23 | } 24 | ], 25 | "user": { 26 | "user_id": "qwERtyUiopaSdfGhJklzXCVBNm/tF=" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/fixtures/single_argument_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "conversation": { 3 | "conversation_id": "1234567890", 4 | "conversation_token": "{\"state\":null,\"data\":{}}", 5 | "type": 2 6 | }, 7 | "inputs": [ 8 | { 9 | "arguments": [ 10 | { 11 | "name": "text", 12 | "raw_text": "this is some raw text", 13 | "text_value": "this is a text value" 14 | } 15 | ], 16 | "intent": "assistant.intent.action.TEXT", 17 | "raw_inputs": [ 18 | { 19 | "input_type": 2, 20 | "query": "this is a query" 21 | } 22 | ] 23 | } 24 | ], 25 | "user": { 26 | "user_id": "qwERtyUiopaSdfGhJklzXCVBNm/tF=" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/google_assistant/response/input_prompt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "google_assistant/response/base" 4 | 5 | module GoogleAssistant 6 | module Response 7 | class InputPrompt < Base 8 | 9 | attr_accessor :prompt, :no_input_prompts 10 | 11 | def initialize(prompt, no_input_prompts, conversation) 12 | @prompt = prompt 13 | @no_input_prompts = [*no_input_prompts].compact 14 | super(conversation) 15 | end 16 | 17 | def to_json 18 | response = super(true) 19 | 20 | expected_intent = build_expected_intent(StandardIntents::TEXT) 21 | expected_inputs = build_expected_inputs(prompt: prompt, no_input_prompts: no_input_prompts, expected_intent: expected_intent) 22 | response[:expected_inputs] = expected_inputs 23 | 24 | response 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/fixtures/coarse_location_granted.json: -------------------------------------------------------------------------------- 1 | { 2 | "conversation": { 3 | "conversation_id": "1234567890", 4 | "conversation_token": "{\"state\":null,\"data\":{}}", 5 | "type": 2 6 | }, 7 | "inputs": [ 8 | { 9 | "intent": "assistant.intent.action.PERMISSION", 10 | "raw_inputs": [ 11 | { 12 | "input_type": 2, 13 | "query": "sure" 14 | } 15 | ], 16 | "arguments": [ 17 | { 18 | "name": "permission_granted", 19 | "raw_text": "sure", 20 | "text_value": "true" 21 | } 22 | ] 23 | } 24 | ], 25 | "user": { 26 | "user_id": "qwERtyUiopaSdfGhJklzXCVBNm/tF=" 27 | }, 28 | "device": { 29 | "location": { 30 | "zip_code": "94043", 31 | "city": "Mountain View" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/fixtures/user_name_granted.json: -------------------------------------------------------------------------------- 1 | { 2 | "conversation": { 3 | "conversation_id": "1234567890", 4 | "conversation_token": "{\"state\":null,\"data\":{}}", 5 | "type": 2 6 | }, 7 | "inputs": [ 8 | { 9 | "intent": "assistant.intent.action.PERMISSION", 10 | "raw_inputs": [ 11 | { 12 | "input_type": 2, 13 | "query": "sure" 14 | } 15 | ], 16 | "arguments": [ 17 | { 18 | "name": "permission_granted", 19 | "raw_text": "sure", 20 | "text_value": "true" 21 | } 22 | ] 23 | } 24 | ], 25 | "user": { 26 | "user_id": "qwERtyUiopaSdfGhJklzXCVBNm/tF=", 27 | "profile": { 28 | "display_name": "John Smith", 29 | "given_name": "John", 30 | "family_name": "Smith" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/test_google_assistant.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "test_helper" 3 | require "google_assistant" 4 | require "google_assistant/intent" 5 | require "google_assistant/dialog_state" 6 | 7 | describe GoogleAssistant do 8 | include TestHelper 9 | 10 | describe "::respond_to" do 11 | 12 | it "creates a new Assistant and calls respond_to on it" do 13 | params = load_json_fixture(:main_intent_request) 14 | response = FakeResponse.new 15 | 16 | assistant = nil 17 | called_intent = false 18 | 19 | GoogleAssistant.respond_to(params, response) do |a| 20 | assistant = a 21 | 22 | a.intent.main { called_intent = true } 23 | end 24 | 25 | assert(called_intent) 26 | assert(assistant.is_a?(GoogleAssistant::Assistant)) 27 | assert_equal(params, assistant.params) 28 | assert_equal("v1", response.headers["Google-Assistant-API-Version"]) 29 | assert_equal(response, assistant.response) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /google_assistant.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "google_assistant/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "google_assistant" 8 | spec.version = GoogleAssistant::VERSION 9 | spec.authors = ["Aaron Milam"] 10 | spec.email = ["armilam@gmail.com"] 11 | 12 | spec.summary = "Ruby SDK for the Google Assistant API" 13 | spec.description = "This SDK provides the framework for creating Google Assistant actions in Ruby. It works in Ruby on Rails and Sinatra (and probably others)." 14 | spec.homepage = "https://github.com/armilam/google-assistant-ruby" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.require_paths = ["lib"] 19 | 20 | spec.add_development_dependency "bundler", "~> 1.13" 21 | spec.add_development_dependency "rake", "~> 12.0" 22 | spec.add_development_dependency "minitest", "~> 5.0" 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Aaron Milam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/google_assistant/intent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GoogleAssistant 4 | class StandardIntents 5 | 6 | # Assistant fires MAIN intent for queries like [talk to $action]. 7 | MAIN = "assistant.intent.action.MAIN" 8 | 9 | # Assistant fires TEXT intent when action issues ask intent. 10 | TEXT = "assistant.intent.action.TEXT" 11 | 12 | # Assistant fires PERMISSION intent when action invokes askForPermission. 13 | PERMISSION = "assistant.intent.action.PERMISSION" 14 | end 15 | 16 | class Intent 17 | attr_reader :intent_string 18 | 19 | def initialize(intent_string) 20 | @intent_string = intent_string 21 | end 22 | 23 | def main(&block) 24 | intents[StandardIntents::MAIN] = block 25 | end 26 | 27 | def text(&block) 28 | intents[StandardIntents::TEXT] = block 29 | end 30 | 31 | def permission(&block) 32 | intents[StandardIntents::PERMISSION] = block 33 | end 34 | 35 | def call 36 | block = intents[intent_string] 37 | return if block.nil? 38 | 39 | block.call 40 | end 41 | 42 | private 43 | 44 | def intents 45 | @_intents ||= {} 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/fixtures/precise_location_granted.json: -------------------------------------------------------------------------------- 1 | { 2 | "conversation": { 3 | "conversation_id": "1234567890", 4 | "conversation_token": "{\"state\":null,\"data\":{}}", 5 | "type": 2 6 | }, 7 | "inputs": [ 8 | { 9 | "intent": "assistant.intent.action.PERMISSION", 10 | "raw_inputs": [ 11 | { 12 | "input_type": 2, 13 | "query": "sure" 14 | } 15 | ], 16 | "arguments": [ 17 | { 18 | "name": "permission_granted", 19 | "raw_text": "sure", 20 | "text_value": "true" 21 | } 22 | ] 23 | } 24 | ], 25 | "user": { 26 | "user_id": "qwERtyUiopaSdfGhJklzXCVBNm/tF=" 27 | }, 28 | "device": { 29 | "location": { 30 | "coordinates": { 31 | "latitude": 37.422, 32 | "longitude": -122.084 33 | }, 34 | "formatted_address": "1600 Amphitheatre Parkway, Mountain View, CA 94043, United States", 35 | "zip_code": "94043", 36 | "city": "Mountain View" 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/google_assistant/dialog_state.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module GoogleAssistant 6 | class DialogState 7 | DEFAULT_STATE = { "state" => nil, "data" => {} }.freeze 8 | 9 | def initialize(state_hash_or_conversation_token = nil) 10 | if state_hash_or_conversation_token.is_a?(String) 11 | @raw_token = state_hash_or_conversation_token 12 | @state_hash = parse_token(state_hash_or_conversation_token) 13 | elsif state_hash_or_conversation_token.is_a?(Hash) 14 | @state_hash = state_hash_or_conversation_token 15 | else 16 | @state_hash = DEFAULT_STATE.dup 17 | end 18 | end 19 | 20 | def state 21 | state_hash["state"] 22 | end 23 | 24 | def state=(state) 25 | state_hash["state"] = state 26 | end 27 | 28 | def data 29 | state_hash["data"] 30 | end 31 | 32 | def data=(data) 33 | raise "DialogState data must be a hash" unless data.is_a?(Hash) 34 | 35 | state_hash["data"] = data 36 | end 37 | 38 | def to_json 39 | state_hash.to_json 40 | end 41 | 42 | private 43 | 44 | attr_reader :state_hash, :raw_token 45 | 46 | def parse_token(token) 47 | JSON.parse(token) 48 | rescue JSON::ParserError, TypeError 49 | DEFAULT_STATE.dup 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/google_assistant/test_user.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "google_assistant/user" 3 | 4 | describe GoogleAssistant::User do 5 | let(:params) do 6 | { 7 | "user_id" => "some user id", 8 | "access_token" => "iuaweLJ7igJgkyUGl7gujy52i8Iu609unjBJbk6", 9 | "profile" => { 10 | "display_name" => "Johnny", 11 | "given_name" => "John", 12 | "family_name" => "Smith" 13 | } 14 | } 15 | end 16 | subject { GoogleAssistant::User.new(params) } 17 | 18 | describe "#initialize" do 19 | 20 | it "sets the class's attributes" do 21 | assert_equal(params["user_id"], subject.id) 22 | assert_equal(params["access_token"], subject.access_token) 23 | assert_equal(params["profile"], subject.profile) 24 | end 25 | end 26 | 27 | describe "#display_name" do 28 | 29 | it "returns the display_name from the hash" do 30 | assert_equal(params["profile"]["display_name"], subject.display_name) 31 | end 32 | end 33 | 34 | describe "#given_name" do 35 | 36 | it "returns the given_name from the hash" do 37 | assert_equal(params["profile"]["given_name"], subject.given_name) 38 | end 39 | end 40 | 41 | describe "#family_name" do 42 | 43 | it "returns the family_name from the hash" do 44 | assert_equal(params["profile"]["family_name"], subject.family_name) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/google_assistant/test_conversation.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "google_assistant/conversation" 3 | 4 | describe GoogleAssistant::Conversation do 5 | let(:conversation_params) { { "conversation_id" => "abc123", "type" => 1, "conversation_token" => { "state" => "some state", "data" => { "some data" => "a value" } } } } 6 | subject { GoogleAssistant::Conversation.new(conversation_params) } 7 | 8 | describe "#initialize" do 9 | 10 | it "returns a Conversation object with the given params" do 11 | assert_equal("abc123", subject.id) 12 | assert_equal(1, subject.type) 13 | assert_equal(GoogleAssistant::DialogState, subject.dialog_state.class) 14 | end 15 | end 16 | 17 | describe "#state" do 18 | 19 | it "delegates to the dialog state object" do 20 | subject.dialog_state.state = "a new state" 21 | assert_equal("a new state", subject.state) 22 | end 23 | end 24 | 25 | describe "#state=" do 26 | 27 | it "delegates to the dialog state object" do 28 | subject.state = "a new state" 29 | assert_equal("a new state", subject.dialog_state.state) 30 | end 31 | end 32 | 33 | describe "#data" do 34 | 35 | it "delegates to the dialog state object" do 36 | subject.dialog_state.data = { "a new data" => "with a new value" } 37 | assert_equal({ "a new data" => "with a new value" }, subject.data) 38 | end 39 | end 40 | 41 | describe "#data=" do 42 | 43 | it "delegates to the dialog state object" do 44 | subject.data = { "a new data" => "with a new value" } 45 | assert_equal({ "a new data" => "with a new value" }, subject.dialog_state.data) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/google_assistant/test_device.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "google_assistant/device" 3 | 4 | describe GoogleAssistant::Device do 5 | let(:params) do 6 | { 7 | "location" => { 8 | "coordinates" => { 9 | "latitude" => 37.422, 10 | "longitude" => -122.084 11 | }, 12 | "formatted_address" => "1600 Amphitheatre Parkway, Mountain View, CA 94043, United States", 13 | "zip_code" => "94043", 14 | "city" => "Mountain View" 15 | } 16 | } 17 | end 18 | subject { GoogleAssistant::Device.new(params) } 19 | 20 | describe "#initialize" do 21 | 22 | it "sets the class's attributes" do 23 | assert_equal(params["location"], subject.location) 24 | assert_equal(params["location"]["coordinates"], subject.coordinates) 25 | end 26 | end 27 | 28 | describe "#city" do 29 | 30 | it "returns the city from the hash" do 31 | assert_equal(params["location"]["city"], subject.city) 32 | end 33 | end 34 | 35 | describe "#zip_code" do 36 | 37 | it "returns the zip_code from the hash" do 38 | assert_equal(params["location"]["zip_code"], subject.zip_code) 39 | end 40 | end 41 | 42 | describe "#formatted_address" do 43 | 44 | it "returns the formatted_address from the hash" do 45 | assert_equal(params["location"]["formatted_address"], subject.formatted_address) 46 | end 47 | end 48 | 49 | describe "#latitude" do 50 | 51 | it "returns the latitude from the hash" do 52 | assert_equal(params["location"]["coordinates"]["latitude"], subject.latitude) 53 | end 54 | end 55 | 56 | describe "#longitude" do 57 | 58 | it "returns the longitude from the hash" do 59 | assert_equal(params["location"]["coordinates"]["longitude"], subject.longitude) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/google_assistant/assistant.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module GoogleAssistant 6 | class Assistant 7 | 8 | attr_reader :params, :response 9 | 10 | def initialize(params, response) 11 | @params = params 12 | @response = response 13 | end 14 | 15 | def respond_to(&block) 16 | yield(self) 17 | 18 | response.headers["Google-Assistant-API-Version"] = "v1" 19 | 20 | intent.call 21 | end 22 | 23 | def intent 24 | @_intent ||= Intent.new(intent_string) 25 | end 26 | 27 | def arguments 28 | @_arguments ||= inputs[0]["arguments"].map do |argument| 29 | Argument.from(argument) 30 | end 31 | end 32 | 33 | def permission_granted? 34 | arguments.any? do |argument| 35 | argument.is_a?(PermissionArgument) && 36 | argument.permission_granted? 37 | end 38 | end 39 | 40 | def conversation 41 | @_conversation ||= Conversation.new(conversation_params) 42 | end 43 | 44 | def user 45 | @_user ||= User.new(user_params) 46 | end 47 | 48 | def device 49 | @_device ||= Device.new(device_params) 50 | end 51 | 52 | def tell(message) 53 | response = Response::SpeechResponse.new(message) 54 | response.to_json 55 | end 56 | 57 | def ask(prompt, no_input_prompts = []) 58 | response = Response::InputPrompt.new(prompt, no_input_prompts, conversation) 59 | response.to_json 60 | end 61 | 62 | def ask_for_permission(context, permissions) 63 | response = Response::AskForPermission.new(context, permissions, conversation) 64 | response.to_json 65 | end 66 | 67 | private 68 | 69 | def inputs 70 | raise MissingRequestInputs if params["inputs"].nil? 71 | params["inputs"] 72 | end 73 | 74 | def intent_string 75 | raise MissingRequestIntent if inputs[0]["intent"].nil? 76 | inputs[0]["intent"] 77 | end 78 | 79 | def conversation_params 80 | params["conversation"] || {} 81 | end 82 | 83 | def user_params 84 | params["user"] || {} 85 | end 86 | 87 | def device_params 88 | params["device"] || {} 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/google_assistant/test_argument.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "google_assistant/argument" 3 | 4 | describe GoogleAssistant::Argument do 5 | let(:params) { { "name" => "a name", "raw_text" => "some raw text", "text_value" => "some text value" } } 6 | subject { GoogleAssistant::Argument.new(params) } 7 | 8 | describe "#from" do 9 | subject { GoogleAssistant::Argument.from(params) } 10 | 11 | describe "when name is permission_granted" do 12 | let(:params) { { "name" => "permission_granted" } } 13 | 14 | it "returns a PermissionArgument object" do 15 | assert_equal(GoogleAssistant::PermissionArgument, subject.class) 16 | end 17 | end 18 | 19 | describe "when name is text" do 20 | let(:params) { { "name" => "text" } } 21 | 22 | it "returns a TextArgument object" do 23 | assert_equal(GoogleAssistant::TextArgument, subject.class) 24 | end 25 | end 26 | 27 | describe "when name is something unknown" do 28 | let(:params) { { "name" => "a name" } } 29 | 30 | it "returns an Argument object" do 31 | assert_equal(GoogleAssistant::Argument, subject.class) 32 | end 33 | end 34 | end 35 | 36 | describe "#initialize" do 37 | 38 | it "sets the class's attributes" do 39 | assert_equal(params["name"], subject.name) 40 | assert_equal(params["raw_text"], subject.raw_text) 41 | assert_equal(params["text_value"], subject.text_value) 42 | end 43 | end 44 | end 45 | 46 | describe GoogleAssistant::TextArgument do 47 | let(:params) { { "name" => "text", "text_value" => "some text value" } } 48 | subject { GoogleAssistant::Argument.from(params) } 49 | 50 | describe "#value" do 51 | 52 | it "returns the value of text_value" do 53 | assert_equal(params["text_value"], subject.value) 54 | end 55 | end 56 | end 57 | 58 | describe GoogleAssistant::PermissionArgument do 59 | let(:params) { { "name" => "permission_granted", "text_value" => text_value } } 60 | subject { GoogleAssistant::Argument.from(params) } 61 | 62 | describe "#permission_granted?" do 63 | 64 | describe "when text_value is true" do 65 | let(:text_value) { "true" } 66 | 67 | it "returns true" do 68 | assert(subject.permission_granted?) 69 | end 70 | end 71 | 72 | describe "when text_value is false" do 73 | let(:text_value) { "false" } 74 | 75 | it "returns false" do 76 | assert_equal(false, subject.permission_granted?) 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/google_assistant/response/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GoogleAssistant 4 | module Response 5 | class Base 6 | 7 | attr_reader :conversation 8 | 9 | def initialize(conversation = nil) 10 | @conversation = conversation 11 | end 12 | 13 | def to_json(expect_user_response) 14 | response = {} 15 | response[:conversation_token] = conversation.dialog_state.to_json if conversation&.dialog_state 16 | response[:expect_user_response] = expect_user_response 17 | 18 | response 19 | end 20 | 21 | protected 22 | 23 | def build_input_prompt(prompt, no_input_prompts) 24 | raise GoogleAssistant::InvalidInputPrompt if prompt.nil? || prompt.empty? 25 | 26 | initial_prompts = [ 27 | { prompt_type(prompt) => prompt } 28 | ] 29 | 30 | no_input_prompts = no_input_prompts.map do |no_input_prompt| 31 | { prompt_type(no_input_prompt) => no_input_prompt } 32 | end 33 | 34 | { 35 | initial_prompts: initial_prompts, 36 | no_input_prompts: no_input_prompts 37 | } 38 | end 39 | 40 | def build_expected_inputs(prompt: "placeholder", no_input_prompts: [], expected_intent:) 41 | prompt = build_input_prompt(prompt, no_input_prompts) 42 | 43 | expected_inputs = [{ 44 | input_prompt: prompt, 45 | possible_intents: [expected_intent] 46 | }] 47 | end 48 | 49 | def build_expected_intent(intent, permissions = nil, context = nil) 50 | raise InvalidIntent if intent.nil? || intent.empty? 51 | 52 | expected_intent = { intent: intent } 53 | 54 | unless permissions.nil? 55 | raise GoogleAssistant::InvalidPermissionContext if context.nil? || context.empty? 56 | raise GoogleAssistant::InvalidPermission if permissions.empty? 57 | raise GoogleAssistant::InvalidPermission unless GoogleAssistant::Permission.valid?(permissions) 58 | 59 | expected_intent[:input_value_spec] = { 60 | permission_value_spec: { 61 | opt_context: context, 62 | permissions: permissions 63 | } 64 | } 65 | end 66 | 67 | expected_intent 68 | end 69 | 70 | private 71 | 72 | def prompt_type(text) 73 | is_ssml?(text) ? :ssml : :text_to_speech 74 | end 75 | 76 | def is_ssml?(text) 77 | text =~ /^]*>(.*?)<\/speak>$/ 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/google_assistant/test_permission.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "google_assistant/permission" 3 | 4 | describe GoogleAssistant::Permission do 5 | 6 | describe "::valid?" do 7 | 8 | describe "when given a single valid permission" do 9 | 10 | it "returns true" do 11 | permissions = [ 12 | GoogleAssistant::Permission::NAME, 13 | GoogleAssistant::Permission::DEVICE_PRECISE_LOCATION, 14 | GoogleAssistant::Permission::DEVICE_COARSE_LOCATION 15 | ] 16 | 17 | permissions.each do |permission| 18 | assert(GoogleAssistant::Permission.valid?(permission)) 19 | end 20 | end 21 | end 22 | 23 | describe "when given a single valid permission in an array" do 24 | 25 | it "returns true" do 26 | permissions = [ 27 | GoogleAssistant::Permission::NAME, 28 | GoogleAssistant::Permission::DEVICE_PRECISE_LOCATION, 29 | GoogleAssistant::Permission::DEVICE_COARSE_LOCATION 30 | ] 31 | 32 | permissions.each do |permission| 33 | assert(GoogleAssistant::Permission.valid?([permission])) 34 | end 35 | end 36 | end 37 | 38 | describe "when given multiple valid permissions in an array" do 39 | 40 | it "returns true" do 41 | permissions = [ 42 | GoogleAssistant::Permission::NAME, 43 | GoogleAssistant::Permission::DEVICE_PRECISE_LOCATION, 44 | GoogleAssistant::Permission::DEVICE_COARSE_LOCATION 45 | ] 46 | 47 | permissions.combination(2).each do |permission_combo| 48 | assert(GoogleAssistant::Permission.valid?(permission_combo)) 49 | end 50 | 51 | assert(GoogleAssistant::Permission.valid?(permissions)) 52 | end 53 | end 54 | 55 | describe "when given a single invalid permission" do 56 | 57 | it "returns false" do 58 | assert(!GoogleAssistant::Permission.valid?("not a valid permission")) 59 | end 60 | end 61 | 62 | describe "when given a single invalid permission in an array" do 63 | 64 | it "returns false" do 65 | assert(!GoogleAssistant::Permission.valid?(["not a valid permission"])) 66 | end 67 | end 68 | 69 | describe "when given an invalid permission in an array of otherwise valid permissions" do 70 | 71 | it "returns false" do 72 | permissions = [ 73 | GoogleAssistant::Permission::NAME, 74 | "not a valid permission" 75 | ] 76 | 77 | assert(!GoogleAssistant::Permission.valid?(permissions)) 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/google_assistant/test_dialog_state.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "google_assistant/dialog_state" 3 | 4 | describe GoogleAssistant::DialogState do 5 | let(:state_hash) { { "state" => "some state", "data" => { "some data" => "a value" } } } 6 | subject { GoogleAssistant::DialogState.new(state_hash) } 7 | 8 | describe "#initialize" do 9 | 10 | describe "when given a string" do 11 | let(:state_hash) { "{\"state\":\"a state\",\"data\":{\"data key\":\"data value\"}}" } 12 | 13 | it "parses the string to a hash" do 14 | assert_equal("a state", subject.state) 15 | assert_equal({ "data key" => "data value" }, subject.data) 16 | end 17 | 18 | describe "when the string cannot be parsed as JSON" do 19 | let(:state_hash) { "this is definitely not a hash" } 20 | 21 | it "uses the default state" do 22 | assert_nil(subject.state) 23 | assert_equal({}, subject.data) 24 | end 25 | end 26 | end 27 | 28 | describe "when given a hash" do 29 | let(:state_hash) { { "state" => "a state", "data" => { "data key" => "data value"} } } 30 | 31 | it "uses the hash for the state and data" do 32 | assert_equal("a state", subject.state) 33 | assert_equal({ "data key" => "data value" }, subject.data) 34 | end 35 | end 36 | 37 | describe "when not given a state or conversation token" do 38 | let(:state_hash) { nil } 39 | 40 | it "uses the default state" do 41 | assert_nil(subject.state) 42 | assert_equal({}, subject.data) 43 | end 44 | end 45 | end 46 | 47 | describe "#state" do 48 | 49 | it "returns the state" do 50 | assert_equal(state_hash["state"], subject.state) 51 | end 52 | end 53 | 54 | describe "#state=" do 55 | 56 | it "set the state" do 57 | subject.state = "a new state" 58 | assert_equal("a new state", subject.state) 59 | end 60 | end 61 | 62 | describe "#data" do 63 | 64 | it "returns the data" do 65 | assert_equal(state_hash["data"], subject.data) 66 | end 67 | end 68 | 69 | describe "#data=" do 70 | 71 | it "sets the data" do 72 | new_data = { "new data" => "new value" } 73 | subject.data = new_data 74 | assert_equal(new_data, subject.data) 75 | end 76 | 77 | describe "when the data is not a hash" do 78 | 79 | it "raises an error" do 80 | assert_raises RuntimeError do 81 | subject.data = "not a hash" 82 | end 83 | end 84 | end 85 | end 86 | 87 | describe "#to_json" do 88 | 89 | it "returns the state_hash as json" do 90 | assert_equal(state_hash.to_json, subject.to_json) 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/google_assistant/test_intent.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "google_assistant/intent" 3 | 4 | describe GoogleAssistant::Intent do 5 | let(:intent_string) { "any old string" } 6 | subject { GoogleAssistant::Intent.new(intent_string) } 7 | 8 | describe "#initialize" do 9 | 10 | it "sets the class's attributes" do 11 | assert_equal(intent_string, subject.intent_string) 12 | end 13 | end 14 | 15 | describe "#main" do 16 | let(:intent_string) { GoogleAssistant::StandardIntents::MAIN } 17 | 18 | it "sets the main intent block" do 19 | it_was_called = false 20 | subject.main do 21 | it_was_called = true 22 | end 23 | 24 | subject.call 25 | 26 | assert(it_was_called) 27 | end 28 | end 29 | 30 | describe "#text" do 31 | let(:intent_string) { GoogleAssistant::StandardIntents::TEXT } 32 | 33 | it "sets the text intent block" do 34 | it_was_called = false 35 | subject.text do 36 | it_was_called = true 37 | end 38 | 39 | subject.call 40 | 41 | assert(it_was_called) 42 | end 43 | end 44 | 45 | describe "#permission" do 46 | let(:intent_string) { GoogleAssistant::StandardIntents::PERMISSION } 47 | 48 | it "sets the permission intent block" do 49 | it_was_called = false 50 | subject.permission do 51 | it_was_called = true 52 | end 53 | 54 | subject.call 55 | 56 | assert(it_was_called) 57 | end 58 | end 59 | 60 | describe "#call" do 61 | 62 | before :each do 63 | subject.main { "main" } 64 | subject.text { "text" } 65 | subject.permission { "permission" } 66 | end 67 | 68 | describe "when the main intent" do 69 | let(:intent_string) { GoogleAssistant::StandardIntents::MAIN } 70 | 71 | it "calls the main intent block" do 72 | called_intent = subject.call 73 | assert_equal("main", called_intent) 74 | end 75 | end 76 | 77 | describe "when the text intent" do 78 | let(:intent_string) { GoogleAssistant::StandardIntents::TEXT } 79 | 80 | it "calls the text intent block" do 81 | called_intent = subject.call 82 | assert_equal("text", called_intent) 83 | end 84 | end 85 | 86 | describe "when the permission intent" do 87 | let(:intent_string) { GoogleAssistant::StandardIntents::PERMISSION } 88 | 89 | it "calls the permission intent block" do 90 | called_intent = subject.call 91 | assert_equal("permission", called_intent) 92 | end 93 | end 94 | 95 | describe "when the given intent block isn't set" do 96 | let(:intent_string) { GoogleAssistant::StandardIntents::MAIN } 97 | 98 | it "returns nil" do 99 | subject.main 100 | 101 | called_intent = subject.call 102 | assert_nil(called_intent) 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Google Assistant Ruby 2 | 3 | Write Google Assistant actions in Ruby. 4 | 5 | GoogleAssistant parses Google Assistant requests and provides the framework to respond appropriately. It works with the Google Assistant API v1. 6 | 7 | ## Installation 8 | 9 | Add this line to your application's Gemfile: 10 | 11 | ```rb 12 | gem "google_assistant" 13 | ``` 14 | 15 | ## Get started 16 | 17 | ### Rails 4 and higher 18 | 19 | In your routes file, allow a `POST` request to your action. All requests from Google Assistant will come here. 20 | 21 | ```rb 22 | Rails.application.routes.draw do 23 | post "/google_assistant" => "google_assistant#conversation" 24 | end 25 | ``` 26 | 27 | Write your controller to handle the request. Use GoogleAssistant to respond to the requests. 28 | 29 | ```rb 30 | class GoogleAssistantController < ApplicationController 31 | 32 | def conversation 33 | assistant_response = GoogleAssistant.respond_to(params, response) do |assistant| 34 | assistant.intent.main do 35 | assistant.ask( 36 | prompt: "Hi there! Say something, please.", 37 | no_input_prompt: [ 38 | "If you said something, I didn't hear you.", 39 | "Did you say something?" 40 | ] 41 | ) 42 | end 43 | 44 | assistant.intent.text do 45 | assistant.tell("I can respond, too!") 46 | end 47 | end 48 | 49 | render json: assistant_response 50 | end 51 | end 52 | ``` 53 | 54 | ## Usage 55 | 56 | GoogleAssistant parses the request from the Google Assistant API and helps you build your response. It takes the `params` and `response` objects in Rails and Sinatra. 57 | 58 | ```rb 59 | assistant_response = GoogleAssistant.respond_to(params, response) do |assistant| 60 | # Response logic goes here 61 | end 62 | ``` 63 | 64 | The Google Assistant API sends a request using the `MAIN` intent for the initial request from the user. This is when the user says "OK Google, talk to #{name_of_your_app}". 65 | 66 | ```rb 67 | assistant_response = GoogleAssistant.respond_to(params, response) do |assistant| 68 | assistant.intent.main do 69 | # Initial request's response goes here. You may want to introduce the app here. 70 | end 71 | end 72 | ``` 73 | 74 | The Google Assistant API sends a request using the `TEXT` intent for subsequent requests from the user. When your app asks for input from the user, the conversation continues. When the user finishes the conversation with "Goodbye" or your app finishes the conversation with a `tell` response, the conversation ends and any new conversations start again with the `MAIN` intent. 75 | 76 | ```rb 77 | assistant_response = GoogleAssistant.respond_to(params, response) do |assistant| 78 | assistant.intent.text do 79 | # Respond to user input here. 80 | end 81 | end 82 | ``` 83 | 84 | ### Ask 85 | 86 | Request user input by sending an `ask` response. Send a prompt and a set of follow-up prompts for when the user fails to respond. 87 | 88 | ```rb 89 | assistant.intent.main do 90 | assistant.ask( 91 | prompt: "Hi there! Say something, please.", # The voice prompt the user will hear. 92 | no_input_prompt: [ 93 | "If you said something, I didn't hear you.", # You can provide a number of "no input prompts". A random 94 | "Did you say something?" # one will be spoken if the user takes too long to respond. 95 | ] 96 | ) 97 | end 98 | ``` 99 | 100 | ### Tell 101 | 102 | Send a final response, ending the conversation. 103 | 104 | ```rb 105 | assistant.intent.text do 106 | assistant.tell("Thanks for talking! Goodbye!") # Both SSML and plain text work here and anywhere you send a prompt. 107 | end 108 | ``` 109 | 110 | ### SSML 111 | 112 | SSML is Google Assistant's markup language for text to speech. It provides options to pause, interpret dates and numbers, and more. You can provide SSML responses or plain text. See [Google's documentation on SSML](https://developers.google.com/actions/reference/ssml). 113 | 114 | ### User input 115 | 116 | GoogleAssistant allows you to read the user's input using `assistant.arguments` so that you can respond appropriately. 117 | 118 | ```rb 119 | assistant.intent.text do 120 | case assistant.arguments[0].text_value.downcase 121 | when "hello" 122 | respond_with = "Hi there!" 123 | when "goodbye" 124 | respond_with = "See you later!" 125 | else 126 | respond_with "I heard you say #{assistant.arguments[0].text_value}, but I don't know what that means." 127 | end 128 | 129 | assistant.tell(respond_with) 130 | end 131 | ``` 132 | 133 | ### Conversation state and data 134 | 135 | You can keep data on the Conversation object. This data will be sent to the Google Assistant API, which will return that data back when the user responds. This way, you can keep ongoing information about the conversation without having to use a database to store that information. 136 | 137 | You can also send a state value with your responses to keep track of where your conversation is at. 138 | 139 | ```rb 140 | GoogleAssistant.respond_to(params, response) do |assistant| 141 | assistant.intent.main do 142 | assistant.conversation.state = "asking favorite color" 143 | 144 | assistant.ask( 145 | prompt: "What is your favorite color?", 146 | no_input_prompt: ["What did you say your favorite color is?"] 147 | ) 148 | end 149 | 150 | assistant.intent.text do 151 | if assistant.conversation.state == "asking favorite color" 152 | assistant.conversation.data["favorite_color"] = assistant.arguments[0].text_value 153 | 154 | assistant.conversation.state = "asking lucky number" 155 | 156 | assistant.ask( 157 | prompt: "What is your lucky number?", 158 | no_input_prompt: ["What did you say your lucky number is?"] 159 | ) 160 | elsif assistant.conversation.state == "asking lucky number" 161 | favorite_color = assistant.conversation.data["favorite_color"] 162 | lucky_number = assistant.arguments[0].text_value 163 | 164 | assistant.tell("Your favorite color is #{favorite_color} and your lucky number is #{lucky_number}. Neat!") 165 | end 166 | end 167 | end 168 | ``` 169 | 170 | ### User ID 171 | 172 | You can get the user's ID. This will allow you to identify the user across conversations. It works much in the same way a cookie might work; it is possible for the user to reset that ID, so don't rely on it too much. 173 | 174 | ```rb 175 | # Get the user's ID 176 | assistant.user.id 177 | ``` 178 | 179 | ### Permissions 180 | 181 | You can request information about the user and their device. Google handles collecting this information, but you can provide a prompt to let the user know why you need this information. The Google Assistant API responds with the `permission` intent after a permission request. 182 | 183 | #### Name 184 | 185 | Request the user's name. This will result in a prompt to the user like: 186 | > So that I can address you by name, I'll just need to get your name from Google. Is that ok? 187 | 188 | ```rb 189 | assistant.intent.main do 190 | # Request the user's name 191 | assistant.ask_for_permission(context: "So that I can address you by name", permissions: GoogleAssistant::Permission::NAME) 192 | end 193 | 194 | assistant.intent.permission do 195 | if assistant.permission_granted? 196 | # Get the user's name from the response 197 | given_name = assistant.user.given_name 198 | family_name = assistant.user.family_name 199 | display_name = assistant.user.display_name 200 | else 201 | # The user denied permission 202 | end 203 | end 204 | ``` 205 | 206 | #### Coarse Location 207 | 208 | Request the device's zip code and city. This will result in a prompt to the user like: 209 | > To provide weather information for where you live, I'll just need to get your zip code from Google. Is that ok? 210 | 211 | ```rb 212 | assistant.intent.main do 213 | # Request the device's zip code and city 214 | assistant.ask_for_permission(context: "To provide weather information for where you live", permissions: GoogleAssistant::Permission::DEVICE_COARSE_LOCATION) 215 | end 216 | 217 | assistant.intent.permission do 218 | if assistant.permission_granted? 219 | # Get the device's location from the response 220 | zip_code = assistant.device.zip_code 221 | city = assistant.device.city 222 | else 223 | # The user denied permission 224 | end 225 | end 226 | ``` 227 | 228 | #### Precise Location 229 | 230 | Request the device's precise location. This will result in a prompt to the user like: 231 | > So that I can find out where you sleep at night, I'll just need to get your street address from Google. Is that ok? 232 | 233 | ```rb 234 | assistant.intent.main do 235 | # Request the device's precise location 236 | assistant.ask_for_permission(context: "So that I can find out where you sleep at night", permissions: GoogleAssistant::Permission::DEVICE_PRECISE_LOCATION) 237 | end 238 | 239 | assistant.intent.permission do 240 | if assistant.permission_granted? 241 | # Get the device's location from the response 242 | zip_code = assistant.device.zip_code 243 | city = assistant.device.city 244 | formatted_address = assistant.device.formatted_address 245 | latitude = assistant.device.latitude 246 | longitude = assistant.device.longitude 247 | else 248 | # The user denied permission 249 | end 250 | end 251 | ``` 252 | 253 | ### Testing your assistant 254 | 255 | You can use any hosting platform. 256 | 257 | 1. [Download the `gactions` CLI](https://developers.google.com/actions/tools/gactions-cli) and add it to your PATH. 258 | - Or if you'd rather not put it in your path, you'll simply need to call it by referencing its full path. 259 | 2. Visit the [Google Cloud Console projects page](https://console.cloud.google.com/project). Create a project and make note of the project ID for configuration and deployment. 260 | 3. Deploy your app to the web. Heroku is a good choice. See [Heroku's documentation](https://devcenter.heroku.com/articles/getting-started-with-ruby#introduction) for more info on how to do this. 261 | 4. Add an `action.json` file at the root of your project. 262 | 263 | ```json 264 | { 265 | "versionLabel": "1.0.0", 266 | "agentInfo": { 267 | "languageCode": "en-US", 268 | "projectId": "your-google-project-id", 269 | "voiceName": "male_1" 270 | }, 271 | "actions": [ 272 | { 273 | "initialTrigger": { 274 | "intent": "assistant.intent.action.MAIN" 275 | }, 276 | "httpExecution": { 277 | "url": "https://yourapp.domain.com/path-to-your-assistant" 278 | } 279 | } 280 | ] 281 | } 282 | ``` 283 | 284 | 5. Run the following command from the root directory of your project. The `invocation_name` is what you will use to activate your assistant. For example, "OK Google, talk to my action". Name it something unique. 285 | 286 | ``` 287 | gactions preview -action_package=action.json -invocation_name="my action" 288 | ``` 289 | 290 | - `gactions` will ask to access your account and Google developer project. Follow the onscreen instructions to do so. 291 | 292 | 6. Use the [web simulator](https://developers.google.com/actions/tools/web-simulator) to simulate. Or better yet, if your Google Home device is logged into the same account you're using to build your action, you can say "OK Google, talk to my action" to test it out directly on your device. 293 | 294 | ## Authentication 295 | 296 | You can require users to log in to your service before using your assistant. Read about it in [Google's documentation](https://developers.google.com/actions/develop/identity/oauth2-code-flow). The basic flow is this: 297 | 298 | 1. User tries to talk to your assistant 299 | 2. Google tells the user they need to sign in, which they can do via the Home app on their phone 300 | 3. The Home app links the user to your Oauth endpoint 301 | 4. User signs in to your app 302 | 5. Google stores the user's Oauth access and refresh tokens 303 | 6. For each subsequent request the user makes to your assistant, Google sends the user's access token so you can identify the user 304 | 305 | In order to set this up in your assistant, the basic instructions are as follows. Read Google's documentation for the full details. 306 | 307 | 1. Implement Oauth in your application 308 | 2. Set up an Oauth client in the [Google Developer Console](https://console.developers.google.com) 309 | 3. In the application's `action.json` file, set up account linking according to [Google's Instructions](https://developers.google.com/actions/develop/identity/account-linking#enabling_account_linking) 310 | 4. Use `assistant.user.access_token` to identify the user 311 | 312 | ## More information 313 | 314 | Check out Google's instructions at https://developers.google.com/actions/develop/sdk/getting-started for more detail on writing and testing a Google Assistant action. 315 | 316 | Check out https://github.com/armilam/google_assistant_example for a simple example of this gem in action. 317 | -------------------------------------------------------------------------------- /test/google_assistant/test_assistant.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "minitest/autorun" 4 | require "test_helper" 5 | require "google_assistant/assistant" 6 | require "google_assistant/intent" 7 | require "google_assistant/dialog_state" 8 | 9 | describe GoogleAssistant::Assistant do 10 | include TestHelper 11 | 12 | let(:response) { FakeResponse.new } 13 | let(:params) { load_json_fixture(:main_intent_request) } 14 | subject { GoogleAssistant::Assistant.new(params, response) } 15 | 16 | describe "#initialize" do 17 | 18 | it "sets the class's attributes" do 19 | assert_equal(params, subject.params) 20 | assert_equal(response, subject.response) 21 | end 22 | end 23 | 24 | describe "#respond_to" do 25 | 26 | it "yields to a block with the assistant" do 27 | subject.respond_to do |assistant| 28 | assert(assistant.is_a?(GoogleAssistant::Assistant)) 29 | end 30 | end 31 | 32 | it "sets google assistant version header on the response" do 33 | subject.respond_to {} 34 | assert_equal("v1", response.headers["Google-Assistant-API-Version"]) 35 | end 36 | 37 | describe "when on the MAIN intent" do 38 | let(:params) { load_json_fixture(:main_intent_request) } 39 | 40 | it "calls the main intent block" do 41 | called_block = nil 42 | subject.respond_to do |assistant| 43 | assistant.intent.main { called_block = "main" } 44 | assistant.intent.text { called_block = "text" } 45 | end 46 | 47 | assert_equal("main", called_block) 48 | end 49 | end 50 | 51 | describe "when on the TEXT intent" do 52 | let(:params) { load_json_fixture(:text_intent_request) } 53 | 54 | it "calls the text intent block" do 55 | called_block = nil 56 | subject.respond_to do |assistant| 57 | assistant.intent.main { called_block = "main" } 58 | assistant.intent.text { called_block = "text" } 59 | end 60 | 61 | assert_equal("text", called_block) 62 | end 63 | end 64 | end 65 | 66 | describe "#intent" do 67 | 68 | describe "with a MAIN intent request" do 69 | let(:params) { load_json_fixture(:main_intent_request) } 70 | 71 | it "returns an instance of Intent with MAIN intent" do 72 | assert_equal(GoogleAssistant::Intent, subject.intent.class) 73 | assert_equal(GoogleAssistant::StandardIntents::MAIN, subject.intent.intent_string) 74 | end 75 | end 76 | 77 | describe "with a TEXT intent request" do 78 | let(:params) { load_json_fixture(:text_intent_request) } 79 | 80 | it "returns an instance of Intent with TEXT intent" do 81 | assert_equal(GoogleAssistant::Intent, subject.intent.class) 82 | assert_equal(GoogleAssistant::StandardIntents::TEXT, subject.intent.intent_string) 83 | end 84 | end 85 | end 86 | 87 | describe "#arguments" do 88 | 89 | describe "when the arguments list is empty" do 90 | let(:params) { load_json_fixture(:empty_arguments_request) } 91 | 92 | it "returns an empty array" do 93 | assert_equal([], subject.arguments) 94 | end 95 | end 96 | 97 | describe "when there is one argument" do 98 | let(:params) { load_json_fixture(:single_argument_request) } 99 | 100 | it "returns a single-item array containing an Argument object" do 101 | assert_equal(1, subject.arguments.size) 102 | 103 | argument = subject.arguments.first 104 | 105 | assert_equal("text", argument.name) 106 | assert_equal("this is some raw text", argument.raw_text) 107 | assert_equal("this is a text value", argument.text_value) 108 | end 109 | end 110 | end 111 | 112 | describe "#permission_granted?" do 113 | let(:params) { load_json_fixture(:user_name_granted) } 114 | 115 | it "returns true" do 116 | assert(subject.permission_granted?) 117 | end 118 | 119 | describe "when permission is not granted" do 120 | let(:params) { load_json_fixture(:permission_denied) } 121 | 122 | it "returns false" do 123 | assert(!subject.permission_granted?) 124 | end 125 | end 126 | 127 | describe "when response is not a permission response" do 128 | let(:params) { load_json_fixture(:text_intent_request) } 129 | 130 | it "returns false" do 131 | assert(!subject.permission_granted?) 132 | end 133 | end 134 | end 135 | 136 | describe "#conversation" do 137 | let(:params) { load_json_fixture(:text_intent_request) } 138 | 139 | it "returns a Conversation object with the given params" do 140 | conversation = subject.conversation 141 | 142 | assert_equal("1234567890", conversation.id) 143 | assert_equal(2, conversation.type) 144 | assert_equal(GoogleAssistant::DialogState, conversation.dialog_state.class) 145 | end 146 | end 147 | 148 | describe "#user" do 149 | let(:params) { load_json_fixture(:main_intent_request) } 150 | 151 | it "returns a User object with the given params" do 152 | user = subject.user 153 | 154 | assert_equal("qwERtyUiopaSdfGhJklzXCVBNm/tF=", user.id) 155 | end 156 | end 157 | 158 | describe "#device" do 159 | let(:params) { load_json_fixture(:coarse_location_granted) } 160 | 161 | it "returns a Device object with the given params" do 162 | device = subject.device 163 | 164 | assert_equal(params["device"]["location"], device.location) 165 | end 166 | end 167 | 168 | describe "#tell" do 169 | 170 | describe "when given an SSML message" do 171 | 172 | it "returns a JSON hash response with SSML" do 173 | message = "An SSML message" 174 | 175 | expected_response = { 176 | expect_user_response: false, 177 | final_response: { 178 | speech_response: { ssml: message } 179 | } 180 | } 181 | 182 | assert_equal(expected_response, subject.tell(message)) 183 | end 184 | end 185 | 186 | describe "when given a plain text message" do 187 | 188 | it "returns a JSON hash response with text" do 189 | message = "A plain text message" 190 | 191 | expected_response = { 192 | expect_user_response: false, 193 | final_response: { 194 | speech_response: { text_to_speech: message } 195 | } 196 | } 197 | 198 | assert_equal(expected_response, subject.tell(message)) 199 | end 200 | end 201 | 202 | describe "when given an empty message" do 203 | 204 | it "raises GoogleAssistant::InvalidMessage" do 205 | message = "" 206 | 207 | assert_raises GoogleAssistant::InvalidMessage do 208 | subject.tell(message) 209 | end 210 | end 211 | end 212 | 213 | describe "when given a nil message" do 214 | 215 | it "raises GoogleAssistant::InvalidMessage" do 216 | message = nil 217 | 218 | assert_raises GoogleAssistant::InvalidMessage do 219 | subject.tell(message) 220 | end 221 | end 222 | end 223 | end 224 | 225 | describe "#ask" do 226 | 227 | describe "when given a nil input prompt" do 228 | 229 | it "raises an error" do 230 | assert_raises GoogleAssistant::InvalidInputPrompt do 231 | subject.ask(nil, nil) 232 | end 233 | end 234 | end 235 | 236 | describe "when given an SSML string input prompt" do 237 | 238 | it "returns a JSON hash response with SSML" do 239 | response = subject.ask("Some SSML input prompt") 240 | 241 | expected_response = { 242 | conversation_token: "{\"state\":null,\"data\":{}}", 243 | expect_user_response: true, 244 | expected_inputs: [ 245 | { 246 | input_prompt: { 247 | initial_prompts: [{ ssml: "Some SSML input prompt" }], 248 | no_input_prompts: [] 249 | }, 250 | possible_intents: [{ intent: "assistant.intent.action.TEXT" }] 251 | } 252 | ] 253 | } 254 | 255 | assert_equal(expected_response, response) 256 | end 257 | end 258 | 259 | describe "when given a plain text string input prompt" do 260 | 261 | it "returns a JSON hash response with text" do 262 | response = subject.ask("Some text input prompt") 263 | 264 | expected_response = { 265 | conversation_token: "{\"state\":null,\"data\":{}}", 266 | expect_user_response: true, 267 | expected_inputs: [ 268 | { 269 | input_prompt: { 270 | initial_prompts: [{ text_to_speech: "Some text input prompt" }], 271 | no_input_prompts: [] 272 | }, 273 | possible_intents: [{ intent: "assistant.intent.action.TEXT" }] 274 | } 275 | ] 276 | } 277 | 278 | assert_equal(expected_response, response) 279 | end 280 | end 281 | 282 | describe "when the conversation dialog state has data" do 283 | 284 | it "includes the state and data" do 285 | dialog_state = subject.conversation.dialog_state 286 | dialog_state.state = "a state" 287 | dialog_state.data = { "a data key" => "the data value" } 288 | response = subject.ask("Some input prompt") 289 | 290 | expected_response = { 291 | conversation_token: { state: "a state", data: { "a data key" => "the data value" } }.to_json, 292 | expect_user_response: true, 293 | expected_inputs: [ 294 | { 295 | input_prompt: { 296 | initial_prompts: [{ text_to_speech: "Some input prompt" }], 297 | no_input_prompts: [] 298 | }, 299 | possible_intents: [{ intent: "assistant.intent.action.TEXT" }] 300 | } 301 | ] 302 | } 303 | 304 | assert_equal(expected_response, response) 305 | end 306 | end 307 | 308 | describe "when given a string no_input_prompt" do 309 | 310 | it "returns a JSON hash response with text" do 311 | response = subject.ask( 312 | "Some text input prompt", 313 | "A no input prompt" 314 | ) 315 | 316 | expected_response = { 317 | conversation_token: "{\"state\":null,\"data\":{}}", 318 | expect_user_response: true, 319 | expected_inputs: [ 320 | { 321 | input_prompt: { 322 | initial_prompts: [{ text_to_speech: "Some text input prompt" }], 323 | no_input_prompts: [{ text_to_speech: "A no input prompt" }] 324 | }, 325 | possible_intents: [{ intent: "assistant.intent.action.TEXT" }] 326 | } 327 | ] 328 | } 329 | 330 | assert_equal(expected_response, response) 331 | end 332 | end 333 | 334 | describe "when given an array of strings for no_input_prompt" do 335 | 336 | it "returns a JSON hash response with text" do 337 | response = subject.ask( 338 | "Some text input prompt", 339 | [ 340 | "A no input prompt", 341 | "Yet another no input prompt" 342 | ] 343 | ) 344 | 345 | expected_response = { 346 | conversation_token: "{\"state\":null,\"data\":{}}", 347 | expect_user_response: true, 348 | expected_inputs: [ 349 | { 350 | input_prompt: { 351 | initial_prompts: [{ text_to_speech: "Some text input prompt" }], 352 | no_input_prompts: [ 353 | { text_to_speech: "A no input prompt" }, 354 | { ssml: "Yet another no input prompt" } 355 | ] 356 | }, 357 | possible_intents: [{ intent: "assistant.intent.action.TEXT" }] 358 | } 359 | ] 360 | } 361 | 362 | assert_equal(expected_response, response) 363 | end 364 | end 365 | end 366 | 367 | describe "#ask_for_permission" do 368 | 369 | describe "when given a nil context" do 370 | 371 | it "raises InvalidPermissionContext" do 372 | assert_raises GoogleAssistant::InvalidPermissionContext do 373 | subject.ask_for_permission(nil, GoogleAssistant::Permission::NAME) 374 | end 375 | end 376 | end 377 | 378 | describe "when given an empty context" do 379 | 380 | it "raises InvalidPermissionContext" do 381 | assert_raises GoogleAssistant::InvalidPermissionContext do 382 | subject.ask_for_permission("", GoogleAssistant::Permission::NAME) 383 | end 384 | end 385 | end 386 | 387 | describe "when given an invalid permission" do 388 | 389 | it "raises InvalidPermission" do 390 | assert_raises GoogleAssistant::InvalidPermission do 391 | subject.ask_for_permission("A context", "invalid permission") 392 | end 393 | end 394 | end 395 | 396 | describe "when given an empty array of permissions" do 397 | 398 | it "raises InvalidPermission" do 399 | assert_raises GoogleAssistant::InvalidPermission do 400 | subject.ask_for_permission("A context", []) 401 | end 402 | end 403 | end 404 | 405 | describe "when given a single permission" do 406 | 407 | it "returns a JSON hash response" do 408 | response = subject.ask_for_permission("A context", GoogleAssistant::Permission::NAME) 409 | 410 | expected_response = { 411 | conversation_token: "{\"state\":null,\"data\":{}}", 412 | expect_user_response: true, 413 | expected_inputs: [ 414 | { 415 | input_prompt: { 416 | initial_prompts: [{ text_to_speech: "placeholder" }], 417 | no_input_prompts: [] 418 | }, 419 | possible_intents: [ 420 | { 421 | intent: "assistant.intent.action.PERMISSION", 422 | input_value_spec: { 423 | permission_value_spec: { 424 | opt_context: "A context", 425 | permissions: ["NAME"] 426 | } 427 | } 428 | } 429 | ] 430 | } 431 | ] 432 | } 433 | 434 | assert_equal(expected_response, response) 435 | end 436 | end 437 | end 438 | end 439 | --------------------------------------------------------------------------------