├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── adapter.go ├── adapter ├── hipchat │ └── hipchat.go ├── irc │ └── irc.go ├── shell │ └── shell.go ├── slack │ ├── api │ │ └── api.go │ ├── http.go │ ├── irc.go │ ├── slack.go │ └── slack_test.go └── test │ └── test.go ├── auth.go ├── config.go ├── docs ├── Makefile ├── build │ └── .gitkeep └── source │ ├── adapters │ ├── campfire.rst │ ├── custom.rst │ ├── hipchat.rst │ ├── index.rst │ ├── irc.rst │ ├── shell.rst │ └── slack.rst │ ├── conf.py │ ├── config │ └── index.rst │ ├── glossary.rst │ ├── handlers │ └── index.rst │ ├── index.rst │ └── stores │ ├── custom.rst │ ├── index.rst │ ├── memory.rst │ └── redis.rst ├── examples ├── complex │ └── main.go ├── redis.go ├── simple │ └── main.go └── store.go ├── hal.go ├── haltest ├── adapter.go ├── robot.go └── store.go ├── handler.go ├── handler ├── echo.go ├── ping.go └── tableflip.go ├── handler_test.go ├── message.go ├── response.go ├── robot.go ├── store.go ├── store ├── memory │ └── memory.go └── redis │ └── redis.go └── user.go /.gitignore: -------------------------------------------------------------------------------- 1 | bin/hal 2 | examples/local.go 3 | .env 4 | docs/build/* 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danryan/hal/56786328954185e9735bdcbfbfc6414c51120462/CHANGELOG.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HAL 2 | 3 | ## Unmaintained. Sorry :( 4 | 5 | 6 | Hal is a chat bot framework written in the Go programming language. 7 | 8 | [![](http://img.shields.io/badge/everything-AWESOME-brightgreen.svg)](http://youtu.be/StTqXEQ2l-Y "Everything status: AWESOME") 9 | 10 | ## Getting started 11 | 12 | ["Good morning, Dr. Chandra. This is HAL. I'm ready for my first lesson." ![](http://img.youtube.com/vi/nXgboDb9ucE/0.jpg)](https://www.youtube.com/watch?v=nXgboDb9ucE#t=180 "Good morning, Dr. Chandra. This is HAL. I'm ready for my first lesson.") 13 | 14 | Hal is Go all the way down, and uses standard packages wherever possible. For an idea of how you can use it, look at [a simple example](examples/simple/main.go), or [a more complex example](examples/complex/main.go). Please see below for additional resources. 15 | 16 | ## Resources 17 | 18 | * [Project documentation](http://hal.readthedocs.org) 19 | * [API documentation](http://godoc.org/github.com/danryan/hal) 20 | * [Mailing list](https://groups.google.com/group/hal-bot) 21 | * [Bugs and feature requests](https://github.com/danryan/hal/issues) 22 | * IRC - ##hal on irc.freenode.net 23 | 24 | ## Is it any good? 25 | 26 | [Probably not.](http://news.ycombinator.com/item?id=3067434) 27 | 28 | ## License 29 | 30 | Copyright 2014 Applied Awesome LLC. 31 | 32 | Licensed under the Apache License, Version 2.0 (the "License"); 33 | you may not use this file except in compliance with the License. 34 | You may obtain a copy of the License at 35 | 36 | http://www.apache.org/licenses/LICENSE-2.0 37 | 38 | Unless required by applicable law or agreed to in writing, software 39 | distributed under the License is distributed on an "AS IS" BASIS, 40 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 41 | See the License for the specific language governing permissions and 42 | limitations under the License. 43 | -------------------------------------------------------------------------------- /adapter.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Adapter interface 8 | type Adapter interface { 9 | // New() (Adapter, error) 10 | Run() error 11 | Stop() error 12 | 13 | Receive(*Message) error 14 | Send(*Response, ...string) error 15 | Emote(*Response, ...string) error 16 | Reply(*Response, ...string) error 17 | Topic(*Response, ...string) error 18 | Play(*Response, ...string) error 19 | 20 | String() string 21 | } 22 | 23 | type adapter struct { 24 | name string 25 | newFunc func(*Robot) (Adapter, error) 26 | sendChan chan *Response 27 | recvChan chan *Message 28 | } 29 | 30 | // AvailableAdapters is a map of registered adapters 31 | var AvailableAdapters = map[string]adapter{} 32 | 33 | // NewAdapter creates a new initialized adapter 34 | func NewAdapter(robot *Robot) (Adapter, error) { 35 | name := Config.AdapterName 36 | if _, ok := AvailableAdapters[name]; !ok { 37 | return nil, fmt.Errorf("%s is not a registered adapter", Config.AdapterName) 38 | } 39 | 40 | adapter, err := AvailableAdapters[name].newFunc(robot) 41 | if err != nil { 42 | return nil, err 43 | } 44 | return adapter, nil 45 | } 46 | 47 | // RegisterAdapter registers an adapter 48 | func RegisterAdapter(name string, newFunc func(*Robot) (Adapter, error)) { 49 | AvailableAdapters[name] = adapter{ 50 | name: name, 51 | newFunc: newFunc, 52 | } 53 | } 54 | 55 | // BasicAdapter declares common functions shared by all adapters 56 | type BasicAdapter struct { 57 | *Robot 58 | } 59 | 60 | // SetRobot sets the adapter's Robot 61 | func (a *BasicAdapter) SetRobot(r *Robot) { 62 | a.Robot = r 63 | } 64 | 65 | func (a *BasicAdapter) String() string { 66 | return Config.AdapterName 67 | } 68 | -------------------------------------------------------------------------------- /adapter/hipchat/hipchat.go: -------------------------------------------------------------------------------- 1 | package hipchat 2 | 3 | import ( 4 | "fmt" 5 | "github.com/daneharrigan/hipchat" 6 | "github.com/danryan/env" 7 | "github.com/danryan/hal" 8 | "strings" 9 | ) 10 | 11 | func init() { 12 | hal.RegisterAdapter("hipchat", New) 13 | } 14 | 15 | // adapter struct 16 | type adapter struct { 17 | hal.BasicAdapter 18 | user string 19 | nick string 20 | name string 21 | password string 22 | resource string 23 | rooms []string 24 | client *hipchat.Client 25 | // config *config 26 | } 27 | 28 | type config struct { 29 | User string `env:"required key=HAL_HIPCHAT_USER"` 30 | Password string `env:"required key=HAL_HIPCHAT_PASSWORD"` 31 | Rooms string `env:"required key=HAL_HIPCHAT_ROOMS"` 32 | Resource string `env:"key=HAL_HIPCHAT_RESOURCE default=bot"` 33 | } 34 | 35 | // New returns an initialized adapter 36 | func New(robot *hal.Robot) (hal.Adapter, error) { 37 | c := &config{} 38 | env.MustProcess(c) 39 | 40 | a := &adapter{ 41 | user: c.User, 42 | password: c.Password, 43 | resource: c.Resource, 44 | rooms: func() []string { return strings.Split(c.Rooms, ",") }(), 45 | } 46 | a.SetRobot(robot) 47 | return a, nil 48 | } 49 | 50 | // Run starts the adapter 51 | func (a *adapter) Run() error { 52 | go a.startConnection() 53 | return nil 54 | } 55 | 56 | // Stop shuts down the adapter 57 | func (a *adapter) Stop() error { 58 | // hipchat package doesn't provide an explicit stop command 59 | return nil 60 | } 61 | 62 | // Send sends a regular response 63 | func (a *adapter) Send(res *hal.Response, strings ...string) error { 64 | for _, str := range strings { 65 | a.client.Say(res.Message.Room, a.name, str) 66 | } 67 | return nil 68 | } 69 | 70 | // Reply sends a direct response 71 | func (a *adapter) Reply(res *hal.Response, strings ...string) error { 72 | newStrings := make([]string, len(strings)) 73 | for _, str := range strings { 74 | s := fmt.Sprintf("@%s: %s", mentionName(res.Envelope.User), str) 75 | newStrings = append(newStrings, s) 76 | } 77 | 78 | return a.Send(res, newStrings...) 79 | } 80 | 81 | // Emote is not implemented. 82 | func (a *adapter) Emote(res *hal.Response, strings ...string) error { 83 | return nil 84 | } 85 | 86 | // Topic is not implemented. 87 | func (a *adapter) Topic(res *hal.Response, strings ...string) error { 88 | return nil 89 | } 90 | 91 | // Play is not implemented. 92 | func (a *adapter) Play(res *hal.Response, strings ...string) error { 93 | return nil 94 | } 95 | 96 | // Receive forwards a message to the robot 97 | func (a *adapter) Receive(msg *hal.Message) error { 98 | hal.Logger.Debug("hipchat - adapter received message") 99 | a.Robot.Receive(msg) 100 | hal.Logger.Debug("hipchat - adapter sent message to robot") 101 | 102 | return nil 103 | } 104 | 105 | func (a *adapter) newMessage(msg *hipchat.Message) *hal.Message { 106 | from := strings.Split(msg.From, "/") 107 | user, _ := a.Robot.Users.GetByName(from[1]) 108 | 109 | return &hal.Message{ 110 | User: user, 111 | Room: from[0], 112 | Text: msg.Body, 113 | } 114 | } 115 | 116 | func mentionName(u *hal.User) string { 117 | mn, ok := u.Options["mentionName"] 118 | if !ok { 119 | return "" 120 | } 121 | return mn.(string) 122 | } 123 | 124 | func (a *adapter) startConnection() error { 125 | client, err := hipchat.NewClient(a.user, a.password, a.resource) 126 | if err != nil { 127 | hal.Logger.Error(err.Error()) 128 | return err 129 | } 130 | 131 | client.Status("chat") 132 | 133 | for _, user := range client.Users() { 134 | // retrieve the name and mention name of our bot from the server 135 | if user.Id == client.Id { 136 | a.name = user.Name 137 | a.nick = user.MentionName 138 | // skip adding the bot to the users map 139 | continue 140 | } 141 | // Initialize a newUser object in case we need it. 142 | newUser := hal.User{ 143 | ID: user.Id, 144 | Name: user.Name, 145 | Options: map[string]interface{}{ 146 | "mentionName": user.MentionName, 147 | }, 148 | } 149 | // Prepopulate our users map because we can easily do so. 150 | // If a user doesn't exist, set it. 151 | u, err := a.Robot.Users.Get(user.Id) 152 | if err != nil { 153 | a.Robot.Users.Set(user.Id, newUser) 154 | } 155 | // If the user doesn't match completely (say, if someone changes their name), 156 | // then adjust what we have stored. 157 | if u.Name != user.Name || mentionName(&u) != user.MentionName { 158 | a.Robot.Users.Set(user.Id, newUser) 159 | } 160 | } 161 | 162 | // Make a map of room JIDs to human names 163 | roomJids := make(map[string]string, len(client.Rooms())) 164 | for _, room := range client.Rooms() { 165 | roomJids[room.Name] = room.Id 166 | } 167 | client.Status("chat") 168 | // Only join the rooms we want 169 | for _, room := range a.rooms { 170 | hal.Logger.Debugf("%s - joined %s", a, room) 171 | client.Join(roomJids[room], a.name) 172 | } 173 | 174 | a.client = client 175 | a.Robot.Alias = a.nick 176 | 177 | // send an empty string every 60 seconds so hipchat doesn't disconnect us 178 | go client.KeepAlive() 179 | 180 | for message := range client.Messages() { 181 | from := strings.Split(message.From, "/") 182 | // ignore messages directly from the channel 183 | // TODO: don't do this :) 184 | if len(from) < 2 { 185 | continue 186 | } 187 | // ingore messages from our bot 188 | if from[1] == a.name { 189 | continue 190 | } 191 | 192 | msg := a.newMessage(message) 193 | a.Receive(msg) 194 | } 195 | return nil 196 | } 197 | -------------------------------------------------------------------------------- /adapter/irc/irc.go: -------------------------------------------------------------------------------- 1 | package irc 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "github.com/danryan/env" 7 | "github.com/danryan/hal" 8 | irc "github.com/thoj/go-ircevent" 9 | "strings" 10 | ) 11 | 12 | func init() { 13 | hal.RegisterAdapter("irc", New) 14 | } 15 | 16 | // adapter struct 17 | type adapter struct { 18 | hal.BasicAdapter 19 | user string 20 | nick string 21 | password string 22 | server string 23 | port int 24 | mode string 25 | channels []string //[]string 26 | useTLS bool 27 | conn *irc.Connection 28 | } 29 | 30 | type config struct { 31 | User string `env:"required key=HAL_IRC_USER"` 32 | Nick string `env:"required key=HAL_IRC_NICK"` 33 | Password string `env:"key=HAL_IRC_PASSWORD"` 34 | Server string `env:"required key=HAL_IRC_SERVER"` 35 | Port int `env:"key=HAL_IRC_PORT default=6667"` 36 | Channels string `env:"required key=HAL_IRC_CHANNELS"` 37 | UseTLS bool `env:"key=HAL_IRC_USE_TLS default=false"` 38 | } 39 | 40 | // New returns an initialized adapter 41 | func New(robot *hal.Robot) (hal.Adapter, error) { 42 | c := &config{} 43 | env.MustProcess(c) 44 | 45 | a := &adapter{ 46 | user: c.User, 47 | nick: c.Nick, 48 | password: c.Password, 49 | server: c.Server, 50 | port: c.Port, 51 | channels: func() []string { return strings.Split(c.Channels, ",") }(), 52 | useTLS: c.UseTLS, 53 | } 54 | // Set the robot name to the IRC nick so respond commands will work 55 | a.SetRobot(robot) 56 | a.Robot.SetName(a.nick) 57 | return a, nil 58 | } 59 | 60 | // Send sends a regular response 61 | func (a *adapter) Send(res *hal.Response, strings ...string) error { 62 | hal.Logger.Debug("irc - sending IRC response") 63 | for _, str := range strings { 64 | s := &ircPayload{ 65 | Channel: res.Message.Room, 66 | Text: str, 67 | } 68 | a.conn.Privmsg(s.Channel, s.Text) 69 | } 70 | hal.Logger.Debug("irc - sent IRC response") 71 | return nil 72 | } 73 | 74 | // Reply sends a direct response 75 | func (a *adapter) Reply(res *hal.Response, strings ...string) error { 76 | newStrings := make([]string, len(strings)) 77 | for _, str := range strings { 78 | newStrings = append(newStrings, res.UserID()+`: `+str) 79 | } 80 | 81 | a.Send(res, newStrings...) 82 | 83 | return nil 84 | } 85 | 86 | // Emote is not implemented. 87 | func (a *adapter) Emote(res *hal.Response, strings ...string) error { 88 | return nil 89 | } 90 | 91 | // Topic sets the topic 92 | func (a *adapter) Topic(res *hal.Response, strings ...string) error { 93 | for _, str := range strings { 94 | a.conn.SendRawf("TOPIC %s %s", res.Room(), str) 95 | } 96 | return nil 97 | } 98 | 99 | // Play is not implemented. 100 | func (a *adapter) Play(res *hal.Response, strings ...string) error { 101 | return nil 102 | } 103 | 104 | // Receive forwards a message to the robot 105 | func (a *adapter) Receive(msg *hal.Message) error { 106 | hal.Logger.Debug("irc - adapter received message") 107 | a.Robot.Receive(msg) 108 | hal.Logger.Debug("irc - adapter sent message to robot") 109 | 110 | return nil 111 | } 112 | 113 | // Run starts the adapter 114 | func (a *adapter) Run() error { 115 | // set up a connection to the IRC gateway 116 | hal.Logger.Debug("irc - starting IRC connection") 117 | go a.startIRCConnection() 118 | hal.Logger.Debug("irc - started IRC connection") 119 | 120 | return nil 121 | } 122 | 123 | // Stop shuts down the adapter 124 | func (a *adapter) Stop() error { 125 | hal.Logger.Debug("irc - stopping IRC connection") 126 | a.stopIRCConnection() 127 | hal.Logger.Debug("irc - stopped IRC connection") 128 | 129 | return nil 130 | } 131 | 132 | func (a *adapter) newMessage(req *irc.Event) *hal.Message { 133 | return &hal.Message{ 134 | User: hal.User{ 135 | ID: req.Nick, 136 | Name: req.Nick, 137 | }, 138 | Room: req.Arguments[0], 139 | Text: req.Message(), 140 | } 141 | } 142 | 143 | type ircPayload struct { 144 | Channel string 145 | Username string 146 | Text string 147 | } 148 | 149 | func (a *adapter) startIRCConnection() { 150 | if a.nick == "" { 151 | a.nick = a.user 152 | } 153 | 154 | conn := irc.IRC(a.nick, a.user) 155 | if a.useTLS { 156 | conn.UseTLS = true 157 | conn.TLSConfig = &tls.Config{ServerName: a.server} 158 | } 159 | conn.Password = a.password 160 | conn.Debug = (hal.Logger.Level() == 10) 161 | 162 | err := conn.Connect(a.connectionString()) 163 | if err != nil { 164 | panic("failed to connect to" + err.Error()) 165 | } 166 | 167 | conn.AddCallback("001", func(e *irc.Event) { 168 | for _, channel := range a.channels { 169 | conn.Join(channel) 170 | hal.Logger.Debug("irc - joined " + channel) 171 | } 172 | }) 173 | 174 | conn.AddCallback("PRIVMSG", func(e *irc.Event) { 175 | message := a.newMessage(e) 176 | a.Receive(message) 177 | }) 178 | 179 | a.conn = conn 180 | hal.Logger.Debug("irc - waiting for server acknowledgement") 181 | conn.Loop() 182 | } 183 | 184 | func (a *adapter) stopIRCConnection() { 185 | hal.Logger.Debug("Stopping irc IRC connection") 186 | a.conn.Quit() 187 | hal.Logger.Debug("Stopped irc IRC connection") 188 | } 189 | 190 | func (a *adapter) connectionString() string { 191 | return fmt.Sprintf("%s:%d", a.server, a.port) 192 | } 193 | -------------------------------------------------------------------------------- /adapter/shell/shell.go: -------------------------------------------------------------------------------- 1 | package shell 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "github.com/danryan/hal" 7 | "io" 8 | "log" 9 | "os" 10 | "strings" 11 | ) 12 | 13 | func init() { 14 | hal.RegisterAdapter("shell", New) 15 | } 16 | 17 | type adapter struct { 18 | hal.BasicAdapter 19 | in *bufio.Reader 20 | out *bufio.Writer 21 | quit chan bool 22 | } 23 | 24 | // New returns an initialized adapter 25 | func New(r *hal.Robot) (hal.Adapter, error) { 26 | a := &adapter{ 27 | out: bufio.NewWriter(os.Stdout), 28 | in: bufio.NewReader(os.Stdin), 29 | quit: make(chan bool), 30 | } 31 | a.SetRobot(r) 32 | return a, nil 33 | } 34 | 35 | // Send sends a regular response 36 | func (a *adapter) Send(res *hal.Response, strings ...string) error { 37 | for _, str := range strings { 38 | err := a.writeString(str) 39 | if err != nil { 40 | log.Println("error: ", err) 41 | return err 42 | } 43 | } 44 | 45 | return nil 46 | } 47 | 48 | // Reply sends a direct response 49 | func (a *adapter) Reply(res *hal.Response, strings ...string) error { 50 | for _, str := range strings { 51 | s := res.UserName() + `: ` + str 52 | err := a.writeString(s) 53 | if err != nil { 54 | log.Println("error: ", err) 55 | return err 56 | } 57 | } 58 | 59 | return nil 60 | } 61 | 62 | // Emote performs an emote 63 | func (a *adapter) Emote(res *hal.Response, strings ...string) error { 64 | return nil 65 | } 66 | 67 | // Topic sets the topic 68 | func (a *adapter) Topic(res *hal.Response, strings ...string) error { 69 | return nil 70 | } 71 | 72 | // Play plays a sound 73 | func (a *adapter) Play(res *hal.Response, strings ...string) error { 74 | return nil 75 | } 76 | 77 | // Receive forwards a message to the robot 78 | func (a *adapter) Receive(msg *hal.Message) error { 79 | a.Robot.Receive(msg) 80 | return nil 81 | } 82 | 83 | // Run executes the adapter run loop 84 | func (a *adapter) Run() error { 85 | prompt() 86 | 87 | go func() { 88 | for { 89 | line, _, err := a.in.ReadLine() 90 | message := a.newMessage(string(line)) 91 | 92 | if err != nil { 93 | if err == io.EOF { 94 | break 95 | // a.Robot.signalChan <- syscall.SIGTERM 96 | } 97 | fmt.Println("error:", err) 98 | } 99 | a.Receive(message) 100 | prompt() 101 | } 102 | }() 103 | 104 | <-a.quit 105 | return nil 106 | } 107 | 108 | // Stop the adapter 109 | func (a *adapter) Stop() error { 110 | a.quit <- true 111 | return nil 112 | } 113 | 114 | func prompt() { 115 | fmt.Print("> ") 116 | } 117 | 118 | // func newMessage(text string) *Message { 119 | func (a *adapter) newMessage(text string) *hal.Message { 120 | return &hal.Message{ 121 | ID: "local-message", 122 | User: hal.User{ID: "1", Name: "shell"}, 123 | Room: "shell", 124 | Text: text, 125 | } 126 | } 127 | 128 | func (a *adapter) writeString(str string) error { 129 | msg := fmt.Sprintf("%s\n", strings.TrimSpace(str)) 130 | 131 | if _, err := a.out.WriteString(msg); err != nil { 132 | return err 133 | } 134 | 135 | if err := a.out.Flush(); err != nil { 136 | return err 137 | } 138 | 139 | return nil 140 | } 141 | -------------------------------------------------------------------------------- /adapter/slack/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | -------------------------------------------------------------------------------- /adapter/slack/http.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | 9 | "github.com/danryan/hal" 10 | ) 11 | 12 | func (a *adapter) newMessageFromHTTP(req *slackRequest) *hal.Message { 13 | return &hal.Message{ 14 | User: hal.User{ 15 | ID: req.UserID, 16 | Name: req.UserName, 17 | }, 18 | Room: req.ChannelID, 19 | Text: req.Text, 20 | } 21 | } 22 | 23 | func (a *adapter) sendHTTP(res *hal.Response, strings ...string) error { 24 | hal.Logger.Debug("slack - sending HTTP response") 25 | for _, str := range strings { 26 | s := &slackPayload{ 27 | Username: a.botname, 28 | Channel: res.Message.Room, 29 | Text: str, 30 | } 31 | 32 | opts := res.Envelope.Options 33 | if i, ok := opts["iconEmoji"]; ok { 34 | s.IconEmoji = i.(string) 35 | } 36 | 37 | if i, ok := opts["iconURL"]; ok { 38 | s.IconURL = i.(string) 39 | } 40 | 41 | if i, ok := opts["unfurlLinks"]; ok { 42 | s.UnfurlLinks = i.(bool) 43 | } 44 | 45 | if i, ok := opts["fallback"]; ok { 46 | s.Fallback = i.(string) 47 | } 48 | 49 | if i, ok := opts["color"]; ok { 50 | s.Color = i.(string) 51 | } 52 | 53 | if i, ok := opts["fields"]; ok { 54 | s.Fields = i.([]map[string]interface{}) 55 | } 56 | 57 | u := fmt.Sprintf("https://%s.slack.com/services/hooks/incoming-webhook?token=%s", a.team, a.token) 58 | payload, _ := json.Marshal(s) 59 | data := url.Values{} 60 | data.Set("payload", string(payload)) 61 | client := http.Client{} 62 | _, err := client.PostForm(u, data) 63 | if err != nil { 64 | return err 65 | } 66 | } 67 | 68 | return nil 69 | } 70 | 71 | func (a *adapter) slackHandler(w http.ResponseWriter, r *http.Request) { 72 | hal.Logger.Debug("slack - HTTP handler received message") 73 | 74 | r.ParseForm() 75 | parsedRequest := a.parseRequest(r.Form) 76 | message := a.newMessageFromHTTP(parsedRequest) 77 | 78 | // hal.Logger.Debug(message) 79 | a.Receive(message) 80 | w.Write([]byte("")) 81 | } 82 | 83 | func (a *adapter) parseRequest(form url.Values) *slackRequest { 84 | return &slackRequest{ 85 | ChannelID: form.Get("channel_id"), 86 | ChannelName: form.Get("channel_name"), 87 | ServiceID: form.Get("service_id"), 88 | TeamID: form.Get("team_id"), 89 | TeamDomain: form.Get("team_domain"), 90 | Text: form.Get("text"), 91 | Timestamp: form.Get("timestamp"), 92 | Token: form.Get("token"), 93 | UserID: form.Get("user_id"), 94 | UserName: form.Get("user_name"), 95 | } 96 | } 97 | 98 | type slackPayload struct { 99 | Channel string `json:"channel,omitempty"` 100 | Username string `json:"username,omitempty"` 101 | Text string `json:"text,omitempty"` 102 | IconEmoji string `json:"icon_emoji,omitempty"` 103 | IconURL string `json:"icon_url,omitempty"` 104 | UnfurlLinks bool `json:"unfurl_links,omitempty"` 105 | Fallback string `json:"fallback,omitempty"` 106 | Color string `json:"color,omitempty"` 107 | Fields []map[string]interface{} `json:"fields,omitempty"` 108 | } 109 | 110 | type slackField struct { 111 | Title string `json:"title,omitempty"` 112 | Value string `json:"value,omitempty"` 113 | Short bool `json:"short,omitempty"` 114 | } 115 | 116 | // the payload of an inbound request (from Slack to us). 117 | type slackRequest struct { 118 | ChannelID string 119 | ChannelName string 120 | ServiceID string 121 | TeamID string 122 | TeamDomain string 123 | Text string 124 | Timestamp string 125 | Token string 126 | UserID string 127 | UserName string 128 | } 129 | -------------------------------------------------------------------------------- /adapter/slack/irc.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "crypto/tls" 5 | 6 | "github.com/danryan/hal" 7 | "github.com/thoj/go-ircevent" 8 | ) 9 | 10 | func (a *adapter) startIRCConnection() { 11 | con := irc.IRC(a.botname, a.botname) 12 | con.UseTLS = true 13 | // con.Debug = true 14 | con.Password = a.ircPassword 15 | con.TLSConfig = &tls.Config{ServerName: "*.irc.slack.com"} 16 | err := con.Connect(a.ircServer()) 17 | if err != nil { 18 | panic("failed to connect to" + err.Error()) 19 | } 20 | 21 | con.AddCallback("001", func(e *irc.Event) { 22 | for _, channel := range a.channels { 23 | hal.Logger.Debugf("slack - joined channel %v", channel) 24 | } 25 | }) 26 | 27 | con.AddCallback("PRIVMSG", func(e *irc.Event) { 28 | hal.Logger.Debug("slack - IRC handler received message") 29 | 30 | message := a.newMessageFromIRC(e) 31 | a.Receive(message) 32 | }) 33 | 34 | a.ircConnection = con 35 | con.Loop() 36 | } 37 | 38 | func (a *adapter) stopIRCConnection() { 39 | hal.Logger.Debug("Stopping slack IRC connection") 40 | a.ircConnection.Quit() 41 | hal.Logger.Debug("Stopped slack IRC connection") 42 | } 43 | 44 | func (a *adapter) newMessageFromIRC(req *irc.Event) *hal.Message { 45 | return &hal.Message{ 46 | User: hal.User{ 47 | ID: req.Nick, 48 | Name: req.Nick, 49 | }, 50 | Room: req.Arguments[0], 51 | Text: req.Message(), 52 | } 53 | } 54 | 55 | func (a *adapter) ircServer() string { 56 | return a.team + `.irc.slack.com:6667` 57 | } 58 | 59 | func (a *adapter) sendIRC(res *hal.Response, strings ...string) error { 60 | hal.Logger.Debug("slack - sending IRC response") 61 | for _, str := range strings { 62 | s := &slackPayload{ 63 | Channel: res.Message.Room, 64 | Text: str, 65 | } 66 | a.ircConnection.Privmsg(s.Channel, s.Text) 67 | } 68 | 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /adapter/slack/slack.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/danryan/env" 10 | "github.com/danryan/hal" 11 | "github.com/davecgh/go-spew/spew" 12 | irc "github.com/thoj/go-ircevent" 13 | ) 14 | 15 | func init() { 16 | hal.RegisterAdapter("slack", New) 17 | } 18 | 19 | type adapter struct { 20 | hal.BasicAdapter 21 | token string 22 | team string 23 | mode string 24 | channels []string 25 | channelMode string 26 | botname string 27 | responseMethod string 28 | iconEmoji string 29 | ircEnabled bool 30 | ircPassword string 31 | ircConnection *irc.Connection 32 | linkNames int 33 | } 34 | 35 | type config struct { 36 | Token string `env:"key=HAL_SLACK_TOKEN required"` 37 | Team string `env:"key=HAL_SLACK_TEAM required"` 38 | Channels string `env:"key=HAL_SLACK_CHANNELS"` 39 | Mode string `env:"key=HAL_SLACK_MODE"` 40 | Botname string `env:"key=HAL_SLACK_BOTNAME default=hal"` 41 | IconEmoji string `env:"key=HAL_SLACK_ICON_EMOJI"` 42 | IrcEnabled bool `env:"key=HAL_SLACK_IRC_ENABLED default=false"` 43 | IrcPassword string `env:"key=HAL_SLACK_IRC_PASSWORD"` 44 | ResponseMethod string `env:"key=HAL_SLACK_RESPONSE_METHOD default=http"` 45 | ChannelMode string `env:"key=HAL_SLACK_CHANNEL_MODE "` 46 | } 47 | 48 | // New returns an initialized adapter 49 | func New(r *hal.Robot) (hal.Adapter, error) { 50 | c := &config{} 51 | env.MustProcess(c) 52 | channels := strings.Split(c.Channels, ",") 53 | a := &adapter{ 54 | token: c.Token, 55 | team: c.Team, 56 | channels: channels, 57 | channelMode: c.ChannelMode, 58 | mode: c.Mode, 59 | botname: c.Botname, 60 | iconEmoji: c.IconEmoji, 61 | ircEnabled: c.IrcEnabled, 62 | ircPassword: c.IrcPassword, 63 | responseMethod: c.ResponseMethod, 64 | } 65 | spew.Dump(c) 66 | hal.Logger.Debugf("%v", os.Getenv("HAL_SLACK_CHANNEL_MODE")) 67 | hal.Logger.Debugf("channel mode: %v", a.channelMode) 68 | // if a.channelMode == "" { 69 | // a.channelMode = "whitelist" 70 | // } 71 | a.SetRobot(r) 72 | return a, nil 73 | } 74 | 75 | // Send sends a regular response 76 | func (a *adapter) Send(res *hal.Response, strings ...string) error { 77 | if a.responseMethod == "irc" { 78 | if !a.ircEnabled { 79 | return errors.New("slack - IRC response method used but IRC is not enabled") 80 | } 81 | a.sendIRC(res, strings...) 82 | 83 | } else { 84 | err := error(a.sendHTTP(res, strings...)) 85 | if err != nil { 86 | return err 87 | } 88 | } 89 | 90 | return nil 91 | } 92 | 93 | // Reply sends a direct response 94 | func (a *adapter) Reply(res *hal.Response, strings ...string) error { 95 | newStrings := make([]string, len(strings)) 96 | for _, str := range strings { 97 | newStrings = append(newStrings, fmt.Sprintf("%s: %s", res.UserName(), str)) 98 | } 99 | 100 | a.Send(res, newStrings...) 101 | 102 | return nil 103 | } 104 | 105 | // Emote is not implemented. 106 | func (a *adapter) Emote(res *hal.Response, strings ...string) error { 107 | return nil 108 | } 109 | 110 | // Topic sets the topic 111 | func (a *adapter) Topic(res *hal.Response, strings ...string) error { 112 | for _ = range strings { 113 | } 114 | return nil 115 | } 116 | 117 | // Play is not implemented. 118 | func (a *adapter) Play(res *hal.Response, strings ...string) error { 119 | return nil 120 | } 121 | 122 | // Receive forwards a message to the robot 123 | func (a *adapter) Receive(msg *hal.Message) error { 124 | hal.Logger.Debug("slack - adapter received message") 125 | 126 | if len(a.channels) > 0 { 127 | if a.channelMode == "blacklist" { 128 | if !a.inChannels(msg.Room) { 129 | hal.Logger.Debugf("slack - %s not in blacklist", msg.Room) 130 | hal.Logger.Debug("slack - adapter sent message to robot") 131 | return a.Robot.Receive(msg) 132 | } 133 | hal.Logger.Debug("slack - message ignored due to blacklist") 134 | return nil 135 | } 136 | 137 | if a.inChannels(msg.Room) { 138 | hal.Logger.Debugf("slack - %s in whitelist", msg.Room) 139 | hal.Logger.Debug("slack - adapter sent message to robot") 140 | return a.Robot.Receive(msg) 141 | } 142 | hal.Logger.Debug("slack - message ignored due to whitelist") 143 | return nil 144 | } 145 | 146 | hal.Logger.Debug("slack - adapter sent message to robot") 147 | return a.Robot.Receive(msg) 148 | } 149 | 150 | // Run starts the adapter 151 | func (a *adapter) Run() error { 152 | if a.ircEnabled { 153 | // set up a connection to the IRC gateway 154 | hal.Logger.Debug("slack - starting IRC connection") 155 | go a.startIRCConnection() 156 | hal.Logger.Debug("slack - started IRC connection") 157 | } else { 158 | // set up handlers 159 | hal.Logger.Debug("slack - adding HTTP request handlers") 160 | hal.Router.HandleFunc("/hal/slack-webhook", a.slackHandler) 161 | // Someday we won't need this :D 162 | hal.Router.HandleFunc("/hubot/slack-webhook", a.slackHandler) 163 | hal.Logger.Debug("slack - added HTTP request handlers") 164 | } 165 | 166 | hal.Logger.Debugf("slack - channelmode=%v channels=%v", a.channelMode, a.channels) 167 | return nil 168 | } 169 | 170 | // Stop shuts down the adapter 171 | func (a *adapter) Stop() error { 172 | if a.ircEnabled { 173 | // set up a connection to the IRC gateway 174 | hal.Logger.Debug("slack - stopping IRC connection") 175 | a.stopIRCConnection() 176 | hal.Logger.Debug("slack - stopped IRC connection") 177 | } 178 | return nil 179 | } 180 | 181 | func (a *adapter) inChannels(room string) bool { 182 | for _, r := range a.channels { 183 | if r == room { 184 | return true 185 | } 186 | } 187 | 188 | return false 189 | } 190 | -------------------------------------------------------------------------------- /adapter/slack/slack_test.go: -------------------------------------------------------------------------------- 1 | package slack_test 2 | 3 | import ( 4 | _ "github.com/danryan/hal/adapter/slack" 5 | ) 6 | -------------------------------------------------------------------------------- /adapter/test/test.go: -------------------------------------------------------------------------------- 1 | package test 2 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | import ( 4 | "fmt" 5 | "github.com/danryan/env" 6 | "strings" 7 | ) 8 | 9 | // UserHasRole determines whether the Response's user has a given role 10 | func UserHasRole(res *Response, role string) bool { 11 | user := res.Envelope.User 12 | for _, r := range user.Roles { 13 | if r == role { 14 | return true 15 | } 16 | } 17 | 18 | return false 19 | } 20 | 21 | // Auth type to group authentication methods 22 | type Auth struct { 23 | robot *Robot 24 | admins []string 25 | } 26 | 27 | type authConfig struct { 28 | Enabled bool `env:"key=HAL_AUTH_ENABLED default=true"` 29 | Admins string `env:"key=HAL_AUTH_ADMIN"` 30 | } 31 | 32 | // NewAuth returns a pointer to an initialized Auth 33 | func NewAuth(r *Robot) *Auth { 34 | a := &Auth{robot: r} 35 | 36 | c := &authConfig{} 37 | env.MustProcess(c) 38 | 39 | if c.Enabled { 40 | if c.Admins != "" { 41 | a.admins = strings.Split(c.Admins, ",") 42 | } 43 | 44 | r.Handle( 45 | addUserRoleHandler, 46 | removeUserRoleHandler, 47 | listUserRolesHandler, 48 | listAdminsHandler, 49 | ) 50 | } 51 | 52 | return a 53 | } 54 | 55 | // Admins returns a slice of admin Users 56 | func (a *Auth) Admins() (admins []User) { 57 | for _, name := range a.admins { 58 | user, err := a.robot.Users.GetByName(name) 59 | if err != nil { 60 | continue 61 | } 62 | admins = append(admins, user) 63 | } 64 | 65 | return 66 | } 67 | 68 | // HasRole checks whether a user located by id has a given role(s) 69 | func (a *Auth) HasRole(id string, roles ...string) bool { 70 | user, err := a.robot.Users.Get(id) 71 | if err != nil { 72 | return false 73 | } 74 | 75 | if len(user.Roles) == 0 { 76 | return false 77 | } 78 | 79 | for _, r := range roles { 80 | for _, b := range user.Roles { 81 | if b == r { 82 | return true 83 | } 84 | } 85 | } 86 | 87 | return false 88 | } 89 | 90 | // UsersWithRole returns a slice of Users that have a given role 91 | func (a *Auth) UsersWithRole(role string) (users []User) { 92 | for _, user := range a.robot.Users.All() { 93 | if a.HasRole(user.ID, role) { 94 | users = append(users, user) 95 | } 96 | } 97 | return 98 | } 99 | 100 | // AddRole adds a role to a User 101 | func (a *Auth) AddRole(user User, r string) error { 102 | if r == "admin" { 103 | return fmt.Errorf(`the "admin" role can only be defined by the HAL_AUTH_ADMIN environment variable`) 104 | } 105 | 106 | if a.HasRole(user.ID, r) { 107 | return fmt.Errorf("%s already has the %s role", user.Name, r) 108 | } 109 | 110 | user.Roles = append(user.Roles, r) 111 | a.robot.Users.Set(user.ID, user) 112 | 113 | return nil 114 | } 115 | 116 | // RemoveRole adds a role to a User 117 | func (a *Auth) RemoveRole(user User, role string) error { 118 | if role == "admin" { 119 | return fmt.Errorf(`the "admin" role can only be defined by the HAL_AUTH_ADMIN environment variable`) 120 | } 121 | 122 | if !a.HasRole(user.ID, role) { 123 | return fmt.Errorf("%s already does not have the %s role", user.Name, role) 124 | } 125 | 126 | roles := make([]string, len(user.Roles)-1) 127 | 128 | for _, r := range user.Roles { 129 | if r != role { 130 | roles = append(roles, role) 131 | } 132 | } 133 | 134 | user.Roles = roles 135 | a.robot.Users.Set(user.ID, user) 136 | 137 | return nil 138 | } 139 | 140 | // IsAdmin checks whether a user is an admin 141 | func (a *Auth) IsAdmin(user User) bool { 142 | for _, a := range a.admins { 143 | if a == user.Name { 144 | return true 145 | } 146 | } 147 | 148 | return false 149 | } 150 | 151 | var addUserRoleHandler = &Handler{ 152 | Pattern: `(?i)@?(.+) (?:has)(?: the)? (["'\w: -_]+) (?:role)`, 153 | Method: RESPOND, 154 | Run: func(res *Response) error { 155 | name := strings.TrimSpace(res.Match[1]) 156 | role := strings.ToLower(res.Match[2]) 157 | 158 | for _, s := range []string{"", "who", "what", "where", "when", "why"} { 159 | if s == name { 160 | return nil // don't match 161 | } 162 | } 163 | 164 | user, err := res.Robot.Users.GetByName(name) 165 | if err != nil { 166 | return res.Reply(err.Error()) 167 | } 168 | 169 | if err := res.Robot.Auth.AddRole(user, role); err != nil { 170 | return res.Reply(err.Error()) 171 | } 172 | 173 | return res.Reply(fmt.Sprintf("%s now has the %s role", name, role)) 174 | }, 175 | } 176 | 177 | var removeUserRoleHandler = &Handler{ 178 | Pattern: `(?i)@?(.+) (?:does(?:n't| not) have)(?: the)? (["'\w: -_]+) (role)`, 179 | Method: RESPOND, 180 | Run: func(res *Response) error { 181 | name := strings.TrimSpace(res.Match[1]) 182 | role := strings.ToLower(res.Match[2]) 183 | 184 | for _, s := range []string{"", "who", "what", "where", "when", "why"} { 185 | if s == name { 186 | return nil // don't match 187 | } 188 | } 189 | 190 | user, err := res.Robot.Users.GetByName(name) 191 | if err != nil { 192 | return res.Reply(err.Error()) 193 | } 194 | 195 | if err := res.Robot.Auth.RemoveRole(user, role); err != nil { 196 | return res.Reply(err.Error()) 197 | } 198 | 199 | return res.Reply(fmt.Sprintf("%s no longer has the %s role", name, role)) 200 | }, 201 | } 202 | 203 | var listUserRolesHandler = &Handler{ 204 | Pattern: `(?i)(?:what roles? does) @?(.+) (?:have)\??`, 205 | Method: RESPOND, 206 | Run: func(res *Response) error { 207 | name := res.Match[1] 208 | 209 | user, err := res.Robot.Users.GetByName(name) 210 | // return if we didn't find a user 211 | if err != nil { 212 | res.Reply(err.Error()) 213 | } 214 | 215 | roles := user.Roles 216 | 217 | if res.Robot.Auth.IsAdmin(user) { 218 | roles = append(roles, "admin") 219 | } 220 | 221 | if len(roles) == 0 { 222 | return res.Reply(name + " has no roles") 223 | } 224 | 225 | return res.Reply(fmt.Sprintf("%s has the following roles: %s", name, strings.Join(roles, ", "))) 226 | }, 227 | } 228 | 229 | var listAdminsHandler = &Handler{ 230 | Pattern: `who (?:has)(?: the)? admin role\??`, 231 | Method: RESPOND, 232 | Run: func(res *Response) error { 233 | admins := res.Robot.Auth.Admins() 234 | names := make([]string, len(admins)) 235 | 236 | if len(names) == 0 { 237 | return res.Reply(`no users have the "admin" role`) 238 | } 239 | 240 | for i, u := range admins { 241 | names[i] = u.Name 242 | } 243 | 244 | return res.Reply(fmt.Sprintf(`the following users have the "admin" role: %s`, strings.Join(names, ", "))) 245 | }, 246 | } 247 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/Sirupsen/logrus" 9 | "github.com/danryan/env" 10 | ) 11 | 12 | // Config struct 13 | type config struct { 14 | Name string `env:"key=HAL_NAME default=hal"` 15 | Alias string `env:"key=HAL_ALIAS"` 16 | AdapterName string `env:"key=HAL_ADAPTER default=shell"` 17 | StoreName string `env:"key=HAL_STORE default=memory"` 18 | Port int `env:"key=PORT default=9000"` 19 | LogLevel string `env:"key=HAL_LOG_LEVEL default=info"` 20 | } 21 | 22 | func newConfig() *config { 23 | c := &config{} 24 | env.MustProcess(c) 25 | return c 26 | } 27 | 28 | func newLogger() *logrus.Logger { 29 | level, err := logrus.ParseLevel(Config.LogLevel) 30 | if err != nil { 31 | panic(err) 32 | } 33 | logger := logrus.New() 34 | logger.Level = level 35 | 36 | return logger 37 | } 38 | 39 | // newRouter initializes a new http.ServeMux and sets up several default routes 40 | func newRouter() *http.ServeMux { 41 | router := http.NewServeMux() 42 | router.HandleFunc("/hal/ping", func(w http.ResponseWriter, r *http.Request) { 43 | fmt.Fprintln(w, "PONG") 44 | }) 45 | 46 | router.HandleFunc("/hal/time", func(w http.ResponseWriter, r *http.Request) { 47 | fmt.Fprintf(w, "Server time is: %s\n", time.Now().UTC()) 48 | }) 49 | 50 | return router 51 | } 52 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Hal.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Hal.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Hal" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Hal" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/build/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danryan/hal/56786328954185e9735bdcbfbfc6414c51120462/docs/build/.gitkeep -------------------------------------------------------------------------------- /docs/source/adapters/campfire.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Campfire 3 | ======== 4 | 5 | Coming soon! 6 | 7 | Usage 8 | ~~~~~ 9 | 10 | Configuration 11 | ~~~~~~~~~~~~~ 12 | 13 | -------------------------------------------------------------------------------- /docs/source/adapters/custom.rst: -------------------------------------------------------------------------------- 1 | .. _custom_adapter: 2 | 3 | ======================= 4 | Adding a Custom Adapter 5 | ======================= 6 | 7 | Coming soon! 8 | -------------------------------------------------------------------------------- /docs/source/adapters/hipchat.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Hipchat 3 | ======= 4 | 5 | Setup 6 | ~~~~~ 7 | 8 | Hal uses Hipchat’s XMPP gateway and so requires a user account to 9 | integrate with Hipchat. Be sure to create one before configuring the 10 | adapter. You will need the XMPP credentials, which can be found at 11 | https://my.hipchat.com/account/xmpp. 12 | 13 | Usage 14 | ~~~~~ 15 | 16 | .. code:: go 17 | 18 | // blank import to register adapter 19 | import _ "github.com/danryan/hal/adapter/hipchat" 20 | 21 | Configuration 22 | ~~~~~~~~~~~~~ 23 | 24 | Set the following environment variables according to your needs. 25 | 26 | ``HAL_ADAPTER`` 27 | ^^^^^^^^^^^^^^^ 28 | 29 | To use the Hipchat adapter, set ``HAL_ADAPTER`` to ``hipchat``. 30 | 31 | ``HAL_HIPCHAT_USER`` 32 | ^^^^^^^^^^^^^^^^^^^^ 33 | 34 | The username is the first part of your XMPP JID before the ``@`` sign. 35 | E.g., if your JID is ``134273_971874@chat.hipchat.com``, then 36 | ``HAL_HIPCHAT_USER`` should be ``134273_971874``. 37 | 38 | - **Default:** none 39 | - **Required:** false 40 | - **Example:** ``HAL_HIPCHAT_USER=134273_971874`` 41 | 42 | ``HAL_HIPCHAT_PASSWORD`` 43 | ^^^^^^^^^^^^^^^^^^^^^^^^ 44 | 45 | The password is the same as the Hipchat user’s password. 46 | 47 | - **Default:** none 48 | - **Required:** true 49 | - **Example:** ``HAL_HIPCHAT_PASSWORD=supersekretpassword`` 50 | 51 | ``HAL_HIPCHAT_ROOMS`` 52 | ^^^^^^^^^^^^^^^^^^^^^ 53 | 54 | This is a comma-separated list of rooms to join. Note that Hipchat has 55 | two ways of specifying rooms: a human-readable format (ex. ``general``); 56 | and an XMPP format (ex. ``134273_general``). Hal expects the former 57 | human-readable format at this time due to a limitation of the 58 | third-party Hipchat package presently used. The rooms are case sensitive 59 | as well. 60 | 61 | Hal will not fail if no rooms are specified, though hal will also not 62 | join any rooms if this is left blank. 63 | 64 | - **Default:** none 65 | - **Required:** false 66 | - **Example:** ``HAL_HIPCHAT_ROOMS="general,room with spaces,random"`` 67 | 68 | ``HAL_HIPCHAT_RESOURCE`` 69 | ^^^^^^^^^^^^^^^^^^^^^^^^ 70 | 71 | This is an optional setting. The default, ``bot``, prevents the channel 72 | history from being sent and thus prevents hal from parsing possibly 73 | already handled messages. If changed from the default, ``bot``, channel 74 | history will be sent. It is recommended that the default be left unless 75 | you need channel history. 76 | 77 | - **Default:** bot 78 | - **Required:** false 79 | - **Example:** ``HAL_HIPCHAT_RESOURCE=something-other-than-bot`` 80 | -------------------------------------------------------------------------------- /docs/source/adapters/index.rst: -------------------------------------------------------------------------------- 1 | .. _adapters: 2 | 3 | ======== 4 | Adapters 5 | ======== 6 | 7 | Adapters are how hal integrates with your chat services. 8 | 9 | .. toctree:: 10 | :maxdepth: 1 11 | 12 | campfire 13 | hipchat 14 | irc 15 | slack 16 | shell 17 | custom_adapter 18 | -------------------------------------------------------------------------------- /docs/source/adapters/irc.rst: -------------------------------------------------------------------------------- 1 | === 2 | IRC 3 | === 4 | 5 | A simple adapter for use with any IRC server. 6 | 7 | Setup 8 | ~~~~~ 9 | 10 | You will need an IRC user and preferred server. If your server requires 11 | a password, be sure to provide it using the environment variable below. 12 | 13 | Usage 14 | ~~~~~ 15 | 16 | .. code:: go 17 | 18 | // blank import to register adapter 19 | import _ "github.com/danryan/hal/adapter/irc" 20 | 21 | Configuration 22 | ~~~~~~~~~~~~~ 23 | 24 | :: 25 | 26 | HAL_ADAPTER=irc # The adapter 27 | # Default: shell 28 | HAL_IRC_USER=blah # IRC username 29 | # Default: none (required) 30 | HAL_IRC_PASSWORD=sekret # IRC password if required 31 | # Default: none (optional) 32 | HAL_IRC_NICK=hal # IRC nick 33 | # Default: HAL_IRC_USER (optional) 34 | HAL_IRC_SERVER=irc.freenode.net # IRC server 35 | # Default: none (required) 36 | HAL_IRC_PORT=6667 # IRC server port 37 | # Default: 6667 38 | HAL_IRC_CHANNELS="#foo,#bar" # Comma-separate list of channels to join after connecting 39 | # Default: none (required) 40 | HAL_IRC_USE_TLS=false # Use an encrypted connection 41 | 42 | -------------------------------------------------------------------------------- /docs/source/adapters/shell.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Shell 3 | ===== 4 | 5 | Hal comes with a default shell adapter, useful for testing your response 6 | handlers locally. 7 | 8 | Setup 9 | ~~~~~ 10 | 11 | No setup is required. 12 | 13 | Usage 14 | ~~~~~ 15 | 16 | .. code:: go 17 | 18 | // blank import to register adapter 19 | import _ "github.com/danryan/hal/adapter/shell" 20 | 21 | Configuration 22 | ~~~~~~~~~~~~~ 23 | 24 | The shell adapter has no special configuration variables. 25 | -------------------------------------------------------------------------------- /docs/source/adapters/slack.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Slack 3 | ===== 4 | 5 | Setup 6 | ~~~~~ 7 | 8 | By default, Hal uses Slack's hubot integration. Currently Hal will 9 | listen in on all public channels, or a custom list of channels if ``HAL_SLACK_CHANNELS`` is declared. Private groups 10 | require the IRC gateway to work around a current limitation of the Slack 11 | API. See `Using IRC Gateway`_. The IRC gateway is the author's 12 | preferred method as your bot will automatically join all channels and 13 | groups to which it belongs, and removing Hal from a room is as simple as a 14 | ``/kick hal`` command. Some advanced features like attachment uploading 15 | are not supported at this time. 16 | 17 | Start by adding the Hubot integration for your team (if you haven't done 18 | so). 19 | 20 | Usage 21 | ~~~~~ 22 | 23 | .. code:: go 24 | 25 | // blank import to register adapter 26 | import _ "github.com/danryan/hal/adapter/slack" 27 | 28 | Configuration 29 | ~~~~~~~~~~~~~ 30 | 31 | :: 32 | 33 | HAL_ADAPTER=slack # The adapter 34 | HAL_SLACK_TOKEN=blah # Your integration token 35 | # Default: none (required) 36 | HAL_SLACK_TEAM=acmeinc # Your Slack subdomain (.slack.com) 37 | # Default: none (required) 38 | HAL_SLACK_BOTNAME=HAL # The username Hal will send replies as 39 | # Default: HAL_NAME 40 | HAL_SLACK_ICON_EMOJI=":poop:" # The emoji shortcut used as the response icon 41 | # Default: none 42 | HAL_SLACK_CHANNELS="" # not yet implemented 43 | HAL_SLACK_CHANNEL_MODE="" # 44 | HAL_SLACK_LINK_NAMES="" # not yet implemented 45 | 46 | ``HAL_SLACK_CHANNEL_MODE`` 47 | ^^^^^^^^^^^^^^^^^^^^^^^^ 48 | 49 | Specify how to treat the list of channels in ``HAL_SLACK_CHANNELS```. Disabled if ``HAL_SLACK_CHANNELS`` is empty. 50 | 51 | - **Options:** whitelist, blacklist 52 | - **Default:** whitelist 53 | - **Required:** false 54 | - **Example:** ``HAL_SLACK_CHANNEL_MODE=whitelist`` 55 | - 56 | 57 | Using IRC Gateway 58 | ^^^^^^^^^^^^^^^^^ 59 | 60 | The default integration only works with public chats. If you want hal to 61 | listen in on private chats, you must utilize the IRC gateway. You'll 62 | need a real user for hal, so be mindful of the username you choose for 63 | it and make sure you configure your bot to use that name so it can login 64 | to the IRC gateway. When enabled, hal will only use the IRC gateway to 65 | listen for messages. Hal can be configured to either respond using the 66 | API or the IRC gateway. 67 | 68 | 1. Enable the IRC gateway in `the admin settings 69 | interface `__ 70 | 71 | - Choose "Enable IRC gateway (SSL only)". You don't want your 72 | private messages sent unencrypted. 73 | 74 | 2. `Register `__ a new user 75 | 3. Sign in as this new user 76 | 4. Capture your new `IRC credentials `__ 77 | 5. Set the following environment variables 78 | 79 | :: 80 | 81 | HAL_SLACK_IRC_ENABLED # Enable the Slack IRC listener 82 | # Default: 0 83 | # Options: 0, 1 ; 0 is disabled, 1 is enabled 84 | HAL_SLACK_IRC_PASSWORD # The IRC gateway password 85 | # Default: none (required) 86 | HAL_SLACK_RESPONSE_METHOD # The method by which hal will respond to a message. 87 | # The irc option requires that the IRC gateway be configured 88 | # Default: http 89 | # Options: http, irc 90 | 91 | For more information, please see the following link: \* `Connecting to 92 | Slack over IRC and 93 | XMPP `__ 94 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Hal documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Aug 22 13:20:42 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | 'sphinx.ext.todo', 33 | ] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ['_templates'] 37 | 38 | # The suffix of source filenames. 39 | source_suffix = '.rst' 40 | 41 | # The encoding of source files. 42 | #source_encoding = 'utf-8-sig' 43 | 44 | # The master toctree document. 45 | master_doc = 'index' 46 | 47 | # General information about the project. 48 | project = u'Hal' 49 | copyright = u'2014, Dan Ryan' 50 | 51 | # The version info for the project you're documenting, acts as replacement for 52 | # |version| and |release|, also used in various other places throughout the 53 | # built documents. 54 | # 55 | # The short X.Y version. 56 | version = '1.0' 57 | # The full version, including alpha/beta/rc tags. 58 | release = '1.0' 59 | 60 | # The language for content autogenerated by Sphinx. Refer to documentation 61 | # for a list of supported languages. 62 | #language = None 63 | 64 | # There are two options for replacing |today|: either, you set today to some 65 | # non-false value, then it is used: 66 | #today = '' 67 | # Else, today_fmt is used as the format for a strftime call. 68 | #today_fmt = '%B %d, %Y' 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | exclude_patterns = [] 73 | 74 | # The reST default role (used for this markup: `text`) to use for all 75 | # documents. 76 | #default_role = None 77 | 78 | # If true, '()' will be appended to :func: etc. cross-reference text. 79 | #add_function_parentheses = True 80 | 81 | # If true, the current module name will be prepended to all description 82 | # unit titles (such as .. function::). 83 | #add_module_names = True 84 | 85 | # If true, sectionauthor and moduleauthor directives will be shown in the 86 | # output. They are ignored by default. 87 | #show_authors = False 88 | 89 | # The name of the Pygments (syntax highlighting) style to use. 90 | pygments_style = 'sphinx' 91 | 92 | # A list of ignored prefixes for module index sorting. 93 | #modindex_common_prefix = [] 94 | 95 | # If true, keep warnings as "system message" paragraphs in the built documents. 96 | #keep_warnings = False 97 | 98 | 99 | # -- Options for HTML output ---------------------------------------------- 100 | 101 | # The theme to use for HTML and HTML Help pages. See the documentation for 102 | # a list of builtin themes. 103 | html_theme = 'default' 104 | 105 | # Theme options are theme-specific and customize the look and feel of a theme 106 | # further. For a list of options available for each theme, see the 107 | # documentation. 108 | #html_theme_options = {} 109 | 110 | # Add any paths that contain custom themes here, relative to this directory. 111 | #html_theme_path = [] 112 | 113 | # The name for this set of Sphinx documents. If None, it defaults to 114 | # " v documentation". 115 | #html_title = None 116 | 117 | # A shorter title for the navigation bar. Default is the same as html_title. 118 | #html_short_title = None 119 | 120 | # The name of an image file (relative to this directory) to place at the top 121 | # of the sidebar. 122 | #html_logo = None 123 | 124 | # The name of an image file (within the static path) to use as favicon of the 125 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 126 | # pixels large. 127 | #html_favicon = None 128 | 129 | # Add any paths that contain custom static files (such as style sheets) here, 130 | # relative to this directory. They are copied after the builtin static files, 131 | # so a file named "default.css" will overwrite the builtin "default.css". 132 | html_static_path = ['_static'] 133 | 134 | # Add any extra paths that contain custom files (such as robots.txt or 135 | # .htaccess) here, relative to this directory. These files are copied 136 | # directly to the root of the documentation. 137 | #html_extra_path = [] 138 | 139 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 140 | # using the given strftime format. 141 | #html_last_updated_fmt = '%b %d, %Y' 142 | 143 | # If true, SmartyPants will be used to convert quotes and dashes to 144 | # typographically correct entities. 145 | #html_use_smartypants = True 146 | 147 | # Custom sidebar templates, maps document names to template names. 148 | #html_sidebars = {} 149 | 150 | # Additional templates that should be rendered to pages, maps page names to 151 | # template names. 152 | #html_additional_pages = {} 153 | 154 | # If false, no module index is generated. 155 | #html_domain_indices = True 156 | 157 | # If false, no index is generated. 158 | #html_use_index = True 159 | 160 | # If true, the index is split into individual pages for each letter. 161 | #html_split_index = False 162 | 163 | # If true, links to the reST sources are added to the pages. 164 | #html_show_sourcelink = True 165 | 166 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 167 | #html_show_sphinx = True 168 | 169 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 170 | #html_show_copyright = True 171 | 172 | # If true, an OpenSearch description file will be output, and all pages will 173 | # contain a tag referring to it. The value of this option must be the 174 | # base URL from which the finished HTML is served. 175 | #html_use_opensearch = '' 176 | 177 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 178 | #html_file_suffix = None 179 | 180 | # Output file base name for HTML help builder. 181 | htmlhelp_basename = 'Haldoc' 182 | 183 | 184 | # -- Options for LaTeX output --------------------------------------------- 185 | 186 | latex_elements = { 187 | # The paper size ('letterpaper' or 'a4paper'). 188 | #'papersize': 'letterpaper', 189 | 190 | # The font size ('10pt', '11pt' or '12pt'). 191 | #'pointsize': '10pt', 192 | 193 | # Additional stuff for the LaTeX preamble. 194 | #'preamble': '', 195 | } 196 | 197 | # Grouping the document tree into LaTeX files. List of tuples 198 | # (source start file, target name, title, 199 | # author, documentclass [howto, manual, or own class]). 200 | latex_documents = [ 201 | ('index', 'Hal.tex', u'Hal Documentation', 202 | u'Dan Ryan', 'manual'), 203 | ] 204 | 205 | # The name of an image file (relative to this directory) to place at the top of 206 | # the title page. 207 | #latex_logo = None 208 | 209 | # For "manual" documents, if this is true, then toplevel headings are parts, 210 | # not chapters. 211 | #latex_use_parts = False 212 | 213 | # If true, show page references after internal links. 214 | #latex_show_pagerefs = False 215 | 216 | # If true, show URL addresses after external links. 217 | #latex_show_urls = False 218 | 219 | # Documents to append as an appendix to all manuals. 220 | #latex_appendices = [] 221 | 222 | # If false, no module index is generated. 223 | #latex_domain_indices = True 224 | 225 | 226 | # -- Options for manual page output --------------------------------------- 227 | 228 | # One entry per manual page. List of tuples 229 | # (source start file, name, description, authors, manual section). 230 | man_pages = [ 231 | ('index', 'hal', u'Hal Documentation', 232 | [u'Dan Ryan'], 1) 233 | ] 234 | 235 | # If true, show URL addresses after external links. 236 | #man_show_urls = False 237 | 238 | 239 | # -- Options for Texinfo output ------------------------------------------- 240 | 241 | # Grouping the document tree into Texinfo files. List of tuples 242 | # (source start file, target name, title, author, 243 | # dir menu entry, description, category) 244 | texinfo_documents = [ 245 | ('index', 'Hal', u'Hal Documentation', 246 | u'Dan Ryan', 'Hal', 'One line description of project.', 247 | 'Miscellaneous'), 248 | ] 249 | 250 | # Documents to append as an appendix to all manuals. 251 | #texinfo_appendices = [] 252 | 253 | # If false, no module index is generated. 254 | #texinfo_domain_indices = True 255 | 256 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 257 | #texinfo_show_urls = 'footnote' 258 | 259 | # If true, do not generate a @detailmenu in the "Top" node's menu. 260 | #texinfo_no_detailmenu = False 261 | -------------------------------------------------------------------------------- /docs/source/config/index.rst: -------------------------------------------------------------------------------- 1 | .. _configuration: 2 | 3 | ============= 4 | Configuration 5 | ============= 6 | 7 | Hal doesn't have any command line options. Instead we utilize 8 | environment variables exclusively, allowing you to use hal in more 9 | flexible ways. 10 | 11 | :: 12 | 13 | PORT=9000 # The port on which the HTTP server will listen. 14 | # Default: 9000 15 | HAL_NAME=hal # The name to which Hal will respond. 16 | # Default: hal 17 | HAL_ADAPTER=shell # The adapter name. 18 | # Default: shell 19 | # Options: shell, slack, irc 20 | HAL_LOG_LEVEL=info # The level of logging desired. 21 | # Default: info 22 | # Options: info, debug, warn, error, critical 23 | 24 | -------------------------------------------------------------------------------- /docs/source/glossary.rst: -------------------------------------------------------------------------------- 1 | .. _glossary: 2 | 3 | ======== 4 | Glossary 5 | ======== 6 | 7 | .. glossary:: 8 | :sorted: 9 | 10 | Hal 11 | Hal is a chat bot framework written in the Go programming language. 12 | 13 | Adapter 14 | chat adapter 15 | 16 | Store 17 | data storage 18 | 19 | Robot 20 | robot 21 | 22 | Handler 23 | A handler is the part of hal that evaluates incoming messages 24 | 25 | User 26 | A chat user 27 | 28 | Message 29 | A message is an incoming request that is processed by handlers 30 | 31 | Response 32 | A response is the return object that is processed by the adapter 33 | 34 | Envelope 35 | An envelope contains metadata about the message and response, used for additional processing by both handlers and adapters. 36 | 37 | Router 38 | A router is an HTTP server used for handling requests (Messages) that did not come through the chat adapter. 39 | -------------------------------------------------------------------------------- /docs/source/handlers/index.rst: -------------------------------------------------------------------------------- 1 | .. _handlers: 2 | 3 | ======== 4 | Handlers 5 | ======== 6 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | === 2 | Hal 3 | === 4 | 5 | Contents: 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | config/index 11 | adapters/index 12 | stores/index 13 | handlers/index 14 | 15 | 16 | Appendices 17 | ========== 18 | 19 | * :ref:`glossary` 20 | -------------------------------------------------------------------------------- /docs/source/stores/custom.rst: -------------------------------------------------------------------------------- 1 | .. _custom_store: 2 | 3 | ===================== 4 | Adding a Custom Store 5 | ===================== 6 | 7 | Providing support for a new backend is fairly uncomplicated. Taking 8 | advantage of Go's interface type, we simply need a new struct type that 9 | implements the ``hal.Store`` interface (plus a handful of helper functions, 10 | but we'll get to that). Let's take a look at the default memory adapter 11 | to see how one works. 12 | 13 | Start by declaring a new package and importing hal (and other packages 14 | you may need). 15 | 16 | .. code:: go 17 | 18 | package memory 19 | 20 | import ( 21 | "fmt" 22 | "github.com/danryan/hal" 23 | ) 24 | 25 | Next, we need to define a hook that will tell hal about the store and 26 | how to create a new one. ``hal.RegisterStore`` take two arguments: a 27 | string to use as a identifying name, and a constructor function that 28 | initializes and returns the store. This should go into the ``init()`` 29 | function so that it is called when the file is parsed. Doing so allows 30 | us to ``import _`` the package for the side effect of registering our 31 | store. 32 | 33 | .. code:: go 34 | 35 | func init() { 36 | hal.RegisterStore("memory", New) 37 | } 38 | 39 | We now need to define a ``store`` struct. Easy enough: 40 | 41 | .. code:: go 42 | 43 | type store struct { 44 | hal.BasicStore 45 | data map[string][]byte 46 | } 47 | 48 | Notice that we embed ``hal.BasicStore`` in our struct. This gives us a 49 | number of extra things, namely the ability to interact with the robot. 50 | The ``data`` field is a basic map of strings to byte-slices. We'll use 51 | this to store and retrieve data. It wouldn't pass a `Jepsen 52 | simulation `__ but it's at least Web 53 | Scale. 54 | 55 | Time to define our constructor function. If you recall, this gets passed 56 | to ``hal.RegisterStore`` so hal knows how to initialize our store. The 57 | expected function signature is ``func(*hal.Robot) (hal.Store, error)``. 58 | 59 | .. code:: go 60 | 61 | func New(robot *hal.Robot) (hal.Store, error) { 62 | // make a new store object and initialize the data field 63 | s := &store{ 64 | data: map[string][]byte{}, 65 | } 66 | 67 | // set the store's robot to the robot we passed as an argument. 68 | s.SetRobot(robot) 69 | 70 | // return the store object 71 | // if this were a more complex adapter, we would need to check for and return errors if applicable. 72 | return s, nil 73 | } 74 | 75 | So far so good! At this point we've handled all of the setup functions 76 | necessary for hal to register and initialize a new store, but we still 77 | need our struct to conform to the ``hal.Store`` interface in order for 78 | our program to compile. So let's do that now! 79 | 80 | ``Open()`` is called immediately after the adapter is initialized and 81 | immediately before the ``robot.Run()`` function returns. This function 82 | would generally be used to initialize a connection to an underlying 83 | database (the [[Redis Store]], for example). We don't *use* it for our 84 | little memory store, but it is *required*, otherwise our store won't 85 | work as ``hal.Store``. 86 | 87 | .. code:: go 88 | 89 | func (s *store) Open() error { 90 | return nil 91 | } 92 | 93 | ``Close()`` is called immediately before the adapter is shut down and 94 | immediately after the ``robot.Stop()`` function begins. This function is 95 | useful for closing connections to a database (much like the [[Redis 96 | Store]] does). We have nothing to close so our function will be very 97 | boring. Just like ``Open``, it is *required* in order to implement the 98 | ``hal.Store`` interface. 99 | 100 | .. code:: go 101 | 102 | func (s *store) Close() error { 103 | return nil 104 | } 105 | 106 | ``Get`` is our way to retrieve a value from a store by a key (a 107 | *key-value store*, if you will). It should take a string *key* and 108 | return a byte-slice and/or an error if necessary. 109 | 110 | .. code:: go 111 | 112 | func (s *store) Get(key string) ([]byte, error) { 113 | val, ok := s.data[key] 114 | if !ok { 115 | return nil, fmt.Errorf("key %s was not found", key) 116 | } 117 | return val, nil 118 | } 119 | 120 | ``Set`` pushes stores a value to a given key. It take a string *key*, a 121 | byte-slice *data*, and may return an error if necessary. 122 | 123 | .. code:: go 124 | 125 | func (s *store) Set(key string, data []byte) error { 126 | s.data[key] = data 127 | return nil 128 | } 129 | 130 | ``Delete`` removes the value referenced by a given key. It expects a 131 | string *key*, and may return an error if necessary. 132 | 133 | .. code:: go 134 | 135 | func (s *store) Delete(key string) error { 136 | if _, ok := s.data[key]; !ok { 137 | return fmt.Errorf("key %s was not found", key) 138 | } 139 | delete(s.data, key) 140 | return nil 141 | } 142 | 143 | And we're done! Now go contribute a store for your favorite key-value backend :) 144 | -------------------------------------------------------------------------------- /docs/source/stores/index.rst: -------------------------------------------------------------------------------- 1 | .. _stores: 2 | 3 | ====== 4 | Stores 5 | ====== 6 | 7 | .. toctree:: 8 | :maxdepth: 1 9 | 10 | memory 11 | redis 12 | custom_store 13 | -------------------------------------------------------------------------------- /docs/source/stores/memory.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Memory 3 | ====== 4 | 5 | Setup 6 | ~~~~~ 7 | 8 | No setup necessary. Data pushed to the in-memory server will not persist 9 | between restarts. 10 | 11 | Configuration 12 | ~~~~~~~~~~~~~ 13 | 14 | No additional configuration is required. 15 | 16 | Usage 17 | ~~~~~ 18 | 19 | .. code:: go 20 | 21 | // blank import to register adapter 22 | import _ "github.com/danryan/hal/store/memory" 23 | -------------------------------------------------------------------------------- /docs/source/stores/redis.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Redis 3 | ===== 4 | 5 | Setup 6 | ~~~~~ 7 | 8 | The Redis store requires an available Redis server. Authentication and 9 | custom databases are not supported at this time. Please `open an 10 | issue `__ if you need this support! 11 | 12 | Configuration 13 | ~~~~~~~~~~~~~ 14 | 15 | ``HAL_STORE`` 16 | ^^^^^^^^^^^^^ 17 | 18 | Set to ``redis`` 19 | 20 | - Default: ``memory`` 21 | - Example: 22 | 23 | :: 24 | 25 | HAL_STORE=redis 26 | 27 | ``HAL_REDIS_URL`` 28 | ^^^^^^^^^^^^^^^^^ 29 | 30 | The Redis server URL 31 | 32 | - Default: ``localhost:6367`` 33 | - Example: 34 | 35 | :: 36 | 37 | HAL_REDIS_URL=redis.example.com:6379 38 | 39 | ``HAL_REDIS_NAMESPACE`` 40 | ^^^^^^^^^^^^^^^^^^^^^^^ 41 | 42 | Set a namespace to prepend to all keys 43 | 44 | - Default: ``hal`` 45 | - Example 46 | 47 | :: 48 | 49 | HAL_REDIS_NAMESPACE=foo 50 | # sets all keys to "foo:" 51 | 52 | Usage 53 | ~~~~~ 54 | 55 | .. code:: go 56 | 57 | // blank import to register adapter 58 | import _ "github.com/danryan/hal/store/redis" 59 | 60 | -------------------------------------------------------------------------------- /examples/complex/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/danryan/hal" 5 | _ "github.com/danryan/hal/adapter/shell" 6 | "github.com/danryan/hal/handler" 7 | _ "github.com/danryan/hal/store/memory" 8 | "os" 9 | ) 10 | 11 | // HAL is just another Go package, which means you are free to organize things 12 | // however you deem best. 13 | 14 | // You can define your handlers in the same file... 15 | var pingHandler = hal.Hear(`ping`, func(res *hal.Response) error { 16 | return res.Send("PONG") 17 | }) 18 | 19 | func run() int { 20 | robot, err := hal.NewRobot() 21 | if err != nil { 22 | hal.Logger.Error(err) 23 | return 1 24 | } 25 | 26 | // Or define them inside another function... 27 | fooHandler := hal.Respond(`foo`, func(res *hal.Response) error { 28 | return res.Send("BAR") 29 | }) 30 | 31 | tableFlipHandler := &hal.Handler{ 32 | Method: hal.HEAR, 33 | Pattern: `tableflip`, 34 | Run: func(res *hal.Response) error { 35 | return res.Send(`(╯°□°)╯︵ ┻━┻`) 36 | }, 37 | } 38 | 39 | robot.Handle( 40 | pingHandler, 41 | fooHandler, 42 | tableFlipHandler, 43 | 44 | // Or stick them in an entirely different package, and reference them 45 | // exactly in the way you would expect. 46 | handler.Ping, 47 | 48 | // Or use a hal.Handler structure complete with usage... 49 | &hal.Handler{ 50 | Method: hal.RESPOND, 51 | Pattern: `SYN`, 52 | Usage: `hal syn - replies with "ACK"`, 53 | Run: func(res *hal.Response) error { 54 | return res.Reply("ACK") 55 | }, 56 | }, 57 | 58 | // Or even inline! 59 | hal.Hear(`yo`, func(res *hal.Response) error { 60 | return res.Send("lo") 61 | }), 62 | ) 63 | 64 | if err := robot.Run(); err != nil { 65 | hal.Logger.Error(err) 66 | return 1 67 | } 68 | return 0 69 | } 70 | 71 | func main() { 72 | os.Exit(run()) 73 | } 74 | -------------------------------------------------------------------------------- /examples/redis.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/danryan/hal" 6 | _ "github.com/danryan/hal/adapter/irc" 7 | _ "github.com/danryan/hal/adapter/shell" 8 | _ "github.com/danryan/hal/adapter/slack" 9 | _ "github.com/danryan/hal/adapter/test" 10 | _ "github.com/danryan/hal/store/redis" 11 | "github.com/davecgh/go-spew/spew" 12 | "os" 13 | ) 14 | 15 | var pingHandler = hal.Hear(`ping`, func(res *hal.Response) error { 16 | return res.Send("PONG") 17 | }) 18 | 19 | var getHandler = hal.Hear(`get (.+)`, func(res *hal.Response) error { 20 | key := res.Match[1] 21 | val, err := res.Robot.Store.Get(key) 22 | if err != nil { 23 | res.Send(err.Error()) 24 | return err 25 | } 26 | return res.Send(string(val)) 27 | }) 28 | 29 | var setHandler = hal.Hear(`set (.+) (.+)`, func(res *hal.Response) error { 30 | key := res.Match[1] 31 | val := res.Match[2] 32 | err := res.Robot.Store.Set(key, []byte(val)) 33 | if err != nil { 34 | res.Send(err.Error()) 35 | return err 36 | } 37 | return res.Send("OK") 38 | }) 39 | 40 | var deleteHandler = hal.Hear(`delete (.+)`, func(res *hal.Response) error { 41 | key := res.Match[1] 42 | if err := res.Robot.Store.Delete(key); err != nil { 43 | res.Send(err.Error()) 44 | return err 45 | } 46 | return res.Send("OK") 47 | }) 48 | 49 | var usersHandler = hal.Hear(`show users`, func(res *hal.Response) error { 50 | // users, _ := res.Robot.Store.Get("hal:users") 51 | lines := []string{} 52 | for _, user := range res.Robot.Users.All() { 53 | lines = append(lines, spew.Sdump(user)) 54 | } 55 | return res.Send(lines...) 56 | }) 57 | 58 | var userHandler = hal.Hear(`user (.+)`, func(res *hal.Response) error { 59 | id := res.Match[1] 60 | user, _ := res.Robot.Users.Get(id) 61 | // users, _ := res.Robot.Store.Get("hal:users") 62 | line := spew.Sdump(user) 63 | return res.Send(line) 64 | }) 65 | 66 | func main() { 67 | os.Exit(Run()) 68 | } 69 | 70 | // Run returns an int so we can return a proper exit code 71 | func Run() int { 72 | robot, err := hal.NewRobot() 73 | if err != nil { 74 | fmt.Println(err) 75 | return 1 76 | } 77 | 78 | robot.Handle( 79 | getHandler, 80 | setHandler, 81 | deleteHandler, 82 | usersHandler, 83 | userHandler, 84 | ) 85 | 86 | if err := robot.Run(); err != nil { 87 | hal.Logger.Error(err) 88 | return 1 89 | } 90 | return 0 91 | } 92 | -------------------------------------------------------------------------------- /examples/simple/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/danryan/hal" 7 | _ "github.com/danryan/hal/adapter/shell" 8 | _ "github.com/danryan/hal/store/memory" 9 | ) 10 | 11 | var pingHandler = hal.Hear(`ping`, func(res *hal.Response) error { 12 | return res.Send("PONG") 13 | }) 14 | 15 | var echoHandler = hal.Respond(`echo (.+)`, func(res *hal.Response) error { 16 | return res.Reply(res.Match[1]) 17 | }) 18 | 19 | func run() int { 20 | robot, err := hal.NewRobot() 21 | if err != nil { 22 | hal.Logger.Error(err) 23 | return 1 24 | } 25 | 26 | robot.Handle( 27 | pingHandler, 28 | echoHandler, 29 | ) 30 | 31 | if err := robot.Run(); err != nil { 32 | hal.Logger.Error(err) 33 | return 1 34 | } 35 | return 0 36 | } 37 | 38 | func main() { 39 | os.Exit(run()) 40 | } 41 | -------------------------------------------------------------------------------- /examples/store.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/danryan/hal" 6 | _ "github.com/danryan/hal/adapter/irc" 7 | _ "github.com/danryan/hal/adapter/shell" 8 | _ "github.com/danryan/hal/adapter/slack" 9 | _ "github.com/danryan/hal/adapter/test" 10 | _ "github.com/danryan/hal/store/memory" 11 | "github.com/davecgh/go-spew/spew" 12 | "os" 13 | ) 14 | 15 | var pingHandler = hal.Hear(`ping`, func(res *hal.Response) error { 16 | return res.Send("PONG") 17 | }) 18 | 19 | var getHandler = hal.Hear(`get (.+)`, func(res *hal.Response) error { 20 | key := res.Match[1] 21 | val, err := res.Robot.Store.Get(key) 22 | if err != nil { 23 | res.Send(err.Error()) 24 | return err 25 | } 26 | return res.Send(fmt.Sprintf("get: %s=%s", key, string(val))) 27 | }) 28 | 29 | var setHandler = hal.Hear(`set (.+) (.+)`, func(res *hal.Response) error { 30 | key := res.Match[1] 31 | val := res.Match[2] 32 | err := res.Robot.Store.Set(key, []byte(val)) 33 | if err != nil { 34 | res.Send(err.Error()) 35 | return err 36 | } 37 | return res.Send(fmt.Sprintf("set: %s=%s", key, val)) 38 | }) 39 | 40 | var deleteHandler = hal.Hear(`delete (.+)`, func(res *hal.Response) error { 41 | key := res.Match[1] 42 | 43 | if err := res.Robot.Store.Delete(key); err != nil { 44 | res.Send(err.Error()) 45 | return err 46 | } 47 | return res.Send(fmt.Sprintf("delete: %s", key)) 48 | }) 49 | 50 | var usersHandler = hal.Hear(`show users`, func(res *hal.Response) error { 51 | // users, _ := res.Robot.Store.Get("hal:users") 52 | users, _ := res.Robot.Users() 53 | line := spew.Sdump("%#v\n", users) 54 | return res.Send(line) 55 | }) 56 | 57 | func main() { 58 | os.Exit(Run()) 59 | } 60 | 61 | // Run returns an int so we can return a proper exit code 62 | func Run() int { 63 | robot, err := hal.NewRobot() 64 | if err != nil { 65 | fmt.Println(err) 66 | return 1 67 | } 68 | 69 | robot.Store.Set("foo", []byte("FOO")) 70 | 71 | robot.Handle( 72 | pingHandler, 73 | getHandler, 74 | setHandler, 75 | deleteHandler, 76 | usersHandler, 77 | ) 78 | 79 | // spew.Dump(robot.Users()) 80 | // spew.Dump(hal.Adapters) 81 | if err := robot.Run(); err != nil { 82 | hal.Logger.Error(err) 83 | return 1 84 | } 85 | return 0 86 | } 87 | -------------------------------------------------------------------------------- /hal.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | // Handler constants 4 | const ( 5 | HEAR = "HEAR" 6 | RESPOND = "RESPOND" 7 | TOPIC = "TOPIC" 8 | ENTER = "ENTER" 9 | LEAVE = "LEAVE" 10 | ) 11 | 12 | var ( 13 | // Config is a global config 14 | Config = newConfig() 15 | // Logger is a global logger 16 | Logger = newLogger() 17 | // Router is a global HTTP muxer 18 | Router = newRouter() 19 | ) 20 | 21 | // New returns a Robot instance. 22 | func New() (*Robot, error) { 23 | return NewRobot() 24 | } 25 | 26 | // Hear a message 27 | func Hear(pattern string, fn func(res *Response) error) handler { 28 | return &Handler{Method: HEAR, Pattern: pattern, Run: fn} 29 | } 30 | 31 | // Respond creates a new listener for Respond messages 32 | func Respond(pattern string, fn func(res *Response) error) handler { 33 | return &Handler{Method: RESPOND, Pattern: pattern, Run: fn} 34 | } 35 | 36 | // Topic returns a new listener for Topic messages 37 | func Topic(pattern string, fn func(res *Response) error) handler { 38 | return &Handler{Method: TOPIC, Run: fn} 39 | } 40 | 41 | // Enter returns a new listener for Enter messages 42 | func Enter(fn func(res *Response) error) handler { 43 | return &Handler{Method: ENTER, Run: fn} 44 | } 45 | 46 | // Leave creates a new listener for Leave messages 47 | func Leave(fn func(res *Response) error) handler { 48 | return &Handler{Method: LEAVE, Run: fn} 49 | } 50 | 51 | // Close shuts down the robot. Unused? 52 | func Close() error { 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /haltest/adapter.go: -------------------------------------------------------------------------------- 1 | package haltest 2 | 3 | import ( 4 | "github.com/danryan/hal" 5 | ) 6 | 7 | func init() { 8 | hal.RegisterAdapter("test", New) 9 | } 10 | 11 | type adapter struct { 12 | hal.BasicAdapter 13 | } 14 | 15 | // New returns an initialized adapter 16 | func New(r *hal.Robot) (hal.Adapter, error) { 17 | a := &adapter{} 18 | a.SetRobot(r) 19 | return a, nil 20 | } 21 | 22 | func (a *adapter) Run() error { return nil } 23 | func (a *adapter) Stop() error { return nil } 24 | func (a *adapter) Receive(*hal.Message) error { return nil } 25 | func (a *adapter) Send(*hal.Response, ...string) error { return nil } 26 | func (a *adapter) Reply(*hal.Response, ...string) error { return nil } 27 | func (a *adapter) Emote(*hal.Response, ...string) error { return nil } 28 | func (a *adapter) Topic(*hal.Response, ...string) error { return nil } 29 | func (a *adapter) Play(*hal.Response, ...string) error { return nil } 30 | func (a *adapter) Name() string { return "test" } 31 | -------------------------------------------------------------------------------- /haltest/robot.go: -------------------------------------------------------------------------------- 1 | package haltest 2 | 3 | import ( 4 | "github.com/danryan/hal" 5 | ) 6 | 7 | type Robot struct { 8 | hal.Robot 9 | } 10 | 11 | type ResponseRecorder struct { 12 | } 13 | 14 | func NewRecorder() *ResponseRecorder { 15 | return &ResponseRecorder{} 16 | } 17 | -------------------------------------------------------------------------------- /haltest/store.go: -------------------------------------------------------------------------------- 1 | package haltest 2 | 3 | import ( 4 | "github.com/danryan/hal" 5 | ) 6 | 7 | func init() { 8 | hal.RegisterStore("test", New) 9 | } 10 | 11 | type store struct { 12 | hal.BasicStore 13 | } 14 | 15 | // New returns an initialized store 16 | func New(robot *hal.Robot) (hal.Store, error) { 17 | s := &store{} 18 | s.SetRobot(robot) 19 | return s, nil 20 | } 21 | 22 | func (s *store) Open() error { return nil } 23 | func (s *store) Close() error { return nil } 24 | func (s *store) Get(key string) ([]byte, error) { return []byte{}, nil } 25 | func (s *store) Set(key string, data []byte) error { return nil } 26 | func (s *store) Delete(key string) error { return nil } 27 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | // RespondRegexp is used to determine whether a received message should be processed as a response 11 | respondRegexp = fmt.Sprintf(`^(?:@?(?:%s|%s)[:,]?)\s+(?:(.+))`, Config.Alias, Config.Name) 12 | // RespondRegexpTemplate expands the RespondRegexp 13 | respondRegexpTemplate = fmt.Sprintf(`^(?:@?(?:%s|%s)[:,]?)\s+(?:${1})`, Config.Alias, Config.Name) 14 | ) 15 | 16 | // handler is an interface for objects to implement in order to respond to messages. 17 | type handler interface { 18 | Handle(res *Response) error 19 | } 20 | 21 | // Handlers is a map of registered handlers 22 | var Handlers = map[string]handler{} 23 | 24 | func handlerMatch(r *regexp.Regexp, text string) bool { 25 | if !r.MatchString(text) { 26 | return false 27 | } 28 | return true 29 | } 30 | 31 | func handlerRegexp(method, pattern string) *regexp.Regexp { 32 | if method == RESPOND { 33 | return regexp.MustCompile(strings.Replace(respondRegexpTemplate, "${1}", pattern, 1)) 34 | } 35 | return regexp.MustCompile(pattern) 36 | } 37 | 38 | // NewHandler checks whether h implements the handler interface, wrapping it in a FullHandler 39 | func NewHandler(h interface{}) (handler, error) { 40 | switch v := h.(type) { 41 | case fullHandler: 42 | return &FullHandler{handler: v}, nil 43 | case handler: 44 | return v, nil 45 | default: 46 | return nil, fmt.Errorf("%v does not implement the handler interface", v) 47 | } 48 | } 49 | 50 | // Handler type 51 | type Handler struct { 52 | Method string 53 | Pattern string 54 | Usage string 55 | Run func(res *Response) error 56 | } 57 | 58 | // Handle func 59 | func (h *Handler) Handle(res *Response) error { 60 | switch { 61 | // handle the response without matching 62 | case h.Pattern == "": 63 | return h.Run(res) 64 | // handle the response after finding matches 65 | case h.match(res): 66 | res.Match = h.regexp().FindAllStringSubmatch(res.Message.Text, -1)[0] 67 | return h.Run(res) 68 | // if we don't find a match, return 69 | default: 70 | return nil 71 | } 72 | } 73 | 74 | func (h *Handler) regexp() *regexp.Regexp { 75 | return handlerRegexp(h.Method, h.Pattern) 76 | } 77 | 78 | // Match func 79 | func (h *Handler) match(res *Response) bool { 80 | return handlerMatch(h.regexp(), res.Message.Text) 81 | } 82 | 83 | // fullHandler is an interface for objects that wish to supply their own define methods 84 | type fullHandler interface { 85 | Run(*Response) error 86 | Usage() string 87 | Pattern() string 88 | Method() string 89 | } 90 | 91 | // FullHandler declares common functions shared by all handlers 92 | type FullHandler struct { 93 | handler fullHandler 94 | } 95 | 96 | // Regexp func 97 | func (h *FullHandler) Regexp() *regexp.Regexp { 98 | return handlerRegexp(h.handler.Method(), h.handler.Pattern()) 99 | } 100 | 101 | // Match func 102 | func (h *FullHandler) Match(res *Response) bool { 103 | return handlerMatch(h.Regexp(), res.Message.Text) 104 | } 105 | 106 | // Handle func 107 | func (h *FullHandler) Handle(res *Response) error { 108 | switch { 109 | // handle the response without matching 110 | case h.handler.Pattern() == "": 111 | return h.handler.Run(res) 112 | // handle the response after finding matches 113 | case h.Match(res): 114 | res.Match = h.Regexp().FindAllStringSubmatch(res.Text(), -1)[0] 115 | return h.handler.Run(res) 116 | // if we don't find a match, return 117 | default: 118 | return nil 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /handler/echo.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/danryan/hal" 5 | ) 6 | 7 | // Echo is an example of a simple handler. 8 | var Echo = hal.Respond(`echo (.+)`, func(res *hal.Response) error { 9 | return res.Reply(res.Match[1]) 10 | }) 11 | -------------------------------------------------------------------------------- /handler/ping.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/danryan/hal" 5 | ) 6 | 7 | type ping struct{} 8 | 9 | func (h *ping) Method() string { 10 | return hal.RESPOND 11 | } 12 | 13 | func (h *ping) Usage() string { 14 | return `ping - responds with "PONG"` 15 | } 16 | 17 | func (h *ping) Pattern() string { 18 | return `(?i)ping` 19 | } 20 | 21 | func (h *ping) Run(res *hal.Response) error { 22 | return res.Send("PONG") 23 | } 24 | 25 | // Ping exports 26 | var Ping = &ping{} 27 | -------------------------------------------------------------------------------- /handler/tableflip.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/danryan/hal" 5 | ) 6 | 7 | // TableFlip is an example of a Handler 8 | var TableFlip = &hal.Handler{ 9 | Method: hal.HEAR, 10 | Pattern: `tableflip`, 11 | Run: func(res *hal.Response) error { 12 | return res.Send(`(╯°□°)╯︵ ┻━┻`) 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /handler_test.go: -------------------------------------------------------------------------------- 1 | package hal_test 2 | 3 | import ( 4 | "github.com/danryan/hal" 5 | ) 6 | 7 | func ExampleHandler_hear() { 8 | res := hal.Response{ 9 | Match: []string{}, 10 | } 11 | h := &hal.Handler{ 12 | Method: hal.HEAR, 13 | Pattern: `echo (.+)`, 14 | Usage: "echo - repeats back", 15 | Run: func(res *hal.Response) error { 16 | res.Send(res.Match[1]) 17 | }, 18 | } 19 | // output: 20 | // > echo foo bar baz 21 | // foo bar baz 22 | } 23 | 24 | func ExampleHandler_respond() { 25 | &Handler{ 26 | Method: hal.RESPOND, 27 | Pattern: `(?i)ping`, // (?i) is a flag that makes the match case insensitive 28 | Usage: `hal ping - replies with "PONG"`, 29 | Run: func(res *hal.Response) error { 30 | res.Send("PONG") 31 | }, 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /message.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | // Message represents an incoming chat message. 4 | type Message struct { 5 | ID string 6 | User User 7 | Room string 8 | Text string 9 | Type string 10 | } 11 | 12 | // String implements the Stringer interface 13 | func (msg *Message) String() string { 14 | return msg.Text 15 | } 16 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | // Response struct 4 | type Response struct { 5 | Robot *Robot 6 | Envelope *Envelope 7 | Message *Message 8 | Match []string 9 | } 10 | 11 | // Envelope contains metadata about the chat message. 12 | type Envelope struct { 13 | Room string 14 | User *User 15 | Options map[string]interface{} 16 | } 17 | 18 | // Options type 19 | type Options map[string]interface{} 20 | 21 | // SetOptions sets the Envelope's Options 22 | func (e *Envelope) SetOptions(opts map[string]interface{}) { 23 | e.Options = opts 24 | } 25 | 26 | // NewResponseFromMessage returns a new Response object with an associated Message 27 | func NewResponseFromMessage(robot *Robot, msg *Message) *Response { 28 | return &Response{ 29 | Robot: robot, 30 | Envelope: &Envelope{ 31 | Room: msg.Room, 32 | User: &msg.User, 33 | }, 34 | Message: msg, 35 | } 36 | } 37 | 38 | // NewResponse returns a new Response object 39 | func NewResponse(robot *Robot) *Response { 40 | return &Response{ 41 | Robot: robot, 42 | Envelope: &Envelope{}, 43 | } 44 | } 45 | 46 | // UserID returns the id of the Envelope's User 47 | func (res *Response) UserID() string { 48 | return res.Envelope.User.ID 49 | } 50 | 51 | // UserName returns the id of the Envelope's User 52 | func (res *Response) UserName() string { 53 | return res.Envelope.User.Name 54 | } 55 | 56 | // UserRoles returns the roles of the Envelope's User 57 | func (res *Response) UserRoles() []string { 58 | return res.Envelope.User.Roles 59 | } 60 | 61 | // Room returns the Envelope room of the response's message 62 | func (res *Response) Room() string { 63 | return res.Envelope.Room 64 | } 65 | 66 | // Text is the text of the response's message 67 | func (res *Response) Text() string { 68 | return res.Message.Text 69 | } 70 | 71 | // Send posts a message back to the chat source 72 | func (res *Response) Send(strings ...string) error { 73 | if err := res.Robot.Adapter.Send(res, strings...); err != nil { 74 | Logger.Error(err) 75 | return err 76 | } 77 | return nil 78 | } 79 | 80 | // Reply posts a message mentioning the current user 81 | func (res *Response) Reply(strings ...string) error { 82 | if err := res.Robot.Adapter.Reply(res, strings...); err != nil { 83 | Logger.Error(err) 84 | return err 85 | } 86 | return nil 87 | } 88 | 89 | // Emote posts an emote back to the chat source 90 | func (res *Response) Emote(strings ...string) error { 91 | if err := res.Robot.Adapter.Emote(res, strings...); err != nil { 92 | Logger.Error(err) 93 | return err 94 | } 95 | return nil 96 | } 97 | 98 | // Topic posts a topic changing message 99 | func (res *Response) Topic(strings ...string) error { 100 | if err := res.Robot.Adapter.Topic(res, strings...); err != nil { 101 | Logger.Error(err) 102 | return err 103 | } 104 | return nil 105 | } 106 | 107 | // Play posts a sound message 108 | func (res *Response) Play(strings ...string) error { 109 | if err := res.Robot.Adapter.Play(res, strings...); err != nil { 110 | Logger.Error(err) 111 | return err 112 | } 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /robot.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "os/signal" 7 | "strconv" 8 | "syscall" 9 | ) 10 | 11 | // Robot receives messages from an adapter and sends them to listeners 12 | type Robot struct { 13 | Name string 14 | Alias string 15 | Adapter Adapter 16 | Store Store 17 | handlers []handler 18 | Users *UserMap 19 | Auth *Auth 20 | signalChan chan os.Signal 21 | } 22 | 23 | // Handlers returns the robot's handlers 24 | func (robot *Robot) Handlers() []handler { 25 | return robot.handlers 26 | } 27 | 28 | // NewRobot returns a new Robot instance 29 | func NewRobot() (*Robot, error) { 30 | robot := &Robot{ 31 | Name: Config.Name, 32 | Alias: Config.Alias, 33 | signalChan: make(chan os.Signal, 1), 34 | } 35 | 36 | adapter, err := NewAdapter(robot) 37 | if err != nil { 38 | Logger.Error(err) 39 | return nil, err 40 | } 41 | robot.SetAdapter(adapter) 42 | 43 | store, err := NewStore(robot) 44 | if err != nil { 45 | Logger.Error(err) 46 | return nil, err 47 | } 48 | robot.SetStore(store) 49 | 50 | robot.Users = NewUserMap(robot) 51 | robot.Auth = NewAuth(robot) 52 | 53 | return robot, nil 54 | } 55 | 56 | // Handle registers a new handler with the robot 57 | func (robot *Robot) Handle(handlers ...interface{}) { 58 | for _, h := range handlers { 59 | nh, err := NewHandler(h) 60 | if err != nil { 61 | Logger.Fatal(err) 62 | panic(err) 63 | } 64 | 65 | robot.handlers = append(robot.handlers, nh) 66 | } 67 | } 68 | 69 | // Receive dispatches messages to our handlers 70 | func (robot *Robot) Receive(msg *Message) error { 71 | Logger.Debugf("%s - robot received message", Config.AdapterName) 72 | 73 | // check if we've seen this user yet, and add if we haven't. 74 | user := msg.User 75 | if _, err := robot.Users.Get(user.ID); err != nil { 76 | Logger.Debug(err) 77 | robot.Users.Set(user.ID, user) 78 | robot.Users.Save() 79 | } 80 | 81 | for _, handler := range robot.handlers { 82 | response := NewResponseFromMessage(robot, msg) 83 | 84 | if err := handler.Handle(response); err != nil { 85 | Logger.Error(err) 86 | return err 87 | } 88 | } 89 | return nil 90 | } 91 | 92 | // Run initiates the startup process 93 | func (robot *Robot) Run() error { 94 | Logger.Info("starting robot") 95 | 96 | // HACK 97 | Logger.Debugf("opening %s store connection", Config.StoreName) 98 | go func() { 99 | robot.Store.Open() 100 | 101 | Logger.Debug("loading users from store") 102 | robot.Users.Load() 103 | }() 104 | 105 | Logger.Debugf("starting %s adapter", Config.AdapterName) 106 | go robot.Adapter.Run() 107 | 108 | // Start the HTTP server after the adapter, as adapter.Run() adds additional 109 | // handlers to the router. 110 | Logger.Debug("starting HTTP server") 111 | go func() { 112 | if err := http.ListenAndServe(`:`+strconv.Itoa(Config.Port), Router); err != nil { 113 | Logger.Debug(err) 114 | } 115 | }() 116 | 117 | signal.Notify(robot.signalChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) 118 | 119 | stop := false 120 | for !stop { 121 | select { 122 | case sig := <-robot.signalChan: 123 | switch sig { 124 | case syscall.SIGINT, syscall.SIGTERM: 125 | stop = true 126 | } 127 | } 128 | } 129 | // Stop listening for new signals 130 | signal.Stop(robot.signalChan) 131 | 132 | // Initiate the shutdown process for our robot 133 | robot.Stop() 134 | 135 | return nil 136 | } 137 | 138 | // Stop initiates the shutdown process 139 | func (robot *Robot) Stop() error { 140 | Logger.Info() // so we don't break up the log formatting when running interactively ;) 141 | 142 | Logger.Debugf("stopping %s adapter", Config.AdapterName) 143 | if err := robot.Adapter.Stop(); err != nil { 144 | return err 145 | } 146 | 147 | Logger.Debugf("closing %s store connection", Config.StoreName) 148 | if err := robot.Store.Close(); err != nil { 149 | return err 150 | } 151 | 152 | Logger.Info("stopping robot") 153 | return nil 154 | } 155 | 156 | // SetName sets robot's name 157 | func (robot *Robot) SetName(name string) { 158 | robot.Name = name 159 | } 160 | 161 | // SetAdapter sets robot's adapter 162 | func (robot *Robot) SetAdapter(adapter Adapter) { 163 | robot.Adapter = adapter 164 | } 165 | 166 | // SetStore sets robot's adapter 167 | func (robot *Robot) SetStore(store Store) { 168 | robot.Store = store 169 | } 170 | -------------------------------------------------------------------------------- /store.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Store interface for storage backends to implement 8 | type Store interface { 9 | Open() error 10 | Close() error 11 | Get(string) ([]byte, error) 12 | Set(key string, data []byte) error 13 | Delete(string) error 14 | } 15 | 16 | type store struct { 17 | name string 18 | newFunc func(*Robot) (Store, error) 19 | } 20 | 21 | // BasicStore struct to be embedded in other stores 22 | type BasicStore struct { 23 | Robot *Robot 24 | } 25 | 26 | // SetRobot sets the adapter's Robot 27 | func (s *BasicStore) SetRobot(r *Robot) { 28 | s.Robot = r 29 | } 30 | 31 | // Stores is a map of registered stores 32 | var Stores = map[string]store{} 33 | 34 | // RegisterStore registers a new store 35 | func RegisterStore(name string, newFunc func(*Robot) (Store, error)) { 36 | Stores[name] = store{ 37 | name: name, 38 | newFunc: newFunc, 39 | } 40 | } 41 | 42 | // NewStore returns an initialized store 43 | func NewStore(robot *Robot) (Store, error) { 44 | name := Config.StoreName 45 | if _, ok := Stores[name]; !ok { 46 | return nil, fmt.Errorf("%s is not a registered store", Config.StoreName) 47 | } 48 | 49 | store, err := Stores[name].newFunc(robot) 50 | if err != nil { 51 | return nil, err 52 | } 53 | return store, nil 54 | } 55 | 56 | func (s *BasicStore) String() string { 57 | return Config.StoreName 58 | } 59 | -------------------------------------------------------------------------------- /store/memory/memory.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "fmt" 5 | "github.com/danryan/hal" 6 | ) 7 | 8 | func init() { 9 | hal.RegisterStore("memory", New) 10 | } 11 | 12 | type store struct { 13 | hal.BasicStore 14 | data map[string][]byte 15 | } 16 | 17 | // New returns an new initialized store 18 | func New(robot *hal.Robot) (hal.Store, error) { 19 | s := &store{ 20 | data: map[string][]byte{}, 21 | } 22 | s.SetRobot(robot) 23 | return s, nil 24 | } 25 | 26 | func (s *store) Open() error { 27 | return nil 28 | } 29 | 30 | func (s *store) Close() error { 31 | return nil 32 | } 33 | 34 | func (s *store) Get(key string) ([]byte, error) { 35 | if val, ok := s.data[key]; ok { 36 | return val, nil 37 | } 38 | 39 | return nil, fmt.Errorf("key %s was not found", key) 40 | } 41 | 42 | func (s *store) Set(key string, data []byte) error { 43 | s.data[key] = data 44 | return nil 45 | } 46 | 47 | func (s *store) Delete(key string) error { 48 | if _, ok := s.data[key]; !ok { 49 | return fmt.Errorf("key %s was not found", key) 50 | } 51 | delete(s.data, key) 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /store/redis/redis.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "fmt" 5 | "github.com/danryan/env" 6 | "github.com/danryan/hal" 7 | "github.com/davecgh/go-spew/spew" 8 | "github.com/garyburd/redigo/redis" 9 | "net/url" 10 | ) 11 | 12 | var _ = spew.Sdump() 13 | 14 | func init() { 15 | hal.RegisterStore("redis", New) 16 | } 17 | 18 | type store struct { 19 | hal.BasicStore 20 | config *config 21 | client redis.Conn 22 | } 23 | 24 | type config struct { 25 | URL string `env:"key=HAL_REDIS_URL default=redis://localhost:6379"` 26 | Namespace string `env:"key=HAL_REDIS_NAMESPACE default=hal"` 27 | } 28 | 29 | // New returns an new initialized store 30 | func New(robot *hal.Robot) (hal.Store, error) { 31 | c := &config{} 32 | env.MustProcess(c) 33 | s := &store{ 34 | config: c, 35 | } 36 | s.SetRobot(robot) 37 | return s, nil 38 | } 39 | 40 | func (s *store) Open() error { 41 | uri, err := url.Parse(s.config.URL) 42 | if err != nil { 43 | hal.Logger.Error(err) 44 | } 45 | 46 | conn, err := redis.Dial("tcp", uri.Host) 47 | if err != nil { 48 | hal.Logger.Error(err) 49 | return err 50 | } 51 | s.client = conn 52 | return nil 53 | } 54 | 55 | func (s *store) Close() error { 56 | if err := s.client.Close(); err != nil { 57 | hal.Logger.Error(err) 58 | return err 59 | } 60 | return nil 61 | } 62 | 63 | func (s *store) Get(key string) ([]byte, error) { 64 | args := s.namespace(key) 65 | data, err := s.client.Do("GET", args) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | if data == nil { 71 | return []byte{}, fmt.Errorf("%s not found", key) 72 | } 73 | return data.([]byte), nil 74 | } 75 | 76 | func (s *store) Set(key string, data []byte) error { 77 | if _, err := s.client.Do("SET", s.namespace(key), data); err != nil { 78 | return err 79 | } 80 | return nil 81 | } 82 | 83 | func (s *store) Delete(key string) error { 84 | res, err := s.client.Do("DEL", s.namespace(key)) 85 | if err != nil { 86 | return err 87 | } 88 | if res.(int64) < 1 { 89 | return fmt.Errorf("%s not found", key) 90 | } 91 | return nil 92 | } 93 | 94 | func (s *store) namespace(key string) string { 95 | return fmt.Sprintf("%s:%s", s.config.Namespace, key) 96 | } 97 | -------------------------------------------------------------------------------- /user.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "sync" 7 | ) 8 | 9 | // User is a chat participant 10 | type User struct { 11 | ID string 12 | Name string 13 | Roles []string 14 | Options map[string]interface{} 15 | } 16 | 17 | func (u *User) Get(k string) (interface{}, error) { 18 | v, ok := u.Options[k] 19 | if !ok { 20 | return nil, fmt.Errorf("%s not found in user options", k) 21 | } 22 | return v, nil 23 | } 24 | 25 | func NewUser() *User { 26 | return &User{ 27 | Options: make(map[string]interface{}), 28 | } 29 | } 30 | 31 | // UserMap handles the known users 32 | type UserMap struct { 33 | Map map[string]User 34 | robot *Robot 35 | sync.Mutex 36 | } 37 | 38 | // NewUserMap returns an initialized UserMap 39 | func NewUserMap(robot *Robot) *UserMap { 40 | return &UserMap{ 41 | Map: make(map[string]User, 0), 42 | robot: robot, 43 | } 44 | } 45 | 46 | // All returns the underlying map of all users 47 | func (um *UserMap) All() []User { 48 | um.Lock() 49 | 50 | users := make([]User, len(um.Map)) 51 | for _, user := range um.Map { 52 | users = append(users, user) 53 | } 54 | 55 | um.Unlock() 56 | return users 57 | } 58 | 59 | // Get looks up a user by id and returns a User object 60 | func (um *UserMap) Get(id string) (User, error) { 61 | um.Lock() 62 | defer um.Unlock() 63 | 64 | user, ok := um.Map[id] 65 | if !ok { 66 | return User{}, fmt.Errorf("could not find user with id %s", id) 67 | } 68 | return user, nil 69 | } 70 | 71 | // GetByName looks up a user by name and returns a User object 72 | func (um *UserMap) GetByName(name string) (User, error) { 73 | um.Lock() 74 | defer um.Unlock() 75 | 76 | for _, user := range um.Map { 77 | if user.Name == name { 78 | if user.Options == nil { 79 | user.Options = make(map[string]interface{}) 80 | } 81 | return user, nil 82 | } 83 | } 84 | return User{Options: make(map[string]interface{})}, fmt.Errorf("could not find user with name %s", name) 85 | } 86 | 87 | // Set adds or updates a user in the UserMap and persists it to the store 88 | func (um *UserMap) Set(id string, user User) error { 89 | um.Lock() 90 | 91 | // initialize user.Options if nothing's in there yet 92 | if user.Options == nil { 93 | user.Options = make(map[string]interface{}) 94 | } 95 | um.Map[id] = user 96 | if err := um.Save(); err != nil { 97 | um.Unlock() 98 | return err 99 | } 100 | 101 | um.Unlock() 102 | return nil 103 | } 104 | 105 | // Encode marshals a UserMap to JSON 106 | func (um *UserMap) Encode() ([]byte, error) { 107 | data, err := json.Marshal(um.Map) 108 | if err != nil { 109 | return []byte{}, err 110 | } 111 | return data, err 112 | } 113 | 114 | // Decode unmarshals a JSON object into a map of strings to Users 115 | func (um *UserMap) Decode() (map[string]User, error) { 116 | data, err := um.robot.Store.Get("users") 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | users := map[string]User{} 122 | if err := json.Unmarshal(data, &users); err != nil { 123 | return users, err 124 | } 125 | 126 | return users, nil 127 | } 128 | 129 | // Load retrieves known users from the store and populates the UserMap 130 | func (um *UserMap) Load() error { 131 | um.Lock() 132 | 133 | data, err := um.Decode() 134 | if err != nil { 135 | um.Unlock() 136 | return err 137 | } 138 | 139 | um.Map = data 140 | 141 | um.Unlock() 142 | return nil 143 | } 144 | 145 | // Save persists known users to the store 146 | func (um *UserMap) Save() error { 147 | data, err := um.Encode() 148 | if err != nil { 149 | return err 150 | } 151 | 152 | return um.robot.Store.Set("users", data) 153 | } 154 | --------------------------------------------------------------------------------