├── .gitignore ├── CoC.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── bot ├── bot.go └── options.go ├── brain ├── bolt.go ├── brain.go ├── brain_test.go ├── detect.go ├── redis.go └── warning.go ├── cuteme.go ├── example-gochatbot-plugin-logger.sh ├── glide.lock ├── glide.yaml ├── gochatbot-multi.go ├── gochatbot.go ├── messages └── messages.go ├── plugins ├── gochatbot-plugin-goapp-release │ └── release.go ├── gochatbot-plugin-ops │ ├── allowedCmds.go │ ├── ops.go │ └── ssh │ │ └── ssh.go ├── gochatbot-plugin-reddit │ └── reddit.go ├── gochatbot-plugin-sentimental │ └── sentimental.go ├── gochatbot-plugin-trello │ ├── gochatbot-plugin-trello │ └── trello.go └── utils.go ├── providers ├── cli.go ├── cli_test.go ├── irc.go ├── providers.go ├── slack.go └── telegram.go ├── rpc-example.php ├── rules.go ├── rules ├── cron │ └── cron.go ├── plugins │ └── plugin.go ├── regex │ └── regex.go └── rpc │ ├── http.go │ └── rpc.go ├── shipit.go └── silent.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | gobot 27 | gochatbot 28 | 29 | vendor/ 30 | gochatbot.db 31 | gochatbot-container 32 | gochatbot-darwin 33 | gochatbot-linux 34 | gochatbot-windows 35 | gochatbot-linux.gz 36 | gochatbot-darwin.gz 37 | gochatbot-windows.gz 38 | -------------------------------------------------------------------------------- /CoC.md: -------------------------------------------------------------------------------- 1 | # A Code of Conduct for the gochatbot community 2 | 3 | *This code of conduct is an adaptation from [Go's Community Code of Conduct](https://golang.org/conduct).* 4 | 5 | ### About the Code of Conduct 6 | 7 | #### Why have a Code of Conduct? 8 | 9 | Online communities include people from many different backgrounds. 10 | The gochatbot contributors are committed to providing a friendly, safe and 11 | welcoming environment for all, regardless of age, disability, gender, 12 | nationality, race, religion, sexuality, or similar personal characteristic. 13 | 14 | The first goal of the Code of Conduct is to specify a baseline standard 15 | of behavior so that people with different social values and communication 16 | styles can talk about gochatbot effectively, productively, and respectfully. 17 | 18 | The second goal is to provide a mechanism for resolving conflicts in the 19 | community when they arise. 20 | 21 | The third goal of the Code of Conduct is to make our community welcoming to 22 | people from different backgrounds. 23 | Diversity is critical to the project; for gochatbot to be successful, it needs 24 | contributors and users from all backgrounds. 25 | 26 | With that said, a healthy community must allow for disagreement and debate. 27 | The Code of Conduct is not a mechanism for people to silence others with whom 28 | they disagree. 29 | 30 | #### Where does the Code of Conduct apply? 31 | 32 | If you participate in or contribute to the gochatbot ecosystem in any way, 33 | you are encouraged to follow the Code of Conduct while doing so. 34 | 35 | Explicit enforcement of the Code of Conduct applies to the 36 | official forums operated by the gochatbot project (“gochatbot spaces”): 37 | - The official [GitHub projects](https://github.com/ccirello/gochatbot/) 38 | 39 | ### gochatbot contributor values 40 | 41 | These are the values to which people in the gochatbot community 42 | (“gochatbot contributor”) should aspire. 43 | 44 | * Be friendly and welcoming 45 | * Be patient 46 | * Remember that people have varying communication styles and that not 47 | everyone is using their native language. 48 | (Meaning and tone can be lost in translation.) 49 | * Be thoughtful 50 | * Productive communication requires effort. 51 | Think about how your words will be interpreted. 52 | * Remember that sometimes it is best to refrain entirely from commenting. 53 | * Be respectful 54 | * In particular, respect differences of opinion. 55 | * Be charitable 56 | * Interpret the arguments of others in good faith, do not seek to disagree. 57 | * When we do disagree, try to understand why. 58 | * Avoid destructive behavior: 59 | * Derailing: stay on topic; if you want to talk about something else, 60 | start a new conversation. 61 | * Unconstructive criticism: don't merely decry the current state of affairs; 62 | offer—or at least solicit—suggestions as to how things may be improved. 63 | * Snarking (pithy, unproductive, sniping comments) 64 | * Discussing potentially offensive or sensitive issues; 65 | this all too often leads to unnecessary conflict. 66 | * Microaggressions: brief and commonplace verbal, behavioral and 67 | environmental indignities that communicate hostile, derogatory or negative 68 | slights and insults to a person or group. 69 | 70 | People are complicated. 71 | You should expect to be misunderstood and to misunderstand others; 72 | when this inevitably occurs, resist the urge to be defensive or assign blame. 73 | Try not to take offense where no offense was intended. 74 | Give people the benefit of the doubt. 75 | Even if the intent was to provoke, do not rise to it. 76 | It is the responsibility of *all parties* to de-escalate conflict when it arises. 77 | 78 | ### Unwelcome behavior 79 | 80 | These actions are explicitly forbidden in gochatbot spaces: 81 | 82 | * Insulting, demeaning, hateful, or threatening remarks. 83 | * Discrimination based on age, disability, gender, nationality, race, 84 | religion, sexuality, or similar personal characteristic. 85 | * Bullying or systematic harassment. 86 | * Unwelcome sexual advances. 87 | * Incitement to any of these. 88 | 89 | ### Moderation 90 | 91 | The gochatbot spaces are not free speech venues; they are for discussion about 92 | gochatbot. 93 | These spaces have moderators. 94 | The goal of the moderators is to facilitate civil discussion about gochatbot. 95 | 96 | When using the official gochatbot spaces you should act in the spirit of the 97 | “gochatbot contributor values”. 98 | If you conduct yourself in a way that is explicitly forbidden by the CoC, 99 | you will be warned and asked to stop. 100 | If you do not stop, you will be removed from our community spaces temporarily. 101 | Repeated, wilful breaches of the CoC will result in a permanent ban. 102 | 103 | Moderators are held to a higher standard than other community members. 104 | If a moderator creates an inappropriate situation, they should expect less 105 | leeway than others, and should expect to be removed from their position if they 106 | cannot adhere to the CoC. 107 | 108 | Complaints about moderator actions must be handled using the reporting process 109 | below. 110 | 111 | ### Reporting issues 112 | 113 | Unfortunately, gochatbot is not a big project with a lot of contributors that 114 | allows the creation of a broad and representative CoC Working Group. 115 | However, its creator is responsible for addressing these issues. 116 | 117 | * Carlos Cirello 118 | 119 | If you encounter a conduct-related issue, you should report it to author using 120 | the process described below. 121 | **Do not** post about the issue publicly or try to rally sentiment against a 122 | particular individual or group. 123 | 124 | * Mail any or more of the CoC Working Group members. 125 | * Your message will reach the Working Group. 126 | * Reports are confidential within the Working Group. 127 | * You may contact a member of the group directly if you do not feel 128 | comfortable contacting the group as a whole. That member will then raise 129 | the issue with the Working Group as a whole, preserving the privacy of the 130 | reporter (if desired). 131 | * If your report concerns a member of the Working Group they will be recused 132 | from Working Group discussions of the report. 133 | * The Working Group will strive to handle reports with discretion and 134 | sensitivity, to protect the privacy of the involved parties, 135 | and to avoid conflicts of interest. 136 | * You should receive a response within 48 hours (likely sooner). 137 | (Should you choose to contact a single Working Group member, 138 | it may take longer to receive a response.) 139 | * The Working Group will meet to review the incident and determine what happened. 140 | * With the permission of person reporting the incident, the Working Group 141 | may reach out to other community members for more context. 142 | * The Working Group will reach a decision as to how to act. These may include: 143 | * Nothing. 144 | * A request for a private or public apology. 145 | * A private or public warning. 146 | * An imposed vacation (for instance, asking someone to abstain for a week 147 | from a subreddit or Github). 148 | * A permanent or temporary ban from some or all gochatbot spaces. 149 | * The Working Group will reach out to the original reporter to let them know 150 | the decision. 151 | * Appeals to the decision may be made to the Working Group, 152 | or to any of its members directly. 153 | 154 | **Note that the goal of the Code of Conduct and the Working Group is to resolve 155 | conflicts in the most harmonious way possible.** 156 | We hope that in most cases issues may be resolved through polite discussion and 157 | mutual agreement. 158 | Bannings and other forceful measures are to be employed only as a last resort. 159 | 160 | (Note: as a small project, we are open to suggestions of how to obtain anonymous 161 | reports to CoC Working Group). 162 | 163 | ### Summary 164 | 165 | * Treat everyone with respect and kindness. 166 | * Be thoughtful in how you communicate. 167 | * Don’t be destructive or inflammatory. 168 | * If you encounter an issue, please mail . 169 | 170 | #### Acknowledgements 171 | 172 | This document is derived from Go's Code of Conduct, which parts were derived 173 | from the Code of Conduct documents of the Django, FreeBSD, and Rust projects. 174 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine 2 | 3 | MAINTAINER Carlos Cirello 4 | 5 | # Uncomment the following line to import plugins into the image 6 | # COPY gochatbot-plugin-* / 7 | COPY gochatbot-container / 8 | 9 | CMD ["/gochatbot-container"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Carlos Cirello 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GCBFLAGS=all 2 | 3 | all: vendor 4 | GO15VENDOREXPERIMENT=1 go build -tags "$(GCBFLAGS)" 5 | 6 | docker: vendor 7 | GOOS=linux GO15VENDOREXPERIMENT=1 go build -tags "all" -o gochatbot-container 8 | docker build -t ccirello/gochatbot . 9 | rm -f gochatbot-container 10 | 11 | vendor: 12 | go get github.com/Masterminds/glide 13 | GO15VENDOREXPERIMENT=1 $(GOPATH)/bin/glide -q install 14 | 15 | clean: 16 | rm -rf vendor/ gochatbot 17 | 18 | novendor: 19 | cat glide.yaml | grep -i '\- package' | awk '{ print $$3 }' | xargs go get -u || exit 0 20 | 21 | quickstart: novendor 22 | go build -tags "$(GCBFLAGS)" 23 | 24 | release: vendor 25 | rm -f gochatbot-linux gochatbot-darwin gochatbot-windows gochatbot-linux.gz gochatbot-darwin.gz gochatbot-windows.gz 26 | GOOS=linux GO15VENDOREXPERIMENT=1 go build -tags "all" -o gochatbot-linux 27 | gzip -f gochatbot-linux 28 | GOOS=darwin GO15VENDOREXPERIMENT=1 go build -tags "all" -o gochatbot-darwin 29 | gzip -f gochatbot-darwin 30 | GOOS=windows GO15VENDOREXPERIMENT=1 go build -tags "all" -o gochatbot-windows 31 | gzip -f gochatbot-windows 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | gochatbot 2 | ========= 3 | 4 | This is a chatops bot implemented in Go. 5 | 6 | It features: 7 | - Support for Slack, Telegram and IRC 8 | - Non-durable and durable memory with BoltDB and Redis 9 | - Two ready to use rulesets: regex parsed messages and cron events 10 | - Easy integration with other programming languages through a plugin system (webservice JSON-RPC). 11 | - Container ready to use and easy to deploy 12 | - Possibility to deploy more than one bot with the same binary 13 | 14 | Requirements: 15 | [glide](https://github.com/Masterminds/glide) and [Go 1.5 or newer](http://golang.org). 16 | 17 | ### Environmental Variables 18 | 19 | Basic 20 | * `GOCHATBOT_NAME` - defines the name of the bot. It will honor this name to 21 | answer messages in chatrooms. 22 | 23 | Durable Memory (BoltDB) 24 | * `GOCHATBOT_BOLT_FILENAME` - local file name of BoltDB file. Set it if you 25 | want BoltDB support activated. 26 | 27 | Durable Memory (Redis) 28 | * `GOCHATBOT_REDIS_DATABASE` - Database index 29 | * `GOCHATBOT_REDIS_HOST` - Redis Hostname (with port) 30 | * `GOCHATBOT_REDIS_PASSWORD` - Redis database password 31 | 32 | IRC message provider 33 | * `GOCHATBOT_IRC_USER` - IRC user to connect to the server 34 | * `GOCHATBOT_IRC_NICK` - IRC nickname for this account (will not handle nick 35 | renaming) 36 | * `GOCHATBOT_IRC_SERVER` - IRC server 37 | * `GOCHATBOT_IRC_CHANNELS` - comma-separated list of channels to connect to. 38 | * `GOCHATBOT_IRC_PASSWORD` - IRC password for user 39 | * `GOCHATBOT_IRC_TLS` - whether TLS connection must be used or not (0/1) 40 | 41 | Slack message provider 42 | * `GOCHATBOT_SLACK_TOKEN` - Slack user token for the chatbot 43 | 44 | Telegram message provider 45 | * `GOCHATBOT_TELEGRAM_TOKEN` - Telegram user token for the chatbot 46 | 47 | RPC 48 | * `GOCHATBOT_RPC_BIND` - local IP address to bind the RPC HTTP server 49 | 50 | ### Quick start (Compiled) 51 | 52 | ```ShellSession 53 | # go get -tags all cirello.io/gochatbot/... 54 | ``` 55 | 56 | ### Quick start (Docker version - Slack - Non-durable memory) 57 | 58 | ```ShellSession 59 | # docker run -e "GOCHATBOT_SLACK_TOKEN=xxxx-xxxx" -d ccirello/gochatbot 60 | ``` 61 | 62 | ### Quick start (Local Docker version - CLI - Non-durable memory) 63 | 64 | ```ShellSession 65 | # brew install glide 66 | # make docker 67 | # docker run -ti ccirello/gochatbot 68 | ``` 69 | (Type `gochatbot jump` and press enter. Do it twice. `docker stop` to exit.) 70 | 71 | ### Quick start (Compiled version - Telegram - BoltDB memory) 72 | 73 | ```ShellSession 74 | # make quickstart 75 | # GOCHATBOT_TELEGRAM_TOKEN=#####:... GOCHATBOT_BOLT_FILENAME=gochatbot.db ./gochatbot 76 | ``` 77 | 78 | ### Extending 79 | 80 | A more thorough manual of how to extend the bot is yet to be written. This 81 | section will cover just adding, removing and modifying rules into the two 82 | shipped rulesets: regex and cron. 83 | 84 | #### Extending regex ruleset 85 | 86 | Edit the file `rules.go` in package root directory. Look for the line starting 87 | with: `var regexRules = []regex.Rule{`. This is the beginning of the data 88 | structure that comprises all regex rules. 89 | 90 | Why regex rule? Because it uses regex to extract from the body of the message 91 | the parameters for later message parsing. 92 | 93 | All regex rules have the following structure: 94 | ```Go 95 | { 96 | `regex pattern (.*)`, `explanation of the rule`, 97 | func(bot bot.Self, msg string, args []string) []string { 98 | var ret []string 99 | 100 | // your logic comes here 101 | 102 | return ret 103 | }, 104 | }, 105 | ``` 106 | 107 | `regex pattern (.*)` - is the regex match pattern to be used against the 108 | incoming message. Note that you can tell apart the messages that are sent in the 109 | room from those sent to the chatbot, by append a Go `text/template` variable. 110 | 111 | ```Go 112 | `{{ .RobotName }} jump`, `tells the robot to jump`, // Only messages starting with bot's name will be parsed 113 | // vs 114 | `jump`, `tells the robot to jump`, // All messages whose content matches jump will be matched 115 | ``` 116 | 117 | `explanation of the rule` - is a human readable explanation of the rule, meant 118 | to be displayed with `{{ .RobotName }} help` command (if you haven't changed the 119 | bot's name, it will be `gochatbot help`). 120 | 121 | `func(bot bot.Self, msg string, args []string) []string {` - is the function 122 | which parses and reacts on the incoming message. Few important as aspects: 123 | * Each incoming message is its own goroutine. It means you can execute blocking 124 | calls and the bot will keep working as usual. 125 | * Look at [bot package](https://godoc.org/cirello.io/gochatbot/bot) documentation 126 | for what you can do with the bot. In the following example, we'll see how we 127 | can use the bot's brain to store state across messages. 128 | * `msg` is the raw version of the message. 129 | * `args` is the slice of strings that matched against the `regex pattern (.*)`. 130 | * It must return a slice of strings, even if it is empty. 131 | 132 | So, this is a practical and annotated version of the `jump` regex rule. 133 | ```Go 134 | var regexRules = []regex.Rule{ 135 | { 136 | `{{ .RobotName }} jump`, // Regex rule. No matching, so args will be empty 137 | `tells the robot to jump`, // Just a simple explanation of the rule 138 | func(bot bot.Self, msg string, args []string) []string { 139 | var ret []string 140 | 141 | ret = append(ret, "{{ .User }}, How high?") // In the outgoing messages, the text/template variable "{{ .User }}" is replaced with username. 142 | 143 | lastJumpTS := bot.MemoryRead("jump", "lastJump") // Reads from the bot's brain the last time this command was executed. 144 | ret = append(ret, fmt.Sprint("{{ .User }} (last time I jumped:", lastJumpTS, ")")) // Append this information to the outgoing messages slice. 145 | 146 | bot.MemorySave("jump", "lastJump", fmt.Sprint(time.Now())) // Saves into the bot's brain that it has just jumped. 147 | 148 | return ret 149 | }, 150 | }, 151 | } 152 | ``` 153 | 154 | #### Extending cron ruleset 155 | 156 | Edit the file `rules.go` in package root directory. Look for the line starting 157 | with: `cronRules = map[string]cron.Rule{`. This is the beginning of the data 158 | structure that comprises all cron rules. 159 | 160 | Why cron rule? Because it uses crontab format to periodically execute tasks, 161 | that may, or may not yield messages onto a chat room. 162 | 163 | All cron rules have the following structure: 164 | ```Go 165 | "job name": { 166 | "crontab format", 167 | func() []messages.Message { 168 | return []messages.Message{} 169 | }, 170 | }, 171 | ``` 172 | 173 | `"job name"` - the name of the cron task to be executed. Each cron task must be 174 | attached to a chatroom, otherwise they don't get executed. This attachment is 175 | done through the command "cron attach _task-name_" in the desired chatroom. 176 | 177 | `"crontab format"` - the crontab-like set that tells how often this rule is 178 | executed. Refer to the 179 | [cronexpr](https://github.com/gorhill/cronexpr#implementation) for the 180 | implementation. 181 | 182 | `func() []messages.Message {` - the niladic function that's executed 183 | periodically. It returns a slice of `messages.Message`. But the only required 184 | value is the `messages.Message.Message` string field. It will overwrite the 185 | other values with the context information for correct delivery. 186 | 187 | So, this is a practical and annotated version of the `good morning` cron rule. 188 | ```Go 189 | var cronRules = map[string]cron.Rule{ 190 | // name of the cron rule 191 | "message of the day": { 192 | "0 10 * * *", // every day at 10:00 193 | func() []messages.Message { 194 | return []messages.Message{ 195 | {Message: "Good morning!"}, // Returns "Good Morning" 196 | } 197 | }, 198 | }, 199 | } 200 | ``` 201 | 202 | ### Integrating with other languages (RPC) 203 | 204 | If `GOCHATBOT_RPC_BIND` is set, gochatbot will open a HTTP server in the given 205 | address and it will expose four endpoints: `/pop` and `/send` for message 206 | handling; `/memoryRead` and `/memorySave` for memory manipulation. 207 | 208 | Message endpoints use a JSON serialized version of the internal representation 209 | of messages. Thus if you get from `/pop` this: 210 | 211 | ```json 212 | { 213 | "Room":"room", 214 | "FromUserID":"fUID", 215 | "FromUserName":"fName", 216 | "ToUserID":"tUID", 217 | "ToUserName":"tName", 218 | "Message":"Message" 219 | } 220 | ``` 221 | 222 | Probably you should be inverting From* with To* and returning something like 223 | this (note the inversion of "from" with "to" values): 224 | 225 | ```json 226 | { 227 | "Room":"room", 228 | "FromUserID":"tUID", 229 | "FromUserName":"tName", 230 | "ToUserID":"fUID", 231 | "ToUserName":"fName", 232 | "Message":"Message" 233 | } 234 | ``` 235 | 236 | Check the [`rpc-example.php`](https://github.com/ccirello/gochatbot/blob/master/rpc-example.php) 237 | file for an implementation of an echo service in PHP. 238 | 239 | For memory manipulation, `/memoryRead` needs to parameters in a GET request: 240 | `GET /memoryRead?namespace=NS&key=K` where `NS` is the namespace isolating the 241 | space of memory of this plugin from the others, and `K` the key name for the 242 | content. The response is a raw string to be parsed by the plugin internals. 243 | 244 | `/memorySave` works similarly as `/memoryRead`, except it expects a POST call, 245 | and the body of the request is the raw string to be stored within the memory 246 | durable storage. 247 | 248 | 249 | ### Multibot mode 250 | 251 | You might find yourself in a situation where you want to deploy the same bot in 252 | two or more places at the same time. Perhaps, even sharing the same memory. It 253 | is possible to activate the multi-mode for gochatbot. Compile with `multi` build 254 | tag: 255 | 256 | ```ShellSession 257 | # go build -tags 'all multi' # 'multi' will use gochatbot-multi.go instead of gochatbot.go 258 | ``` 259 | 260 | Each configuration variable must be adapted with an index. Therefore: 261 | 262 | ```ShellSession 263 | # export GOCHATBOT_NAME="gochatbot" 264 | # export GOCHATBOT_BOLT_FILENAME="gochatbot.db" 265 | # export GOCHATBOT_SLACK_TOKEN="slack-token" 266 | ``` 267 | 268 | will need to be translated with an index number in the middle, like: 269 | 270 | ```ShellSession 271 | # export GOCHATBOT_0_NAME="gochatbot" 272 | # export GOCHATBOT_0_BOLT_FILENAME="gochatbot.db" 273 | # export GOCHATBOT_0_SLACK_TOKEN="slack-token" 274 | 275 | # export GOCHATBOT_1_NAME="gochatbot" 276 | # export GOCHATBOT_1_BOLT_FILENAME="gochatbot.db" 277 | # export GOCHATBOT_1_TELEGRAM_TOKEN="telegram-token" 278 | ``` 279 | 280 | (The bot will connect to both Slack and Telegram) 281 | 282 | ### Plugins 283 | 284 | Gochatbot accept external plugins to its core. The plugin interface is actually 285 | a facade for the RPC ruleset and external files. If any file named beginning 286 | with `gochatbot-plugin-` and it has the execution bit set, it will be executed 287 | by main gochatbot process with an environmental variable `GOCHATBOT_RPC_BIND` 288 | containing an IPv4 address endpoint. All endpoints available for RPC ruleset 289 | are available in this exposition. Check `example-gochatbot-plugin-logger.sh` for 290 | an example of a plugin. 291 | 292 | It also means that, as long as you are to execute the file, your plugin can be 293 | written in any language. 294 | 295 | #### Reddit plugin 296 | 297 | It is possible to track Reddit content with gochatbot. Useful when the team 298 | needs to monitor it for upcoming events, like release notifications. 299 | 300 | ``` 301 | reddit follow subreddit- follow one subreddit in a room 302 | reddit unfollow subreddit - unfollow one subreddit in a room 303 | ``` 304 | 305 | Compile `plugins/gochatbot-plugin-reddit`, and place the resulting binary in the 306 | same working directory of main gochatbot binary. 307 | 308 | ### Guarantees 309 | 310 | I guarantee that I will maintain this chatops bot for the next 2 years, provide 311 | it with updates, Github Issues support and issue updates to keep it compatible 312 | with newer Go versions. 313 | 314 | Also, I will work to my best to ensure a vibrant community around this bot, so 315 | even in the case I step down, I hope by the end of the guaranteed period, to 316 | have a project bigger than one man effort. 317 | 318 | The last day of guaranteed action on this bot is `2017-12-05`. 319 | 320 | -------------------------------------------------------------------------------- /bot/bot.go: -------------------------------------------------------------------------------- 1 | package bot // import "cirello.io/gochatbot/bot" 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | "sync" 8 | 9 | "cirello.io/gochatbot/brain" 10 | "cirello.io/gochatbot/messages" 11 | ) 12 | 13 | // Self encapsulates all the necessary state to have a robot running. Including 14 | // identity (Name). 15 | type Self struct { 16 | name string 17 | providerIn chan messages.Message 18 | providerOut chan messages.Message 19 | rules []RuleParser 20 | 21 | brain brain.Memorizer 22 | } 23 | 24 | var processOnce sync.Once // protects Process 25 | 26 | // Option type is the self-referencing method of tweaking gobot's internals. 27 | type Option func(*Self) 28 | 29 | // New creates a new gobot. 30 | func New(name string, memo brain.Memorizer, opts ...Option) *Self { 31 | s := &Self{ 32 | name: name, 33 | brain: memo, 34 | providerIn: make(chan messages.Message), 35 | providerOut: make(chan messages.Message), 36 | } 37 | log.Println("bot: applying options") 38 | for _, opt := range opts { 39 | opt(s) 40 | } 41 | return s 42 | } 43 | 44 | // Process connects the flow of incoming messages with the ruleset, and 45 | // dispatches the outgoing messages generated by the ruleset. Each message lives 46 | // in its own goroutine. 47 | func (s *Self) Process() { 48 | processOnce.Do(func() { 49 | log.Println("bot: starting main loop") 50 | for in := range s.providerIn { 51 | if strings.HasPrefix(in.Message, s.Name()+" help") { 52 | go func(self Self, msg messages.Message) { 53 | helpMsg := fmt.Sprintln("available commands:") 54 | for _, rule := range s.rules { 55 | helpMsg = fmt.Sprintln(helpMsg, rule.HelpMessage(self, in.Room)) 56 | } 57 | s.providerOut <- messages.Message{ 58 | Room: msg.Room, 59 | ToUserID: msg.FromUserID, 60 | ToUserName: msg.FromUserName, 61 | Message: helpMsg, 62 | } 63 | }(*s, in) 64 | continue 65 | } 66 | go func(self Self, msg messages.Message) { 67 | defer func() { 68 | if r := recover(); r != nil { 69 | log.Printf("panic recovered when parsing message: %#v. Panic: %v", msg, r) 70 | } 71 | }() 72 | for _, rule := range s.rules { 73 | responses := rule.ParseMessage(self, msg) 74 | for _, r := range responses { 75 | s.providerOut <- r 76 | } 77 | } 78 | }(*s, in) 79 | } 80 | }) 81 | } 82 | 83 | // MemoryRead reads an arbitraty value from the robot's Brain. 84 | func (s *Self) MemoryRead(namespace, key string) []byte { 85 | return s.brain.Read(namespace, key) 86 | } 87 | 88 | // MemorySave reads an arbitraty value from the robot's Brain. 89 | func (s *Self) MemorySave(namespace, key string, value []byte) { 90 | s.brain.Save(namespace, key, value) 91 | } 92 | 93 | // MessageProviderOut getter for message dispatch channel 94 | func (s *Self) MessageProviderOut() chan messages.Message { 95 | return s.providerOut 96 | } 97 | 98 | // Name returns robot's name - identity used for answering direct messages. 99 | func (s *Self) Name() string { 100 | return s.name 101 | } 102 | -------------------------------------------------------------------------------- /bot/options.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "log" 5 | 6 | "cirello.io/gochatbot/messages" 7 | "cirello.io/gochatbot/providers" 8 | ) 9 | 10 | // MessageProvider is the self-referencing option that plugs Message Providers 11 | // into the robot. 12 | func MessageProvider(provider providers.Provider) Option { 13 | return func(s *Self) { 14 | log.Printf("bot: changing message provider %T\n", provider) 15 | s.providerIn = provider.IncomingChannel() 16 | s.providerOut = provider.OutgoingChannel() 17 | } 18 | } 19 | 20 | // RuleParser explains the interface needed for a certain type to be considered 21 | // a valid message parsing rule. 22 | type RuleParser interface { 23 | Name() string 24 | Boot(*Self) 25 | HelpMessage(Self, string) string 26 | ParseMessage(Self, messages.Message) []messages.Message 27 | } 28 | 29 | // RegisterRuleset is the self-referencing option that plugs Rules into the robot. 30 | func RegisterRuleset(rule RuleParser) Option { 31 | return func(s *Self) { 32 | log.Printf("bot: registering ruleset %T", rule) 33 | rule.Boot(s) 34 | s.rules = append(s.rules, rule) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /brain/bolt.go: -------------------------------------------------------------------------------- 1 | // +build all bolt 2 | 3 | package brain // import "cirello.io/gochatbot/brain" 4 | 5 | import ( 6 | "log" 7 | 8 | "github.com/boltdb/bolt" 9 | ) 10 | 11 | func init() { 12 | availableDrivers = append(availableDrivers, func(getenv func(string) string) (Memorizer, bool) { 13 | log.Println("brain: trying registering bolt") 14 | memo, ok := BoltFromEnv(getenv) 15 | if ok { 16 | log.Println("brain: registered bolt") 17 | } else { 18 | log.Println("brain: if you want BoltDB enabled, please set a valid value for the environment variable", boltMemoryFilename) 19 | } 20 | return memo, ok 21 | }) 22 | } 23 | 24 | type BoltMemory struct { 25 | brain *brainMemory 26 | bolt *bolt.DB 27 | 28 | err error 29 | } 30 | 31 | const ( 32 | boltMemoryFilename = "GOCHATBOT_BOLT_FILENAME" 33 | ) 34 | 35 | func BoltFromEnv(getenv func(string) string) (*BoltMemory, bool) { 36 | fn := getenv(boltMemoryFilename) 37 | if fn == "" { 38 | return nil, false 39 | } 40 | return Bolt(fn), true 41 | } 42 | 43 | func Bolt(dbFn string) *BoltMemory { 44 | b := &BoltMemory{ 45 | brain: Brain(), 46 | } 47 | 48 | db, err := bolt.Open(dbFn, 0600, nil) 49 | if err != nil { 50 | b.err = err 51 | return nil 52 | } 53 | 54 | b.bolt = db 55 | return b 56 | } 57 | 58 | func (b *BoltMemory) Error() error { 59 | return b.err 60 | } 61 | 62 | // Save stores into Brain some arbritary value. 63 | func (b *BoltMemory) Save(ruleName, key string, value []byte) { 64 | b.brain.Save(ruleName, key, value) 65 | 66 | b.bolt.Update(func(tx *bolt.Tx) error { 67 | b, err := tx.CreateBucketIfNotExists([]byte(ruleName)) 68 | if err != nil { 69 | log.Println("bolt: error saving:", err) 70 | return err 71 | } 72 | return b.Put([]byte(key), value) 73 | }) 74 | } 75 | 76 | // Read reads from Brain some arbritary value. 77 | func (b *BoltMemory) Read(ruleName, key string) []byte { 78 | v := b.brain.Read(ruleName, key) 79 | if len(v) > 0 { 80 | return v 81 | } 82 | 83 | var found []byte 84 | b.bolt.View(func(tx *bolt.Tx) error { 85 | b := tx.Bucket([]byte(ruleName)) 86 | if b == nil { 87 | return nil 88 | } 89 | found = b.Get([]byte(key)) 90 | 91 | return nil 92 | }) 93 | 94 | return found 95 | } 96 | -------------------------------------------------------------------------------- /brain/brain.go: -------------------------------------------------------------------------------- 1 | package brain // import "cirello.io/gochatbot/brain" 2 | 3 | import "sync" 4 | 5 | type brainMemory struct { 6 | mu sync.Mutex // serializes items access 7 | items map[string][]byte // rule name + key 8 | } 9 | 10 | // Brain constructs brainMemory 11 | func Brain() *brainMemory { 12 | b := &brainMemory{ 13 | items: make(map[string][]byte), 14 | } 15 | return b 16 | } 17 | 18 | // Save stores into Brain some arbritary value. 19 | func (b *brainMemory) Save(ruleName, key string, value []byte) { 20 | b.mu.Lock() 21 | defer b.mu.Unlock() 22 | 23 | k := fullKeyName(ruleName, key) 24 | b.items[k] = value 25 | } 26 | 27 | // Read reads from Brain some arbritary value. 28 | func (b *brainMemory) Read(ruleName, key string) []byte { 29 | b.mu.Lock() 30 | defer b.mu.Unlock() 31 | 32 | k := fullKeyName(ruleName, key) 33 | v, ok := b.items[k] 34 | if !ok { 35 | return []byte{} 36 | } 37 | return v 38 | } 39 | 40 | func (b *brainMemory) Error() error { 41 | return nil 42 | } 43 | 44 | func fullKeyName(ruleName, key string) string { 45 | return "\x02" + ruleName + "\x03" + "\x02" + key + "\x03" 46 | } 47 | -------------------------------------------------------------------------------- /brain/brain_test.go: -------------------------------------------------------------------------------- 1 | package brain 2 | 3 | import "testing" 4 | 5 | func TestBrain(t *testing.T) { 6 | brain := Brain() 7 | brain.Save("fake", "key", []byte("1")) 8 | if len(brain.items) != 1 { 9 | t.Error("error storing values in Brain. Expected 1 item. Got:", len(brain.items)) 10 | } 11 | 12 | realKey := fullKeyName("fake", "key") 13 | if string(brain.items[realKey]) != "1" { 14 | t.Error("corruption of information stored in Brain. Expected 1 item. Got:", brain.items[realKey]) 15 | } 16 | 17 | impossibleValue := brain.Read("invalid", "key") 18 | if string(impossibleValue) != "" { 19 | t.Error("corruption of information stored in Brain. Expected empty slice. Got:", impossibleValue) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /brain/detect.go: -------------------------------------------------------------------------------- 1 | package brain // import "cirello.io/gochatbot/brain" 2 | 3 | var availableDrivers []func(func(string) string) (Memorizer, bool) 4 | 5 | // Memorizer interface describes functions to cross-messages memory. 6 | type Memorizer interface { 7 | Save(ruleName, key string, value []byte) 8 | Read(ruleName, key string) []byte 9 | Error() error 10 | } 11 | 12 | // Detect finds a suitable durable memory driver by analyzing the environment. 13 | func Detect(getenv func(string) string) Memorizer { 14 | for _, driver := range availableDrivers { 15 | memo, ok := driver(getenv) 16 | if ok { 17 | return memo 18 | } 19 | } 20 | return Brain() 21 | } 22 | -------------------------------------------------------------------------------- /brain/redis.go: -------------------------------------------------------------------------------- 1 | // +build all redis 2 | 3 | package brain // import "cirello.io/gochatbot/brain" 4 | 5 | import ( 6 | "log" 7 | "strconv" 8 | 9 | redis "gopkg.in/redis.v3" 10 | ) 11 | 12 | func init() { 13 | availableDrivers = append(availableDrivers, func(getenv func(string) string) (Memorizer, bool) { 14 | log.Println("brain: trying registering redis") 15 | memo, ok := RedisFromEnv(getenv) 16 | if ok { 17 | log.Println("brain: registered redis") 18 | } else { 19 | log.Println("brain: if you want Redis enabled, please set a valid value for the environment variables", redisMemoryDatabase, redisMemoryHostPort, redisMemoryPassword) 20 | } 21 | return memo, ok 22 | }) 23 | } 24 | 25 | type RedisMemory struct { 26 | brain *brainMemory 27 | db *redis.Client 28 | 29 | err error 30 | } 31 | 32 | const ( 33 | redisMemoryDatabase = "GOCHATBOT_REDIS_DATABASE" 34 | redisMemoryHostPort = "GOCHATBOT_REDIS_HOST" 35 | redisMemoryPassword = "GOCHATBOT_REDIS_PASSWORD" 36 | ) 37 | 38 | func RedisFromEnv(getenv func(string) string) (*RedisMemory, bool) { 39 | var dbID int64 = 0 40 | rawDbID := getenv(redisMemoryDatabase) 41 | if rawDbID != "" { 42 | var err error 43 | dbID, err = strconv.ParseInt(rawDbID, 10, 0) 44 | if err != nil { 45 | return nil, false 46 | } 47 | } 48 | hostPort, password := getenv(redisMemoryHostPort), getenv(redisMemoryPassword) 49 | if hostPort == "" { 50 | return nil, false 51 | } 52 | r := Redis(hostPort, password, dbID) 53 | if r.err != nil { 54 | return nil, false 55 | } 56 | return r, true 57 | } 58 | 59 | func Redis(hostPort, password string, dbID int64) *RedisMemory { 60 | r := &RedisMemory{ 61 | brain: Brain(), 62 | } 63 | 64 | r.db = redis.NewClient(&redis.Options{ 65 | Addr: hostPort, 66 | Password: password, // no password set 67 | DB: dbID, // use default DB 68 | }) 69 | 70 | _, err := r.db.Ping().Result() 71 | if err != nil { 72 | r.err = err 73 | } 74 | 75 | return r 76 | } 77 | 78 | func (r *RedisMemory) Error() error { 79 | return r.err 80 | } 81 | 82 | func (r *RedisMemory) calculateKey(ruleName, key string) string { 83 | return "\x02" + ruleName + "\x03" + "\x02" + key + "\x03" 84 | } 85 | 86 | // Save stores into Brain some arbritary value. 87 | func (r *RedisMemory) Save(ruleName, key string, value []byte) { 88 | r.brain.Save(ruleName, key, value) 89 | 90 | if err := r.db.Set(r.calculateKey(ruleName, key), value, 0).Err(); err != nil { 91 | log.Println("redis err (set):", err) 92 | r.err = err 93 | return 94 | } 95 | } 96 | 97 | // Read reads from Brain some arbritary value. 98 | func (r *RedisMemory) Read(ruleName, key string) []byte { 99 | v := r.brain.Read(ruleName, key) 100 | if len(v) > 0 { 101 | return v 102 | } 103 | 104 | found, err := r.db.Get(r.calculateKey(ruleName, key)).Result() 105 | if err != nil { 106 | log.Println("redis err (get):", err) 107 | r.err = err 108 | return nil 109 | } 110 | 111 | return []byte(found) 112 | } 113 | -------------------------------------------------------------------------------- /brain/warning.go: -------------------------------------------------------------------------------- 1 | // +build !all,!bolt,!redis 2 | 3 | package brain 4 | 5 | import "log" 6 | 7 | func init() { 8 | availableDrivers = append(availableDrivers, func(getenv func(string) string) (Memorizer, bool) { 9 | log.Println("You have not built gochatbot with any durable database.") 10 | log.Println("It will not persist state across service restarts.") 11 | log.Println("Consider rebuilding it choosing one or more of these drivers:") 12 | log.Println(" $ go build -tags bolt # Bolt") 13 | log.Println(" $ go build -tags redis # Redis") 14 | log.Println("Should you want to have all drivers available, run:") 15 | log.Println(" $ go build -tags all") 16 | return nil, false 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /cuteme.go: -------------------------------------------------------------------------------- 1 | // +build all cuteme 2 | 3 | package main 4 | 5 | import ( 6 | "encoding/json" 7 | "math/rand" 8 | 9 | "cirello.io/gochatbot/bot" 10 | "cirello.io/gochatbot/rules/regex" 11 | ) 12 | 13 | // Ported from github.com/hubot-scripts/hubot-cute-me 14 | func init() { 15 | cuteMe := func(bot bot.Self, msg string, matches []string) []string { 16 | r, err := httpGet("http://www.reddit.com/r/aww/.json") 17 | if err != nil { 18 | return []string{err.Error()} 19 | } 20 | defer r.Close() 21 | 22 | var data struct { 23 | Data struct { 24 | Children []struct { 25 | Data struct { 26 | URL string `json:"url"` 27 | } `json:"data"` 28 | } `json:"children"` 29 | } `json:"data"` 30 | } 31 | if err := json.NewDecoder(r).Decode(&data); err != nil { 32 | return []string{err.Error()} 33 | } 34 | options := data.Data.Children 35 | if len(options) == 0 { 36 | return []string{"could not find a cute thing for you."} 37 | } 38 | return []string{options[rand.Intn(len(options)-1)].Data.URL} 39 | } 40 | 41 | regexRules = append(regexRules, regex.Rule{ 42 | `unicorn chaser `, `Receive a cute thing`, 43 | cuteMe, 44 | }) 45 | 46 | regexRules = append(regexRules, regex.Rule{ 47 | `{{ .RobotName }} cute me`, `Receive a cute thing`, 48 | cuteMe, 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /example-gochatbot-plugin-logger.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # the RPC endpoint is exposed to the plugin by the environment variable: 4 | # GOCHATBOT_RPC_BIND 5 | # move this file to the same directory in which gochatbot binary is compiled in, 6 | # and rename it to a name beginning with "gochatbot-plugin-", e.g. 7 | # "gochatbot-plugin-logger.sh" 8 | # Ensure also it has been enabled with execution bit: 9 | # chmod +x gochatbot-plugin-logger.sh 10 | 11 | httpEndPoint="http://$GOCHATBOT_RPC_BIND/pop" 12 | while true; 13 | do 14 | wget $httpEndPoint -q -O - >> chatlog.txt 15 | sleep 1; 16 | done; -------------------------------------------------------------------------------- /glide.lock: -------------------------------------------------------------------------------- 1 | hash: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 2 | updated: 2015-12-14T01:43:18.995506378+01:00 3 | imports: 4 | - name: github.com/boltdb/bolt 5 | version: 6e1ca38c6a73025366cd8705553b404746ee6e63 6 | subpackages: 7 | - /... 8 | - name: github.com/gorhill/cronexpr 9 | version: a557574d6c024ed6e36acc8b610f5f211c91568a 10 | - name: github.com/jeffail/gabs 11 | version: b69c9e648a2529c8d7b927ba7f87bc7fe0b3b0ea 12 | - name: github.com/Syfaro/telegram-bot-api 13 | version: 6960b6fd7985a30c07edbf810480fe0a2bc1b7dc 14 | - name: github.com/technoweenie/multipartstreamer 15 | version: a90a01d73ae432e2611d178c18367fbaa13e0154 16 | - name: github.com/thoj/go-ircevent 17 | version: db3338ebd4546123dfcde5dfad6057d1d646a42d 18 | - name: golang.org/x/crypto 19 | version: beef0f4390813b96e8e68fd78570396d0f4751fc 20 | subpackages: 21 | - /ssh/terminal 22 | - name: golang.org/x/net 23 | version: 55cccaa02af1a99c69ba3213e33468628b61be4b 24 | - name: golang.org/x/text 25 | version: e6847002810c51f892a128333573eac5e2a62024 26 | subpackages: 27 | - /encoding 28 | - name: gopkg.in/bsm/ratelimit.v1 29 | version: fbc2d5a2daac4dc3801ee3073874800e2be6ccd1 30 | - name: gopkg.in/redis.v3 31 | version: 54a9acc11fe8e6aa91905e3c032aa8b4f871bc9b 32 | devImports: [] 33 | -------------------------------------------------------------------------------- /glide.yaml: -------------------------------------------------------------------------------- 1 | package: main 2 | import: 3 | - package: golang.org/x/net 4 | version: 55cccaa02af1a99c69ba3213e33468628b61be4b 5 | - package: golang.org/x/text 6 | version: e6847002810c51f892a128333573eac5e2a62024 7 | subpackages: 8 | - /encoding 9 | - package: golang.org/x/crypto 10 | version: beef0f4390813b96e8e68fd78570396d0f4751fc 11 | subpackages: 12 | - /ssh/terminal 13 | - package: github.com/boltdb/bolt 14 | version: 6e1ca38c6a73025366cd8705553b404746ee6e63 15 | subpackages: 16 | - /... 17 | - package: gopkg.in/redis.v3 18 | version: 54a9acc11fe8e6aa91905e3c032aa8b4f871bc9b 19 | - package: gopkg.in/bsm/ratelimit.v1 20 | version: fbc2d5a2daac4dc3801ee3073874800e2be6ccd1 21 | - package: github.com/Syfaro/telegram-bot-api 22 | version: 6960b6fd7985a30c07edbf810480fe0a2bc1b7dc 23 | - package: github.com/technoweenie/multipartstreamer 24 | version: a90a01d73ae432e2611d178c18367fbaa13e0154 25 | - package: github.com/thoj/go-ircevent 26 | version: db3338ebd4546123dfcde5dfad6057d1d646a42d 27 | - package: github.com/gorhill/cronexpr 28 | version: a557574d6c024ed6e36acc8b610f5f211c91568a 29 | - package: github.com/jeffail/gabs 30 | version: b69c9e648a2529c8d7b927ba7f87bc7fe0b3b0ea 31 | -------------------------------------------------------------------------------- /gochatbot-multi.go: -------------------------------------------------------------------------------- 1 | // +build all,multi multi 2 | 3 | package main // import "cirello.io/gochatbot" 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | "net" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "sync" 13 | 14 | "cirello.io/gochatbot/bot" 15 | "cirello.io/gochatbot/brain" 16 | "cirello.io/gochatbot/providers" 17 | "cirello.io/gochatbot/rules/cron" 18 | "cirello.io/gochatbot/rules/plugins" 19 | "cirello.io/gochatbot/rules/regex" 20 | "cirello.io/gochatbot/rules/rpc" 21 | ) 22 | 23 | func main() { 24 | 25 | var wg sync.WaitGroup 26 | var botCount int 27 | wd, err := filepath.Abs(filepath.Dir(os.Args[0])) 28 | if err != nil { 29 | log.Fatalln("plugins: error detecting working directory:", err) 30 | } 31 | 32 | for { 33 | e := &envGet{botCount} 34 | if e.getenv("GOCHATBOT_NAME") == "" { 35 | break 36 | } 37 | wg.Add(1) 38 | go func(e *envGet) { 39 | name := e.getenv("GOCHATBOT_NAME") 40 | if name == "" { 41 | name = "gochatbot" 42 | } 43 | 44 | provider := providers.Detect(e.getenv) 45 | if err := provider.Error(); err != nil { 46 | log.SetOutput(os.Stderr) 47 | log.Fatalln("error in message provider:", err) 48 | } 49 | 50 | memory := brain.Detect(e.getenv) 51 | if err := memory.Error(); err != nil { 52 | log.SetOutput(os.Stderr) 53 | log.Fatalln("error in brain memory:", err) 54 | } 55 | 56 | options := []bot.Option{ 57 | bot.MessageProvider(provider), 58 | bot.RegisterRuleset(regex.New(regexRules)), 59 | bot.RegisterRuleset(cron.New(cronRules)), 60 | bot.RegisterRuleset(plugins.New(wd)), 61 | } 62 | 63 | rpcHostAddr := e.getenv("GOCHATBOT_RPC_BIND") 64 | if rpcHostAddr != "" { 65 | l, err := net.Listen("tcp4", rpcHostAddr) 66 | if err != nil { 67 | log.Fatalf("rpc: cannot bind. err: %v", err) 68 | } 69 | options = append( 70 | options, 71 | bot.RegisterRuleset(rpc.New(l)), 72 | ) 73 | } 74 | 75 | bot.New( 76 | name, 77 | memory, 78 | options..., 79 | ).Process() 80 | wg.Done() 81 | }(e) 82 | botCount++ 83 | } 84 | wg.Wait() 85 | } 86 | 87 | type envGet struct { 88 | idx int 89 | } 90 | 91 | func (e envGet) getenv(key string) string { 92 | newKey := strings.Replace(key, "GOCHATBOT_", fmt.Sprint("GOCHATBOT_", e.idx, "_"), 1) 93 | return os.Getenv(newKey) 94 | } 95 | -------------------------------------------------------------------------------- /gochatbot.go: -------------------------------------------------------------------------------- 1 | // +build all,!multi !multi 2 | 3 | package main // import "cirello.io/gochatbot" 4 | 5 | import ( 6 | "log" 7 | "net" 8 | "os" 9 | "path/filepath" 10 | 11 | "cirello.io/gochatbot/bot" 12 | "cirello.io/gochatbot/brain" 13 | "cirello.io/gochatbot/providers" 14 | "cirello.io/gochatbot/rules/cron" 15 | "cirello.io/gochatbot/rules/plugins" 16 | "cirello.io/gochatbot/rules/regex" 17 | "cirello.io/gochatbot/rules/rpc" 18 | ) 19 | 20 | func main() { 21 | name := os.Getenv("GOCHATBOT_NAME") 22 | if name == "" { 23 | name = "gochatbot" 24 | } 25 | 26 | provider := providers.Detect(os.Getenv) 27 | if err := provider.Error(); err != nil { 28 | log.SetOutput(os.Stderr) 29 | log.Fatalln("error in message provider:", err) 30 | } 31 | 32 | memory := brain.Detect(os.Getenv) 33 | if err := memory.Error(); err != nil { 34 | log.SetOutput(os.Stderr) 35 | log.Fatalln("error in brain memory:", err) 36 | } 37 | 38 | wd, err := filepath.Abs(filepath.Dir(os.Args[0])) 39 | if err != nil { 40 | log.Fatalln("error detecting working directory:", err) 41 | } 42 | 43 | options := []bot.Option{ 44 | bot.MessageProvider(provider), 45 | bot.RegisterRuleset(regex.New(regexRules)), 46 | bot.RegisterRuleset(cron.New(cronRules)), 47 | bot.RegisterRuleset(plugins.New(wd)), 48 | } 49 | 50 | rpcHostAddr := os.Getenv("GOCHATBOT_RPC_BIND") 51 | if rpcHostAddr != "" { 52 | l, err := net.Listen("tcp4", rpcHostAddr) 53 | if err != nil { 54 | log.Fatalf("rpc: cannot bind. err: %v", err) 55 | } 56 | options = append( 57 | options, 58 | bot.RegisterRuleset(rpc.New(l)), 59 | ) 60 | } 61 | 62 | bot.New( 63 | name, 64 | memory, 65 | options..., 66 | ).Process() 67 | } 68 | -------------------------------------------------------------------------------- /messages/messages.go: -------------------------------------------------------------------------------- 1 | package messages // import "cirello.io/gochatbot/messages" 2 | 3 | // Message holds all the metadata for each sent/received message by the bot. 4 | type Message struct { 5 | Room string 6 | FromUserID string 7 | FromUserName string 8 | ToUserID string 9 | ToUserName string 10 | Message string 11 | Direct bool 12 | } 13 | -------------------------------------------------------------------------------- /plugins/gochatbot-plugin-goapp-release/release.go: -------------------------------------------------------------------------------- 1 | package main // import "cirello.io/gochatbot/plugins/gochatbot-plugin-goapp-release" 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "cirello.io/gochatbot/messages" 13 | "cirello.io/gochatbot/plugins" 14 | ) 15 | 16 | type GoAppReleasePlugin struct { 17 | comm *plugins.Comm 18 | botName string 19 | } 20 | 21 | func main() { 22 | rpcBind := os.Getenv("GOCHATBOT_RPC_BIND") 23 | if rpcBind == "" { 24 | log.Fatal("GOCHATBOT_RPC_BIND empty or not set. Cannot start plugin.") 25 | } 26 | botName := os.Getenv("GOCHATBOT_NAME") 27 | if botName == "" { 28 | log.Fatal("GOCHATBOT_NAME empty or not set. Cannot start plugin.") 29 | } 30 | 31 | r := &GoAppReleasePlugin{comm: plugins.NewComm(rpcBind), botName: botName} 32 | for { 33 | in, err := r.comm.Pop() 34 | if err != nil { 35 | log.Println("goapp-release: error popping message from gochatbot:", err) 36 | continue 37 | } 38 | if in.Message == "" { 39 | time.Sleep(1 * time.Second) 40 | } 41 | if err := r.parseMessage(in); err != nil { 42 | log.Println("goapp-release: error parsing message:", err) 43 | } 44 | } 45 | } 46 | 47 | func (r GoAppReleasePlugin) helpMessage() string { 48 | helpMsg := fmt.Sprintln(r.botName, "release - releases a google app engine project") 49 | return helpMsg 50 | } 51 | 52 | func (r *GoAppReleasePlugin) parseMessage(in *messages.Message) error { 53 | if in.Message == r.botName+" release" || in.Message == "help" || in.Message == "release help" { 54 | return r.comm.Send(&messages.Message{ 55 | Room: in.Room, 56 | ToUserID: in.FromUserID, 57 | ToUserName: in.FromUserName, 58 | Message: r.helpMessage(), 59 | }) 60 | } 61 | 62 | msg := strings.TrimSpace(in.Message) 63 | checkPrefix := r.botName + " release " 64 | if msg == "" || !strings.HasPrefix(msg, checkPrefix) { 65 | return nil 66 | } 67 | 68 | project := filepath.Clean(strings.TrimSpace(strings.TrimPrefix(msg, checkPrefix))) 69 | 70 | r.comm.Send(&messages.Message{ 71 | Room: in.Room, 72 | ToUserID: in.FromUserID, 73 | ToUserName: in.FromUserName, 74 | Message: fmt.Sprintf("git-pull project %s ...", project), 75 | }) 76 | 77 | outGit, err := exec.Command("git", "-C", project, "pull").CombinedOutput() 78 | if err != nil { 79 | return r.comm.Send(&messages.Message{ 80 | Room: in.Room, 81 | ToUserID: in.FromUserID, 82 | ToUserName: in.FromUserName, 83 | Message: fmt.Sprintf("could not do git-pull for %s: %s", project, outGit), 84 | }) 85 | } 86 | 87 | r.comm.Send(&messages.Message{ 88 | Room: in.Room, 89 | ToUserID: in.FromUserID, 90 | ToUserName: in.FromUserName, 91 | Message: fmt.Sprintf("running appcfg.py on project %s ...", project), 92 | }) 93 | 94 | outAppcfg, err := exec.Command("appcfg.py", "update", project).CombinedOutput() 95 | if err != nil { 96 | return r.comm.Send(&messages.Message{ 97 | Room: in.Room, 98 | ToUserID: in.FromUserID, 99 | ToUserName: in.FromUserName, 100 | Message: fmt.Sprintf("could not do appcfg.py for %s: %s", project, outAppcfg), 101 | }) 102 | } 103 | 104 | return r.comm.Send(&messages.Message{ 105 | Room: in.Room, 106 | ToUserID: in.FromUserID, 107 | ToUserName: in.FromUserName, 108 | Message: fmt.Sprintf("Release log:\n```\n%s\n```", outAppcfg), 109 | }) 110 | } 111 | -------------------------------------------------------------------------------- /plugins/gochatbot-plugin-ops/allowedCmds.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var allowedCmds = map[string]string{ 4 | "uptime": "get 'uptime' of all hosts of a host-group", 5 | "df -h": "get 'df -h' of all hosts of a host-group", 6 | "free -m": "get 'free -m' of all hosts of a host-group", 7 | } 8 | -------------------------------------------------------------------------------- /plugins/gochatbot-plugin-ops/ops.go: -------------------------------------------------------------------------------- 1 | package main // import "cirello.io/gochatbot/plugins/gochatbot-plugin-ops" 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net" 8 | "os" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "cirello.io/gochatbot/messages" 14 | "cirello.io/gochatbot/plugins" 15 | "cirello.io/gochatbot/plugins/gochatbot-plugin-ops/ssh" 16 | ) 17 | 18 | type sshConf struct { 19 | Username string 20 | SSHKeyFile string 21 | } 22 | 23 | type OpsPlugin struct { 24 | comm *plugins.Comm 25 | botName string 26 | 27 | cmds map[string]string 28 | 29 | mu sync.Mutex 30 | hostGroups map[string][]string 31 | hostGroupsConf map[string]sshConf 32 | } 33 | 34 | func main() { 35 | rpcBind := os.Getenv("GOCHATBOT_RPC_BIND") 36 | if rpcBind == "" { 37 | log.Fatal("GOCHATBOT_RPC_BIND empty or not set. Cannot start plugin.") 38 | } 39 | botName := os.Getenv("GOCHATBOT_NAME") 40 | if botName == "" { 41 | log.Fatal("GOCHATBOT_NAME empty or not set. Cannot start plugin.") 42 | } 43 | 44 | r := &OpsPlugin{ 45 | comm: plugins.NewComm(rpcBind), 46 | botName: botName, 47 | 48 | cmds: allowedCmds, 49 | 50 | hostGroups: make(map[string][]string), 51 | hostGroupsConf: make(map[string]sshConf), 52 | } 53 | 54 | log.Println("ops: reading from memory") 55 | memHG, err := r.comm.MemoryRead("ops", "hostGroups") 56 | if err != nil { 57 | log.Println("ops: error reading hostGroups from Bots memory") 58 | } 59 | if err := json.Unmarshal(memHG, &r.hostGroups); err == nil { 60 | log.Println("ops: hostGroups read") 61 | } 62 | 63 | memHGC, err := r.comm.MemoryRead("ops", "hostGroupsConf") 64 | if err != nil { 65 | log.Println("ops: error reading hostGroupsConf from Bots memory") 66 | } 67 | if err := json.Unmarshal(memHGC, &r.hostGroupsConf); err == nil { 68 | log.Println("ops: hostGroupsConf read") 69 | } 70 | 71 | for { 72 | in, err := r.comm.Pop() 73 | if err != nil { 74 | log.Println("ops: error popping message from gochatbot:", err) 75 | continue 76 | } 77 | if in.Message == "" { 78 | time.Sleep(1 * time.Second) 79 | } 80 | if err := r.parseMessage(in); err != nil { 81 | log.Println("ops: error parsing message:", err) 82 | } 83 | } 84 | } 85 | 86 | func (r OpsPlugin) helpMessage() string { 87 | msg := fmt.Sprintln(r.botName, "ops add host host-group - add host to host-group (the group is created at first host addition)") 88 | msg = fmt.Sprintln(msg, r.botName, "ops remove host host-group - remove host from host-group (the group is removed after last host deletion)") 89 | msg = fmt.Sprintln(msg, r.botName, "ops configure hostgroup username keyfile - configure ssh login credentials (don't provide keyfile to force the use of ssh-agent)") 90 | 91 | for cmd, desc := range r.cmds { 92 | msg = fmt.Sprintln(msg, r.botName, "ops", cmd, "-", desc) 93 | } 94 | return msg 95 | } 96 | 97 | func (r OpsPlugin) parseMessage(in *messages.Message) error { 98 | if in.Message == "help" || in.Message == "ops help" { 99 | return r.comm.Send(&messages.Message{ 100 | Room: in.Room, 101 | ToUserID: in.FromUserID, 102 | ToUserName: in.FromUserName, 103 | Message: r.helpMessage(), 104 | }) 105 | } 106 | 107 | cmd := strings.TrimSpace(strings.TrimPrefix(in.Message, r.botName)) 108 | parts := strings.Split(cmd, " ") 109 | 110 | if strings.HasPrefix(cmd, "ops add") { 111 | host, hostGroup := parts[2], parts[3] 112 | return r.comm.Send(&messages.Message{ 113 | Room: in.Room, 114 | FromUserID: in.ToUserID, 115 | FromUserName: in.ToUserName, 116 | ToUserID: in.FromUserID, 117 | ToUserName: in.FromUserName, 118 | Message: r.add(host, hostGroup), 119 | }) 120 | } else if strings.HasPrefix(cmd, "ops remove") { 121 | host, hostGroup := parts[2], parts[3] 122 | return r.comm.Send(&messages.Message{ 123 | Room: in.Room, 124 | FromUserID: in.ToUserID, 125 | FromUserName: in.ToUserName, 126 | ToUserID: in.FromUserID, 127 | ToUserName: in.FromUserName, 128 | Message: r.remove(host, hostGroup), 129 | }) 130 | } else if strings.HasPrefix(cmd, "ops configure") { 131 | var sshKeyFile string 132 | hostGroup, username := parts[2], parts[3] 133 | if len(parts) == 5 { 134 | sshKeyFile = parts[4] 135 | } 136 | return r.comm.Send(&messages.Message{ 137 | Room: in.Room, 138 | FromUserID: in.ToUserID, 139 | FromUserName: in.ToUserName, 140 | ToUserID: in.FromUserID, 141 | ToUserName: in.FromUserName, 142 | Message: r.configure(hostGroup, username, sshKeyFile), 143 | }) 144 | } else { 145 | for allowedCmd := range r.cmds { 146 | if strings.HasPrefix(cmd, strings.TrimSpace(fmt.Sprintln("ops", allowedCmd))) { 147 | hostGroup := strings.TrimSpace(strings.TrimPrefix(cmd, strings.TrimSpace(fmt.Sprintln("ops", allowedCmd)))) 148 | go r.run(in, hostGroup, allowedCmd) 149 | return r.comm.Send(&messages.Message{ 150 | Room: in.Room, 151 | FromUserID: in.ToUserID, 152 | FromUserName: in.ToUserName, 153 | ToUserID: in.FromUserID, 154 | ToUserName: in.FromUserName, 155 | Message: fmt.Sprintf("dispatched '%s' call to %s", allowedCmd, hostGroup), 156 | }) 157 | } 158 | } 159 | } 160 | 161 | return nil 162 | } 163 | 164 | func (r *OpsPlugin) add(host, hostGroup string) string { 165 | r.mu.Lock() 166 | defer r.mu.Unlock() 167 | 168 | r.hostGroups[hostGroup] = append(r.hostGroups[hostGroup], strings.TrimSpace(host)) 169 | 170 | b, err := json.Marshal(r.hostGroups) 171 | if err != nil { 172 | return fmt.Sprintf("error adding host to host group. got:", err) 173 | } 174 | r.comm.MemorySave("ops", "hostGroups", b) 175 | 176 | return fmt.Sprintln(host, "added to host group", hostGroup) 177 | } 178 | 179 | func (r *OpsPlugin) remove(host, hostGroup string) string { 180 | r.mu.Lock() 181 | defer r.mu.Unlock() 182 | 183 | if _, ok := r.hostGroups[hostGroup]; !ok { 184 | return "host group not found" 185 | } 186 | 187 | var newHG []string 188 | for _, h := range r.hostGroups[hostGroup] { 189 | if strings.TrimSpace(h) == strings.TrimSpace(host) { 190 | continue 191 | } 192 | newHG = append(newHG, host) 193 | } 194 | r.hostGroups[hostGroup] = newHG 195 | 196 | b, err := json.Marshal(r.hostGroups) 197 | if err != nil { 198 | return fmt.Sprintf("error removing host from host group. got: %v", err) 199 | } 200 | r.comm.MemorySave("ops", "hostGroups", b) 201 | 202 | return fmt.Sprintln(host, "removed from host group", hostGroup) 203 | } 204 | 205 | func (r *OpsPlugin) configure(hostGroup, username, sshKeyFile string) string { 206 | r.mu.Lock() 207 | defer r.mu.Unlock() 208 | 209 | r.hostGroupsConf[hostGroup] = sshConf{ 210 | Username: username, 211 | SSHKeyFile: sshKeyFile, 212 | } 213 | 214 | b, err := json.Marshal(r.hostGroupsConf) 215 | if err != nil { 216 | return fmt.Sprintf("error configuring host group. got: %v", err) 217 | } 218 | r.comm.MemorySave("ops", "hostGroupsConf", b) 219 | 220 | return fmt.Sprintln(hostGroup, "configured") 221 | } 222 | 223 | func (r *OpsPlugin) run(in *messages.Message, hostGroup, cmd string) { 224 | if _, ok := r.hostGroups[hostGroup]; !ok { 225 | r.comm.Send(&messages.Message{ 226 | Room: in.Room, 227 | FromUserID: in.ToUserID, 228 | FromUserName: in.ToUserName, 229 | ToUserID: in.FromUserID, 230 | ToUserName: in.FromUserName, 231 | Message: fmt.Sprintln("could not find hostgroup", hostGroup), 232 | }) 233 | return 234 | } 235 | if _, ok := r.hostGroupsConf[hostGroup]; !ok { 236 | r.comm.Send(&messages.Message{ 237 | Room: in.Room, 238 | FromUserID: in.ToUserID, 239 | FromUserName: in.ToUserName, 240 | ToUserID: in.FromUserID, 241 | ToUserName: in.FromUserName, 242 | Message: fmt.Sprintln("could not find configuration for hostgroup", hostGroup), 243 | }) 244 | return 245 | } 246 | 247 | conf := r.hostGroupsConf[hostGroup] 248 | 249 | for _, hostname := range r.hostGroups[hostGroup] { 250 | go func(conf sshConf, hostname string) { 251 | host, port, err := net.SplitHostPort(hostname) 252 | if err != nil { 253 | r.comm.Send(&messages.Message{ 254 | Room: in.Room, 255 | FromUserID: in.ToUserID, 256 | FromUserName: in.ToUserName, 257 | ToUserID: in.FromUserID, 258 | ToUserName: in.FromUserName, 259 | Message: err.Error(), 260 | }) 261 | return 262 | } 263 | authMethod := ssh.PublicKeyFile(conf.SSHKeyFile) 264 | if conf.SSHKeyFile == "" { 265 | authMethod = ssh.SshAgent(os.Getenv) 266 | } 267 | out, err := ssh.Run( 268 | cmd, 269 | []string{}, 270 | conf.Username, 271 | host, 272 | port, 273 | authMethod, 274 | ) 275 | if err != nil { 276 | r.comm.Send(&messages.Message{ 277 | Room: in.Room, 278 | FromUserID: in.ToUserID, 279 | FromUserName: in.ToUserName, 280 | ToUserID: in.FromUserID, 281 | ToUserName: in.FromUserName, 282 | Message: err.Error(), 283 | }) 284 | return 285 | } 286 | r.comm.Send(&messages.Message{ 287 | Room: in.Room, 288 | FromUserID: in.ToUserID, 289 | FromUserName: in.ToUserName, 290 | ToUserID: in.FromUserID, 291 | ToUserName: in.FromUserName, 292 | Message: fmt.Sprintf("%s: %s", hostname, out), 293 | }) 294 | }(conf, hostname) 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /plugins/gochatbot-plugin-ops/ssh/ssh.go: -------------------------------------------------------------------------------- 1 | package ssh // import "cirello.io/gochatbot/plugins/gochatbot-plugin-ops/ssh" 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net" 7 | "strings" 8 | 9 | "golang.org/x/crypto/ssh" 10 | "golang.org/x/crypto/ssh/agent" 11 | ) 12 | 13 | type sshCommand struct { 14 | command string 15 | env []string 16 | } 17 | 18 | type clientSSH struct { 19 | config *ssh.ClientConfig 20 | host string 21 | port string 22 | } 23 | 24 | func (client *clientSSH) runCommand(cmd *sshCommand) ([]byte, error) { 25 | var session *ssh.Session 26 | var err error 27 | 28 | if session, err = client.newSession(); err != nil { 29 | return []byte{}, err 30 | } 31 | defer session.Close() 32 | 33 | if err = client.prepareCommand(session, cmd); err != nil { 34 | return []byte{}, err 35 | } 36 | 37 | return session.CombinedOutput(cmd.command) 38 | } 39 | 40 | func (client *clientSSH) prepareCommand(session *ssh.Session, cmd *sshCommand) error { 41 | for _, env := range cmd.env { 42 | variable := strings.Split(env, "=") 43 | if len(variable) != 2 { 44 | continue 45 | } 46 | 47 | if err := session.Setenv(variable[0], variable[1]); err != nil { 48 | return err 49 | } 50 | } 51 | 52 | return nil 53 | } 54 | 55 | func (client *clientSSH) newSession() (*ssh.Session, error) { 56 | connection, err := ssh.Dial("tcp", net.JoinHostPort(client.host, client.port), client.config) 57 | if err != nil { 58 | return nil, fmt.Errorf("cannot dial: %s", err) 59 | } 60 | 61 | session, err := connection.NewSession() 62 | if err != nil { 63 | return nil, fmt.Errorf("cannot create session: %s", err) 64 | } 65 | 66 | modes := ssh.TerminalModes{ 67 | ssh.TTY_OP_ISPEED: 14400, 68 | ssh.TTY_OP_OSPEED: 14400, 69 | } 70 | 71 | if err := session.RequestPty("xterm", 80, 40, modes); err != nil { 72 | session.Close() 73 | return nil, fmt.Errorf("cannot request pseudo terminal: %s", err) 74 | } 75 | 76 | return session, nil 77 | } 78 | 79 | func PublicKeyFile(file string) ssh.AuthMethod { 80 | b, err := ioutil.ReadFile(file) 81 | if err != nil { 82 | return nil 83 | } 84 | 85 | key, err := ssh.ParsePrivateKey(b) 86 | if err != nil { 87 | return nil 88 | } 89 | 90 | return ssh.PublicKeys(key) 91 | } 92 | 93 | func SshAgent(getenv func(string) string) ssh.AuthMethod { 94 | if sshAgent, err := net.Dial("unix", getenv("SSH_AUTH_SOCK")); err == nil { 95 | return ssh.PublicKeysCallback(agent.NewClient(sshAgent).Signers) 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func Run(command string, env []string, username, host, port string, authMethod ssh.AuthMethod) ([]byte, error) { 102 | sshConfig := &ssh.ClientConfig{ 103 | User: username, 104 | Auth: []ssh.AuthMethod{authMethod}, 105 | } 106 | 107 | client := &clientSSH{ 108 | config: sshConfig, 109 | host: host, 110 | port: port, 111 | } 112 | if client.port == "" { 113 | client.port = "22" 114 | } 115 | 116 | cmd := &sshCommand{ 117 | command: command, 118 | env: env, 119 | } 120 | 121 | return client.runCommand(cmd) 122 | } 123 | -------------------------------------------------------------------------------- /plugins/gochatbot-plugin-reddit/reddit.go: -------------------------------------------------------------------------------- 1 | package main // import "cirello.io/gochatbot/plugins/gochatbot-plugin-reddit" 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "github.com/Jeffail/gabs" 16 | 17 | "cirello.io/gochatbot/messages" 18 | "cirello.io/gochatbot/plugins" 19 | ) 20 | 21 | const baseURL = "https://www.reddit.com/r/" 22 | 23 | type RedditPlugin struct { 24 | comm *plugins.Comm 25 | mu sync.Mutex 26 | subreddits map[string][]string 27 | recents map[string]map[string]string 28 | } 29 | 30 | func main() { 31 | rpcBind := os.Getenv("GOCHATBOT_RPC_BIND") 32 | if rpcBind == "" { 33 | log.Fatal("GOCHATBOT_RPC_BIND empty or not set. Cannot start plugin.") 34 | } 35 | r := &RedditPlugin{ 36 | comm: plugins.NewComm(rpcBind), 37 | subreddits: make(map[string][]string), 38 | recents: make(map[string]map[string]string), 39 | } 40 | r.loadMemory() 41 | go r.watch() 42 | for { 43 | in, err := r.comm.Pop() 44 | if err != nil { 45 | log.Println("reddit: error popping message from gochatbot:", err) 46 | continue 47 | } 48 | if in.Message == "" { 49 | time.Sleep(1 * time.Second) 50 | } 51 | if err := r.parseMessage(in); err != nil { 52 | log.Println("reddit: error parsing message:", err) 53 | } 54 | } 55 | } 56 | 57 | func (r *RedditPlugin) loadMemory() { 58 | log.Println("reddit: reading from memory") 59 | followMem, err := r.comm.MemoryRead("reddit", "follow") 60 | if err != nil { 61 | log.Println("reddit: error memory (follow) read:", err) 62 | return 63 | } 64 | if err := json.Unmarshal(followMem, &r.subreddits); err == nil { 65 | log.Println("reddit: memory (follow) read") 66 | } 67 | 68 | recentsMem, err := r.comm.MemoryRead("reddit", "recents") 69 | if err != nil { 70 | log.Println("reddit: error memory (recent) read:", err) 71 | return 72 | } 73 | if err := json.Unmarshal(recentsMem, &r.recents); err == nil { 74 | log.Println("reddit: memory (recent) read") 75 | } 76 | } 77 | 78 | func (r RedditPlugin) helpMessage() string { 79 | helpMsg := fmt.Sprintln("reddit follow - follow one subreddit in a room") 80 | helpMsg = fmt.Sprintln(helpMsg, "reddit unfollow - unfollow one subreddit in a room") 81 | helpMsg = fmt.Sprintln(helpMsg, "reddit list - list the followed subreddits in a room") 82 | helpMsg = fmt.Sprintln(helpMsg, "reddit help - this message") 83 | 84 | return helpMsg 85 | } 86 | 87 | func (r *RedditPlugin) parseMessage(in *messages.Message) error { 88 | if in.Message == "help" || in.Message == "reddit help" { 89 | return r.comm.Send(&messages.Message{ 90 | Room: in.Room, 91 | ToUserID: in.FromUserID, 92 | ToUserName: in.FromUserName, 93 | Message: r.helpMessage(), 94 | }) 95 | } 96 | if strings.HasPrefix(in.Message, "reddit follow") { 97 | subreddit := strings.TrimSpace(strings.TrimPrefix(in.Message, "reddit follow")) 98 | return r.comm.Send(&messages.Message{ 99 | Room: in.Room, 100 | ToUserID: in.FromUserID, 101 | ToUserName: in.FromUserName, 102 | Message: r.follow(subreddit, in.Room), 103 | }) 104 | } 105 | 106 | if strings.HasPrefix(in.Message, "reddit unfollow") { 107 | subreddit := strings.TrimSpace(strings.TrimPrefix(in.Message, "reddit unfollow")) 108 | return r.comm.Send(&messages.Message{ 109 | Room: in.Room, 110 | ToUserID: in.FromUserID, 111 | ToUserName: in.FromUserName, 112 | Message: r.unfollow(subreddit, in.Room), 113 | }) 114 | } 115 | 116 | if strings.HasPrefix(in.Message, "reddit list") { 117 | return r.comm.Send(&messages.Message{ 118 | Room: in.Room, 119 | ToUserID: in.FromUserID, 120 | ToUserName: in.FromUserName, 121 | Message: r.list(in.Room), 122 | }) 123 | } 124 | 125 | return nil 126 | } 127 | 128 | func (r *RedditPlugin) list(room string) string { 129 | r.mu.Lock() 130 | defer r.mu.Unlock() 131 | 132 | return fmt.Sprintln("followed subreddits in this room:", r.subreddits[room]) 133 | } 134 | 135 | func (r *RedditPlugin) follow(subreddit, room string) string { 136 | r.mu.Lock() 137 | defer r.mu.Unlock() 138 | 139 | if !strings.HasPrefix(subreddit, "/r/") { 140 | subreddit = fmt.Sprint("/r/", subreddit) 141 | } 142 | 143 | subredditURL, err := subredditURL(subreddit) 144 | if err != nil { 145 | return "could not follow " + subreddit 146 | } 147 | 148 | for _, sr := range r.subreddits[room] { 149 | if sr == subredditURL { 150 | return subreddit + " already followed in this room" 151 | } 152 | } 153 | 154 | r.subreddits[room] = append(r.subreddits[room], subredditURL) 155 | 156 | b, err := json.Marshal(r.subreddits) 157 | if err != nil { 158 | return "could not follow " + subreddit 159 | } 160 | r.comm.MemorySave("reddit", "follow", b) 161 | return subredditURL + " followed in this room" 162 | } 163 | 164 | func (r *RedditPlugin) unfollow(subreddit, room string) string { 165 | r.mu.Lock() 166 | defer r.mu.Unlock() 167 | 168 | if _, ok := r.subreddits[room]; !ok { 169 | return "room not found in reddit memory" 170 | } 171 | 172 | if !strings.HasPrefix(subreddit, "/r/") { 173 | subreddit = fmt.Sprint("/r/", subreddit) 174 | } 175 | 176 | url, err := subredditURL(subreddit) 177 | if err != nil { 178 | return subreddit + " cannot not be removed" 179 | } 180 | var newRoom []string 181 | for _, sr := range r.subreddits[room] { 182 | if sr == url { 183 | continue 184 | } 185 | newRoom = append(newRoom, sr) 186 | } 187 | r.subreddits[room] = newRoom 188 | 189 | b, err := json.Marshal(r.subreddits) 190 | if err != nil { 191 | return "could not unfollow " + subreddit 192 | } 193 | r.comm.MemorySave("reddit", "follow", b) 194 | 195 | return subreddit + " not followed in this room anymore" 196 | } 197 | 198 | func (r *RedditPlugin) watch() { 199 | c := time.Tick(30 * time.Second) 200 | for range c { 201 | r.mu.Lock() 202 | for room, subreddits := range r.subreddits { 203 | for _, subreddit := range subreddits { 204 | r.readSubreddit(subreddit, room) 205 | } 206 | } 207 | r.mu.Unlock() 208 | } 209 | } 210 | 211 | func (r *RedditPlugin) readSubreddit(subreddit, room string) { 212 | resp, err := http.Get(subreddit + ".json") 213 | if err != nil { 214 | log.Printf("redit: error loading subreddit %s. got: %v", subreddit, err) 215 | return 216 | } 217 | defer resp.Body.Close() 218 | 219 | body, err := ioutil.ReadAll(resp.Body) 220 | if err != nil { 221 | log.Printf("redit: error reading subreddit %s. got: %v", subreddit, err) 222 | return 223 | } 224 | 225 | jsonParsed, err := gabs.ParseJSON(body) 226 | if err != nil { 227 | log.Printf("redit: error parsing subreddit json %s. got: %v", subreddit, err) 228 | return 229 | } 230 | 231 | children, err := jsonParsed.S("data", "children").Children() 232 | if err != nil { 233 | log.Printf("redit: error parsing subreddit json %s. got: %v", subreddit, err) 234 | return 235 | } 236 | 237 | var recent string 238 | subredditName := strings.TrimSuffix(strings.TrimPrefix(subreddit, baseURL), ".json") 239 | if _, ok := r.recents[room]; !ok { 240 | r.recents[room] = make(map[string]string) 241 | } 242 | if _, ok := r.recents[room][subredditName]; !ok { 243 | r.recents[room][subredditName] = "" 244 | } 245 | 246 | for _, child := range children { 247 | title := child.Path("data.title").Data() 248 | url := child.Path("data.url").Data() 249 | 250 | if recent == "" { 251 | recent = fmt.Sprint(title, url) 252 | } 253 | 254 | if fmt.Sprint(title, url) == r.recents[room][subredditName] { 255 | break 256 | } 257 | 258 | r.comm.Send(&messages.Message{ 259 | Room: room, 260 | Message: fmt.Sprint("/r/", subredditName, ": ", title, " (", url, ")"), 261 | }) 262 | 263 | if r.recents[room][subredditName] == "" { 264 | break 265 | } 266 | } 267 | 268 | r.recents[room][subredditName] = recent 269 | 270 | b, err := json.Marshal(r.recents) 271 | if err != nil { 272 | log.Printf("redit: error serializing subreddit json. got: %v", err) 273 | } 274 | r.comm.MemorySave("reddit", "recents", b) 275 | } 276 | 277 | func subredditURL(subreddit string) (string, error) { 278 | u, err := url.Parse(strings.ToLower(subreddit)) 279 | if err != nil { 280 | return "", err 281 | } 282 | base, err := url.Parse(baseURL) 283 | if err != nil { 284 | return "", err 285 | } 286 | 287 | return base.ResolveReference(u).String(), nil 288 | } 289 | -------------------------------------------------------------------------------- /plugins/gochatbot-plugin-sentimental/sentimental.go: -------------------------------------------------------------------------------- 1 | package main // import "cirello.io/gochatbot/plugins/gochatbot-plugin-sentimental" 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "cirello.io/HumorChecker" 13 | "cirello.io/gochatbot/messages" 14 | "cirello.io/gochatbot/plugins" 15 | ) 16 | 17 | const baseURL = "https://www.reddit.com/r/" 18 | 19 | type SentimentalPlugin struct { 20 | comm *plugins.Comm 21 | botName string 22 | quiet bool 23 | } 24 | 25 | type userScore struct { 26 | Messages int 27 | Score float64 28 | Average float64 29 | } 30 | 31 | func main() { 32 | rpcBind := os.Getenv("GOCHATBOT_RPC_BIND") 33 | if rpcBind == "" { 34 | log.Fatal("GOCHATBOT_RPC_BIND empty or not set. Cannot start plugin.") 35 | } 36 | botName := os.Getenv("GOCHATBOT_NAME") 37 | if botName == "" { 38 | log.Fatal("GOCHATBOT_NAME empty or not set. Cannot start plugin.") 39 | } 40 | quiet, err := strconv.ParseBool(os.Getenv("GOCHATBOT_SENTIMENTAL_QUIET")) 41 | if err != nil { 42 | log.Println("error reading variable GOCHATBOT_SENTIMENTAL_QUIET, defaulting to false") 43 | quiet = false 44 | } 45 | 46 | r := &SentimentalPlugin{comm: plugins.NewComm(rpcBind), botName: botName, quiet: quiet} 47 | for { 48 | in, err := r.comm.Pop() 49 | if err != nil { 50 | log.Println("sentimental: error popping message from gochatbot:", err) 51 | continue 52 | } 53 | if in.Message == "" { 54 | time.Sleep(1 * time.Second) 55 | } 56 | if err := r.parseMessage(in); err != nil { 57 | log.Println("sentimental: error parsing message:", err) 58 | } 59 | } 60 | } 61 | 62 | func (r SentimentalPlugin) helpMessage() string { 63 | helpMsg := fmt.Sprintln(r.botName, "check ") 64 | helpMsg = fmt.Sprintln(helpMsg, r.botName, "check everyone") 65 | 66 | return helpMsg 67 | } 68 | 69 | func (r *SentimentalPlugin) parseMessage(in *messages.Message) error { 70 | msg := strings.TrimSpace(in.Message) 71 | checkPrefix := r.botName + " check on " 72 | if msg == "" || (strings.HasPrefix(msg, r.botName) && !strings.HasPrefix(msg, checkPrefix)) { 73 | return nil 74 | } 75 | 76 | if strings.HasPrefix(msg, checkPrefix) { 77 | scorecard, err := r.readUsersSentiment() 78 | if err != nil { 79 | return err 80 | } 81 | if len(scorecard) == 0 { 82 | return r.comm.Send(&messages.Message{ 83 | Room: in.Room, 84 | ToUserID: in.FromUserID, 85 | ToUserName: in.FromUserName, 86 | Message: "no sentiment on anybody collected yet", 87 | }) 88 | } 89 | username := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(msg), checkPrefix)) 90 | log.Printf("sentimental: checking user `%s`", username) 91 | if username == "everyone" { 92 | var out string 93 | for u, sc := range scorecard { 94 | out = fmt.Sprintln(out, u, "has a happiness average of", sc.Average) 95 | } 96 | return r.comm.Send(&messages.Message{ 97 | Room: in.Room, 98 | ToUserID: in.FromUserID, 99 | ToUserName: in.FromUserName, 100 | Message: out, 101 | }) 102 | } 103 | if _, ok := scorecard[username]; !ok && username != "everyone" { 104 | return r.comm.Send(&messages.Message{ 105 | Room: in.Room, 106 | ToUserID: in.FromUserID, 107 | ToUserName: in.FromUserName, 108 | Message: fmt.Sprintf("%s has no happiness average yet", username), 109 | }) 110 | } 111 | 112 | return r.comm.Send(&messages.Message{ 113 | Room: in.Room, 114 | ToUserID: in.FromUserID, 115 | ToUserName: in.FromUserName, 116 | Message: fmt.Sprintln(username, "has a happiness average of", scorecard[username].Average), 117 | }) 118 | 119 | } 120 | 121 | if in.FromUserName == "" { 122 | log.Println("sentimental: got empty username") 123 | return nil 124 | } 125 | 126 | scorecard, err := r.readUsersSentiment() 127 | if err != nil { 128 | return err 129 | } 130 | 131 | if _, ok := scorecard[in.FromUserName]; !ok { 132 | scorecard[in.FromUserName] = userScore{} 133 | } 134 | 135 | score := HumorChecker.Analyze(in.Message) 136 | sc := r.updateScore(scorecard[in.FromUserName], score) 137 | scorecard[in.FromUserName] = sc 138 | 139 | if err := r.storeUsersSentiment(scorecard); err != nil { 140 | return err 141 | } 142 | 143 | log.Printf("sentimental: %s now has %v / %v", in.FromUserName, sc.Score, sc.Average) 144 | 145 | if score.Score < -2 && !r.quiet { 146 | return r.comm.Send(&messages.Message{ 147 | Room: in.Room, 148 | ToUserID: in.FromUserID, 149 | ToUserName: in.FromUserName, 150 | Message: fmt.Sprintln("stay positive", in.FromUserName), 151 | }) 152 | } 153 | 154 | return nil 155 | } 156 | 157 | func (r *SentimentalPlugin) readUsersSentiment() (map[string]userScore, error) { 158 | scorecard := make(map[string]userScore) 159 | b, err := r.comm.MemoryRead("sentimental", "userScoreCard") 160 | if err != nil { 161 | return nil, err 162 | } 163 | if err := json.Unmarshal(b, &scorecard); len(b) > 0 && err != nil { 164 | return nil, err 165 | } 166 | return scorecard, nil 167 | } 168 | 169 | func (r *SentimentalPlugin) updateScore(sc userScore, score HumorChecker.FullScore) userScore { 170 | sc.Messages++ 171 | sc.Score += score.Score 172 | sc.Average = sc.Score / float64(sc.Messages) 173 | return sc 174 | } 175 | 176 | func (r *SentimentalPlugin) storeUsersSentiment(scorecard map[string]userScore) error { 177 | record, err := json.Marshal(scorecard) 178 | if err != nil { 179 | return err 180 | } 181 | return r.comm.MemorySave("sentimental", "userScoreCard", record) 182 | } 183 | -------------------------------------------------------------------------------- /plugins/gochatbot-plugin-trello/gochatbot-plugin-trello: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ucirello/gochatbot/0c4e201e78e229d909c4046f3cd774e6fcd18d83/plugins/gochatbot-plugin-trello/gochatbot-plugin-trello -------------------------------------------------------------------------------- /plugins/gochatbot-plugin-trello/trello.go: -------------------------------------------------------------------------------- 1 | package main // import "cirello.io/gochatbot/plugins/gochatbot-plugin-trello" 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "time" 8 | 9 | "cirello.io/gochatbot/messages" 10 | "cirello.io/gochatbot/plugins" 11 | "github.com/VojtechVitek/go-trello" 12 | ) 13 | 14 | type TrelloPlugin struct { 15 | comm *plugins.Comm 16 | botName string 17 | 18 | client *trello.Client 19 | board string 20 | } 21 | 22 | func main() { 23 | rpcBind := os.Getenv("GOCHATBOT_RPC_BIND") 24 | if rpcBind == "" { 25 | log.Fatal("GOCHATBOT_RPC_BIND empty or not set. Cannot start plugin.") 26 | } 27 | botName := os.Getenv("GOCHATBOT_NAME") 28 | if botName == "" { 29 | log.Fatal("GOCHATBOT_NAME empty or not set. Cannot start plugin.") 30 | } 31 | 32 | trelloKey := os.Getenv("GOCHABOT_TRELLO_KEY") 33 | if trelloKey == "" { 34 | log.Fatal("GOCHABOT_TRELLO_KEY empty or not set. Cannot start plugin.") 35 | } 36 | trelloToken := os.Getenv("GOCHABOT_TRELLO_TOKEN") 37 | if trelloToken == "" { 38 | log.Fatal("GOCHABOT_TRELLO_TOKEN empty or not set. Cannot start plugin.") 39 | } 40 | trelloBoard := os.Getenv("GOCHABOT_TRELLO_BOARD") 41 | if trelloBoard == "" { 42 | log.Fatal("GOCHABOT_TRELLO_BOARD empty or not set. Cannot start plugin.") 43 | } 44 | 45 | tc, err := trello.NewAuthClient(trelloKey, &trelloToken) 46 | if err != nil { 47 | log.Fatal("could not connect to Trello") 48 | } 49 | 50 | r := &TrelloPlugin{ 51 | comm: plugins.NewComm(rpcBind), 52 | botName: botName, 53 | client: tc, 54 | board: trelloBoard, 55 | } 56 | for { 57 | in, err := r.comm.Pop() 58 | if err != nil { 59 | log.Println("trello error popping message from gochatbot:", err) 60 | continue 61 | } 62 | if in.Message == "" { 63 | time.Sleep(1 * time.Second) 64 | } 65 | if err := r.parseMessage(in); err != nil { 66 | log.Println("trello error parsing message:", err) 67 | } 68 | } 69 | } 70 | 71 | func (r TrelloPlugin) helpMessage() string { 72 | helpMsg := fmt.Sprintln(r.botName, "new ") 73 | helpMsg = fmt.Sprintln(helpMsg, r.botName, "list ") 74 | helpMsg = fmt.Sprintln(helpMsg, r.botName, "move ") 75 | 76 | return helpMsg 77 | } 78 | 79 | func (r *TrelloPlugin) parseMessage(in *messages.Message) error { 80 | // return r.comm.Send(&messages.Message{ 81 | // Room: in.Room, 82 | // ToUserID: in.FromUserID, 83 | // ToUserName: in.FromUserName, 84 | // Message: fmt.Sprintln("stay positive", in.FromUserName), 85 | // }) 86 | 87 | return nil 88 | } 89 | 90 | func (r *TrelloPlugin) createCard(in *messages.Message, list, card string) *messages.Message { 91 | return nil 92 | } 93 | 94 | func (r *TrelloPlugin) showCards(in *messages.Message, list string) *messages.Message { 95 | return nil 96 | } 97 | 98 | func (r *TrelloPlugin) moveCard(in *messages.Message, cardId, list string) *messages.Message { 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /plugins/utils.go: -------------------------------------------------------------------------------- 1 | package plugins // import "cirello.io/gochatbot/plugins" 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | 11 | "cirello.io/gochatbot/messages" 12 | ) 13 | 14 | type Comm struct { 15 | rpcAddr string 16 | } 17 | 18 | func NewComm(rpcAddr string) *Comm { 19 | return &Comm{rpcAddr} 20 | } 21 | 22 | func (p Comm) Pop() (*messages.Message, error) { 23 | resp, err := http.Get(fmt.Sprint("http://", p.rpcAddr, "/pop")) 24 | if err != nil { 25 | return nil, fmt.Errorf("error talking to gochatbot (pop): %v", err) 26 | } 27 | defer resp.Body.Close() 28 | 29 | var msg messages.Message 30 | if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil { 31 | return nil, fmt.Errorf("error parsing message from gochatbot: %v", err) 32 | } 33 | 34 | return &msg, nil 35 | } 36 | 37 | func (p Comm) Send(msg *messages.Message) error { 38 | var buf bytes.Buffer 39 | if err := json.NewEncoder(&buf).Encode(&msg); err != nil { 40 | return fmt.Errorf("error serializing message to gochatbot: %v", err) 41 | } 42 | 43 | resp, err := http.Post(fmt.Sprint("http://", p.rpcAddr, "/send"), "application/octet-stream", &buf) 44 | if err != nil { 45 | return fmt.Errorf("error talking to gochatbot (send): %v", err) 46 | } 47 | defer resp.Body.Close() 48 | 49 | return nil 50 | } 51 | 52 | func (p Comm) MemoryRead(ns, key string) ([]byte, error) { 53 | resp, err := http.Get( 54 | fmt.Sprintf("http://%s/memoryRead?namespace=%s&key=%s", p.rpcAddr, url.QueryEscape(ns), url.QueryEscape(key)), 55 | ) 56 | if err != nil { 57 | return []byte{}, fmt.Errorf("error talking to gochatbot (memory read): %v", err) 58 | } 59 | defer resp.Body.Close() 60 | 61 | b, err := ioutil.ReadAll(resp.Body) 62 | if err != nil { 63 | return []byte{}, fmt.Errorf("error reading memory from gochatbot: %v", err) 64 | } 65 | 66 | return b, nil 67 | } 68 | 69 | func (p Comm) MemorySave(ns, key string, content []byte) error { 70 | buf := bytes.NewReader(content) 71 | resp, err := http.Post( 72 | fmt.Sprintf("http://%s/memorySave?namespace=%s&key=%s", p.rpcAddr, url.QueryEscape(ns), url.QueryEscape(key)), 73 | "application/octet-stream", 74 | buf, 75 | ) 76 | defer resp.Body.Close() 77 | 78 | if err != nil { 79 | return fmt.Errorf("error talking to gochatbot (memory save): %v", err) 80 | } 81 | 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /providers/cli.go: -------------------------------------------------------------------------------- 1 | package providers // import "cirello.io/gochatbot/providers" 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "html/template" 8 | "io" 9 | "os" 10 | 11 | "cirello.io/gochatbot/messages" 12 | ) 13 | 14 | var ( 15 | stdin io.Reader = os.Stdin 16 | outPrompt io.Writer = os.Stdout 17 | ) 18 | 19 | type providerCLI struct { 20 | in chan messages.Message 21 | out chan messages.Message 22 | } 23 | 24 | // CLI is the message provider meant to be used in development of rule sets. 25 | func CLI() *providerCLI { 26 | cli := &providerCLI{ 27 | in: make(chan messages.Message), 28 | out: make(chan messages.Message), 29 | } 30 | go cli.loop() 31 | return cli 32 | } 33 | 34 | func (c *providerCLI) IncomingChannel() chan messages.Message { 35 | return c.in 36 | } 37 | 38 | func (c *providerCLI) OutgoingChannel() chan messages.Message { 39 | return c.out 40 | } 41 | 42 | func (c *providerCLI) Error() error { 43 | return nil 44 | } 45 | 46 | func (c *providerCLI) loop() { 47 | go func() { 48 | scanner := bufio.NewScanner(stdin) 49 | for scanner.Scan() { 50 | c.in <- messages.Message{ 51 | Room: "CLI", 52 | FromUserID: "CLI", 53 | FromUserName: "CLI", 54 | Message: scanner.Text(), 55 | } 56 | forLoop: 57 | for { 58 | select { 59 | case msg := <-c.out: 60 | fmt.Fprint(outPrompt, processOutMessage(msg)) 61 | default: 62 | break forLoop 63 | } 64 | } 65 | } 66 | }() 67 | go func() { 68 | for msg := range c.out { 69 | fmt.Fprint(outPrompt, processOutMessage(msg)) 70 | } 71 | }() 72 | } 73 | 74 | func processOutMessage(msg messages.Message) string { 75 | var finalMsg bytes.Buffer 76 | template.Must(template.New("tmpl").Parse(msg.Message)).Execute(&finalMsg, struct{ User string }{msg.ToUserID}) 77 | 78 | return fmt.Sprintln("\nout:>", msg.Room, msg.ToUserID, msg.ToUserName, ":", finalMsg.String()) 79 | } 80 | -------------------------------------------------------------------------------- /providers/cli_test.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "cirello.io/gochatbot/messages" 10 | ) 11 | 12 | func TestProviderCLI(t *testing.T) { 13 | const rawMsg = "hello world" 14 | stdin = strings.NewReader(rawMsg) 15 | var buf bytes.Buffer 16 | outPrompt = &buf 17 | 18 | cli := CLI() 19 | 20 | inMsg := <-cli.IncomingChannel() 21 | if inMsg.Message != rawMsg { 22 | t.Error("CLI provider not ingesting incoming messages") 23 | } 24 | 25 | outChan := cli.OutgoingChannel() 26 | outChan <- messages.Message{Room: "room", ToUserID: "uid", ToUserName: "name", Message: rawMsg} 27 | close(outChan) 28 | 29 | to := time.After(5 * time.Second) 30 | for buf.Len() == 0 { 31 | select { 32 | case <-to: 33 | t.Fatal("could not read output buffer") 34 | default: 35 | } 36 | } 37 | 38 | const expectedOutPrompt = "\nout:> room uid name : hello world\n" 39 | gotOut := buf.String() 40 | if expectedOutPrompt != gotOut { 41 | t.Errorf("wrong output prompt. Expected output:%v. Got: %v", expectedOutPrompt, gotOut) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /providers/irc.go: -------------------------------------------------------------------------------- 1 | // +build all irc 2 | 3 | package providers 4 | 5 | import ( 6 | "bytes" 7 | "crypto/tls" 8 | "fmt" 9 | "log" 10 | "net" 11 | "strconv" 12 | "strings" 13 | "text/template" 14 | 15 | "cirello.io/gochatbot/messages" 16 | 17 | ircevent "github.com/thoj/go-ircevent" 18 | ) 19 | 20 | // IRC message provider configuration environment variables. 21 | const ( 22 | IrcUserEnvVarName = "GOCHATBOT_IRC_USER" 23 | IrcNickEnvVarName = "GOCHATBOT_IRC_NICK" 24 | IrcServerEnvVarName = "GOCHATBOT_IRC_SERVER" 25 | IrcChannelsEnvVarName = "GOCHATBOT_IRC_CHANNELS" 26 | IrcPasswordEnvVarName = "GOCHATBOT_IRC_PASSWORD" 27 | IrcTLSEnvVarName = "GOCHATBOT_IRC_TLS" 28 | ) 29 | 30 | func init() { 31 | availableProviders = append(availableProviders, func(getenv func(string) string) (Provider, bool) { 32 | user := getenv(IrcUserEnvVarName) 33 | nick := getenv(IrcNickEnvVarName) 34 | server := getenv(IrcServerEnvVarName) 35 | channels := getenv(IrcChannelsEnvVarName) 36 | password := getenv(IrcPasswordEnvVarName) 37 | tls := getenv(IrcTLSEnvVarName) 38 | 39 | if server == "" || channels == "" || user == "" || nick == "" { 40 | log.Println("providers: skipping IRC. If you want IRC enabled, please set a valid value for the environment variables", IrcUserEnvVarName, IrcNickEnvVarName, IrcServerEnvVarName, IrcChannelsEnvVarName) 41 | return nil, false 42 | } 43 | return IRC(user, nick, server, channels, password, tls), true 44 | }) 45 | } 46 | 47 | type providerIRC struct { 48 | channels []string 49 | ircConn *ircevent.Connection 50 | in chan messages.Message 51 | out chan messages.Message 52 | err error 53 | } 54 | 55 | // IRC returns the IRC message provider 56 | func IRC(user, nick, server, channels, password, useTLS string) *providerIRC { 57 | pi := &providerIRC{ 58 | channels: strings.Split(channels, ","), 59 | in: make(chan messages.Message), 60 | out: make(chan messages.Message), 61 | } 62 | 63 | ircConn := ircevent.IRC(user, nick) 64 | ircConn.Password = password 65 | 66 | ircConn.UseTLS = false 67 | if useTLS != "" { 68 | log.Println("irc: activating TLS") 69 | serverName, _, err := net.SplitHostPort(server) 70 | if err != nil { 71 | pi.err = fmt.Errorf("error spliting host port: %v", err) 72 | return pi 73 | } 74 | 75 | useTLSBool, err := strconv.ParseBool(useTLS) 76 | if err != nil { 77 | pi.err = fmt.Errorf("error parsing bool (useTLS): %v", err) 78 | return pi 79 | } 80 | ircConn.UseTLS = useTLSBool 81 | if useTLSBool { 82 | ircConn.TLSConfig = &tls.Config{ 83 | ServerName: serverName, 84 | } 85 | } 86 | } 87 | 88 | ircConn.AddCallback( 89 | "001", 90 | func(e *ircevent.Event) { 91 | for _, channel := range pi.channels { 92 | ircConn.Join(channel) 93 | } 94 | }, 95 | ) 96 | 97 | ircConn.AddCallback("PRIVMSG", func(e *ircevent.Event) { 98 | msg := messages.Message{ 99 | Room: e.Arguments[0], 100 | FromUserName: e.Nick, 101 | Message: e.Message(), 102 | // Direct: strings.HasPrefix(data.Channel, "D"), 103 | } 104 | pi.in <- msg 105 | }) 106 | 107 | if err := ircConn.Connect(server); err != nil { 108 | pi.err = fmt.Errorf("error connecting to server (%s): %v", server, err) 109 | return pi 110 | } 111 | pi.ircConn = ircConn 112 | log.Println("irc: starting message loops") 113 | go pi.ircConn.Loop() 114 | go pi.dispatchLoop() 115 | return pi 116 | } 117 | 118 | func (p *providerIRC) dispatchLoop() { 119 | for msg := range p.out { 120 | channel := msg.Room 121 | if p.ircConn.GetNick() == msg.Room { 122 | channel = msg.FromUserName 123 | } 124 | 125 | var finalMsg bytes.Buffer 126 | template.Must(template.New("tmpl").Parse(msg.Message)).Execute(&finalMsg, struct{ User string }{msg.ToUserName}) 127 | 128 | if strings.TrimSpace(finalMsg.String()) == "" { 129 | continue 130 | } 131 | 132 | msgs := strings.Split(finalMsg.String(), "\n") 133 | for _, m := range msgs { 134 | p.ircConn.Privmsg(channel, m) 135 | } 136 | } 137 | } 138 | 139 | func (p *providerIRC) IncomingChannel() chan messages.Message { 140 | return p.in 141 | } 142 | 143 | func (p *providerIRC) OutgoingChannel() chan messages.Message { 144 | return p.out 145 | } 146 | 147 | func (p *providerIRC) Error() error { 148 | return p.err 149 | } 150 | -------------------------------------------------------------------------------- /providers/providers.go: -------------------------------------------------------------------------------- 1 | package providers // import "cirello.io/gochatbot/providers" 2 | 3 | import ( 4 | "log" 5 | 6 | "cirello.io/gochatbot/messages" 7 | ) 8 | 9 | // Provider explains the interface for pluggable message providers (CLI, Slack, 10 | // IRC etc.) 11 | type Provider interface { 12 | IncomingChannel() chan messages.Message 13 | OutgoingChannel() chan messages.Message 14 | Error() error 15 | } 16 | 17 | var availableProviders []func(func(string) string) (Provider, bool) 18 | 19 | // Detect try all available providers, and return the one which manages to 20 | // configure itself by inspecting the environment. If all fail, them CLI is 21 | // returned. 22 | func Detect(getenv func(string) string) Provider { 23 | for _, ap := range availableProviders { 24 | if ret, ok := ap(getenv); ok { 25 | if ret.Error() != nil { 26 | log.Printf("providers: %T %v", ret, ret.Error()) 27 | continue 28 | } 29 | return ret 30 | } 31 | } 32 | log.Println("providers: no message provider found.") 33 | log.Println("providers: falling back to CLI.") 34 | return CLI() 35 | } 36 | -------------------------------------------------------------------------------- /providers/slack.go: -------------------------------------------------------------------------------- 1 | // +build all slack 2 | 3 | package providers 4 | 5 | import ( 6 | "bytes" 7 | "encoding/json" 8 | "fmt" 9 | "html" 10 | "html/template" 11 | "log" 12 | "net/http" 13 | "net/url" 14 | "strings" 15 | "sync" 16 | "time" 17 | 18 | "golang.org/x/net/websocket" 19 | 20 | "cirello.io/gochatbot/messages" 21 | ) 22 | 23 | const ( 24 | slackEnvVarName = "GOCHATBOT_SLACK_TOKEN" 25 | urlSlackAPI = "https://slack.com/api/" 26 | ) 27 | 28 | func init() { 29 | availableProviders = append(availableProviders, func(getenv func(string) string) (Provider, bool) { 30 | token := getenv(slackEnvVarName) 31 | if token == "" { 32 | log.Println("providers: skipping Slack. if you want Slack enabled, please set a valid value for the environment variables", slackEnvVarName) 33 | return nil, false 34 | } 35 | return Slack(token), true 36 | }) 37 | } 38 | 39 | type providerSlack struct { 40 | token string 41 | wsURL string 42 | selfID string 43 | wsConnMu sync.Mutex 44 | wsConn *websocket.Conn 45 | 46 | in chan messages.Message 47 | out chan messages.Message 48 | err error 49 | 50 | mu sync.Mutex 51 | usernames map[string]string 52 | } 53 | 54 | // Slack is the message provider meant to be used in development of rule sets. 55 | func Slack(token string) *providerSlack { 56 | slack := &providerSlack{ 57 | token: token, 58 | in: make(chan messages.Message), 59 | out: make(chan messages.Message), 60 | usernames: make(map[string]string), 61 | } 62 | slack.handshake() 63 | slack.dial() 64 | if slack.err == nil { 65 | go slack.intakeLoop() 66 | go slack.dispatchLoop() 67 | } 68 | go slack.reconnect() 69 | return slack 70 | } 71 | 72 | func (p *providerSlack) IncomingChannel() chan messages.Message { 73 | return p.in 74 | } 75 | 76 | func (p *providerSlack) OutgoingChannel() chan messages.Message { 77 | return p.out 78 | } 79 | 80 | func (p *providerSlack) Error() error { 81 | return p.err 82 | } 83 | 84 | func (p *providerSlack) handshake() { 85 | log.Println("slack: connecting to HTTP API handshake interface") 86 | resp, err := http.Get(fmt.Sprint(urlSlackAPI, "rtm.start?no_unreads&simple_latest&token=", p.token)) 87 | if err != nil { 88 | p.err = err 89 | return 90 | } 91 | defer resp.Body.Close() 92 | var data struct { 93 | OK interface{} `json:"ok"` 94 | URL string `json:"url"` 95 | Self struct { 96 | ID string `json:"id"` 97 | } `json:"self"` 98 | } 99 | if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { 100 | p.err = err 101 | return 102 | } 103 | 104 | switch v := data.OK.(type) { 105 | case bool: 106 | if !v { 107 | p.err = err 108 | return 109 | } 110 | default: 111 | p.err = err 112 | return 113 | } 114 | p.wsURL = data.URL 115 | p.selfID = data.Self.ID 116 | } 117 | 118 | func (p *providerSlack) dial() { 119 | log.Println("slack: dialing to HTTP WS rtm interface") 120 | if p.wsURL == "" { 121 | p.err = fmt.Errorf("could not connnect to Slack HTTP WS rtm. please, check your connection and your token (%s). error: %v", slackEnvVarName, p.err) 122 | return 123 | } 124 | ws, err := websocket.Dial(p.wsURL, "", urlSlackAPI) 125 | if err != nil { 126 | p.err = err 127 | return 128 | } 129 | p.wsConnMu.Lock() 130 | p.wsConn = ws 131 | p.wsConnMu.Unlock() 132 | } 133 | 134 | func (p *providerSlack) intakeLoop() { 135 | log.Println("slack: started message intake loop") 136 | for { 137 | var data struct { 138 | Type string `json:"type"` 139 | Channel string `json:"channel"` 140 | UserID string `json:"user"` 141 | Text string `json:"text"` 142 | } 143 | 144 | p.wsConnMu.Lock() 145 | wsConn := p.wsConn 146 | p.wsConnMu.Unlock() 147 | 148 | if err := json.NewDecoder(wsConn).Decode(&data); err != nil { 149 | continue 150 | } 151 | 152 | if data.Type != "message" { 153 | continue 154 | } 155 | 156 | msg := messages.Message{ 157 | Room: data.Channel, 158 | FromUserID: data.UserID, 159 | FromUserName: p.getUserName(data.UserID), 160 | Message: data.Text, 161 | Direct: strings.HasPrefix(data.Channel, "D"), 162 | } 163 | p.in <- msg 164 | } 165 | } 166 | 167 | func (p *providerSlack) getUserName(id string) string { 168 | p.mu.Lock() 169 | if name, ok := p.usernames[id]; ok { 170 | p.mu.Unlock() 171 | return name 172 | } 173 | p.mu.Unlock() 174 | 175 | log.Println("slack: reading username from id") 176 | resp, err := http.Get(fmt.Sprint(urlSlackAPI, "users.info?token=", p.token, "&user=", url.QueryEscape(id))) 177 | if err != nil { 178 | log.Println("slack: failed reading username - returning blank") 179 | return "" 180 | } 181 | defer resp.Body.Close() 182 | 183 | var data struct { 184 | OK interface{} `json:"ok"` 185 | User struct { 186 | Name string `json:"name"` 187 | } `json:"user"` 188 | } 189 | if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { 190 | log.Println("slack: failed parsing username - returning blank") 191 | return "" 192 | } 193 | 194 | p.mu.Lock() 195 | p.usernames[id] = data.User.Name 196 | p.mu.Unlock() 197 | 198 | log.Printf("slack: %s is %v", id, data.User.Name) 199 | return p.usernames[id] 200 | } 201 | 202 | func (p *providerSlack) dispatchLoop() { 203 | log.Println("slack: started message dispatch loop") 204 | for msg := range p.out { 205 | // TODO(ccf): find a way in that text/template does not escape username DMs. 206 | var finalMsg bytes.Buffer 207 | template.Must(template.New("tmpl").Parse(msg.Message)).Execute(&finalMsg, struct{ User string }{"<@" + msg.ToUserID + ">"}) 208 | 209 | if strings.TrimSpace(finalMsg.String()) == "" { 210 | continue 211 | } 212 | 213 | data := struct { 214 | Type string `json:"type"` 215 | User string `json:"user"` 216 | Channel string `json:"channel"` 217 | Text string `json:"text"` 218 | }{"message", p.selfID, msg.Room, html.UnescapeString(finalMsg.String())} 219 | 220 | // TODO(ccf): look for an idiomatic way of doing limited writers 221 | b, err := json.Marshal(data) 222 | if err != nil { 223 | continue 224 | } 225 | 226 | wsMsg := string(b) 227 | if len(wsMsg) > 16*1024 { 228 | continue 229 | } 230 | 231 | p.wsConnMu.Lock() 232 | wsConn := p.wsConn 233 | p.wsConnMu.Unlock() 234 | 235 | fmt.Fprint(wsConn, wsMsg) 236 | 237 | time.Sleep(1 * time.Second) // https://api.slack.com/docs/rate-limits 238 | } 239 | } 240 | 241 | func (p *providerSlack) reconnect() { 242 | for { 243 | time.Sleep(1 * time.Second) 244 | 245 | p.wsConnMu.Lock() 246 | wsConn := p.wsConn 247 | p.wsConnMu.Unlock() 248 | 249 | if wsConn == nil { 250 | log.Println("slack: cannot reconnect") 251 | break 252 | } 253 | 254 | if _, err := wsConn.Write([]byte(`{"type":"hello"}`)); err != nil { 255 | log.Printf("slack: reconnecting (%v)", err) 256 | p.handshake() 257 | p.dial() 258 | } 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /providers/telegram.go: -------------------------------------------------------------------------------- 1 | // +build all telegram 2 | 3 | package providers 4 | 5 | import ( 6 | "bytes" 7 | "log" 8 | "strconv" 9 | "strings" 10 | "text/template" 11 | "time" 12 | 13 | "cirello.io/gochatbot/messages" 14 | tgbotapi "github.com/Syfaro/telegram-bot-api" 15 | ) 16 | 17 | const ( 18 | telegramEnvVarName = "GOCHATBOT_TELEGRAM_TOKEN" 19 | ) 20 | 21 | func init() { 22 | availableProviders = append(availableProviders, func(getenv func(string) string) (Provider, bool) { 23 | token := getenv(telegramEnvVarName) 24 | if token == "" { 25 | log.Println("providers: skipping Telegram. if you want Telegram enabled, please set a valid value for the environment variables", telegramEnvVarName) 26 | return nil, false 27 | } 28 | return Telegram(token), true 29 | }) 30 | } 31 | 32 | type providerTelegram struct { 33 | token string 34 | tg *tgbotapi.BotAPI 35 | 36 | in chan messages.Message 37 | out chan messages.Message 38 | err error 39 | } 40 | 41 | // Telegram is the message provider meant to be used in development of rule sets. 42 | func Telegram(token string) *providerTelegram { 43 | telegram := &providerTelegram{ 44 | token: token, 45 | in: make(chan messages.Message), 46 | out: make(chan messages.Message), 47 | } 48 | 49 | tg, err := tgbotapi.NewBotAPI(token) 50 | if err != nil { 51 | telegram.err = err 52 | return telegram 53 | } 54 | 55 | log.Println("telegram: logged as", tg.Self.UserName) 56 | telegram.tg = tg 57 | 58 | go telegram.intakeLoop() 59 | go telegram.dispatchLoop() 60 | return telegram 61 | } 62 | 63 | func (p *providerTelegram) IncomingChannel() chan messages.Message { 64 | return p.in 65 | } 66 | 67 | func (p *providerTelegram) OutgoingChannel() chan messages.Message { 68 | return p.out 69 | } 70 | 71 | func (p *providerTelegram) Error() error { 72 | return p.err 73 | } 74 | 75 | func (p *providerTelegram) intakeLoop() { 76 | u := tgbotapi.NewUpdate(0) 77 | u.Timeout = 60 78 | updates, err := p.tg.GetUpdatesChan(u) 79 | if err != nil { 80 | p.err = err 81 | return 82 | } 83 | 84 | go func(upds <-chan tgbotapi.Update) { 85 | log.Println("telegram: started message intake loop") 86 | for { 87 | for update := range upds { 88 | senderID := strconv.Itoa(update.Message.From.ID) 89 | senderName := update.Message.From.FirstName 90 | targetID := strconv.FormatInt(update.Message.Chat.ID, 10) 91 | targetName := update.Message.Chat.FirstName 92 | msg := messages.Message{ 93 | Room: "", 94 | FromUserID: senderID, 95 | FromUserName: senderName, 96 | ToUserID: targetID, 97 | ToUserName: targetName, 98 | Message: update.Message.Text, 99 | // todo(carlos): do DM detection 100 | // Direct: strings.HasPrefix(data.Channel, "D"), 101 | } 102 | p.in <- msg 103 | } 104 | } 105 | }(updates) 106 | } 107 | 108 | func (p *providerTelegram) dispatchLoop() { 109 | log.Println("telegram: started message dispatch loop") 110 | for msg := range p.out { 111 | id, err := strconv.ParseInt(msg.ToUserID, 10, 64) 112 | if err != nil { 113 | continue 114 | } 115 | var finalMsg bytes.Buffer 116 | template.Must(template.New("tmpl").Parse(msg.Message)).Execute(&finalMsg, struct{ User string }{"@" + msg.ToUserName}) 117 | 118 | if strings.TrimSpace(finalMsg.String()) == "" { 119 | continue 120 | } 121 | 122 | p.tg.Send(tgbotapi.NewMessage(id, finalMsg.String())) 123 | time.Sleep(1 * time.Second) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /rpc-example.php: -------------------------------------------------------------------------------- 1 | [ 28 | 'header' => "Content-type: application/json\r\n", 29 | 'method' => 'POST', 30 | 'content' => $json, 31 | ], 32 | ]; 33 | $context = stream_context_create($options); 34 | return file_get_contents($url, false, $context); 35 | } 36 | 37 | while (true) { 38 | $msg = botPop($rpcServer); 39 | if (empty($msg['Message'])) { 40 | continue; 41 | } 42 | echo 'Got:', PHP_EOL; 43 | print_r($msg); 44 | 45 | $newMsg = [ 46 | 'Room' => $msg['Room'], 47 | 'FromUserID' => $msg['ToUserID'], 48 | 'FromUserName' => $msg['ToUserName'], 49 | 'ToUserID' => $msg['FromUserID'], 50 | 'ToUserName' => $msg['FromUserName'], 51 | 'Message' => 'echo: ' . $msg['Message'], 52 | ]; 53 | 54 | echo 'Sending:', PHP_EOL; 55 | print_r($newMsg); 56 | 57 | botSend($rpcServer, $newMsg); 58 | } -------------------------------------------------------------------------------- /rules.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "cirello.io/gochatbot/bot" 14 | "cirello.io/gochatbot/messages" 15 | "cirello.io/gochatbot/rules/cron" 16 | "cirello.io/gochatbot/rules/regex" 17 | ) 18 | 19 | var cronRules = map[string]cron.Rule{ 20 | "message of the day": { 21 | "0 10 * * *", 22 | func() []messages.Message { 23 | return []messages.Message{ 24 | {Message: "Good morning!"}, 25 | } 26 | }, 27 | }, 28 | } 29 | 30 | var regexRules = []regex.Rule{ 31 | { 32 | `{{ .RobotName }} jump`, `tells the robot to jump`, 33 | func(bot bot.Self, msg string, matches []string) []string { 34 | var ret []string 35 | ret = append(ret, "{{ .User }}, How high?") 36 | lastJumpTS := bot.MemoryRead("jump", "lastJump") 37 | ret = append(ret, fmt.Sprintf("{{ .User }} (last time I jumped: %s)", lastJumpTS)) 38 | bot.MemorySave("jump", "lastJump", []byte(time.Now().String())) 39 | 40 | return ret 41 | }, 42 | }, 43 | { 44 | `{{ .RobotName }} qr code (.*)`, `turn a URL into a QR Code`, 45 | func(bot bot.Self, msg string, matches []string) []string { 46 | const qrUrl = "https://chart.googleapis.com/chart?chs=178x178&cht=qr&chl=%s" 47 | return []string{ 48 | fmt.Sprintf(qrUrl, url.QueryEscape(matches[1])), 49 | } 50 | }, 51 | }, 52 | { 53 | `{{ .RobotName }} http status (.*)`, `return the description of the given HTTP status`, 54 | func(bot bot.Self, msg string, matches []string) []string { 55 | httpCode, err := strconv.Atoi(matches[1]) 56 | if err != nil { 57 | return []string{fmt.Sprintln("I could not convert", matches[1], "into HTTP code.")} 58 | } 59 | return []string{ 60 | fmt.Sprintln( 61 | "{{ .User }},", matches[1], "is", 62 | http.StatusText(httpCode), 63 | ), 64 | } 65 | }, 66 | }, 67 | { 68 | `{{ .RobotName }} explainshell (.*)`, `links to explainshell.com on given command`, 69 | func(bot bot.Self, msg string, matches []string) []string { 70 | const explainShellUrl = "http://explainshell.com/explain?cmd=%s" 71 | return []string{ 72 | strings.Replace( 73 | fmt.Sprintf(explainShellUrl, url.QueryEscape(matches[1])), 74 | "%20", 75 | "+", 76 | -1, 77 | ), 78 | } 79 | }, 80 | }, 81 | { 82 | `{{ .RobotName }} godoc (.*)`, `search godoc.org and return the first result`, 83 | func(bot bot.Self, msg string, matches []string) []string { 84 | if len(matches) < 2 { 85 | return []string{} 86 | } 87 | 88 | respBody, err := httpGet(fmt.Sprintf("http://api.godoc.org/search?q=%s", url.QueryEscape(matches[1]))) 89 | if err != nil { 90 | return []string{err.Error()} 91 | } 92 | defer respBody.Close() 93 | 94 | var data struct { 95 | Results []struct { 96 | Path string `json:"path"` 97 | Synopsis string `json:"synopsis"` 98 | } `json:"results"` 99 | } 100 | 101 | if err := json.NewDecoder(respBody).Decode(&data); err != nil { 102 | return []string{err.Error()} 103 | } 104 | 105 | if len(data.Results) == 0 { 106 | return []string{"package not found"} 107 | } 108 | 109 | return []string{fmt.Sprintf("%s %s/%s", data.Results[0].Synopsis, "http://godoc.org", data.Results[0].Path)} 110 | 111 | }, 112 | }, 113 | } 114 | 115 | func httpGet(u string) (io.ReadCloser, error) { 116 | resp, err := http.Get(u) 117 | if err != nil { 118 | return nil, err 119 | } 120 | return resp.Body, nil 121 | } 122 | -------------------------------------------------------------------------------- /rules/cron/cron.go: -------------------------------------------------------------------------------- 1 | package cron // import "cirello.io/gochatbot/rules/cron" 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "cirello.io/gochatbot/bot" 12 | "cirello.io/gochatbot/messages" 13 | "github.com/gorhill/cronexpr" 14 | ) 15 | 16 | type Rule struct { 17 | When string 18 | Action func() []messages.Message 19 | } 20 | 21 | type cronRuleset struct { 22 | outCh chan messages.Message 23 | cronRules map[string]Rule 24 | 25 | mu sync.Mutex 26 | attachedCrons map[string][]string 27 | stopChan []chan struct{} 28 | } 29 | 30 | // Name returns this rules name - meant for debugging. 31 | func (r *cronRuleset) Name() string { 32 | return "Cron Ruleset" 33 | } 34 | 35 | // Boot runs preparatory steps for ruleset execution 36 | func (r *cronRuleset) Boot(self *bot.Self) { 37 | r.outCh = self.MessageProviderOut() 38 | r.loadMemory(self) 39 | } 40 | 41 | func (r *cronRuleset) loadMemory(self *bot.Self) { 42 | log.Println("cron: reading from memory") 43 | v := self.MemoryRead("cron", "attached") 44 | 45 | if err := json.Unmarshal(v, &r.attachedCrons); err != nil { 46 | log.Println("cron: error reading memory:", err, v) 47 | return 48 | } 49 | 50 | log.Println("cron: memory read") 51 | r.start() 52 | } 53 | 54 | func (r cronRuleset) HelpMessage(self bot.Self, _ string) string { 55 | helpMsg := fmt.Sprintln("cron attach - attach one cron job to a room") 56 | helpMsg = fmt.Sprintln(helpMsg, "cron detach - detach one cron job from a room") 57 | helpMsg = fmt.Sprintln(helpMsg, "cron list - list all available crons") 58 | helpMsg = fmt.Sprintln(helpMsg, "cron start - start all crons") 59 | helpMsg = fmt.Sprintln(helpMsg, "cron stop - stop all crons") 60 | helpMsg = fmt.Sprintln(helpMsg, "cron help - this message") 61 | 62 | return helpMsg 63 | } 64 | 65 | func (r *cronRuleset) ParseMessage(self bot.Self, in messages.Message) []messages.Message { 66 | if strings.HasPrefix(in.Message, "cron attach") { 67 | ruleName := strings.TrimSpace(strings.TrimPrefix(in.Message, "cron attach")) 68 | ret := []messages.Message{ 69 | { 70 | Room: in.Room, 71 | ToUserID: in.FromUserID, 72 | ToUserName: in.FromUserName, 73 | Message: r.attach(self, ruleName, in.Room), 74 | }, 75 | } 76 | r.start() 77 | return ret 78 | } 79 | 80 | if strings.HasPrefix(in.Message, "cron detach") { 81 | ruleName := strings.TrimSpace(strings.TrimPrefix(in.Message, "cron detach")) 82 | return []messages.Message{ 83 | { 84 | Room: in.Room, 85 | ToUserID: in.FromUserID, 86 | ToUserName: in.FromUserName, 87 | Message: r.detach(self, ruleName, in.Room), 88 | }, 89 | } 90 | } 91 | 92 | if in.Message == "cron list" { 93 | var ret []messages.Message 94 | for ruleName, rule := range r.cronRules { 95 | ret = append(ret, messages.Message{ 96 | Room: in.Room, 97 | ToUserID: in.FromUserID, 98 | ToUserName: in.FromUserName, 99 | Message: "@" + rule.When + " " + ruleName, 100 | }) 101 | } 102 | return ret 103 | } 104 | 105 | if in.Message == "cron start" { 106 | r.start() 107 | return []messages.Message{ 108 | { 109 | Room: in.Room, 110 | ToUserID: in.FromUserID, 111 | ToUserName: in.FromUserName, 112 | Message: "all cron jobs started", 113 | }, 114 | } 115 | } 116 | 117 | if in.Message == "cron stop" { 118 | r.stop() 119 | return []messages.Message{ 120 | { 121 | Room: in.Room, 122 | ToUserID: in.FromUserID, 123 | ToUserName: in.FromUserName, 124 | Message: "all cron jobs stopped", 125 | }, 126 | } 127 | } 128 | 129 | return []messages.Message{} 130 | } 131 | 132 | func (r *cronRuleset) attach(self bot.Self, ruleName, room string) string { 133 | r.mu.Lock() 134 | defer r.mu.Unlock() 135 | 136 | if _, ok := r.cronRules[ruleName]; !ok { 137 | return ruleName + " not found" 138 | } 139 | 140 | for _, rn := range r.attachedCrons[room] { 141 | if rn == ruleName { 142 | return ruleName + " already attached to this room" 143 | } 144 | } 145 | r.attachedCrons[room] = append(r.attachedCrons[room], ruleName) 146 | 147 | b, err := json.Marshal(r.attachedCrons) 148 | if err != nil { 149 | return fmt.Sprintf("error attaching %s: %v", ruleName, err) 150 | } 151 | 152 | self.MemorySave("cron", "attached", b) 153 | return ruleName + " attached to this room" 154 | } 155 | 156 | func (r *cronRuleset) detach(self bot.Self, ruleName, room string) string { 157 | r.mu.Lock() 158 | defer r.mu.Unlock() 159 | 160 | if _, ok := r.attachedCrons[room]; !ok { 161 | return "room not found in cron memory" 162 | } 163 | 164 | var newRoom []string 165 | for _, rn := range r.attachedCrons[room] { 166 | if rn == ruleName { 167 | continue 168 | } 169 | newRoom = append(newRoom, rn) 170 | } 171 | r.attachedCrons[room] = newRoom 172 | 173 | b, err := json.Marshal(r.attachedCrons) 174 | if err != nil { 175 | return fmt.Sprintf("error detaching %s: %v", ruleName, err) 176 | } 177 | self.MemorySave("cron", "attached", b) 178 | return ruleName + " detached to this room" 179 | } 180 | 181 | func (r *cronRuleset) start() { 182 | r.stop() 183 | 184 | r.mu.Lock() 185 | defer r.mu.Unlock() 186 | 187 | for room, rules := range r.attachedCrons { 188 | for _, rule := range rules { 189 | c := make(chan struct{}) 190 | r.stopChan = append(r.stopChan, c) 191 | go processCronRule(r.cronRules[rule], c, r.outCh, room) 192 | } 193 | } 194 | } 195 | 196 | func processCronRule(rule Rule, stop chan struct{}, outCh chan messages.Message, cronRoom string) { 197 | nextTime := cronexpr.MustParse(rule.When).Next(time.Now()) 198 | for { 199 | select { 200 | case <-stop: 201 | return 202 | default: 203 | if nextTime.Format("2006-01-02 15:04") == time.Now().Format("2006-01-02 15:04") { 204 | msgs := rule.Action() 205 | for _, msg := range msgs { 206 | msg.Room = cronRoom 207 | outCh <- msg 208 | } 209 | } 210 | nextTime = cronexpr.MustParse(rule.When).Next(time.Now()) 211 | time.Sleep(2 * time.Second) 212 | } 213 | } 214 | } 215 | 216 | func (r *cronRuleset) stop() { 217 | r.mu.Lock() 218 | defer r.mu.Unlock() 219 | 220 | for _, c := range r.stopChan { 221 | c <- struct{}{} 222 | } 223 | r.stopChan = []chan struct{}{} 224 | } 225 | 226 | // New returns a cron rule set 227 | func New(rules map[string]Rule) *cronRuleset { 228 | r := &cronRuleset{ 229 | attachedCrons: make(map[string][]string), 230 | cronRules: rules, 231 | } 232 | return r 233 | } 234 | -------------------------------------------------------------------------------- /rules/plugins/plugin.go: -------------------------------------------------------------------------------- 1 | package plugins // import "cirello.io/gochatbot/rules/plugins" 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "net" 8 | "os" 9 | "os/exec" 10 | "path" 11 | "strings" 12 | 13 | "cirello.io/gochatbot/bot" 14 | "cirello.io/gochatbot/messages" 15 | "cirello.io/gochatbot/rules/rpc" 16 | ) 17 | 18 | type pluginRuleset struct { 19 | pluginBins []string 20 | plugins []bot.RuleParser 21 | } 22 | 23 | // Name returns this rules name - meant for debugging. 24 | func (r *pluginRuleset) Name() string { 25 | return "Plugins Ruleset" 26 | } 27 | 28 | // Boot runs preparatory steps for ruleset execution 29 | func (r *pluginRuleset) Boot(self *bot.Self) { 30 | for _, pluginBin := range r.pluginBins { 31 | l, err := net.Listen("tcp4", "0.0.0.0:0") 32 | if err != nil { 33 | log.Println("could not start plugin %s. error while setting listener: %v", pluginBin, err) 34 | continue 35 | } 36 | log.Printf("plugin: starting %s", pluginBin) 37 | rs := rpc.New(l) 38 | rs.Boot(self) 39 | cmd := exec.Command(pluginBin) 40 | cmd.Env = os.Environ() 41 | cmd.Env = append(cmd.Env, fmt.Sprintf("GOCHATBOT_RPC_BIND=%s", l.Addr())) 42 | cmd.Env = append(cmd.Env, fmt.Sprintf("GOCHATBOT_NAME=%s", self.Name())) 43 | cmd.Stderr = os.Stderr 44 | if err := cmd.Start(); err != nil { 45 | log.Printf("plugin: %s - error: %v", pluginBin, err) 46 | log.Printf("plugin: %s closing listener.", pluginBin) 47 | l.Close() 48 | continue 49 | } 50 | r.plugins = append(r.plugins, rs) 51 | } 52 | } 53 | 54 | func (r pluginRuleset) HelpMessage(self bot.Self, room string) string { 55 | if len(r.plugins) > 0 { 56 | var names []string 57 | for _, pluginBin := range r.pluginBins { 58 | names = append(names, path.Base(pluginBin)) 59 | } 60 | msg := fmt.Sprintln("Loaded plugins:", names) 61 | for _, plugin := range r.plugins { 62 | go plugin.ParseMessage(self, messages.Message{Room: room, Message: "help"}) 63 | } 64 | return msg 65 | } 66 | return "no external plugins loaded" 67 | } 68 | 69 | func (r *pluginRuleset) ParseMessage(self bot.Self, in messages.Message) []messages.Message { 70 | for _, plugin := range r.plugins { 71 | go plugin.ParseMessage(self, in) 72 | } 73 | return []messages.Message{} 74 | } 75 | 76 | func (p *pluginRuleset) detectPluginBinaries(workdir string) error { 77 | files, err := ioutil.ReadDir(workdir) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | for _, file := range files { 83 | fn := file.Name() 84 | if strings.HasPrefix(fn, "gochatbot-plugin-") && file.Mode()&0111 != 0 { 85 | p.pluginBins = append(p.pluginBins, path.Join(workdir, fn)) 86 | } 87 | } 88 | 89 | return nil 90 | } 91 | 92 | // New returns plugin ruleset 93 | func New(workdir string) *pluginRuleset { 94 | p := &pluginRuleset{} 95 | if err := p.detectPluginBinaries(workdir); err != nil { 96 | log.Fatal("error loading plugins: %v", err) 97 | } 98 | return p 99 | } 100 | -------------------------------------------------------------------------------- /rules/regex/regex.go: -------------------------------------------------------------------------------- 1 | package regex // import "cirello.io/gochatbot/rules/regex" 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | "text/template" 9 | "unicode" 10 | 11 | "cirello.io/gochatbot/bot" 12 | "cirello.io/gochatbot/messages" 13 | ) 14 | 15 | type Rule struct { 16 | Regex string 17 | HelpMessage string 18 | ParseMessage func(bot.Self, string, []string) []string 19 | } 20 | 21 | type regexRuleset struct { 22 | regexes map[string]*template.Template 23 | rules []Rule 24 | } 25 | 26 | // Name returns this rules name - meant for debugging. 27 | func (r regexRuleset) Name() string { 28 | return "Regex Ruleset" 29 | } 30 | 31 | // Boot runs preparatory steps for ruleset execution 32 | func (r regexRuleset) Boot(_ *bot.Self) { 33 | } 34 | 35 | func (r regexRuleset) HelpMessage(self bot.Self, _ string) string { 36 | botName := self.Name() 37 | var helpMsg string 38 | for _, rule := range r.rules { 39 | var finalRegex bytes.Buffer 40 | r.regexes[rule.Regex].Execute(&finalRegex, struct{ RobotName string }{botName}) 41 | 42 | helpMsg = fmt.Sprintln(helpMsg, finalRegex.String(), "-", rule.HelpMessage) 43 | } 44 | return strings.TrimLeftFunc(helpMsg, unicode.IsSpace) 45 | } 46 | 47 | func (r regexRuleset) ParseMessage(self bot.Self, in messages.Message) []messages.Message { 48 | for _, rule := range r.rules { 49 | botName := self.Name() 50 | if in.Direct { 51 | botName = "" 52 | } 53 | 54 | var finalRegex bytes.Buffer 55 | if _, ok := r.regexes[rule.Regex]; !ok { 56 | r.regexes[rule.Regex] = template.Must(template.New(rule.Regex).Parse(rule.Regex)) 57 | } 58 | r.regexes[rule.Regex].Execute(&finalRegex, struct{ RobotName string }{botName}) 59 | sanitizedRegex := strings.TrimSpace(finalRegex.String()) 60 | re := regexp.MustCompile(sanitizedRegex) 61 | matched := re.MatchString(in.Message) 62 | if !matched { 63 | continue 64 | } 65 | 66 | args := re.FindStringSubmatch(in.Message) 67 | if ret := rule.ParseMessage(self, in.Message, args); len(ret) > 0 { 68 | var retMsgs []messages.Message 69 | for _, m := range ret { 70 | retMsgs = append( 71 | retMsgs, 72 | messages.Message{ 73 | Room: in.Room, 74 | ToUserID: in.FromUserID, 75 | ToUserName: in.FromUserName, 76 | Message: m, 77 | }, 78 | ) 79 | } 80 | return retMsgs 81 | } 82 | } 83 | 84 | return []messages.Message{} 85 | } 86 | 87 | // New returns a regex rule set 88 | func New(rules []Rule) *regexRuleset { 89 | r := ®exRuleset{ 90 | regexes: make(map[string]*template.Template), 91 | rules: rules, 92 | } 93 | for _, rule := range rules { 94 | r.regexes[rule.Regex] = template.Must(template.New(rule.Regex).Parse(rule.Regex)) 95 | } 96 | return r 97 | } 98 | -------------------------------------------------------------------------------- /rules/rpc/http.go: -------------------------------------------------------------------------------- 1 | package rpc // import "cirello.io/gochatbot/rules/rpc" 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | 10 | "cirello.io/gochatbot/messages" 11 | ) 12 | 13 | func (r *rpcRuleset) httpPop(w http.ResponseWriter, req *http.Request) { 14 | r.mu.Lock() 15 | defer r.mu.Unlock() 16 | defer req.Body.Close() 17 | 18 | var msg messages.Message 19 | if len(r.inbox) > 1 { 20 | msg, r.inbox = r.inbox[0], r.inbox[1:] 21 | } else if len(r.inbox) == 1 { 22 | msg = r.inbox[0] 23 | r.inbox = []messages.Message{} 24 | } else if len(r.inbox) == 0 { 25 | fmt.Fprint(w, "{}") 26 | return 27 | } 28 | 29 | if err := json.NewEncoder(w).Encode(&msg); err != nil { 30 | log.Fatal(err) 31 | } 32 | } 33 | 34 | func (r *rpcRuleset) httpSend(w http.ResponseWriter, req *http.Request) { 35 | r.mu.Lock() 36 | defer r.mu.Unlock() 37 | 38 | var msg messages.Message 39 | if err := json.NewDecoder(req.Body).Decode(&msg); err != nil { 40 | log.Fatal(err) 41 | } 42 | defer req.Body.Close() 43 | 44 | go func(m messages.Message) { 45 | r.outCh <- m 46 | }(msg) 47 | fmt.Fprintln(w, "OK") 48 | } 49 | 50 | func (r *rpcRuleset) httpMemoryRead(w http.ResponseWriter, req *http.Request) { 51 | r.mu.Lock() 52 | defer r.mu.Unlock() 53 | defer req.Body.Close() 54 | 55 | namespace := req.URL.Query().Get("namespace") 56 | key := req.URL.Query().Get("key") 57 | 58 | fmt.Fprintf(w, "%s", r.memoryRead(namespace, key)) 59 | } 60 | 61 | func (r *rpcRuleset) httpMemorySave(w http.ResponseWriter, req *http.Request) { 62 | r.mu.Lock() 63 | defer r.mu.Unlock() 64 | defer req.Body.Close() 65 | 66 | namespace := req.URL.Query().Get("namespace") 67 | key := req.URL.Query().Get("key") 68 | 69 | b, err := ioutil.ReadAll(req.Body) 70 | if err != nil { 71 | fmt.Fprintln(w, err) 72 | return 73 | } 74 | r.memorySave(namespace, key, b) 75 | fmt.Fprintln(w, "OK") 76 | } 77 | -------------------------------------------------------------------------------- /rules/rpc/rpc.go: -------------------------------------------------------------------------------- 1 | package rpc // import "cirello.io/gochatbot/rules/rpc" 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net" 7 | "net/http" 8 | "sync" 9 | 10 | "cirello.io/gochatbot/bot" 11 | "cirello.io/gochatbot/messages" 12 | ) 13 | 14 | type rpcRuleset struct { 15 | mux *http.ServeMux 16 | 17 | memoryRead func(ruleName, key string) []byte 18 | memorySave func(ruleName, key string, value []byte) 19 | 20 | listener net.Listener 21 | outCh chan messages.Message 22 | 23 | mu sync.Mutex 24 | inbox []messages.Message 25 | } 26 | 27 | // Name returns this rules name - meant for debugging. 28 | func (r *rpcRuleset) Name() string { 29 | return "RPC Ruleset" 30 | } 31 | 32 | // Boot runs preparatory steps for ruleset execution 33 | func (r *rpcRuleset) Boot(self *bot.Self) { 34 | r.memoryRead = self.MemoryRead 35 | r.memorySave = self.MemorySave 36 | r.outCh = self.MessageProviderOut() 37 | r.mux.HandleFunc("/pop", r.httpPop) 38 | r.mux.HandleFunc("/send", r.httpSend) 39 | r.mux.HandleFunc("/memoryRead", r.httpMemoryRead) 40 | r.mux.HandleFunc("/memorySave", r.httpMemorySave) 41 | log.Println("rpc: listening", r.listener.Addr()) 42 | srv := &http.Server{Handler: r.mux} 43 | srv.SetKeepAlivesEnabled(false) 44 | go srv.Serve(r.listener) 45 | } 46 | 47 | func (r rpcRuleset) HelpMessage(self bot.Self, _ string) string { 48 | return fmt.Sprintln("RPC listens to", r.listener.Addr(), "for RPC calls") 49 | } 50 | 51 | func (r *rpcRuleset) ParseMessage(self bot.Self, in messages.Message) []messages.Message { 52 | r.mu.Lock() 53 | defer r.mu.Unlock() 54 | 55 | r.inbox = append(r.inbox, in) 56 | 57 | return []messages.Message{} 58 | } 59 | 60 | // New returns a RPC ruleset 61 | func New(listener net.Listener) *rpcRuleset { 62 | return &rpcRuleset{ 63 | mux: http.NewServeMux(), 64 | listener: listener, 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /shipit.go: -------------------------------------------------------------------------------- 1 | // +build all shipit 2 | 3 | package main 4 | 5 | import ( 6 | "math/rand" 7 | 8 | "cirello.io/gochatbot/bot" 9 | "cirello.io/gochatbot/rules/regex" 10 | ) 11 | 12 | // Ported from github.com/hubot-scripts/hubot-shipit 13 | func init() { 14 | regexRules = append(regexRules, regex.Rule{ 15 | `\bship(ping|z|s|ped)?\s*it\b`, `display a motivation squirrel`, 16 | func(bot bot.Self, msg string, matches []string) []string { 17 | squirrels := []string{ 18 | "http://1.bp.blogspot.com/_v0neUj-VDa4/TFBEbqFQcII/AAAAAAAAFBU/E8kPNmF1h1E/s640/squirrelbacca-thumb.jpg", 19 | "http://28.media.tumblr.com/tumblr_lybw63nzPp1r5bvcto1_500.jpg", 20 | "http://d2f8dzk2mhcqts.cloudfront.net/0772_PEW_Roundup/09_Squirrel.jpg", 21 | "http://i.imgur.com/DPVM1.png", 22 | "http://images.cheezburger.com/completestore/2011/11/2/46e81db3-bead-4e2e-a157-8edd0339192f.jpg", 23 | "http://images.cheezburger.com/completestore/2011/11/2/aa83c0c4-2123-4bd3-8097-966c9461b30c.jpg", 24 | "http://img70.imageshack.us/img70/4853/cutesquirrels27rn9.jpg", 25 | "http://img70.imageshack.us/img70/9615/cutesquirrels15ac7.jpg", 26 | "http://shipitsquirrel.github.io/images/ship%20it%20squirrel.png", 27 | "http://www.cybersalt.org/images/funnypictures/s/supersquirrel.jpg", 28 | "http://www.zmescience.com/wp-content/uploads/2010/09/squirrel.jpg", 29 | "https://dl.dropboxusercontent.com/u/602885/github/sniper-squirrel.jpg", 30 | "https://dl.dropboxusercontent.com/u/602885/github/soldier-squirrel.jpg", 31 | "https://dl.dropboxusercontent.com/u/602885/github/squirrelmobster.jpeg", 32 | } 33 | 34 | chosenSquirrels := squirrels[rand.Intn(len(squirrels)-1)] 35 | 36 | return []string{chosenSquirrels} 37 | }, 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /silent.go: -------------------------------------------------------------------------------- 1 | // +build silent 2 | 3 | package main 4 | 5 | import ( 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | ) 10 | 11 | func init() { 12 | verbose := os.Getenv("GOCHATBOT_VERBOSE") 13 | if verbose != "1" { 14 | log.SetOutput(ioutil.Discard) 15 | } 16 | } 17 | --------------------------------------------------------------------------------