├── .codecov.yml ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── doc.go ├── doc_test.go ├── examples └── buy-elephant │ └── main.go ├── go.mod ├── go.sum ├── handler.go ├── request.go ├── request_entity.go ├── request_test.go ├── response.go ├── response_test.go └── speaker ├── effect.go ├── sound.go └── speaker_test.go /.codecov.yml: -------------------------------------------------------------------------------- 1 | --- 2 | coverage: 3 | range: "60...80" 4 | status: 5 | patch: 6 | default: 7 | threshold: 1 8 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @AlekSi 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | strategy: 7 | matrix: 8 | go: [1.15.x] 9 | 10 | name: Test with Go ${{ matrix.go }} 11 | runs-on: ubuntu-18.04 12 | 13 | steps: 14 | - name: Set up Go ${{ matrix.go }} 15 | uses: actions/setup-go@v1 16 | with: 17 | go-version: ${{ matrix.go }} 18 | id: go 19 | 20 | - name: Check out code 21 | uses: actions/checkout@v1 22 | 23 | - name: Download dependencies 24 | run: go get -t ./... 25 | 26 | - name: Install 27 | run: go install -v ./... 28 | 29 | - name: Test 30 | run: go test -v -coverprofile=cover.out -covermode=count ./... 31 | 32 | - name: Upload coverage report 33 | run: curl -s https://codecov.io/bash | bash -s -- -X fix 34 | env: 35 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | cover.out 3 | ngrok.yml 4 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | linters-settings: 3 | goimports: 4 | local-prefixes: github.com/AlekSi/alice 5 | 6 | lll: 7 | line-length: 110 8 | tab-width: 4 9 | 10 | linters: 11 | enable-all: true 12 | 13 | issues: 14 | exclude-use-default: false 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 4 | and this project adheres to Semantic Import Versioning for Go modules. 5 | 6 | ## [Unreleased] 7 | 8 | ## [0.2.0] - 2019-06-22 9 | 10 | * Add `speaker` package. 11 | 12 | [0.2.0]: https://github.com/AlekSi/alice/releases/tag/v0.2.0 13 | 14 | ## [0.1.0] - 2019-03-31 15 | 16 | * Initial release. 17 | 18 | [0.1.0]: https://github.com/AlekSi/alice/releases/tag/v0.1.0 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alexey Palazhchenko 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # alice 2 | 3 | [![CI status](https://github.com/AlekSi/alice/workflows/CI/badge.svg)](https://github.com/AlekSi/alice/actions) 4 | [![Codecov](https://codecov.io/gh/AlekSi/alice/branch/main/graph/badge.svg)](https://codecov.io/gh/AlekSi/alice) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/AlekSi/alice)](https://goreportcard.com/report/github.com/AlekSi/alice) 6 | [![Go Reference](https://pkg.go.dev/badge/github.com/AlekSi/alice.svg)](https://pkg.go.dev/github.com/AlekSi/alice) 7 | 8 | Package alice provides helpers for developing skills for Alice virtual assistant 9 | via Yandex.Dialogs platform. 10 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package alice provides helpers for developing skills for Alice virtual assistant 2 | // via Yandex.Dialogs platform. 3 | // 4 | // See https://alice.yandex.ru for general information about Alice. 5 | // See https://tech.yandex.ru/dialogs/alice/ for information about Yandex.Dialogs platform. 6 | // See https://tech.yandex.ru/dialogs/alice/doc/ for technical documentation. 7 | // 8 | // Example 9 | // 10 | // See more examples in "examples" directory. 11 | package alice // import "github.com/AlekSi/alice" 12 | -------------------------------------------------------------------------------- /doc_test.go: -------------------------------------------------------------------------------- 1 | package alice_test 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/AlekSi/alice" 9 | ) 10 | 11 | func Example() { 12 | h := alice.NewHandler(func(ctx context.Context, request *alice.Request) (*alice.ResponsePayload, error) { 13 | return &alice.ResponsePayload{ 14 | Text: "Bye!", 15 | EndSession: true, 16 | }, nil 17 | }) 18 | 19 | h.Errorf = log.Printf 20 | http.Handle("/", h) 21 | 22 | const addr = "127.0.0.1:8080" 23 | log.Printf("Listening on http://%s ...", addr) 24 | log.Fatal(http.ListenAndServe(addr, nil)) 25 | } 26 | -------------------------------------------------------------------------------- /examples/buy-elephant/main.go: -------------------------------------------------------------------------------- 1 | // +build ignore 2 | 3 | // Port of https://github.com/yandex/alice-skills/tree/master/python/buy-elephant 4 | package main 5 | 6 | import ( 7 | "context" 8 | "flag" 9 | "fmt" 10 | "log" 11 | "net/http" 12 | "os" 13 | "strings" 14 | "sync" 15 | 16 | "github.com/AlekSi/alice" 17 | ) 18 | 19 | //nolint:gochecknoglobals 20 | var ( 21 | sessionM sync.Mutex 22 | sessionStorage = make(map[string][]string) 23 | ) 24 | 25 | func getSuggests(userID string) []alice.ResponseButton { 26 | res := make([]alice.ResponseButton, 0, 2) 27 | 28 | // select two first suggestions 29 | for _, suggest := range sessionStorage[userID] { 30 | res = append(res, alice.ResponseButton{ 31 | Title: suggest, 32 | Hide: true, 33 | }) 34 | } 35 | if len(res) > 2 { 36 | res = res[:2] 37 | } 38 | 39 | // remove first stored suggestion 40 | if len(sessionStorage[userID]) != 0 { 41 | sessionStorage[userID] = sessionStorage[userID][1:] 42 | } 43 | 44 | // add Yandex.Market suggestion 45 | if len(res) < 2 { 46 | res = append(res, alice.ResponseButton{ 47 | Title: "Ладно", 48 | URL: "https://market.yandex.ru/search?text=слон", 49 | Hide: true, 50 | }) 51 | } 52 | 53 | return res 54 | } 55 | 56 | func main() { 57 | flag.Parse() 58 | 59 | h := alice.NewHandler(func(ctx context.Context, request *alice.Request) (*alice.ResponsePayload, error) { 60 | sessionM.Lock() 61 | defer sessionM.Unlock() 62 | 63 | userID := request.Session.UserID 64 | if request.Session.New { 65 | sessionStorage[userID] = []string{ 66 | "Не хочу.", 67 | "Не буду.", 68 | "Отстань!", 69 | } 70 | 71 | return &alice.ResponsePayload{ 72 | Text: "Привет! Купи слона!", 73 | Buttons: getSuggests(userID), 74 | }, nil 75 | } 76 | 77 | req := strings.ToLower(request.Request.OriginalUtterance) 78 | for _, expected := range []string{"ладно", "куплю", "покупаю", "хорошо"} { 79 | if req == expected { 80 | return &alice.ResponsePayload{ 81 | Text: "Слона можно найти на Яндекс.Маркете!", 82 | }, nil 83 | } 84 | } 85 | 86 | return &alice.ResponsePayload{ 87 | Text: fmt.Sprintf("Все говорят %q, а ты купи слона!", req), 88 | Buttons: getSuggests(userID), 89 | }, nil 90 | }) 91 | 92 | h.Errorf = log.New(os.Stdout, "error: ", 0).Printf 93 | h.Debugf = log.New(os.Stdout, "debug: ", 0).Printf 94 | h.Indent = true 95 | http.Handle("/", h) 96 | 97 | const addr = "127.0.0.1:8080" 98 | log.Printf("Listening on http://%s ...", addr) 99 | log.Fatal(http.ListenAndServe(addr, nil)) 100 | } 101 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/AlekSi/alice 2 | 3 | go 1.15 4 | 5 | require github.com/stretchr/testify v1.4.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 6 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 7 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 10 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 11 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 12 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package alice 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httputil" 10 | "strconv" 11 | "time" 12 | ) 13 | 14 | // Printf is a log.Printf-like function that can be used for logging. 15 | type Printf func(format string, a ...interface{}) 16 | 17 | // Responder is a function that should be implemented to handle Yandex.Dialogs requests. 18 | // 19 | // Passed context is derived from HTTP request's context with added handler's timeout. 20 | // It is canceled when the request is canceled (see https://golang.org/pkg/net/http/#Request.Context) 21 | // or on timeout. 22 | // 23 | // Only response payload can be returned; other response fields (session, version) will be set automatically. 24 | // If error is returned, it is logged with error logger, and 500 Internal server error is sent in response. 25 | type Responder func(ctx context.Context, request *Request) (*ResponsePayload, error) 26 | 27 | // Handler accepts Yandex.Dialogs requests, decodes them, handles "ping" requests itself, 28 | // and delegates other requests to responder. 29 | type Handler struct { 30 | r Responder 31 | 32 | Timeout time.Duration // responder's timeout 33 | Errorf Printf // error logger 34 | 35 | // debugging options 36 | Debugf Printf // debug logger 37 | Indent bool // indent requests and responses 38 | StrictDecoder bool // disallow unexpected fields in requests 39 | } 40 | 41 | // NewHandler creates new handler with given responder and default timeout (3s). 42 | // Exported fields of the returned object can be changed before usage. 43 | func NewHandler(r Responder) *Handler { 44 | return &Handler{ 45 | r: r, 46 | Timeout: 3 * time.Second, 47 | } 48 | } 49 | 50 | func (h *Handler) errorf(format string, a ...interface{}) { 51 | if h.Errorf != nil { 52 | h.Errorf(format, a...) 53 | } 54 | } 55 | 56 | func (h *Handler) debugf(format string, a ...interface{}) { 57 | if h.Debugf != nil { 58 | h.Debugf(format, a...) 59 | } 60 | } 61 | 62 | func pingResponder(ctx context.Context, request *Request) (*ResponsePayload, error) { 63 | return &ResponsePayload{ 64 | Text: "pong", 65 | EndSession: true, 66 | }, nil 67 | } 68 | 69 | func internalError(rw http.ResponseWriter) { 70 | http.Error(rw, "Internal server error.", 500) 71 | } 72 | 73 | // ServeHTTP implements http.Handler interface. 74 | func (h *Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 75 | ctx, cancel := context.WithTimeout(req.Context(), h.Timeout) 76 | defer cancel() 77 | 78 | if h.Debugf != nil { 79 | if h.Indent { 80 | b, err := ioutil.ReadAll(req.Body) 81 | if err != nil { 82 | h.errorf("Failed to read request: %s.", err) 83 | internalError(rw) 84 | return 85 | } 86 | 87 | var body bytes.Buffer 88 | if err = json.Indent(&body, b, "", " "); err != nil { 89 | h.errorf("Failed to indent request: %s.", err) 90 | internalError(rw) 91 | return 92 | } 93 | req.Body = ioutil.NopCloser(&body) 94 | } 95 | 96 | b, err := httputil.DumpRequest(req, true) 97 | if err != nil { 98 | h.errorf("Failed to dump request: %s.", err) 99 | internalError(rw) 100 | return 101 | } 102 | h.debugf("Request:\n%s", b) 103 | } 104 | 105 | request := new(Request) 106 | decoder := json.NewDecoder(req.Body) 107 | if h.StrictDecoder { 108 | decoder.DisallowUnknownFields() 109 | } 110 | if err := decoder.Decode(request); err != nil { 111 | h.errorf("Failed to read or decode request body: %s.", err) 112 | http.Error(rw, "Failed to decode request body.", 400) 113 | return 114 | } 115 | 116 | r := h.r 117 | if request.Request.Type == SimpleUtterance && request.Request.OriginalUtterance == "ping" { 118 | r = pingResponder 119 | } 120 | 121 | payload, err := r(ctx, request) 122 | if err != nil { 123 | h.errorf("Responder failed: %s.", err) 124 | internalError(rw) 125 | return 126 | } 127 | if payload == nil { 128 | h.errorf("Responder returned nil payload without error.") 129 | internalError(rw) 130 | return 131 | } 132 | response := &Response{ 133 | Response: *payload, 134 | Session: ResponseSession{ 135 | SessionID: request.Session.SessionID, 136 | MessageID: request.Session.MessageID, 137 | UserID: request.Session.UserID, 138 | }, 139 | Version: request.Version, 140 | } 141 | 142 | var body bytes.Buffer 143 | encoder := json.NewEncoder(&body) 144 | if h.Indent { 145 | encoder.SetIndent("", " ") 146 | } 147 | if err := encoder.Encode(response); err != nil { 148 | h.errorf("Failed to encode response body: %s.", err) 149 | internalError(rw) 150 | return 151 | } 152 | h.debugf("Response body:\n%s", body.Bytes()) 153 | 154 | rw.Header().Set("Content-Type", "application/json; charset=utf-8") 155 | rw.Header().Set("Content-Length", strconv.Itoa(body.Len())) 156 | rw.WriteHeader(200) 157 | if _, err := rw.Write(body.Bytes()); err != nil { 158 | h.errorf("Failed to write response body: %s.", err) 159 | } 160 | } 161 | 162 | // check interface 163 | var _ http.Handler = (*Handler)(nil) 164 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package alice 2 | 3 | type RequestPayloadType string 4 | 5 | const ( 6 | SimpleUtterance RequestPayloadType = "SimpleUtterance" 7 | ButtonPressed RequestPayloadType = "ButtonPressed" 8 | ) 9 | 10 | // https://tech.yandex.ru/dialogs/alice/doc/protocol-docpage/#request 11 | type Request struct { 12 | Version string `json:"version"` 13 | Meta RequestMeta `json:"meta"` 14 | Request RequestPayload `json:"request"` 15 | Session RequestSession `json:"session"` 16 | } 17 | 18 | type RequestMeta struct { 19 | Locale string `json:"locale"` 20 | Timezone string `json:"timezone"` 21 | ClientID string `json:"client_id"` 22 | Interfaces map[string]interface{} `json:"interfaces"` 23 | } 24 | 25 | type RequestPayload struct { 26 | Command string `json:"command"` 27 | OriginalUtterance string `json:"original_utterance"` 28 | Type RequestPayloadType `json:"type"` 29 | Markup RequestMarkup `json:"markup"` 30 | Payload interface{} `json:"payload,omitempty"` 31 | NLU RequestNLU `json:"nlu"` 32 | } 33 | 34 | type RequestMarkup struct { 35 | DangerousContext bool `json:"dangerous_context"` 36 | } 37 | 38 | type RequestNLU struct { 39 | Tokens []string `json:"tokens"` 40 | Entities []Entity `json:"entities"` 41 | } 42 | 43 | type RequestSession struct { 44 | New bool `json:"new"` 45 | MessageID int `json:"message_id"` 46 | SessionID string `json:"session_id"` 47 | SkillID string `json:"skill_id"` 48 | UserID string `json:"user_id"` 49 | } 50 | 51 | // HasScreen returns true if user's device has screen. 52 | func (m RequestMeta) HasScreen() bool { 53 | return m.Interfaces["screen"] != nil 54 | } 55 | -------------------------------------------------------------------------------- /request_entity.go: -------------------------------------------------------------------------------- 1 | package alice 2 | 3 | import ( 4 | "encoding/json" 5 | "strconv" 6 | ) 7 | 8 | // EntityType represents entity type. 9 | type EntityType string 10 | 11 | // Entity types. 12 | const ( 13 | EntityYandexFio EntityType = "YANDEX.FIO" 14 | EntityYandexGeo EntityType = "YANDEX.GEO" 15 | EntityYandexDateTime EntityType = "YANDEX.DATETIME" 16 | EntityYandexNumber EntityType = "YANDEX.NUMBER" 17 | ) 18 | 19 | // Entity represents NLU-extracted named entity. 20 | // See https://yandex.ru/dev/dialogs/alice/doc/nlu-docpage/. 21 | type Entity struct { 22 | Tokens EntityTokens `json:"tokens"` 23 | Type EntityType `json:"type"` 24 | Value interface{} `json:"value"` 25 | } 26 | 27 | // EntityTokens represents the place of extracted named entity in tokens slice. 28 | type EntityTokens struct { 29 | Start int `json:"start"` 30 | End int `json:"end"` 31 | } 32 | 33 | // YandexFio represents extracted full name. 34 | type YandexFio struct { 35 | FirstName string 36 | PatronymicName string 37 | LastName string 38 | } 39 | 40 | // YandexFio extracts full name from entity, or nil. 41 | func (e *Entity) YandexFio() *YandexFio { 42 | if e.Type != EntityYandexFio { 43 | return nil 44 | } 45 | v, _ := e.Value.(map[string]interface{}) 46 | if v == nil { 47 | return nil 48 | } 49 | 50 | f, _ := v["first_name"].(string) 51 | p, _ := v["patronymic_name"].(string) 52 | l, _ := v["last_name"].(string) 53 | 54 | return &YandexFio{ 55 | FirstName: f, 56 | PatronymicName: p, 57 | LastName: l, 58 | } 59 | } 60 | 61 | // YandexGeo represents extracted address. 62 | type YandexGeo struct { 63 | Country string 64 | City string 65 | Street string 66 | HouseNumber string 67 | Airport string 68 | } 69 | 70 | // YandexGeo extracts address from entity, or nil. 71 | func (e *Entity) YandexGeo() *YandexGeo { 72 | if e.Type != EntityYandexGeo { 73 | return nil 74 | } 75 | v, _ := e.Value.(map[string]interface{}) 76 | if v == nil { 77 | return nil 78 | } 79 | 80 | co, _ := v["country"].(string) 81 | ci, _ := v["city"].(string) 82 | s, _ := v["street"].(string) 83 | h, _ := v["house_number"].(string) 84 | a, _ := v["airport"].(string) 85 | 86 | return &YandexGeo{ 87 | Country: co, 88 | City: ci, 89 | Street: s, 90 | HouseNumber: h, 91 | Airport: a, 92 | } 93 | } 94 | 95 | // YandexDateTime represents extracted date and/or time. 96 | type YandexDateTime struct { 97 | Year int 98 | Month int 99 | Day int 100 | Hour int 101 | Minute int 102 | 103 | YearIsRelative bool 104 | MonthIsRelative bool 105 | DayIsRelative bool 106 | HourIsRelative bool 107 | MinuteIsRelative bool 108 | } 109 | 110 | // YandexDateTime extracts date and/or time from entity, or nil. 111 | func (e *Entity) YandexDateTime() *YandexDateTime { 112 | if e.Type != EntityYandexDateTime { 113 | return nil 114 | } 115 | v, _ := e.Value.(map[string]interface{}) 116 | if v == nil { 117 | return nil 118 | } 119 | 120 | keys := []string{"year", "month", "day", "hour", "minute"} 121 | 122 | // extract absolute values 123 | abs := make(map[string]int) 124 | for _, k := range keys { 125 | switch v := v[k].(type) { 126 | case json.Number: 127 | i64, _ := v.Int64() 128 | abs[k] = int(i64) 129 | case float64: 130 | abs[k] = int(v) 131 | } 132 | } 133 | 134 | // extract relative flags 135 | rel := make(map[string]bool) 136 | for _, k := range keys { 137 | rel[k], _ = v[k+"_is_relative"].(bool) 138 | } 139 | 140 | return &YandexDateTime{ 141 | Year: abs["year"], 142 | Month: abs["month"], 143 | Day: abs["day"], 144 | Hour: abs["hour"], 145 | Minute: abs["minute"], 146 | 147 | YearIsRelative: rel["year"], 148 | MonthIsRelative: rel["month"], 149 | DayIsRelative: rel["day"], 150 | HourIsRelative: rel["hour"], 151 | MinuteIsRelative: rel["minute"], 152 | } 153 | } 154 | 155 | // YandexNumber represents extracted integer or float number. 156 | type YandexNumber struct { 157 | json.Number 158 | } 159 | 160 | // YandexNumber extracts integer or float number from entity, or nil. 161 | func (e *Entity) YandexNumber() *YandexNumber { 162 | if e.Type != EntityYandexNumber { 163 | return nil 164 | } 165 | 166 | switch v := e.Value.(type) { 167 | case json.Number: 168 | return &YandexNumber{v} 169 | case float64: 170 | return &YandexNumber{json.Number(strconv.FormatFloat(v, 'f', -1, 64))} 171 | default: 172 | return nil 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /request_test.go: -------------------------------------------------------------------------------- 1 | package alice 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestRequestDecode(t *testing.T) { 13 | // from https://tech.yandex.ru/dialogs/alice/doc/protocol-docpage/#request 14 | b := []byte(` 15 | { 16 | "meta": { 17 | "locale": "ru-RU", 18 | "timezone": "Europe/Moscow", 19 | "client_id": "ru.yandex.searchplugin/5.80 (Samsung Galaxy; Android 4.4)", 20 | "interfaces": { 21 | "screen": {} 22 | } 23 | }, 24 | "request": { 25 | "command": "закажи пиццу на улицу льва толстого, 16 на завтра", 26 | "original_utterance": "закажи пиццу на улицу льва толстого, 16 на завтра", 27 | "type": "SimpleUtterance", 28 | "markup": { 29 | "dangerous_context": true 30 | }, 31 | "payload": {}, 32 | "nlu": { 33 | "tokens": [ 34 | "закажи", 35 | "пиццу", 36 | "на", 37 | "льва", 38 | "толстого", 39 | "16", 40 | "на", 41 | "завтра" 42 | ], 43 | "entities": [ 44 | { 45 | "tokens": { 46 | "start": 2, 47 | "end": 6 48 | }, 49 | "type": "YANDEX.GEO", 50 | "value": { 51 | "house_number": "16", 52 | "street": "льва толстого" 53 | } 54 | }, 55 | { 56 | "tokens": { 57 | "start": 3, 58 | "end": 5 59 | }, 60 | "type": "YANDEX.FIO", 61 | "value": { 62 | "first_name": "лев", 63 | "last_name": "толстой" 64 | } 65 | }, 66 | { 67 | "tokens": { 68 | "start": 5, 69 | "end": 6 70 | }, 71 | "type": "YANDEX.NUMBER", 72 | "value": 16 73 | }, 74 | { 75 | "tokens": { 76 | "start": 6, 77 | "end": 8 78 | }, 79 | "type": "YANDEX.DATETIME", 80 | "value": { 81 | "day": 1, 82 | "day_is_relative": true 83 | } 84 | } 85 | ] 86 | } 87 | }, 88 | "session": { 89 | "new": true, 90 | "message_id": 4, 91 | "session_id": "2eac4854-fce721f3-b845abba-20d60", 92 | "skill_id": "3ad36498-f5rd-4079-a14b-788652932056", 93 | "user_id": "AC9WC3DF6FCE052E45A4566A48E6B7193774B84814CE49A922E163B8B29881DC" 94 | }, 95 | "version": "1.0" 96 | }`) 97 | 98 | d := json.NewDecoder(bytes.NewReader(b)) 99 | d.DisallowUnknownFields() 100 | var req Request 101 | require.NoError(t, d.Decode(&req)) 102 | assert.True(t, req.Meta.HasScreen()) 103 | assert.Equal(t, []string{"закажи", "пиццу", "на", "льва", "толстого", "16", "на", "завтра"}, req.Request.NLU.Tokens) 104 | require.Len(t, req.Request.NLU.Entities, 4) 105 | 106 | geo := req.Request.NLU.Entities[0] 107 | assert.Equal(t, 2, geo.Tokens.Start) 108 | assert.Equal(t, 6, geo.Tokens.End) 109 | assert.Equal(t, EntityYandexGeo, geo.Type) 110 | assert.Equal(t, &YandexGeo{Street: "льва толстого", HouseNumber: "16"}, geo.YandexGeo()) 111 | 112 | fio := req.Request.NLU.Entities[1] 113 | assert.Equal(t, 3, fio.Tokens.Start) 114 | assert.Equal(t, 5, fio.Tokens.End) 115 | assert.Equal(t, EntityYandexFio, fio.Type) 116 | assert.Equal(t, &YandexFio{FirstName: "лев", LastName: "толстой"}, fio.YandexFio()) 117 | 118 | num := req.Request.NLU.Entities[2] 119 | assert.Equal(t, 5, num.Tokens.Start) 120 | assert.Equal(t, 6, num.Tokens.End) 121 | assert.Equal(t, EntityYandexNumber, num.Type) 122 | assert.Equal(t, "16", num.YandexNumber().String()) 123 | 124 | dt := req.Request.NLU.Entities[3] 125 | assert.Equal(t, 6, dt.Tokens.Start) 126 | assert.Equal(t, 8, dt.Tokens.End) 127 | assert.Equal(t, EntityYandexDateTime, dt.Type) 128 | assert.Equal(t, &YandexDateTime{Day: 1, DayIsRelative: true}, dt.YandexDateTime()) 129 | } 130 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | package alice 2 | 3 | type ResponseCardType string 4 | 5 | const ( 6 | BigImage ResponseCardType = "BigImage" 7 | ItemsList ResponseCardType = "ItemsList" 8 | ) 9 | 10 | // Response represents main response object. 11 | // See https://tech.yandex.ru/dialogs/alice/doc/protocol-docpage/#response 12 | type Response struct { 13 | Response ResponsePayload `json:"response"` 14 | Session ResponseSession `json:"session"` 15 | Version string `json:"version"` 16 | } 17 | 18 | // ResponsePayload contains response payload. 19 | type ResponsePayload struct { 20 | Text string `json:"text"` 21 | Tts string `json:"tts,omitempty"` 22 | Card *ResponseCard `json:"card,omitempty"` 23 | Buttons []ResponseButton `json:"buttons,omitempty"` 24 | EndSession bool `json:"end_session"` 25 | } 26 | 27 | // ResponseCard contains response card. 28 | type ResponseCard struct { 29 | Type ResponseCardType `json:"type"` 30 | 31 | // For BigImage type. 32 | *ResponseCardItem `json:",omitempty"` 33 | 34 | // For ItemsList type. 35 | *ResponseCardItemsList `json:",omitempty"` 36 | } 37 | 38 | type ResponseCardItem struct { 39 | ImageID string `json:"image_id,omitempty"` 40 | Title string `json:"title,omitempty"` 41 | Description string `json:"description,omitempty"` 42 | Button *ResponseCardButton `json:"button,omitempty"` 43 | } 44 | 45 | type ResponseCardItemsList struct { 46 | Header *ResponseCardHeader `json:"header,omitempty"` 47 | Items []ResponseCardItem `json:"items,omitempty"` 48 | Footer *ResponseCardFooter `json:"footer,omitempty"` 49 | } 50 | 51 | type ResponseCardHeader struct { 52 | Text string `json:"text"` 53 | } 54 | 55 | type ResponseCardFooter struct { 56 | Text string `json:"text"` 57 | Button *ResponseCardButton `json:"button,omitempty"` 58 | } 59 | 60 | type ResponseCardButton struct { 61 | Text string `json:"text"` 62 | URL string `json:"url,omitempty"` 63 | Payload interface{} `json:"payload,omitempty"` 64 | } 65 | 66 | type ResponseButton struct { 67 | Title string `json:"title"` 68 | Payload interface{} `json:"payload,omitempty"` 69 | URL string `json:"url,omitempty"` 70 | Hide bool `json:"hide,omitempty"` 71 | } 72 | 73 | // ResponseSession contains response session. 74 | type ResponseSession struct { 75 | SessionID string `json:"session_id"` 76 | MessageID int `json:"message_id"` 77 | UserID string `json:"user_id"` 78 | } 79 | -------------------------------------------------------------------------------- /response_test.go: -------------------------------------------------------------------------------- 1 | package alice 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestResponse(t *testing.T) { 14 | // from https://tech.yandex.ru/dialogs/alice/doc/protocol-docpage/#response 15 | 16 | t.Run("NoImage", func(t *testing.T) { 17 | b := []byte(strings.TrimSpace(` 18 | { 19 | "response": { 20 | "text": "Здравствуйте! Это мы, хороводоведы.", 21 | "tts": "Здравствуйте! Это мы, хоров+одо в+еды.", 22 | "buttons": [ 23 | { 24 | "title": "Надпись на кнопке", 25 | "payload": {}, 26 | "url": "https://example.com/", 27 | "hide": true 28 | } 29 | ], 30 | "end_session": false 31 | }, 32 | "session": { 33 | "session_id": "2eac4854-fce721f3-b845abba-20d60", 34 | "message_id": 4, 35 | "user_id": "AC9WC3DF6FCE052E45A4566A48E6B7193774B84814CE49A922E163B8B29881DC" 36 | }, 37 | "version": "1.0" 38 | }`)) 39 | var actual Response 40 | 41 | t.Run("Decode", func(t *testing.T) { 42 | d := json.NewDecoder(bytes.NewReader(b)) 43 | d.DisallowUnknownFields() 44 | err := d.Decode(&actual) 45 | require.NoError(t, err, "%#v", err) 46 | expected := Response{ 47 | Version: "1.0", 48 | Response: ResponsePayload{ 49 | Text: "Здравствуйте! Это мы, хороводоведы.", 50 | Tts: "Здравствуйте! Это мы, хоров+одо в+еды.", 51 | Buttons: []ResponseButton{{ 52 | Title: "Надпись на кнопке", 53 | Payload: map[string]interface{}{}, 54 | URL: "https://example.com/", 55 | Hide: true, 56 | }}, 57 | }, 58 | Session: ResponseSession{ 59 | SessionID: "2eac4854-fce721f3-b845abba-20d60", 60 | MessageID: 4, 61 | UserID: "AC9WC3DF6FCE052E45A4566A48E6B7193774B84814CE49A922E163B8B29881DC", 62 | }, 63 | } 64 | assert.Equal(t, expected, actual) 65 | }) 66 | 67 | t.Run("Encode", func(t *testing.T) { 68 | actualB, err := json.MarshalIndent(actual, "\t\t", "\t") 69 | require.NoError(t, err) 70 | assert.Equal(t, strings.Split(string(b), "\n"), strings.Split(string(actualB), "\n")) 71 | }) 72 | }) 73 | 74 | t.Run("BigImage", func(t *testing.T) { 75 | b := []byte(strings.TrimSpace(` 76 | { 77 | "response": { 78 | "text": "Здравствуйте! Это мы, хороводоведы.", 79 | "tts": "Здравствуйте! Это мы, хоров+одо в+еды.", 80 | "card": { 81 | "type": "BigImage", 82 | "image_id": "1027858/46r960da47f60207e924", 83 | "title": "Заголовок для изображения", 84 | "description": "Описание изображения.", 85 | "button": { 86 | "text": "Надпись на кнопке", 87 | "url": "http://example.com/", 88 | "payload": {} 89 | } 90 | }, 91 | "buttons": [ 92 | { 93 | "title": "Надпись на кнопке", 94 | "payload": {}, 95 | "url": "https://example.com/", 96 | "hide": true 97 | } 98 | ], 99 | "end_session": false 100 | }, 101 | "session": { 102 | "session_id": "2eac4854-fce721f3-b845abba-20d60", 103 | "message_id": 4, 104 | "user_id": "AC9WC3DF6FCE052E45A4566A48E6B7193774B84814CE49A922E163B8B29881DC" 105 | }, 106 | "version": "1.0" 107 | }`)) 108 | var actual Response 109 | 110 | t.Run("Decode", func(t *testing.T) { 111 | d := json.NewDecoder(bytes.NewReader(b)) 112 | d.DisallowUnknownFields() 113 | err := d.Decode(&actual) 114 | require.NoError(t, err, "%#v", err) 115 | expected := Response{ 116 | Version: "1.0", 117 | Response: ResponsePayload{ 118 | Text: "Здравствуйте! Это мы, хороводоведы.", 119 | Tts: "Здравствуйте! Это мы, хоров+одо в+еды.", 120 | Card: &ResponseCard{ 121 | Type: BigImage, 122 | ResponseCardItem: &ResponseCardItem{ 123 | ImageID: "1027858/46r960da47f60207e924", 124 | Title: "Заголовок для изображения", 125 | Description: "Описание изображения.", 126 | Button: &ResponseCardButton{ 127 | Text: "Надпись на кнопке", 128 | URL: "http://example.com/", 129 | Payload: map[string]interface{}{}, 130 | }, 131 | }, 132 | }, 133 | Buttons: []ResponseButton{{ 134 | Title: "Надпись на кнопке", 135 | Payload: map[string]interface{}{}, 136 | URL: "https://example.com/", 137 | Hide: true, 138 | }}, 139 | }, 140 | Session: ResponseSession{ 141 | SessionID: "2eac4854-fce721f3-b845abba-20d60", 142 | MessageID: 4, 143 | UserID: "AC9WC3DF6FCE052E45A4566A48E6B7193774B84814CE49A922E163B8B29881DC", 144 | }, 145 | } 146 | assert.Equal(t, expected, actual) 147 | }) 148 | 149 | t.Run("Encode", func(t *testing.T) { 150 | actualB, err := json.MarshalIndent(actual, "\t\t", "\t") 151 | require.NoError(t, err) 152 | assert.Equal(t, strings.Split(string(b), "\n"), strings.Split(string(actualB), "\n")) 153 | }) 154 | }) 155 | 156 | t.Run("ItemsList", func(t *testing.T) { 157 | b := []byte(strings.TrimSpace(` 158 | { 159 | "response": { 160 | "text": "Здравствуйте! Это мы, хороводоведы.", 161 | "tts": "Здравствуйте! Это мы, хоров+одо в+еды.", 162 | "card": { 163 | "type": "ItemsList", 164 | "header": { 165 | "text": "Заголовок галереи изображений" 166 | }, 167 | "items": [ 168 | { 169 | "image_id": "image_id", 170 | "title": "Заголовок для изображения.", 171 | "description": "Описание изображения.", 172 | "button": { 173 | "text": "Надпись на кнопке", 174 | "url": "http://example.com/", 175 | "payload": {} 176 | } 177 | } 178 | ], 179 | "footer": { 180 | "text": "Текст блока под изображением.", 181 | "button": { 182 | "text": "Надпись на кнопке", 183 | "url": "https://example.com/", 184 | "payload": {} 185 | } 186 | } 187 | }, 188 | "buttons": [ 189 | { 190 | "title": "Надпись на кнопке", 191 | "payload": {}, 192 | "url": "https://example.com/", 193 | "hide": true 194 | } 195 | ], 196 | "end_session": false 197 | }, 198 | "session": { 199 | "session_id": "2eac4854-fce721f3-b845abba-20d60", 200 | "message_id": 4, 201 | "user_id": "AC9WC3DF6FCE052E45A4566A48E6B7193774B84814CE49A922E163B8B29881DC" 202 | }, 203 | "version": "1.0" 204 | }`)) 205 | var actual Response 206 | 207 | t.Run("Decode", func(t *testing.T) { 208 | d := json.NewDecoder(bytes.NewReader(b)) 209 | d.DisallowUnknownFields() 210 | err := d.Decode(&actual) 211 | require.NoError(t, err, "%#v", err) 212 | expected := Response{ 213 | Version: "1.0", 214 | Response: ResponsePayload{ 215 | Text: "Здравствуйте! Это мы, хороводоведы.", 216 | Tts: "Здравствуйте! Это мы, хоров+одо в+еды.", 217 | Card: &ResponseCard{ 218 | Type: ItemsList, 219 | ResponseCardItemsList: &ResponseCardItemsList{ 220 | Header: &ResponseCardHeader{ 221 | Text: "Заголовок галереи изображений", 222 | }, 223 | Items: []ResponseCardItem{ 224 | { 225 | ImageID: "image_id", 226 | Title: "Заголовок для изображения.", 227 | Description: "Описание изображения.", 228 | Button: &ResponseCardButton{ 229 | Text: "Надпись на кнопке", 230 | URL: "http://example.com/", 231 | Payload: map[string]interface{}{}, 232 | }, 233 | }, 234 | }, 235 | Footer: &ResponseCardFooter{ 236 | Text: "Текст блока под изображением.", 237 | Button: &ResponseCardButton{ 238 | Text: "Надпись на кнопке", 239 | URL: "https://example.com/", 240 | Payload: map[string]interface{}{}, 241 | }, 242 | }, 243 | }, 244 | }, 245 | Buttons: []ResponseButton{{ 246 | Title: "Надпись на кнопке", 247 | Payload: map[string]interface{}{}, 248 | URL: "https://example.com/", 249 | Hide: true, 250 | }}, 251 | }, 252 | Session: ResponseSession{ 253 | SessionID: "2eac4854-fce721f3-b845abba-20d60", 254 | MessageID: 4, 255 | UserID: "AC9WC3DF6FCE052E45A4566A48E6B7193774B84814CE49A922E163B8B29881DC", 256 | }, 257 | } 258 | assert.Equal(t, expected, actual) 259 | }) 260 | 261 | t.Run("Encode", func(t *testing.T) { 262 | actualB, err := json.MarshalIndent(actual, "\t\t", "\t") 263 | require.NoError(t, err) 264 | assert.Equal(t, strings.Split(string(b), "\n"), strings.Split(string(actualB), "\n")) 265 | }) 266 | }) 267 | } 268 | -------------------------------------------------------------------------------- /speaker/effect.go: -------------------------------------------------------------------------------- 1 | // Package speaker provides various sounds and effects. 2 | package speaker 3 | 4 | import ( 5 | "fmt" 6 | ) 7 | 8 | // Effect defines an effect you can apply to text. 9 | // See https://yandex.ru/dev/dialogs/alice/doc/speech-effects-docpage/. 10 | type Effect string 11 | 12 | // Those contants define various effects. 13 | const ( 14 | BehindTheWall = Effect("behind_the_wall") 15 | Hamster = Effect("hamster") 16 | Megaphone = Effect("megaphone") 17 | PitchDown = Effect("pitch_down") 18 | Psychodelic = Effect("psychodelic") 19 | Pulse = Effect("pulse") 20 | TrainAnnounce = Effect("train_announce") 21 | ) 22 | 23 | // Apply wraps given text with effect. 24 | func (e Effect) Apply(text string) string { 25 | return fmt.Sprintf(`%s`, e, text) 26 | } 27 | -------------------------------------------------------------------------------- /speaker/sound.go: -------------------------------------------------------------------------------- 1 | package speaker 2 | 3 | // Those contants define various sounds. 4 | // See https://yandex.ru/dev/dialogs/alice/doc/sounds/things-docpage/ and subpages. 5 | // 6 | // TODO Please contribute more constants. See https://github.com/AlekSi/alice/issues/6 7 | const ( 8 | Bell1 = `` 9 | Bell2 = `` 10 | Car1 = `` 11 | Car2 = `` 12 | Chainsaw1 = `` 13 | Construction1 = `` 14 | Construction2 = `` 15 | CuckooClock1 = `` 16 | CuckooClock2 = `` 17 | Door1 = `` 18 | Door2 = `` 19 | Explosion1 = `` 20 | Glass1 = `` 21 | Glass2 = `` 22 | Gun1 = `` 23 | OldPhone1 = `` 24 | OldPhone2 = `` 25 | Phone1 = `` 26 | Phone2 = `` 27 | Phone3 = `` 28 | Phone4 = `` 29 | Phone5 = `` 30 | ShipHorn1 = `` 31 | ShipHorn2 = `` 32 | Siren1 = `` 33 | Siren2 = `` 34 | Switch1 = `` 35 | Switch2 = `` 36 | Sword1 = `` 37 | Sword2 = `` 38 | Sword3 = `` 39 | Toilet1 = `` 40 | Water1 = `` 41 | Water2 = `` 42 | Water3 = `` 43 | ) 44 | -------------------------------------------------------------------------------- /speaker/speaker_test.go: -------------------------------------------------------------------------------- 1 | package speaker 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | //nolint:lll 8 | func Example() { 9 | fmt.Println(BehindTheWall.Apply("Hello") + Chainsaw1 + " world!") 10 | // Output: 11 | // Hello world! 12 | } 13 | --------------------------------------------------------------------------------