├── .rspec ├── .github ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE.md ├── Gemfile ├── lib ├── doll │ ├── version.rb │ ├── parameter.rb │ ├── message │ │ └── text.rb │ ├── event │ │ └── message.rb │ ├── config.rb │ ├── dialog.rb │ ├── request.rb │ ├── adapter │ │ ├── plain.rb │ │ ├── base.rb │ │ └── facebook.rb │ ├── response.rb │ ├── server.rb │ ├── nlp │ │ └── wit.rb │ └── converse.rb └── doll.rb ├── .gitignore ├── Rakefile ├── bin ├── setup └── console ├── spec ├── doll_spec.rb ├── doll │ ├── adapter │ │ ├── plain_spec.rb │ │ └── facebook_spec.rb │ ├── config_spec.rb │ ├── request_spec.rb │ └── response_spec.rb └── spec_helper.rb ├── .travis.yml ├── doll.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Reason 2 | 3 | ### Changes 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in doll.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/doll/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Doll 4 | VERSION = '0.2.0'.freeze 5 | end 6 | -------------------------------------------------------------------------------- /lib/doll/parameter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Doll 4 | class Parameter < Hash 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /spec/doll_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Doll do 4 | it 'has a version number' do 5 | expect(Doll::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Expected Behavior 2 | 3 | ### Actual Behavior 4 | 5 | ### Steps to Reproduce the Problem 6 | 7 | 1. 8 | 2. 9 | 3. 10 | 11 | ### Specifications 12 | 13 | * **Version**: 14 | * **OS**: 15 | * **Ruby Version**: 16 | -------------------------------------------------------------------------------- /lib/doll/message/text.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Doll 4 | module Message 5 | # Chatbot Message - Text 6 | class Text 7 | attr_reader :text 8 | 9 | def initialize(text) 10 | @text = text 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "doll" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /lib/doll/event/message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Doll 4 | module Event 5 | # Chatbot Event - Message 6 | # TODO: Should implement full feature for this class 7 | class Message 8 | attr_reader :user_id, :body 9 | 10 | def initialize(user_id, body) 11 | @user_id = user_id 12 | @body = body 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.3.3 5 | - 2.4.1 6 | before_script: 7 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 8 | - chmod +x ./cc-test-reporter 9 | - ./cc-test-reporter before-build 10 | before_install: gem install bundler -v 1.13.6 11 | after_script: 12 | - if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT; fi 13 | -------------------------------------------------------------------------------- /spec/doll/adapter/plain_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Doll::Adapter::Plain do 4 | let(:adapter) { Doll::Adapter::Plain.new } 5 | 6 | describe '#verify_signature' do 7 | it 'always return true' do 8 | expect(adapter.verify_signature(nil)).to be_truthy 9 | end 10 | end 11 | 12 | describe '#process' do 13 | it 'can handle text message' do 14 | text = 'Type something...' 15 | expect(Doll::Message::Text).to receive(:new).with(text) 16 | adapter.process(text) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/doll/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Doll 4 | # Chatbot Configuration 5 | class Config 6 | attr_reader :adapters, :middlewares 7 | 8 | def initialize 9 | @adapters = {} 10 | @middlewares = [] 11 | end 12 | 13 | def adapter(adapter) 14 | @adapters[adapter.name.to_sym] = adapter 15 | end 16 | 17 | def adapter_loaded?(name) 18 | return false if name.nil? 19 | @adapters.key?(name.to_sym) 20 | end 21 | 22 | def use(middleware) 23 | @middlewares.push(middleware) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | 3 | require 'simplecov' 4 | require 'coveralls' 5 | 6 | SimpleCov.start do 7 | formatter SimpleCov::Formatter::MultiFormatter.new( 8 | [ 9 | SimpleCov::Formatter::HTMLFormatter, 10 | Coveralls::SimpleCov::Formatter 11 | ] 12 | ) 13 | 14 | load_profile 'test_frameworks' 15 | 16 | add_group 'Adapter', 'lib/doll/adapter' 17 | add_group 'NLP', 'lib/doll/nlp' 18 | add_group 'Event', 'lib/doll/event' 19 | add_group 'Message', 'lib/doll/message' 20 | end 21 | 22 | require 'doll' 23 | -------------------------------------------------------------------------------- /spec/doll/config_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Doll::Config do 4 | let(:config) { Doll::Config.new } 5 | 6 | describe 'adapter' do 7 | it 'can add adapter' do 8 | config.adapter Doll::Adapter::Plain 9 | expect(config.adapter_loaded?(:'Doll::Adapter::Plain')).to be_truthy 10 | end 11 | end 12 | 13 | describe 'middleware' do 14 | it 'can add middleware' do 15 | middleware = Doll::NLP::Wit.new('example') 16 | 17 | config.use middleware 18 | expect(config.middlewares).to include(middleware) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/doll/dialog.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Doll 4 | # Chatbot Base Controller 5 | class Dialog 6 | def initialize(context, adapter) 7 | # TODO: Use parameter class 8 | @params = context 9 | @params[:adapter] = adapter 10 | 11 | setup(context) 12 | end 13 | 14 | def process 15 | raise NotImplementedError, "Dialog's process method should be implemented" 16 | end 17 | 18 | private 19 | 20 | attr_reader :params 21 | 22 | def setup(context) 23 | # TODO: Support all types event 24 | # @params[:text] = context.body.text 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/doll/request.rb: -------------------------------------------------------------------------------- 1 | # forzen_string_literal: true 2 | 3 | module Doll 4 | # API Request Object 5 | class Request < Rack::Request 6 | ADAPTER_PATTERN = %r{^\/(?[^\/.]+)?.*} 7 | 8 | # TODO: Add support for multiple chatbot 9 | attr_reader :adapter 10 | 11 | def initialize(env) 12 | super 13 | 14 | load_adapter 15 | end 16 | 17 | def support? 18 | Doll.config.adapter_loaded?(adapter) 19 | end 20 | 21 | protected 22 | 23 | def load_adapter 24 | matchers = ADAPTER_PATTERN.match(path_info) 25 | @adapter = matchers[:adapter]&.to_sym 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/doll/adapter/plain.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Doll 4 | module Adapter 5 | # The base adapter 6 | class Plain < Base 7 | def initialize 8 | @name = :plain 9 | end 10 | 11 | def process(body) 12 | [ 13 | Event::Message.new( 14 | 'Unknown', 15 | Message::Text.new(body) 16 | ) 17 | ] 18 | end 19 | 20 | def verify_signature(_request) 21 | true 22 | end 23 | 24 | def reply(_event, message) 25 | # TODO: Output to somewhere 26 | puts 'Chatbot:' 27 | puts message.text 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/doll/request_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Doll::Request do 4 | let(:adapter) { :plain } 5 | 6 | let(:env) do 7 | Rack::MockRequest.env_for( 8 | "https://localhost:3000/#{adapter}" 9 | ) 10 | end 11 | 12 | let(:request) { Doll::Request.new(env) } 13 | 14 | describe '#adapter' do 15 | it 'equals request path' do 16 | expect(request.adapter).to eq(adapter) 17 | end 18 | end 19 | 20 | describe '#support?' do 21 | before do 22 | allow_any_instance_of(Doll::Config) 23 | .to receive(:adapter_loaded?).with(adapter).and_return(true) 24 | end 25 | 26 | it 'return ture if adapter is loaded' do 27 | expect(request.support?).to be_truthy 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/doll/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Doll 4 | # API Response Object 5 | class Response < Rack::Response 6 | class << self 7 | def ok(message = 'OK') 8 | new(200, message: message).finish 9 | end 10 | 11 | def error(code, reason, status = 500) 12 | new(status, error: { code: code, message: reason }).finish 13 | end 14 | 15 | # TODO: Provide Doll::Response support this mode 16 | def plain(body, status = 200) 17 | res = Rack::Response.new 18 | res.headers['Content-Type'] = 'text/plain' 19 | res.status = status 20 | res.body = [body] 21 | res.finish 22 | end 23 | 24 | def not_found(reason) 25 | new(404, error: { message: reason }).finish 26 | end 27 | 28 | def api_status 29 | new(200, version: Doll::VERSION).finish 30 | end 31 | end 32 | 33 | def initialize(status, body = {}) 34 | super() 35 | self.status = status 36 | self.body = [body.to_json] 37 | 38 | headers['Content-Type'] = 'application/json' 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/doll.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rack' 4 | require 'json' 5 | require 'openssl' 6 | require 'net/http' 7 | 8 | require 'doll/version' 9 | require 'doll/config' 10 | require 'doll/server' 11 | require 'doll/request' 12 | require 'doll/response' 13 | require 'doll/converse' 14 | require 'doll/dialog' 15 | require 'doll/parameter' 16 | 17 | require 'doll/adapter/base' 18 | require 'doll/adapter/plain' 19 | require 'doll/adapter/facebook' 20 | 21 | require 'doll/nlp/wit' 22 | 23 | require 'doll/event/message' 24 | 25 | require 'doll/message/text' 26 | 27 | # The Chatbot Framework written in Ruby 28 | module Doll 29 | def self.config 30 | @config ||= Config.new 31 | end 32 | 33 | def self.configure(&block) 34 | return config unless block_given? 35 | config.instance_exec(config, &block) 36 | end 37 | 38 | def self.server 39 | Server.init 40 | end 41 | 42 | def self.converse(&block) 43 | @converse ||= Converse.new 44 | @converse.instance_eval(&block) if block_given? 45 | end 46 | 47 | def self.dispatch(event, adapter) 48 | @converse.dispatch(event, adapter) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/doll/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Doll 4 | # The Chatbot API server 5 | class Server 6 | class << self 7 | attr_reader :instance 8 | 9 | LOCK = Mutex.new 10 | 11 | def init 12 | LOCK.synchronize { compile } unless instance 13 | instance 14 | end 15 | 16 | def compile 17 | @instance ||= new 18 | end 19 | 20 | def call(env) 21 | init 22 | instance.call(env) 23 | end 24 | end 25 | 26 | def call(env) 27 | request = Request.new(env) 28 | res = handle_by_chatbot(request) 29 | return res unless res.nil? 30 | # TODO: More Doll information 31 | Response.api_status 32 | end 33 | 34 | protected 35 | 36 | def handle_by_chatbot(request) 37 | # TODO: Improve error handler 38 | return Response.not_found('Adapter not found.') unless request.support? 39 | return verify_token(request) if request.get? 40 | Doll.config.adapters[request.adapter].handle(request) 41 | end 42 | 43 | def verify_token(request) 44 | Doll.config.adapters[request.adapter].verify_token(request) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/doll/nlp/wit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Doll 4 | module NLP 5 | # The NLP Service from Facebook 6 | class Wit 7 | HOST = 'https://api.wit.ai'.freeze 8 | VERSION = '20170725'.freeze 9 | 10 | def initialize(token) 11 | @token = token 12 | end 13 | 14 | def call(params) 15 | return params if params[:text].nil? 16 | intent = query(params[:text]).dig(:entities, :intent).first[:value] 17 | params[:intent] = intent if intent 18 | params 19 | end 20 | 21 | private 22 | 23 | def query(string) 24 | uri = URI("#{HOST}/message") 25 | uri.query = URI.encode_www_form(q: string, n: 5) 26 | 27 | request = Net::HTTP::Get.new(uri.request_uri) 28 | request['Authorization'] = "Bearer #{@token}" 29 | request['Accept'] = "application/vnd.wit.#{VERSION}+json" 30 | request['Content-Type'] = 'application/json' 31 | 32 | result = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| 33 | http.request(request) 34 | end 35 | 36 | JSON.parse(result.body, symbolize_names: true) 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /doll.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | lib = File.expand_path('../lib', __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'doll/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'doll' 9 | spec.version = Doll::VERSION 10 | spec.authors = ['蒼時弦也'] 11 | spec.email = ['elct9620@frost.tw'] 12 | 13 | spec.summary = 'The Chatbot Framework written in Ruby' 14 | spec.description = 'The Chatbot Framework written in Ruby' 15 | spec.homepage = 'https://github.com/elct9620/doll' 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 18 | f.match(%r{^(test|spec|features)/}) 19 | end 20 | spec.bindir = 'exe' 21 | spec.executables = spec.files.grep(%r{^(exe/|.github)}) { |f| File.basename(f) } 22 | spec.require_paths = ['lib'] 23 | 24 | spec.add_runtime_dependency 'rack', '~> 2.0' 25 | 26 | spec.add_development_dependency 'bundler', '~> 1.13' 27 | spec.add_development_dependency 'rake', '~> 10.0' 28 | spec.add_development_dependency 'rspec', '~> 3.0' 29 | spec.add_development_dependency 'simplecov' 30 | spec.add_development_dependency 'coveralls' 31 | spec.add_development_dependency 'codeclimate-test-reporter' 32 | end 33 | -------------------------------------------------------------------------------- /lib/doll/adapter/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Doll 4 | module Adapter 5 | # The base adapter 6 | class Base 7 | attr_reader :name 8 | 9 | def initialize 10 | @name = :base 11 | end 12 | 13 | def handle(request) 14 | # TODO: Specify Error code when verify failed 15 | return Response.error(0, 'Failed') unless verify_signature(request) 16 | body = request.body.read 17 | dispatch(process(body)) 18 | Response.ok 19 | end 20 | 21 | def process(_body) 22 | raise NotImplementedError, 23 | "Adapter's process method should be implemented" 24 | end 25 | 26 | def verify_token(_request) 27 | Response.ok 28 | end 29 | 30 | def verify_signature(_request) 31 | raise NotImplementedError, 32 | "Adapter's verify_signature method should be implemented" 33 | end 34 | 35 | def reply(_event, _message) 36 | raise NotImplementedError, 37 | "Adapter's reply method should be implemented" 38 | end 39 | 40 | private 41 | 42 | def dispatch(events) 43 | # TODO: Add support for job scheduler 44 | # TODO: Handle thread errors 45 | Thread.abort_on_exception = true 46 | events.each do |event| 47 | # TODO: Improve apply middleware 48 | event = Doll.config 49 | .middlewares 50 | .reduce(event) { |p, fn| fn.call(p) } 51 | Thread.new { Doll.dispatch(event, name) } 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/doll/converse.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Doll 4 | # The router for chatbot 5 | class Converse 6 | def initialize 7 | @routes = [] 8 | @not_found = nil 9 | end 10 | 11 | def not_found(_options = {}, &block) 12 | # TODO: Support options support 13 | @not_found = block if block_given? 14 | end 15 | 16 | def match(rule, options = {}) 17 | @routes.push(Match.new(rule, to: options[:to])) 18 | end 19 | 20 | def intent(name) 21 | @routes.push(Intent.new(name)) 22 | end 23 | 24 | # TODO: Add user session support 25 | def dispatch(params, adapter) 26 | # TODO: Add priority support 27 | selected = @routes.each do |route| 28 | break route if route.match?(params) 29 | end 30 | reply = unless selected.is_a?(Array) 31 | selected.dialog.new(params, adapter).process 32 | else 33 | Message::Text.new(@not_found.call) 34 | end 35 | Doll.config.adapters[adapter].reply(params, reply) 36 | end 37 | 38 | # :nodoc: 39 | class Match 40 | def initialize(rule, to:) 41 | @rule = rule 42 | @to = to.to_s 43 | end 44 | 45 | def match?(params) 46 | @rule.match?(params[:text]) 47 | end 48 | 49 | def dialog 50 | Kernel.const_get("#{@to.capitalize}::StartDialog") 51 | end 52 | end 53 | 54 | # :nodoc: 55 | class Intent 56 | def initialize(intent) 57 | @intent = intent.to_s 58 | end 59 | 60 | def match?(params) 61 | @intent == params[:intent] 62 | end 63 | 64 | def dialog 65 | Kernel.const_get("#{@intent.capitalize}::StartDialog") 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/doll/response_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # rubocop:disable Metrics/BlockLength 4 | RSpec.describe Doll::Response do 5 | context 'Class Methods' do 6 | let(:res) { Doll::Response.ok } 7 | let(:json) { JSON.parse(res.last.body.first) } 8 | 9 | describe '.ok' do 10 | it 'return success response' do 11 | res = Doll::Response.ok 12 | expect(res.first).to eq(200) 13 | end 14 | 15 | context 'message' do 16 | let(:res) { Doll::Response.ok('Done') } 17 | it 'can change it' do 18 | expect(json['message']).to eq('Done') 19 | end 20 | end 21 | end 22 | 23 | describe '.api_status' do 24 | let(:res) { Doll::Response.api_status } 25 | 26 | it 'return current version' do 27 | expect(json['version']).to eq(Doll::VERSION) 28 | end 29 | end 30 | 31 | describe '.plain' do 32 | let(:res) { Doll::Response.plain('Hello World') } 33 | 34 | it 'response plain text' do 35 | header = res.drop(1).first 36 | expect(header['Content-Type']).to eq('text/plain') 37 | end 38 | 39 | context 'status' do 40 | let(:res) { Doll::Response.plain('Not Found', 404) } 41 | 42 | it 'can be changed' do 43 | status = res.first 44 | expect(status).to be(404) 45 | end 46 | end 47 | end 48 | 49 | describe '.not_found' do 50 | let(:reason) { 'No support adapter' } 51 | let(:res) { Doll::Response.not_found(reason) } 52 | 53 | it 'response with error reason' do 54 | expect(json['error']['message']).to eq(reason) 55 | end 56 | end 57 | 58 | describe '.error' do 59 | let(:code) { 100 } 60 | let(:reason) { 'Adapter not configured' } 61 | let(:status) { 500 } 62 | let(:res) { Doll::Response.error(code, reason, status) } 63 | 64 | it 'response with error code' do 65 | expect(json['error']['code']).to eq(code) 66 | end 67 | 68 | it 'response with error reason' do 69 | expect(json['error']['message']).to eq(reason) 70 | end 71 | 72 | context 'status' do 73 | let(:status) { 503 } 74 | 75 | it 'can be changed' do 76 | expect(res.first).to eq(status) 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/doll/adapter/facebook_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # rubocop:disable Metrics/BlockLength 4 | RSpec.describe Doll::Adapter::Facebook do 5 | let(:access_token) { 'ACCESS_TOKEN' } 6 | let(:secret_token) { 'SECRET_TOKEN' } 7 | let(:verify_token) { 'VERIFY_TOKEN' } 8 | 9 | let(:adapter) do 10 | Doll::Adapter::Facebook.new( 11 | access_token, 12 | secret_token, 13 | verify_token 14 | ) 15 | end 16 | 17 | let(:raw_request) do 18 | Rack::MockRequest.env_for( 19 | 'https://localhost:3000/facebook', 20 | request_options 21 | ) 22 | end 23 | 24 | let(:request_options) { {} } 25 | let(:request) { Doll::Request.new(raw_request) } 26 | 27 | describe '#verify_token' do 28 | let(:request_options) do 29 | { 30 | params: { 31 | 'hub.verify_token' => verify_token 32 | } 33 | } 34 | end 35 | 36 | it 'return true if valid' do 37 | res = adapter.verify_token(request) 38 | expect(res.first).to eq(200) 39 | end 40 | end 41 | 42 | describe '#verify_signature' do 43 | let(:request_options) do 44 | { 45 | :input => 'Hello World', 46 | 'HTTP_X_HUB_SIGNATURE' => 47 | 'sha1=ba1294b712d4bb7cc1a1dbcbb0eceba4f133e3ca' 48 | } 49 | end 50 | 51 | it 'return true if valid' do 52 | result = adapter.verify_signature(request) 53 | expect(result).to be_truthy 54 | end 55 | end 56 | 57 | describe '#reply' do 58 | let(:params) do 59 | { 60 | recipient: { 61 | id: '12345678' 62 | } 63 | } 64 | end 65 | 66 | let(:message) { Doll::Message::Text.new('Example') } 67 | 68 | it 'can send reply text message' do 69 | expect(adapter).to receive(:send).with( 70 | params.dup.merge(message: { text: message.text }) 71 | ) 72 | adapter.reply(params, message) 73 | end 74 | end 75 | 76 | describe '#process' do 77 | let(:body) do 78 | { 79 | entry: [ 80 | { 81 | messaging: [ 82 | { 83 | sender: { 84 | id: '12345678' 85 | }, 86 | message: { 87 | text: 'Example' 88 | } 89 | } 90 | ] 91 | } 92 | ] 93 | }.to_json 94 | end 95 | 96 | it 'convert json to message object' do 97 | message = adapter.process(body).first 98 | expect(message[:text]).to eq('Example') 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/doll/adapter/facebook.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # TODO: Create doll-adapter-facebook gem for this 4 | module Doll 5 | module Adapter 6 | # The base adapter 7 | class Facebook < Base 8 | def initialize(access_token, secret_token, verify_token) 9 | @name = :facebook 10 | @access_token = access_token 11 | @secret_token = secret_token 12 | @verify_token = verify_token 13 | end 14 | 15 | def process(body) 16 | parsed_body = JSON.parse(body, symbolize_names: true) 17 | # TODO: Improve parser 18 | parsed_body[:entry].map do |entry| 19 | entry[:messaging].map do |message| 20 | next if message.fetch(:message, {}).fetch(:text).nil? 21 | # TODO: Build parameter or event 22 | build_parameter(message) 23 | end 24 | end.flatten.compact 25 | end 26 | 27 | def verify_token(request) 28 | token = request.params['hub.verify_token'] 29 | return Response.plain(request.params['hub.challenge']) if @verify_token == token 30 | Response.plain('Verify token is invalid', 403) 31 | end 32 | 33 | def verify_signature(request) 34 | signature = request.env['HTTP_X_HUB_SIGNATURE'].to_s 35 | body = request.body.read 36 | request.body.rewind 37 | Rack::Utils.secure_compare(signature, signature_for(body)) 38 | end 39 | 40 | def reply(params, message) 41 | # TODO: Deal with send error 42 | send( 43 | recipient: { id: params[:recipient][:id] }, 44 | message: { text: message.text } 45 | ) 46 | end 47 | 48 | protected 49 | 50 | def build_parameter(message) 51 | Parameter[ 52 | recipient: { 53 | id: message[:sender][:id] 54 | }, 55 | text: message[:message][:text] 56 | ] 57 | end 58 | 59 | def signature_for(string) 60 | format('sha1=%s'.freeze, generate_hmac(string)) 61 | end 62 | 63 | def generate_hmac(content) 64 | OpenSSL::HMAC.hexdigest('sha1'.freeze, 65 | @secret_token, 66 | content) 67 | end 68 | 69 | def send(body) 70 | uri = URI('https://graph.facebook.com/v2.6/me/messages') 71 | uri.query = URI.encode_www_form(access_token: @access_token) 72 | 73 | request = Net::HTTP::Post.new(uri.request_uri) 74 | request['Content-Type'] = 'application/json' 75 | request.body = body.to_json 76 | 77 | Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| 78 | http.request(request) 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Doll [![Gem Version](https://badge.fury.io/rb/doll.svg)](https://badge.fury.io/rb/doll) [![Build Status](https://travis-ci.org/elct9620/doll.svg?branch=master)](https://travis-ci.org/elct9620/doll) [![Coverage Status](https://coveralls.io/repos/github/elct9620/doll/badge.svg?branch=master)](https://coveralls.io/github/elct9620/doll?branch=master) [![Code Climate](https://codeclimate.com/github/elct9620/doll/badges/gpa.svg)](https://codeclimate.com/github/elct9620/doll) 2 | === 3 | 4 | The Chatbot Framework written in Ruby 5 | 6 | ## Installation 7 | 8 | Add this line to your application's Gemfile: 9 | 10 | ```ruby 11 | gem 'doll' 12 | ``` 13 | 14 | And then execute: 15 | 16 | $ bundle 17 | 18 | Or install it yourself as: 19 | 20 | $ gem install doll 21 | 22 | # Requirement 23 | 24 | * Ruby 2.3+ 25 | 26 | ## Usage 27 | 28 | Prepare your Rack (`config.ru`) 29 | ```ruby 30 | require 'doll' 31 | 32 | require './config' 33 | require './converse' 34 | 35 | run Doll.server 36 | ``` 37 | 38 | Configure your chatbot (`config.rb`) 39 | ```ruby 40 | Doll.configure do 41 | # Support Adapters 42 | adapter Doll::Adapter::Plain.new 43 | adapter Doll::Adapter::Facebook.new( 44 | 'ACCESS_TOKEN', 45 | 'SECRET_TOKEN', 46 | 'VERIFY_TOKEN' 47 | ) 48 | 49 | # NLP Support 50 | use Doll::NLP::Wit.new('API_TOKEN') 51 | end 52 | ``` 53 | 54 | Configure your chatbot converse rules (`converse.rb`) 55 | ```ruby 56 | Doll.converse do 57 | match /[Hh]ello/, to: :hello 58 | # Current only support `intent` as predict entity for Wit.ai 59 | intent :buy 60 | 61 | not_found { 'I cannot figure out what you say....' } 62 | end 63 | ``` 64 | 65 | Create your dialog classes 66 | ```ruby 67 | # TODO: Namespace and Class name can be improved 68 | 69 | module Hello 70 | # Initialize Dialog 71 | class StartDialog 72 | def process 73 | # TODO: View-like helper comming soon 74 | Doll::Message::Text.new('Hi, Human!') 75 | end 76 | end 77 | end 78 | ``` 79 | 80 | ```ruby 81 | # TODO: Namespace and Class name can be improved 82 | 83 | module Buy 84 | # Initialize Dialog 85 | class StartDialog 86 | def process 87 | # TODO: View-like helper comming soon 88 | Doll::Message::Text.new('Ok, I know you want buy something') 89 | end 90 | end 91 | end 92 | ``` 93 | 94 | Start your server 95 | ```bash 96 | $ puma -C config.ru 97 | ``` 98 | 99 | Now, you can access your chatbot via `https://example.com/facebook` 100 | 101 | ### Rails Integrate 102 | 103 | Mount doll routes 104 | ```ruby 105 | mount Doll.server => '/doll' 106 | ``` 107 | 108 | Add configuration and converse to `config/initializes/doll.rb` 109 | ```ruby 110 | Doll.configurate do 111 | adapter # ... 112 | end 113 | 114 | Doll.converse do 115 | match # ... 116 | intent # ... 117 | end 118 | ``` 119 | 120 | Add dialog classes into `app/bot` 121 | 122 | ```ruby 123 | # app/bot/hello/start_dialog.rb 124 | 125 | module Hello 126 | class StartDialog < Doll::Dialog 127 | def process 128 | # ... 129 | end 130 | end 131 | end 132 | ``` 133 | 134 | ## Roadmap 135 | 136 | * [x] Workable Chatbot 137 | * [ ] Converse 138 | * [x] Regexp Matcher 139 | * [x] NLP intent 140 | * [ ] Routing options 141 | * [ ] Improved routing 142 | * [ ] Session 143 | * [ ] Store 144 | * [ ] Memory-based 145 | * [ ] Redis 146 | * [ ] Converse Management 147 | * [ ] Dialog 148 | * [ ] Response Builder 149 | * [ ] Parameter 150 | * [ ] Adaptetr 151 | * [ ] Facebook 152 | * [x] Text Message 153 | * [ ] Image Message 154 | * [ ] LINE 155 | * [ ] Text Message 156 | * [ ] Image Message 157 | * [ ] Middleware 158 | * [ ] NLP 159 | * [x] Wit.ai 160 | * [ ] LUIS.ai 161 | 162 | ## Development 163 | 164 | TODO: Write development guide 165 | 166 | ## Contributing 167 | 168 | Bug reports and pull requests are welcome on GitHub at https://github.com/elct9620/doll. 169 | 170 | --------------------------------------------------------------------------------