├── .circleci └── config.yml ├── .gitignore ├── Dockerfile ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── Makefile ├── README.md ├── active.en.toml ├── active.ru.toml ├── api ├── api.go ├── api_test.go ├── handlers.go └── swagger.yaml ├── botuser ├── botuser.go ├── botuser_test.go ├── deadlines.go ├── deadlines_test.go ├── notifications.go ├── notifications_test.go ├── onbording_message.go ├── reporting.go ├── standupers.go ├── standupers_test.go ├── submittion_days.go └── tz.go ├── config ├── config.go └── config_test.go ├── docker-compose.test-setup.yml ├── docker-compose.test.yml ├── docker-compose.yaml ├── docs ├── logo.png ├── slack.md ├── translations.md └── usage.md ├── main.go ├── migrations ├── 001_create_table_standups.sql ├── 002_create_table_standupers.sql ├── 003_create_table_workspaces.sql ├── 004_create_table_projects.sql └── 005_create_table_notification_threads.sql ├── model ├── model.go └── model_test.go └── storage ├── channels.go ├── channels_test.go ├── mysql.go ├── mysql_test.go ├── notifications_thread.go ├── notifications_thread_test.go ├── standupers.go ├── standupers_test.go ├── standups.go ├── standups_test.go ├── workspaces.go └── workspaces_test.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/golang:1.12 6 | working_directory: /go/src/github.com/maddevsio/comedian 7 | steps: 8 | - checkout 9 | - run: go get -u github.com/golang/dep/cmd/dep 10 | - run: 11 | name: run build 12 | command: | 13 | dep ensure 14 | go build -v 15 | test: 16 | docker: 17 | - image: circleci/golang:1.12 18 | working_directory: /go/src/github.com/maddevsio/comedian 19 | steps: 20 | - checkout 21 | - run: go get -u github.com/mattn/goveralls 22 | - run: 23 | name: run test 24 | command: | 25 | go test -v -cover -race ./... -coverprofile=coverage.out 26 | - run: 27 | name: send report to coveralls 28 | command: | 29 | goveralls -coverprofile=coverage.out -service=circle-ci -repotoken=$COVERALLS_TOKEN -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | comedian 3 | .env -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.11.4 2 | COPY . /go/src/github.com/maddevsio/comedian 3 | WORKDIR /go/src/github.com/maddevsio/comedian 4 | RUN go get -u github.com/golang/dep/cmd/dep 5 | RUN dep ensure 6 | RUN GOOS=linux GOARCH=amd64 go build -o comedian main.go 7 | 8 | FROM debian:9.8 9 | LABEL maintainer="Anatoliy Fedorenko " 10 | RUN apt-get update \ 11 | && apt-get install -y --no-install-recommends ca-certificates locales wget \ 12 | && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 13 | RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 14 | ENV LANG en_US.utf8 15 | 16 | ENV DOCKERIZE_VERSION v0.6.1 17 | RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ 18 | && tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ 19 | && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz 20 | COPY active.en.toml / 21 | COPY active.ru.toml / 22 | COPY --from=0 /go/src/github.com/maddevsio/comedian/comedian / 23 | COPY migrations /migrations 24 | ENTRYPOINT ["./comedian"] 25 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | digest = "1:ee907e9ead8d8b090f1e2c07967ff1ec7ed20687c2ffd270d1c6c1b71dff5496" 6 | name = "github.com/AlekSi/pointer" 7 | packages = ["."] 8 | pruneopts = "UT" 9 | revision = "08a25bac605b3fcb6cc27f3917b2c2c87451963d" 10 | version = "v1.0.0" 11 | 12 | [[projects]] 13 | digest = "1:9f3b30d9f8e0d7040f729b82dcbc8f0dead820a133b3147ce355fc451f32d761" 14 | name = "github.com/BurntSushi/toml" 15 | packages = ["."] 16 | pruneopts = "UT" 17 | revision = "3012a1dbe2e4bd1391d42b32f0577cb7bbc7f005" 18 | version = "v0.3.1" 19 | 20 | [[projects]] 21 | branch = "master" 22 | digest = "1:fdfcd99fd77a0d3e3228d4d0b5061d9a611d40cd369ea44e333401b06936fdc4" 23 | name = "github.com/araddon/dateparse" 24 | packages = ["."] 25 | pruneopts = "UT" 26 | revision = "0fb0a474d195a3449cf412ae0176faa193f0ef0b" 27 | 28 | [[projects]] 29 | digest = "1:ffe9824d294da03b391f44e1ae8281281b4afc1bdaa9588c9097785e3af10cec" 30 | name = "github.com/davecgh/go-spew" 31 | packages = ["spew"] 32 | pruneopts = "UT" 33 | revision = "8991bc29aa16c548c550c7ff78260e27b9ab7c73" 34 | version = "v1.1.1" 35 | 36 | [[projects]] 37 | digest = "1:76dc72490af7174349349838f2fe118996381b31ea83243812a97e5a0fd5ed55" 38 | name = "github.com/dgrijalva/jwt-go" 39 | packages = ["."] 40 | pruneopts = "UT" 41 | revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e" 42 | version = "v3.2.0" 43 | 44 | [[projects]] 45 | digest = "1:ec6f9bf5e274c833c911923c9193867f3f18788c461f76f05f62bb1510e0ae65" 46 | name = "github.com/go-sql-driver/mysql" 47 | packages = ["."] 48 | pruneopts = "UT" 49 | revision = "72cd26f257d44c1114970e19afddcd812016007e" 50 | version = "v1.4.1" 51 | 52 | [[projects]] 53 | digest = "1:7b5c6e2eeaa9ae5907c391a91c132abfd5c9e8a784a341b5625e750c67e6825d" 54 | name = "github.com/gorilla/websocket" 55 | packages = ["."] 56 | pruneopts = "UT" 57 | revision = "66b9c49e59c6c48f0ffce28c2d8b8a5678502c6d" 58 | version = "v1.4.0" 59 | 60 | [[projects]] 61 | branch = "master" 62 | digest = "1:b4410479bb93439096b45bb1d131988114a1c8f7d06164eaddd6787f627d39c3" 63 | name = "github.com/jmoiron/sqlx" 64 | packages = [ 65 | ".", 66 | "reflectx", 67 | ] 68 | pruneopts = "UT" 69 | revision = "38398a30ed8516ffda617a04c822de09df8a3ec5" 70 | 71 | [[projects]] 72 | digest = "1:fd9bea48bbc5bba66d9891c72af7255fbebecdff845c37c679406174ece5ca1b" 73 | name = "github.com/kelseyhightower/envconfig" 74 | packages = ["."] 75 | pruneopts = "UT" 76 | revision = "0b417c4ec4a8a82eecc22a1459a504aa55163d61" 77 | version = "v1.4.0" 78 | 79 | [[projects]] 80 | digest = "1:31e761d97c76151dde79e9d28964a812c46efc5baee4085b86f68f0c654450de" 81 | name = "github.com/konsorten/go-windows-terminal-sequences" 82 | packages = ["."] 83 | pruneopts = "UT" 84 | revision = "f55edac94c9bbba5d6182a4be46d86a2c9b5b50e" 85 | version = "v1.0.2" 86 | 87 | [[projects]] 88 | digest = "1:efd601ad1d1189884f37ed075fadb5bbd17f1f5acf5862827247c6da56d125ba" 89 | name = "github.com/labstack/echo" 90 | packages = [ 91 | ".", 92 | "middleware", 93 | ] 94 | pruneopts = "UT" 95 | revision = "38772c686c76b501f94bd6cd5b77f5842e93b559" 96 | version = "v3.3.10" 97 | 98 | [[projects]] 99 | digest = "1:6e15357cae900fa1a8015c7fc9efdb406f72b644549a3422f3f1b7f465836a84" 100 | name = "github.com/labstack/gommon" 101 | packages = [ 102 | "bytes", 103 | "color", 104 | "log", 105 | "random", 106 | ] 107 | pruneopts = "UT" 108 | revision = "ab0bfd9a5eba33a8c364bf3390d809ed23c31f97" 109 | version = "v0.2.9" 110 | 111 | [[projects]] 112 | digest = "1:7c084e0e780596dd2a7e20d25803909a9a43689c153de953520dfbc0b0e51166" 113 | name = "github.com/mattn/go-colorable" 114 | packages = ["."] 115 | pruneopts = "UT" 116 | revision = "8029fb3788e5a4a9c00e415f586a6d033f5d38b3" 117 | version = "v0.1.2" 118 | 119 | [[projects]] 120 | digest = "1:9b90c7639a41697f3d4ad12d7d67dfacc9a7a4a6e0bbfae4fc72d0da57c28871" 121 | name = "github.com/mattn/go-isatty" 122 | packages = ["."] 123 | pruneopts = "UT" 124 | revision = "1311e847b0cb909da63b5fecfb5370aa66236465" 125 | version = "v0.0.8" 126 | 127 | [[projects]] 128 | digest = "1:9ec326968a90b62ddaf7696f3c0381102be1c9a2330e7460d4e61785542c1947" 129 | name = "github.com/nicksnyder/go-i18n" 130 | packages = [ 131 | "v2/i18n", 132 | "v2/internal", 133 | "v2/internal/plural", 134 | ] 135 | pruneopts = "UT" 136 | revision = "0c6ce6ac1e8c119993c3d5638af4f7cd67ff068a" 137 | version = "v2.0.2" 138 | 139 | [[projects]] 140 | digest = "1:0d01bcdbda5760553e5ab803faa3d3a0dc6a3ef357eb179b8ac8501b358d49ce" 141 | name = "github.com/nlopes/slack" 142 | packages = [ 143 | ".", 144 | "slackevents", 145 | "slackutilsx", 146 | ] 147 | pruneopts = "UT" 148 | revision = "b9033a72a20bf84563485e86a2adbea4bf265804" 149 | version = "v0.4.0" 150 | 151 | [[projects]] 152 | branch = "master" 153 | digest = "1:0870734581fede93c3c37bc8ce0437ad9d5aa1491713e9848b08ba9003e5ff7c" 154 | name = "github.com/olebedev/when" 155 | packages = [ 156 | ".", 157 | "rules", 158 | "rules/br", 159 | "rules/common", 160 | "rules/en", 161 | "rules/ru", 162 | ] 163 | pruneopts = "UT" 164 | revision = "c3b538a972545a9584eadaea351b97264f29e8a8" 165 | 166 | [[projects]] 167 | digest = "1:cf31692c14422fa27c83a05292eb5cbe0fb2775972e8f1f8446a71549bd8980b" 168 | name = "github.com/pkg/errors" 169 | packages = ["."] 170 | pruneopts = "UT" 171 | revision = "ba968bfe8b2f7e042a574c888954fccecfa385b4" 172 | version = "v0.8.1" 173 | 174 | [[projects]] 175 | digest = "1:0028cb19b2e4c3112225cd871870f2d9cf49b9b4276531f03438a88e94be86fe" 176 | name = "github.com/pmezard/go-difflib" 177 | packages = ["difflib"] 178 | pruneopts = "UT" 179 | revision = "792786c7400a136282c1664665ae0a8db921c6c2" 180 | version = "v1.0.0" 181 | 182 | [[projects]] 183 | digest = "1:7d99649602f68b73ade640ca6975ccc38735f329e159c6a507b63f00213678c0" 184 | name = "github.com/pressly/goose" 185 | packages = ["."] 186 | pruneopts = "UT" 187 | revision = "e4b98955473e91a12fc7d8816c28d06376d1d92c" 188 | version = "v2.6.0" 189 | 190 | [[projects]] 191 | digest = "1:04457f9f6f3ffc5fea48e71d62f2ca256637dee0a04d710288e27e05c8b41976" 192 | name = "github.com/sirupsen/logrus" 193 | packages = ["."] 194 | pruneopts = "UT" 195 | revision = "839c75faf7f98a33d445d181f3018b5c3409a45e" 196 | version = "v1.4.2" 197 | 198 | [[projects]] 199 | digest = "1:5da8ce674952566deae4dbc23d07c85caafc6cfa815b0b3e03e41979cedb8750" 200 | name = "github.com/stretchr/testify" 201 | packages = [ 202 | "assert", 203 | "require", 204 | ] 205 | pruneopts = "UT" 206 | revision = "ffdc059bfe9ce6a4e144ba849dbedead332c6053" 207 | version = "v1.3.0" 208 | 209 | [[projects]] 210 | digest = "1:c468422f334a6b46a19448ad59aaffdfc0a36b08fdcc1c749a0b29b6453d7e59" 211 | name = "github.com/valyala/bytebufferpool" 212 | packages = ["."] 213 | pruneopts = "UT" 214 | revision = "e746df99fe4a3986f4d4f79e13c1e0117ce9c2f7" 215 | version = "v1.0.0" 216 | 217 | [[projects]] 218 | digest = "1:4d29fdc69817829d8c78473d61613d984ce59675110cee7a2f0314f332cc70a2" 219 | name = "github.com/valyala/fasttemplate" 220 | packages = ["."] 221 | pruneopts = "UT" 222 | revision = "8b5e4e491ab636663841c42ea3c5a9adebabaf36" 223 | version = "v1.0.1" 224 | 225 | [[projects]] 226 | branch = "master" 227 | digest = "1:cb823a5f7777276059b25eaadda6a12892f3beb6d0deee01df0d8f8692ffcc82" 228 | name = "golang.org/x/crypto" 229 | packages = [ 230 | "acme", 231 | "acme/autocert", 232 | ] 233 | pruneopts = "UT" 234 | revision = "4def268fd1a49955bfb3dda92fe3db4f924f2285" 235 | 236 | [[projects]] 237 | branch = "master" 238 | digest = "1:15a159ca3e34c429f800dd822cbb37707e98552c9523b8d07fdd244dba62f04b" 239 | name = "golang.org/x/net" 240 | packages = ["idna"] 241 | pruneopts = "UT" 242 | revision = "da137c7871d730100384dbcf36e6f8fa493aef5b" 243 | 244 | [[projects]] 245 | branch = "master" 246 | digest = "1:730ba27cd66db3b98ec8f51a6f20d45ec277d490cca36b1f54e31d3fcaf4840e" 247 | name = "golang.org/x/sys" 248 | packages = ["unix"] 249 | pruneopts = "UT" 250 | revision = "04f50cda93cbb67f2afa353c52f342100e80e625" 251 | 252 | [[projects]] 253 | digest = "1:8d8faad6b12a3a4c819a3f9618cb6ee1fa1cfc33253abeeea8b55336721e3405" 254 | name = "golang.org/x/text" 255 | packages = [ 256 | "collate", 257 | "collate/build", 258 | "internal/colltab", 259 | "internal/gen", 260 | "internal/language", 261 | "internal/language/compact", 262 | "internal/tag", 263 | "internal/triegen", 264 | "internal/ucd", 265 | "language", 266 | "secure/bidirule", 267 | "transform", 268 | "unicode/bidi", 269 | "unicode/cldr", 270 | "unicode/norm", 271 | "unicode/rangetable", 272 | ] 273 | pruneopts = "UT" 274 | revision = "342b2e1fbaa52c93f31447ad2c6abc048c63e475" 275 | version = "v0.3.2" 276 | 277 | [[projects]] 278 | digest = "1:c25289f43ac4a68d88b02245742347c94f1e108c534dda442188015ff80669b3" 279 | name = "google.golang.org/appengine" 280 | packages = ["cloudsql"] 281 | pruneopts = "UT" 282 | revision = "b2f4a3cf3c67576a2ee09e1fe62656a5086ce880" 283 | version = "v1.6.1" 284 | 285 | [[projects]] 286 | digest = "1:4d2e5a73dc1500038e504a8d78b986630e3626dc027bc030ba5c75da257cdb96" 287 | name = "gopkg.in/yaml.v2" 288 | packages = ["."] 289 | pruneopts = "UT" 290 | revision = "51d6538a90f86fe93ac480b35f37b2be17fef232" 291 | version = "v2.2.2" 292 | 293 | [solve-meta] 294 | analyzer-name = "dep" 295 | analyzer-version = 1 296 | input-imports = [ 297 | "github.com/BurntSushi/toml", 298 | "github.com/araddon/dateparse", 299 | "github.com/go-sql-driver/mysql", 300 | "github.com/jmoiron/sqlx", 301 | "github.com/kelseyhightower/envconfig", 302 | "github.com/labstack/echo", 303 | "github.com/labstack/echo/middleware", 304 | "github.com/nicksnyder/go-i18n/v2/i18n", 305 | "github.com/nlopes/slack", 306 | "github.com/nlopes/slack/slackevents", 307 | "github.com/olebedev/when", 308 | "github.com/olebedev/when/rules/en", 309 | "github.com/olebedev/when/rules/ru", 310 | "github.com/pressly/goose", 311 | "github.com/sirupsen/logrus", 312 | "github.com/stretchr/testify/assert", 313 | "github.com/stretchr/testify/require", 314 | "golang.org/x/text/language", 315 | "gopkg.in/yaml.v2", 316 | ] 317 | solver-name = "gps-cdcl" 318 | solver-version = 1 319 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://golang.github.io/dep/docs/Gopkg.toml.html 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | # 22 | # [prune] 23 | # non-go = false 24 | # go-tests = true 25 | # unused-packages = true 26 | 27 | 28 | [[constraint]] 29 | name = "github.com/BurntSushi/toml" 30 | version = "0.3.0" 31 | 32 | [[constraint]] 33 | name = "github.com/go-sql-driver/mysql" 34 | version = "1.4.0" 35 | 36 | [[constraint]] 37 | branch = "master" 38 | name = "github.com/jmoiron/sqlx" 39 | 40 | [[constraint]] 41 | name = "github.com/kelseyhightower/envconfig" 42 | version = "1.3.0" 43 | 44 | [[constraint]] 45 | name = "github.com/labstack/echo" 46 | version = "3.3.6" 47 | 48 | [[constraint]] 49 | name = "github.com/nicksnyder/go-i18n" 50 | version = "2.0.0-beta.5" 51 | 52 | [[constraint]] 53 | name = "github.com/nlopes/slack" 54 | version = "0.4.0" 55 | 56 | [[constraint]] 57 | name = "github.com/sirupsen/logrus" 58 | version = "1.0.6" 59 | 60 | [[constraint]] 61 | name = "github.com/stretchr/testify" 62 | version = "1.2.2" 63 | 64 | [[constraint]] 65 | name = "golang.org/x/text" 66 | version = "0.3.0" 67 | 68 | [prune] 69 | go-tests = true 70 | unused-packages = true 71 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Mad Devs 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | run: 2 | docker-compose up --build 3 | 4 | clear: 5 | docker-compose down --remove-orphans 6 | 7 | test: 8 | docker-compose -f docker-compose.test.yml up --build --abort-on-container-exit 9 | docker-compose -f docker-compose.test.yml down --remove-orphans 10 | 11 | setup: 12 | docker-compose -f docker-compose.test-setup.yml up --build -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |
Team management system that helps track performance and assist team members in daily remote standups meetings 6 | 7 | [![Developed by Mad Devs](https://maddevs.io/badge-dark.svg)](https://maddevs.io/) 8 | [![Project Status: Active – The project has reached a stable, usable state and is being actively developed.](https://www.repostatus.org/badges/latest/active.svg)](https://www.repostatus.org/#active) 9 | [![Go Report Card](https://goreportcard.com/badge/github.com/maddevsio/comedian)](https://goreportcard.com/report/github.com/maddevsio/comedian) 10 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 11 | 12 |
13 | 14 | ## Comedian Features 15 | 16 | - [x] Handle standups and show warnings if standup is not complete 17 | - [x] Assign team members various roles 18 | - [x] Set deadlines for standups submissions in channels 19 | - [x] Set up individual timetables (schedules) for developers to submit standups 20 | - [x] Remind about upcoming deadlines for teams and individuals 21 | - [x] Tag non-reporters in channels when deadline is missed 22 | - [x] Provide daily & weekly reports on team's performance 23 | - [x] Support English and Russian languages 24 | 25 | 26 | Comedian works with Slack apps only, if you do not have a slack app configured follow [slack installations guide](docs/slack.md), otherwise: 27 | 28 | ### Run Comedian locally 29 | 30 | From project root directory run Comedian with `make run` command from your terminal. In case you do not have `docker` and `docker-compose`, install them on your machine and try again. 31 | 32 | ### Migrations 33 | 34 | Comedian uses [goose](https://github.com/pressly/goose) to run migrations. Read more about the tool itself in official docs from repo. Migrations are executed in runtime after you run project. You can setup database and run migrations manually with goose binary. 35 | 36 | When adding migrations follow naming conventions of migrations like `000_migration_name.sql` 37 | 38 | ### Translations 39 | Comedian works both with English and Russian languages. This feature is implemented with the help of https://github.com/nicksnyder/go-i18n tool. Learn more about the tool in documentation. 40 | 41 | If you need to update or add any translations in the project, follow [translation guidelines](docs/translations.md) 42 | 43 | ## Testing 44 | 45 | Run tests with `make test` command. This will run integration tests and output the result. 46 | 47 | If you want to do manual testing for separate components / or see code coverage with `vscode` or `go test`, use `make setup` first to setup database for testing purposes and then execute tests. 48 | -------------------------------------------------------------------------------- /active.en.toml: -------------------------------------------------------------------------------- 1 | addStandupTime = "Updated standup deadline to {{.Deadline}} in {{.TZ}} timezone" 2 | createStanduperFailed = "Could not add you to standup team" 3 | deadlineNotSet = "Could not change channel deadline" 4 | failedLeaveStandupers = "Could not remove you from standup team" 5 | failedRecognizeTZ = "Failed to recognize new TZ you entered, double check the tz name and try again" 6 | failedUpdateOnbordingMessage = "Failed to update onbording message" 7 | failedUpdateSumittionDays = "Failed to update Sumittion Days" 8 | failedUpdateTZ = "Failed to update Timezone" 9 | leaveStanupers = "You no longer have to submit standups, thanks for all your standups and messages" 10 | listNoStandupers = "No standupers in the team, /start to start standuping. " 11 | noProblemsMention = "- no 'problems' keywords detected: {{.Keywords}}" 12 | noTodayMention = "- no 'today' keywords detected: {{.Keywords}}" 13 | noYesterdayMention = "- no 'yesterday' keywords detected: {{.Keywords}}" 14 | notStanduper = "You do not standup yet" 15 | onbordingMessageNotSet = "Could not change channel onbording message" 16 | removeStandupTime = "Standup deadline removed" 17 | showNoStandupTime = "Standup deadline is not set" 18 | showNoSubmittionDays = "No submittion days" 19 | showStandupTime = "Standup deadline is {{.Deadline}}" 20 | showSubmittionDays = "Submit standups on {{.SD}}" 21 | showTZ = "Channel Time Zone is {{.TZ}}" 22 | submittionDaysNotSet = "Could not change channel submittion days" 23 | tzNotSet = "Could not change channel time zone" 24 | updateOnbordingMessage = "Channel onbording message is updated, new message is {{.OM}}" 25 | updateSubmittionDays = "Channel submittion days are updated, new schedule is {{.SD}}" 26 | updateTZ = "Channel timezone is updated, new TZ is {{.TZ}}" 27 | welcomeNoDedline = "Welcome to the standup team, no standup deadline has been setup yet" 28 | welcomeWithDedline = "Welcome to the standup team, please, submit your standups no later than {{.Deadline}}" 29 | wrongDeadlineFormat = "Could not recognize deadline time. Use 1pm or 13:00 formats" 30 | youAlreadyStandup = "You are already a part of standup team" 31 | 32 | [minutes] 33 | few = "{{.time}} minutes" 34 | many = "{{.time}} minutes" 35 | one = "{{.time}} minute" 36 | other = "{{.time}} minutes" 37 | two = "{{.time}} minutes" 38 | 39 | [showStandupers] 40 | few = "{{.Standupers}} submit standups in the team. " 41 | many = "{{.Standupers}} submit standups in the team. " 42 | one = "Only {{.Standupers}} submits standups in the team, '/start' to begin. " 43 | other = "{{.Standupers}} submit standups in the team. " 44 | two = "{{.Standupers}} submit standups in the team. " 45 | 46 | [tagNonReporters] 47 | few = "{{.users}} you have missed standup deadlines, shame!" 48 | many = "{{.users}} you have missed standup deadlines, shame!" 49 | one = "{{.user}}, you are the only one missed standup, shame!" 50 | other = "{{.users}} you have missed standup deadlines, shame!" 51 | two = "{{.users}} you have missed standup deadlines, shame!" 52 | 53 | [warnNonReporters] 54 | few = "{{.users}} you may miss the deadline in {{.minutes}}" 55 | many = "{{.users}} you may miss the deadline in {{.minutes}}" 56 | one = "{{.user}}, you are the only one to miss standup, in {{.minutes}}, hurry up!" 57 | other = "{{.users}} you may miss the deadline in {{.minutes}}" 58 | two = "{{.users}} you may miss the deadline in {{.minutes}}" 59 | 60 | [tagStillNonReporters] 61 | one = "{{.user}},you still haven't written a standup! Write a standup!" 62 | two = "{{.users}} you still haven't written a standup! Write a standup!" 63 | few = "{{.users}} you still haven't written a standup! Write a standup!" 64 | many = "{{.users}} you still haven't written a standup! Write a standup!" 65 | other = "{{.users}} you still haven't written a standup! Write a standup!" 66 | -------------------------------------------------------------------------------- /active.ru.toml: -------------------------------------------------------------------------------- 1 | [addStandupTime] 2 | hash = "sha1-d820883161054de1a4528d2254f2f4190ceda0aa" 3 | other = "Время сдачи стендапов установленно на {{.Deadline}} по часовому поясу {{.TZ}}" 4 | 5 | [createStanduperFailed] 6 | hash = "sha1-0c2c7f510c062191a09701b7d62a1f7ce754054b" 7 | other = "Не смог добавить вас в стендаперы" 8 | 9 | [deadlineNotSet] 10 | hash = "sha1-96363e9a8f2900fd8b5b07bcf0dff5efa9dacbc9" 11 | other = "Не смог изменить срок сдачи стендапов" 12 | 13 | [failedLeaveStandupers] 14 | hash = "sha1-c7374272c4a00a4dc1b1d8f6ac46c75a5e2f8129" 15 | other = "Не смог убрать вас из стендаперов" 16 | 17 | [failedRecognizeTZ] 18 | hash = "sha1-a31bd479bb70e1789ef1b53beaca1f4ee22931c5" 19 | other = "Не смог распознать часовую зону, перепроветь и попробуй заново" 20 | 21 | [failedUpdateOnbordingMessage] 22 | hash = "sha1-08f3ab189f4d4ec308afc8f6abd28a1c582be68e" 23 | other = "Не смог обновить приветственное сообщение" 24 | 25 | [failedUpdateSumittionDays] 26 | hash = "sha1-601994513da4afccda485542532c2d2703bf4e02" 27 | other = "Не смог обновить дни сдачи стендапа" 28 | 29 | [failedUpdateTZ] 30 | hash = "sha1-ce1fbc677f0e60cb0930a0daffc6cf3effeea900" 31 | other = "Не смог обновить часовой пояс группы" 32 | 33 | [leaveStanupers] 34 | hash = "sha1-aa349b49e8cfa8132c055dabfa72436424101503" 35 | other = "Спасибо за все ваши сообщения, вы можете больше не стендапить" 36 | 37 | [listNoStandupers] 38 | hash = "sha1-b632f5be18aab00f18e7e524a5367ccdfdef01bb" 39 | other = "Никто не стендапит, сделай /start чтобы начать!" 40 | 41 | [minutes] 42 | few = "{{.time}} минуты" 43 | hash = "sha1-5ae748c57f8a044c6a481e3b0d9304fe3b5446ef" 44 | many = "{{.time}} минут" 45 | one = "{{.time}} минута" 46 | other = "{{.time}} минут" 47 | 48 | [noProblemsMention] 49 | hash = "sha1-fd5ada3d46270c013bc30233b94e7c12a304fbc0" 50 | other = "- нет ключевых слов блока 'проблемы': {{.Keywords}}" 51 | 52 | [noTodayMention] 53 | hash = "sha1-a414039575828892ae739899cf3303299a3094f7" 54 | other = "- нет ключевых слов блока 'сегодня': {{.Keywords}}" 55 | 56 | [noYesterdayMention] 57 | hash = "sha1-bdff0c3bc740bf4fb242f13c35b3f78894e49b0e" 58 | other = "- нет ключевых слов блока 'вчера': {{.Keywords}}" 59 | 60 | [notStanduper] 61 | hash = "sha1-1c88a37c3eb3279a3f0cf6b8cb6f0a0ee737f61b" 62 | other = "Вы еще не стендапите" 63 | 64 | [onbordingMessageNotSet] 65 | hash = "sha1-062d1abd28341ca8af3dfedc76eb77428785c640" 66 | other = "Не смог изменить приветственное сообщение" 67 | 68 | [removeStandupTime] 69 | hash = "sha1-6444dd89936abbd9a8cc0a99e16394a0ca1b9dc6" 70 | other = "Удалил срок сдачи стендапов" 71 | 72 | [showNoStandupTime] 73 | hash = "sha1-a1e4959733ee1f6f257bc4e5b81be38cf58ecc6b" 74 | other = "Время сдачи стендапов не установлено" 75 | 76 | [showNoSubmittionDays] 77 | hash = "sha1-9d8a19dd0e76f70a8b072333b20502bfc38cb8ab" 78 | other = "Не установлены дни в которые надо стендапить" 79 | 80 | [showStandupTime] 81 | hash = "sha1-154ef4fc36a38ceccf1a1238ed6abcbcc7b43ee9" 82 | other = "Крайний срок сдачи стендапов: {{.Deadline}}" 83 | 84 | [showStandupers] 85 | few = "{{.Standupers}} пишут стендапы в группе. " 86 | hash = "sha1-dc9d26b759ed97660216e735b676a6996a0700e4" 87 | many = "{{.Standupers}} пишут стендапы в группе. " 88 | one = "{{.Standupers}} пишет стендапы в группе. " 89 | other = "{{.Standupers}} пишут стендапы в группе. " 90 | 91 | [showSubmittionDays] 92 | hash = "sha1-d2549596eff1f3106ba4355b7932077f725545be" 93 | other = "Сдавать стендапы на по следующим дням: {{.SD}}" 94 | 95 | [showTZ] 96 | hash = "sha1-e4b985b98f56db40949e7c51a972d094b93fe42b" 97 | other = "Часовой пояс группы: {{.TZ}}" 98 | 99 | [submittionDaysNotSet] 100 | hash = "sha1-98faae8499372fc181a60286f8b63f5b0dd1316a" 101 | other = "Не установлены дни в которые надо стендапить" 102 | 103 | [tagNonReporters] 104 | few = "{{.users}} не сдали стендапы, позор!" 105 | hash = "sha1-626cd784df0f80c1c2f83a488c1c6c32832a8e82" 106 | many = "{{.users}} не сдали стендапы, позор!" 107 | one = "{{.users}} не сдал стендап, позор!" 108 | other = "{{.users}} не сдали стендапы, позор!" 109 | 110 | [tzNotSet] 111 | hash = "sha1-1786b808bc0bcc03fbf56dbf9598eccb6732db4f" 112 | other = "Не смог обновить часовой пояс группы" 113 | 114 | [updateOnbordingMessage] 115 | hash = "sha1-cf1c8d20b7a967b9967ec964576c25a91b06891a" 116 | other = "Приветственное сообщение обновленно: {{.OM}}" 117 | 118 | [updateSubmittionDays] 119 | hash = "sha1-05e56c98213460891d47a8c75095e8ad4ec15bf3" 120 | other = "Новое расписание: {{.SD}}" 121 | 122 | [updateTZ] 123 | hash = "sha1-7e97c0499ee4d1c4e7deb06dcac91bd85b99ba43" 124 | other = "Новый часовой пояс группы: {{.TZ}}" 125 | 126 | [warnNonReporters] 127 | few = "{{.users}}, вы пропустите дедлайн через {{.minutes}}" 128 | hash = "sha1-cc2c2df6f8c38a8ef85324ebc6f98ef590476c7e" 129 | many = "{{.users}}, вы пропустите дедлайн через {{.minutes}}" 130 | one = "{{.users}}, по пропустишь дедлайн через {{.minutes}}" 131 | other = "{{.users}}, вы пропустите дедлайн через {{.minutes}}" 132 | 133 | [welcomeNoDedline] 134 | hash = "sha1-e57fbe3ef11584376832f8981aa8728f0b291e32" 135 | other = "Добро пожаловать в стендап команду, крайний срок сдачи стендапов еще не был установлен" 136 | 137 | [welcomeWithDedline] 138 | hash = "sha1-9c0fb2113888323c689d5d30bd4641f5caf57505" 139 | other = "Добро пожаловать в стендап команду, пожалуйста, сдавайте стендапы до {{.Deadline}}" 140 | 141 | [wrongDeadlineFormat] 142 | hash = "sha1-51fdd67be14fe92e3e3f5aa5e62be47c39b37b67" 143 | other = "Не распознал формат времени. Используйте 1pm или 13:00 как форматы" 144 | 145 | [youAlreadyStandup] 146 | hash = "sha1-f03147e6936098294841cbd1c82cdbe70b8e9a3d" 147 | other = "Вы уже стендапите" 148 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "regexp" 10 | "strings" 11 | "time" 12 | 13 | "github.com/nicksnyder/go-i18n/v2/i18n" 14 | 15 | "github.com/araddon/dateparse" 16 | "github.com/labstack/echo" 17 | "github.com/labstack/echo/middleware" 18 | "github.com/maddevsio/comedian/botuser" 19 | "github.com/maddevsio/comedian/config" 20 | "github.com/maddevsio/comedian/model" 21 | "github.com/maddevsio/comedian/storage" 22 | "github.com/nlopes/slack" 23 | "github.com/nlopes/slack/slackevents" 24 | log "github.com/sirupsen/logrus" 25 | ) 26 | 27 | // ComedianAPI struct used to handle slack requests (slash commands) 28 | type ComedianAPI struct { 29 | echo *echo.Echo 30 | db *storage.DB 31 | config *config.Config 32 | bundle *i18n.Bundle 33 | bots []*botuser.Bot 34 | } 35 | 36 | type swagger struct { 37 | Swagger string 38 | Info map[string]interface{} 39 | Host string 40 | BasePath string `yaml:"basePath"` 41 | Tags []struct { 42 | Name string 43 | Description string 44 | } 45 | Schemes []string 46 | Paths map[string]interface{} 47 | Definitions map[string]interface{} 48 | } 49 | 50 | //LoginPayload represents loginPayload from UI 51 | type LoginPayload struct { 52 | Code string `json:"code"` 53 | RedirectURI string `json:"redirect_uri"` 54 | } 55 | 56 | //Event represents slack challenge event 57 | type Event struct { 58 | Token string `json:"token"` 59 | Challenge string `json:"challenge"` 60 | Type string `json:"type"` 61 | } 62 | 63 | type teamMember struct { 64 | standuper model.Standuper 65 | teamWorklogs int 66 | } 67 | 68 | var echoRouteRegex = regexp.MustCompile(`(?P.*):(?P[^\/]*)(?P.*)`) 69 | var dbService *storage.DB 70 | 71 | //New creates API instance 72 | func New(config *config.Config, db *storage.DB, bundle *i18n.Bundle) *ComedianAPI { 73 | 74 | echo := echo.New() 75 | echo.Use(middleware.CORS()) 76 | echo.Pre(middleware.RemoveTrailingSlash()) 77 | echo.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ 78 | Format: "method:${method}, uri:${uri}, status:${status}\n", 79 | })) 80 | 81 | dbService = db 82 | 83 | api := ComedianAPI{ 84 | echo: echo, 85 | db: db, 86 | config: config, 87 | bots: []*botuser.Bot{}, 88 | bundle: bundle, 89 | } 90 | 91 | echo.GET("/healthcheck", api.healthcheck) 92 | echo.POST("/login", api.login) 93 | echo.POST("/event", api.handleEvent) 94 | echo.POST("/service-message", api.handleServiceMessage) 95 | echo.POST("/commands", api.handleCommands) 96 | echo.POST("/team-worklogs", api.showTeamWorklogs) 97 | echo.POST("/user-commands", api.handleUsersCommands) 98 | echo.GET("/auth", api.auth) 99 | 100 | g := echo.Group("/v1") 101 | g.Use(AuthPreRequest) 102 | 103 | g.GET("/bots/:id", api.getBot) 104 | g.PATCH("/bots/:id", api.updateBot) 105 | 106 | g.GET("/standups", api.listStandups) 107 | g.GET("/standups/:id", api.getStandup) 108 | g.PATCH("/standups/:id", api.updateStandup) 109 | g.DELETE("/standups/:id", api.deleteStandup) 110 | 111 | g.GET("/channels", api.listChannels) 112 | g.PATCH("/channels/:id", api.updateChannel) 113 | g.DELETE("/channels/:id", api.deleteChannel) 114 | 115 | g.GET("/standupers", api.listStandupers) 116 | g.PATCH("/standupers/:id", api.updateStanduper) 117 | g.DELETE("/standupers/:id", api.deleteStanduper) 118 | 119 | return &api 120 | } 121 | 122 | // AuthPreRequest is the middleware function. 123 | func AuthPreRequest(next echo.HandlerFunc) echo.HandlerFunc { 124 | return func(c echo.Context) error { 125 | 126 | accessToken := c.Request().Header.Get(echo.HeaderAuthorization) 127 | if accessToken == "" { 128 | return echo.NewHTTPError(http.StatusUnauthorized, "Missing or incorrect Bot Access Token") 129 | } 130 | 131 | bot, err := dbService.GetWorkspaceByBotAccessToken(accessToken) 132 | if err != nil { 133 | return echo.NewHTTPError(http.StatusUnauthorized, "Missing or incorrect Bot Access Token") 134 | } 135 | 136 | c.Set("teamID", bot.WorkspaceID) 137 | 138 | return next(c) 139 | } 140 | } 141 | 142 | //SelectBot returns bot by its team id or teamname if found 143 | func (api *ComedianAPI) SelectBot(team string) (*botuser.Bot, error) { 144 | var bot botuser.Bot 145 | 146 | for _, b := range api.bots { 147 | if b.Suits(team) { 148 | return b, nil 149 | } 150 | } 151 | 152 | return &bot, errors.New("bot not found") 153 | } 154 | 155 | func (api *ComedianAPI) removeBot(team string) { 156 | var index int 157 | for i, b := range api.bots { 158 | if b.Suits(team) { 159 | index = i 160 | } 161 | } 162 | 163 | api.bots = append(api.bots[:index], api.bots[index+1:]...) 164 | } 165 | 166 | // Start starts http server 167 | func (api *ComedianAPI) Start() error { 168 | 169 | settings, err := api.db.GetAllWorkspaces() 170 | if err != nil { 171 | return err 172 | } 173 | 174 | for _, bs := range settings { 175 | bot := botuser.New(api.config, api.bundle, bs, api.db) 176 | api.bots = append(api.bots, bot) 177 | bot.Start() 178 | } 179 | 180 | return api.echo.Start(api.config.HTTPBindAddr) 181 | } 182 | 183 | func (api *ComedianAPI) healthcheck(c echo.Context) error { 184 | return c.JSON(http.StatusOK, "Comedian is healthy") 185 | } 186 | 187 | func (api *ComedianAPI) login(c echo.Context) error { 188 | logingPayload := new(LoginPayload) 189 | if err := c.Bind(logingPayload); err != nil { 190 | return echo.NewHTTPError(http.StatusBadRequest, incorrectDataFormat) 191 | } 192 | 193 | resp, err := slack.GetOAuthResponse(api.config.SlackClientID, api.config.SlackClientSecret, logingPayload.Code, logingPayload.RedirectURI, false) 194 | if err != nil { 195 | log.Errorf("GetOAuthResponse failed: %v", err) 196 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 197 | } 198 | 199 | slackClient := slack.New(resp.AccessToken) 200 | 201 | userIdentity, err := slackClient.GetUserIdentity() 202 | if err != nil { 203 | log.Errorf("GetUserIdentity failed: %v", err) 204 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 205 | } 206 | 207 | bot, err := api.db.GetWorkspaceByWorkspaceID(userIdentity.Team.ID) 208 | if err != nil { 209 | log.Errorf("GetWorkspaceByWorkspaceID failed: %v for teamID %v", err, userIdentity.Team.ID) 210 | return echo.NewHTTPError(http.StatusNotFound, "Comedian was not invited to your Slack. Please, add it and try again") 211 | } 212 | 213 | slackClient = slack.New(bot.BotAccessToken) 214 | 215 | user, err := slackClient.GetUserInfo(userIdentity.User.ID) 216 | if err != nil { 217 | log.Errorf("GetUserInfo failed: %v for userID %v", err, userIdentity.User.ID) 218 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 219 | } 220 | 221 | channels, err := api.db.ListWorkspaceProjects(bot.WorkspaceID) 222 | if err != nil { 223 | log.Errorf("ListWorkspaceProjects failed: %v for workspace %v", err, bot.WorkspaceID) 224 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 225 | } 226 | 227 | return c.JSON(http.StatusOK, map[string]interface{}{ 228 | "user": user, 229 | "channels:": channels, 230 | "bot": bot, 231 | }) 232 | } 233 | 234 | func (api *ComedianAPI) handleEvent(c echo.Context) error { 235 | var incomingEvent Event 236 | 237 | body, err := ioutil.ReadAll(c.Request().Body) 238 | if err != nil { 239 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 240 | } 241 | 242 | err = json.Unmarshal(body, &incomingEvent) 243 | if err != nil { 244 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 245 | } 246 | 247 | if incomingEvent.Token != api.config.SlackVerificationToken { 248 | return echo.NewHTTPError(http.StatusUnauthorized, "verification token does not match") 249 | } 250 | 251 | if incomingEvent.Type == slackevents.URLVerification { 252 | return c.JSON(http.StatusOK, incomingEvent.Challenge) 253 | } 254 | 255 | if incomingEvent.Type == slackevents.CallbackEvent { 256 | var event slackevents.EventsAPICallbackEvent 257 | err = json.Unmarshal(body, &event) 258 | if err != nil { 259 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 260 | } 261 | 262 | err = api.HandleCallbackEvent(event) 263 | if err != nil { 264 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 265 | } 266 | } 267 | 268 | return c.JSON(http.StatusOK, "Success") 269 | } 270 | 271 | func (api *ComedianAPI) handleServiceMessage(c echo.Context) error { 272 | 273 | var incomingEvent model.ServiceEvent 274 | 275 | body, err := ioutil.ReadAll(c.Request().Body) 276 | if err != nil { 277 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 278 | } 279 | 280 | err = json.Unmarshal(body, &incomingEvent) 281 | if err != nil { 282 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 283 | } 284 | 285 | err = api.HandleEvent(incomingEvent) 286 | if err != nil { 287 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 288 | } 289 | 290 | return c.JSON(http.StatusOK, "Message handled!") 291 | } 292 | 293 | func (api *ComedianAPI) handleCommands(c echo.Context) error { 294 | slashCommand, err := slack.SlashCommandParse(c.Request()) 295 | if err != nil { 296 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 297 | } 298 | 299 | if !slashCommand.ValidateToken(api.config.SlackVerificationToken) { 300 | return echo.NewHTTPError(http.StatusBadRequest, "wrong verification token") 301 | } 302 | 303 | bot, err := api.SelectBot(slashCommand.TeamID) 304 | if err != nil { 305 | log.WithFields(log.Fields{ 306 | "error": err, 307 | "fucntion": "select bot", 308 | "data": slashCommand}, 309 | ).Error("handleCommands failed") 310 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) 311 | } 312 | 313 | message := bot.ImplementCommands(slashCommand) 314 | 315 | return c.String(http.StatusOK, message) 316 | } 317 | 318 | func (api *ComedianAPI) handleUsersCommands(c echo.Context) error { 319 | slashCommand, err := slack.SlashCommandParse(c.Request()) 320 | if err != nil { 321 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 322 | } 323 | 324 | if !slashCommand.ValidateToken(api.config.SlackVerificationToken) { 325 | return c.JSON(http.StatusBadRequest, "Invalid verification token") 326 | } 327 | 328 | bot, err := api.SelectBot(slashCommand.TeamID) 329 | if err != nil { 330 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) 331 | } 332 | 333 | today := time.Now() 334 | dateFrom := fmt.Sprintf("%d-%02d-%02d", today.Year(), today.Month(), 1) 335 | dateTo := fmt.Sprintf("%d-%02d-%02d", today.Year(), today.Month(), today.Day()) 336 | dataOnUser, err := bot.GetCollectorData("users", slashCommand.UserID, dateFrom, dateTo) 337 | if err != nil { 338 | return c.JSON(http.StatusOK, "Failed to get data from Collector. Make sure you were added to Collector database and try again") 339 | } 340 | 341 | message := fmt.Sprintf("You have logged %v from the begining of the month", botuser.SecondsToHuman(dataOnUser.Worklogs)) 342 | 343 | return c.JSON(http.StatusOK, message) 344 | } 345 | 346 | //HandleEvent sends message to Slack Workspace 347 | func (api *ComedianAPI) HandleEvent(incomingEvent model.ServiceEvent) error { 348 | bot, err := api.SelectBot(incomingEvent.TeamName) 349 | if err != nil { 350 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) 351 | } 352 | 353 | if bot.Settings().BotAccessToken != incomingEvent.AccessToken { 354 | return errors.New("Wrong access token") 355 | } 356 | 357 | return bot.SendMessage(incomingEvent.Channel, incomingEvent.Message, incomingEvent.Attachments) 358 | } 359 | 360 | //HandleCallbackEvent choses bot to deal with event and then handles event 361 | func (api *ComedianAPI) HandleCallbackEvent(event slackevents.EventsAPICallbackEvent) error { 362 | bot, err := api.SelectBot(event.TeamID) 363 | if err != nil { 364 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) 365 | } 366 | 367 | ev := map[string]interface{}{} 368 | data, err := event.InnerEvent.MarshalJSON() 369 | if err != nil { 370 | return err 371 | } 372 | 373 | if err := json.Unmarshal(data, &ev); err != nil { 374 | return err 375 | } 376 | 377 | log.Info("New event: ", ev["type"].(string)) 378 | 379 | switch ev["type"].(string) { 380 | case "message": 381 | message := &slack.MessageEvent{} 382 | if err := json.Unmarshal(data, message); err != nil { 383 | return err 384 | } 385 | return bot.HandleMessage(message) 386 | 387 | case "member_joined_channel": 388 | join := &slack.MemberJoinedChannelEvent{} 389 | if err := json.Unmarshal(data, join); err != nil { 390 | return err 391 | } 392 | _, err := bot.HandleJoin(join) 393 | return err 394 | case "app_uninstalled": 395 | bot.Stop() 396 | api.removeBot(event.TeamID) 397 | return api.db.DeleteWorkspace(event.TeamID) 398 | default: 399 | log.WithFields(log.Fields{"event": string(data)}).Warning("unrecognized event!") 400 | return nil 401 | } 402 | } 403 | 404 | func (api *ComedianAPI) showTeamWorklogs(c echo.Context) error { 405 | slashCommand, err := slack.SlashCommandParse(c.Request()) 406 | if err != nil { 407 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 408 | } 409 | 410 | if !slashCommand.ValidateToken(api.config.SlackVerificationToken) { 411 | return c.JSON(http.StatusBadRequest, "Invalid verification token") 412 | } 413 | 414 | bot, err := api.SelectBot(slashCommand.TeamID) 415 | if err != nil { 416 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) 417 | } 418 | 419 | standupers, err := api.db.ListProjectStandupers(slashCommand.ChannelID) 420 | if err != nil { 421 | return c.JSON(http.StatusOK, "Could not retreve standupers of the project") 422 | } 423 | 424 | if len(standupers) == 0 { 425 | return c.JSON(http.StatusOK, "No one standups in the project. No data") 426 | } 427 | 428 | channel := standupers[0].ChannelName 429 | 430 | dates := strings.Split(slashCommand.Text, "-") 431 | var from, to time.Time 432 | 433 | if len(dates) == 2 { 434 | 435 | from, err = dateparse.ParseIn(strings.TrimSpace(dates[0]), time.Local) 436 | if err != nil { 437 | return c.JSON(http.StatusOK, err) 438 | } 439 | 440 | to, err = dateparse.ParseIn(strings.TrimSpace(dates[1]), time.Local) 441 | if err != nil { 442 | return c.JSON(http.StatusOK, err) 443 | } 444 | } else { 445 | today := time.Now() 446 | from = time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.Local) 447 | to = today 448 | } 449 | 450 | dateFrom := fmt.Sprintf("%d-%02d-%02d", from.Year(), from.Month(), from.Day()) 451 | dateTo := fmt.Sprintf("%d-%02d-%02d", to.Year(), to.Month(), to.Day()) 452 | 453 | var message string 454 | var total int 455 | 456 | message += fmt.Sprintf("Worklogs of %s, from %s to %s: \n", channel, dateFrom, dateTo) 457 | members := []teamMember{} 458 | 459 | for _, standuper := range standupers { 460 | userInProject := fmt.Sprintf("%v/%v", standuper.UserID, standuper.ChannelName) 461 | dataOnUserInProject, err := bot.GetCollectorData("user-in-project", userInProject, dateFrom, dateTo) 462 | if err != nil { 463 | 464 | continue 465 | } 466 | members = append(members, teamMember{ 467 | standuper: standuper, 468 | teamWorklogs: dataOnUserInProject.Worklogs, 469 | }) 470 | total += dataOnUserInProject.Worklogs 471 | } 472 | 473 | members = sortTeamMembers(members) 474 | 475 | for _, member := range members { 476 | message += fmt.Sprintf("%s - %.2f \n", member.standuper.RealName, float32(member.teamWorklogs)/3600) 477 | } 478 | 479 | message += fmt.Sprintf("In total: %.2f", float32(total)/3600) 480 | 481 | return c.JSON(http.StatusOK, message) 482 | } 483 | 484 | func (api *ComedianAPI) auth(c echo.Context) error { 485 | 486 | urlValues, err := c.FormParams() 487 | if err != nil { 488 | log.WithFields(log.Fields(map[string]interface{}{"error": err})).Error("auth failed on c.FormParams()") 489 | return c.String(http.StatusUnauthorized, err.Error()) 490 | } 491 | 492 | code := urlValues.Get("code") 493 | 494 | resp, err := slack.GetOAuthResponse(api.config.SlackClientID, api.config.SlackClientSecret, code, "", false) 495 | if err != nil { 496 | log.WithFields(log.Fields(map[string]interface{}{"config": api.config, "urlValues": urlValues, "error": err})).Error("auth failed on GetOAuthResponse") 497 | return err 498 | } 499 | 500 | workspaceSettings, err := api.db.GetWorkspaceByWorkspaceID(resp.TeamID) 501 | if err != nil { 502 | cp, err := api.db.CreateWorkspace(model.Workspace{ 503 | CreatedAt: time.Now().Unix(), 504 | BotUserID: resp.Bot.BotUserID, 505 | NotifierInterval: 30, 506 | Language: "en", 507 | MaxReminders: 3, 508 | ReminderOffset: 10, 509 | BotAccessToken: resp.Bot.BotAccessToken, 510 | WorkspaceID: resp.TeamID, 511 | WorkspaceName: resp.TeamName, 512 | ReportingChannel: "", 513 | ReportingTime: "10am", 514 | ProjectsReportsEnabled: false, 515 | }) 516 | 517 | if err != nil { 518 | log.WithFields(log.Fields(map[string]interface{}{"resp": resp, "error": err})).Error("auth failed on CreateBotSettings") 519 | return err 520 | } 521 | 522 | bot := botuser.New(api.config, api.bundle, cp, api.db) 523 | 524 | api.bots = append(api.bots, bot) 525 | 526 | bot.Start() 527 | 528 | return c.Redirect(http.StatusMovedPermanently, api.config.UIurl) 529 | } 530 | 531 | workspaceSettings.BotAccessToken = resp.Bot.BotAccessToken 532 | workspaceSettings.BotUserID = resp.Bot.BotUserID 533 | 534 | settings, err := api.db.UpdateWorkspace(workspaceSettings) 535 | if err != nil { 536 | log.WithFields(log.Fields(map[string]interface{}{"resp": resp, "error": err})).Error("auth failed on CreateBotSettings") 537 | return err 538 | } 539 | 540 | bot, err := api.SelectBot(resp.TeamID) 541 | if err != nil { 542 | log.Error(err) 543 | return err 544 | } 545 | 546 | bot.SetProperties(&settings) 547 | 548 | return c.Redirect(http.StatusMovedPermanently, api.config.UIurl) 549 | 550 | } 551 | 552 | func sortTeamMembers(entries []teamMember) []teamMember { 553 | var members []teamMember 554 | 555 | for i := 0; i < len(entries); i++ { 556 | if !sweep(entries, i) { 557 | break 558 | } 559 | } 560 | 561 | for _, item := range entries { 562 | members = append(members, item) 563 | } 564 | 565 | return members 566 | } 567 | 568 | func sweep(entries []teamMember, prevPasses int) bool { 569 | var N = len(entries) 570 | var didSwap = false 571 | var firstIndex = 0 572 | var secondIndex = 1 573 | 574 | for secondIndex < (N - prevPasses) { 575 | 576 | var firstItem = entries[firstIndex] 577 | var secondItem = entries[secondIndex] 578 | if entries[firstIndex].teamWorklogs < entries[secondIndex].teamWorklogs { 579 | entries[firstIndex] = secondItem 580 | entries[secondIndex] = firstItem 581 | didSwap = true 582 | } 583 | firstIndex++ 584 | secondIndex++ 585 | } 586 | 587 | return didSwap 588 | } 589 | -------------------------------------------------------------------------------- /api/api_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/maddevsio/comedian/config" 10 | "github.com/maddevsio/comedian/storage" 11 | "github.com/stretchr/testify/assert" 12 | yaml "gopkg.in/yaml.v2" 13 | ) 14 | 15 | func TestSwaggerRoutesExistInEcho(t *testing.T) { 16 | c, err := config.Get() 17 | assert.NoError(t, err) 18 | sw, err := getSwagger() 19 | assert.NoError(t, err) 20 | db, err := storage.New(c.DatabaseURL, "../migrations") 21 | assert.NoError(t, err) 22 | api := New(c, db, nil) 23 | routes := api.echo.Routes() 24 | 25 | for k, v := range sw.Paths { 26 | m := v.(map[interface{}]interface{}) 27 | for method := range m { 28 | found := false 29 | for _, route := range routes { 30 | if route.Path == "/swagger.yaml" { 31 | continue 32 | } 33 | path := replaceParams(route.Path) 34 | m := method.(string) 35 | 36 | s := strings.ToLower(route.Method) 37 | if strings.Contains(path, k) && m == s { 38 | found = true 39 | } 40 | } 41 | if !found { 42 | t.Errorf("could not find %v in routes for method %v", k, method) 43 | } 44 | } 45 | } 46 | } 47 | 48 | func TestGetSwagger(t *testing.T) { 49 | _, err := getSwagger() 50 | assert.NoError(t, err) 51 | } 52 | 53 | func getSwagger() (swagger, error) { 54 | var sw swagger 55 | data, err := ioutil.ReadFile("swagger.yaml") 56 | if err != nil { 57 | return sw, err 58 | } 59 | err = yaml.Unmarshal(data, &sw) 60 | return sw, err 61 | } 62 | 63 | func replaceParams(route string) string { 64 | if !echoRouteRegex.MatchString(route) { 65 | return route 66 | } 67 | matches := echoRouteRegex.FindAllStringSubmatch(route, -1) 68 | return fmt.Sprintf("%s{%s}%s", matches[0][1], matches[0][2], matches[0][3]) 69 | } 70 | -------------------------------------------------------------------------------- /api/handlers.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/labstack/echo" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | var ( 12 | incorrectID = "Incorrect value for 'id', must be integer" 13 | accessDenied = "Entity belongs to a different team, access denied" 14 | doesNotExist = "Entity does not yet exist" 15 | incorrectDataFormat = "Incorrect data format, double check request body" 16 | somethingWentWrong = "Something went wrong" 17 | ) 18 | 19 | func (api *ComedianAPI) getBot(c echo.Context) error { 20 | id, err := strconv.ParseInt(c.Param("id"), 0, 64) 21 | if err != nil { 22 | return echo.NewHTTPError(http.StatusBadRequest, incorrectID) 23 | } 24 | 25 | bot, err := api.db.GetWorkspace(id) 26 | if err != nil { 27 | return echo.NewHTTPError(http.StatusNotFound, doesNotExist) 28 | } 29 | 30 | if bot.WorkspaceID != c.Get("teamID") { 31 | return echo.NewHTTPError(http.StatusUnauthorized, accessDenied) 32 | } 33 | 34 | return c.JSON(http.StatusOK, map[string]interface{}{"bot": bot}) 35 | } 36 | 37 | func (api *ComedianAPI) updateBot(c echo.Context) error { 38 | id, err := strconv.ParseInt(c.Param("id"), 0, 64) 39 | if err != nil { 40 | log.WithFields(log.Fields{ 41 | "error": err, 42 | "fucntion": "strconv.ParseInt", 43 | "data": c.Param("id")}, 44 | ).Error("updateBot failed") 45 | return echo.NewHTTPError(http.StatusBadRequest, incorrectID) 46 | } 47 | 48 | settings, err := api.db.GetWorkspace(id) 49 | if err != nil { 50 | log.WithFields(log.Fields{ 51 | "error": err, 52 | "fucntion": "api.db.GetWorkspace", 53 | "data": id}, 54 | ).Error("updateBot failed") 55 | return echo.NewHTTPError(http.StatusNotFound, doesNotExist) 56 | } 57 | 58 | if settings.WorkspaceID != c.Get("teamID") { 59 | 60 | return echo.NewHTTPError(http.StatusForbidden, accessDenied) 61 | } 62 | 63 | if err := c.Bind(&settings); err != nil { 64 | log.WithFields(log.Fields{ 65 | "error": err, 66 | "fucntion": "c.Bind(&settings)", 67 | "data": settings}, 68 | ).Error("updateBot failed") 69 | return echo.NewHTTPError(http.StatusBadRequest, incorrectDataFormat) 70 | } 71 | 72 | res, err := api.db.UpdateWorkspace(settings) 73 | if err != nil { 74 | log.WithFields(log.Fields{ 75 | "error": err, 76 | "fucntion": "api.db.UpdateWorkspace", 77 | "data": settings}, 78 | ).Error("updateBot failed") 79 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 80 | } 81 | 82 | for _, b := range api.bots { 83 | log.Info("Bot languages before update: ", b.Settings()) 84 | } 85 | 86 | bot, err := api.SelectBot(settings.WorkspaceName) 87 | if err != nil { 88 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) 89 | } 90 | 91 | bot.SetProperties(&res) 92 | 93 | for _, b := range api.bots { 94 | log.Info("Bot languages after update: ", b.Settings()) 95 | } 96 | 97 | return c.JSON(http.StatusOK, map[string]interface{}{"bot": res}) 98 | } 99 | 100 | func (api *ComedianAPI) getStandup(c echo.Context) error { 101 | 102 | id, err := strconv.ParseInt(c.Param("id"), 0, 64) 103 | if err != nil { 104 | return echo.NewHTTPError(http.StatusBadRequest, incorrectID) 105 | } 106 | 107 | standup, err := api.db.GetStandup(id) 108 | if err != nil { 109 | return echo.NewHTTPError(http.StatusNotFound, doesNotExist) 110 | } 111 | 112 | if standup.WorkspaceID != c.Get("teamID") { 113 | return echo.NewHTTPError(http.StatusUnauthorized, accessDenied) 114 | } 115 | 116 | return c.JSON(http.StatusOK, map[string]interface{}{"standup": standup}) 117 | } 118 | 119 | func (api *ComedianAPI) listStandups(c echo.Context) error { 120 | 121 | standups, err := api.db.ListTeamStandups(c.Get("teamID").(string)) 122 | if err != nil { 123 | echo.NewHTTPError(http.StatusUnauthorized, somethingWentWrong) 124 | } 125 | 126 | return c.JSON(http.StatusOK, map[string]interface{}{"standups": standups}) 127 | } 128 | 129 | func (api *ComedianAPI) updateStandup(c echo.Context) error { 130 | id, err := strconv.ParseInt(c.Param("id"), 0, 64) 131 | if err != nil { 132 | return echo.NewHTTPError(http.StatusBadRequest, incorrectID) 133 | } 134 | 135 | standup, err := api.db.GetStandup(id) 136 | if err != nil { 137 | return echo.NewHTTPError(http.StatusNotFound, doesNotExist) 138 | } 139 | 140 | if err := c.Bind(&standup); err != nil { 141 | return echo.NewHTTPError(http.StatusBadRequest, incorrectDataFormat) 142 | } 143 | 144 | if standup.WorkspaceID != c.Get("teamID") { 145 | return echo.NewHTTPError(http.StatusUnauthorized, accessDenied) 146 | } 147 | 148 | standup, err = api.db.UpdateStandup(standup) 149 | if err != nil { 150 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 151 | } 152 | 153 | return c.JSON(http.StatusOK, map[string]interface{}{"standup": standup}) 154 | } 155 | 156 | func (api *ComedianAPI) deleteStandup(c echo.Context) error { 157 | id, err := strconv.ParseInt(c.Param("id"), 0, 64) 158 | if err != nil { 159 | return echo.NewHTTPError(http.StatusBadRequest, incorrectID) 160 | } 161 | 162 | standup, err := api.db.GetStandup(id) 163 | if err != nil { 164 | return echo.NewHTTPError(http.StatusNotFound, doesNotExist) 165 | } 166 | 167 | if standup.WorkspaceID != c.Get("teamID") { 168 | return echo.NewHTTPError(http.StatusUnauthorized, accessDenied) 169 | } 170 | 171 | err = api.db.DeleteStandup(id) 172 | if err != nil { 173 | echo.NewHTTPError(http.StatusInternalServerError, somethingWentWrong) 174 | } 175 | 176 | return c.JSON(http.StatusNoContent, "") 177 | } 178 | 179 | func (api *ComedianAPI) listChannels(c echo.Context) error { 180 | 181 | channels, err := api.db.ListWorkspaceProjects(c.Get("teamID").(string)) 182 | if err != nil { 183 | echo.NewHTTPError(http.StatusInternalServerError, somethingWentWrong) 184 | } 185 | 186 | return c.JSON(http.StatusOK, map[string]interface{}{"channels": channels}) 187 | } 188 | 189 | func (api *ComedianAPI) updateChannel(c echo.Context) error { 190 | id, err := strconv.ParseInt(c.Param("id"), 0, 64) 191 | if err != nil { 192 | return echo.NewHTTPError(http.StatusBadRequest, incorrectID) 193 | } 194 | 195 | channel, err := api.db.GetProject(id) 196 | if err != nil { 197 | return echo.NewHTTPError(http.StatusNotFound, doesNotExist) 198 | } 199 | 200 | if err := c.Bind(&channel); err != nil { 201 | return echo.NewHTTPError(http.StatusBadRequest, incorrectDataFormat) 202 | } 203 | 204 | if channel.WorkspaceID != c.Get("teamID") { 205 | return echo.NewHTTPError(http.StatusUnauthorized, accessDenied) 206 | } 207 | 208 | channel, err = api.db.UpdateProject(channel) 209 | if err != nil { 210 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 211 | } 212 | 213 | return c.JSON(http.StatusOK, map[string]interface{}{"channel": channel}) 214 | } 215 | 216 | func (api *ComedianAPI) deleteChannel(c echo.Context) error { 217 | id, err := strconv.ParseInt(c.Param("id"), 0, 64) 218 | if err != nil { 219 | return echo.NewHTTPError(http.StatusBadRequest, incorrectID) 220 | } 221 | 222 | channel, err := api.db.GetProject(id) 223 | if err != nil { 224 | return echo.NewHTTPError(http.StatusNotFound, doesNotExist) 225 | } 226 | 227 | if channel.WorkspaceID != c.Get("teamID") { 228 | return echo.NewHTTPError(http.StatusUnauthorized, accessDenied) 229 | } 230 | 231 | err = api.db.DeleteProject(id) 232 | if err != nil { 233 | return echo.NewHTTPError(http.StatusInternalServerError, somethingWentWrong) 234 | } 235 | 236 | return c.JSON(http.StatusNoContent, "") 237 | } 238 | 239 | func (api *ComedianAPI) listStandupers(c echo.Context) error { 240 | 241 | standupers, err := api.db.ListWorkspaceStandupers(c.Get("teamID").(string)) 242 | if err != nil { 243 | return echo.NewHTTPError(http.StatusInternalServerError, somethingWentWrong) 244 | } 245 | 246 | return c.JSON(http.StatusOK, map[string]interface{}{"standupers": standupers}) 247 | } 248 | 249 | func (api *ComedianAPI) updateStanduper(c echo.Context) error { 250 | id, err := strconv.ParseInt(c.Param("id"), 0, 64) 251 | if err != nil { 252 | return echo.NewHTTPError(http.StatusBadRequest, incorrectID) 253 | } 254 | 255 | standuper, err := api.db.GetStanduper(id) 256 | if err != nil { 257 | return echo.NewHTTPError(http.StatusNotFound, doesNotExist) 258 | } 259 | 260 | if err := c.Bind(&standuper); err != nil { 261 | return echo.NewHTTPError(http.StatusBadRequest, incorrectDataFormat) 262 | } 263 | 264 | if standuper.WorkspaceID != c.Get("teamID") { 265 | return echo.NewHTTPError(http.StatusUnauthorized, accessDenied) 266 | } 267 | 268 | standuper, err = api.db.UpdateStanduper(standuper) 269 | if err != nil { 270 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 271 | } 272 | 273 | return c.JSON(http.StatusOK, map[string]interface{}{"standuper": standuper}) 274 | } 275 | 276 | func (api *ComedianAPI) deleteStanduper(c echo.Context) error { 277 | id, err := strconv.ParseInt(c.Param("id"), 0, 64) 278 | if err != nil { 279 | return echo.NewHTTPError(http.StatusBadRequest, incorrectID) 280 | } 281 | 282 | standuper, err := api.db.GetStanduper(id) 283 | if err != nil { 284 | return echo.NewHTTPError(http.StatusNotFound, doesNotExist) 285 | } 286 | 287 | if standuper.WorkspaceID != c.Get("teamID") { 288 | return echo.NewHTTPError(http.StatusUnauthorized, accessDenied) 289 | } 290 | 291 | err = api.db.DeleteStanduper(id) 292 | if err != nil { 293 | return echo.NewHTTPError(http.StatusInternalServerError, somethingWentWrong) 294 | } 295 | 296 | return c.JSON(http.StatusNoContent, "") 297 | } 298 | -------------------------------------------------------------------------------- /api/swagger.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | version: "1.0.0" 4 | title: "Mad Devs Comedian" 5 | contact: 6 | email: "fedorenko.tolik@gmail.com" 7 | license: 8 | name: "Apache 2.0" 9 | url: "http://www.apache.org/licenses/LICENSE-2.0.html" 10 | host: "comedian.maddevs.co" 11 | basePath: "/" 12 | tags: 13 | - name: "standups" 14 | description: "get, list, update and delete standups" 15 | - name: "channels" 16 | description: "Slack team channels (aka projects) tracked by Comedian" 17 | - name: "standupers" 18 | description: "Project standupers tracked by Comedian" 19 | - name: "bots" 20 | description: "Slack team bot settings (configuration)" 21 | schemes: 22 | - "https" 23 | - "http" 24 | securityDefinitions: 25 | Auth: 26 | description: "Valid Bot Access Token" 27 | type: apiKey 28 | name: Authorization 29 | in: header 30 | paths: 31 | /healthcheck: 32 | get: 33 | summary: "Check if Comedian is healthy" 34 | produces: 35 | - "application/json" 36 | responses: 37 | 200: 38 | description: "Comedian is healthy" 39 | /login: 40 | post: 41 | summary: "Login with Slack auth" 42 | consumes: 43 | - "application/json" 44 | produces: 45 | - "application/json" 46 | parameters: 47 | - in: body 48 | name: body 49 | required: true 50 | description: login params 51 | schema: 52 | $ref: '#/definitions/Login' 53 | responses: 54 | 400: 55 | description: "Incorrect data format, double check request body" 56 | 404: 57 | description: "Comedian was not invited to your Slack. Please, add it and try again" 58 | 200: 59 | description: "Login successful, returns bot info and slack user info" 60 | schema: 61 | type: object 62 | properties: 63 | user: 64 | type: object 65 | $ref: "#/definitions/User" 66 | bot: 67 | type: object 68 | $ref: "#/definitions/Bot" 69 | /event: 70 | post: 71 | summary: "Not UI related. Handles Slack events" 72 | description: "Handles different Slack triggers such as bot removal, or URL verification" 73 | responses: 74 | 200: 75 | description: "Success" 76 | 400: 77 | description: "Returns error description" 78 | 401: 79 | description: "verification token does not match" 80 | /service-message: 81 | post: 82 | summary: "Not UI related. Handles messages from different Comedian services." 83 | consumes: 84 | - "application/json" 85 | produces: 86 | - "application/json" 87 | parameters: 88 | - in: body 89 | name: body 90 | required: true 91 | description: "note: slack attachment has particular structure, learn more at slack documentation" 92 | schema: 93 | $ref: '#/definitions/ServiceMessage' 94 | responses: 95 | 400: 96 | description: "incorrect data format" 97 | 200: 98 | description: "Message handled!" 99 | /commands: 100 | post: 101 | summary: "Not UI related. Handles Slack slash commands requests." 102 | description: "This endpoint is needed for integration with Slack API" 103 | responses: 104 | 200: 105 | description: "Message from Comedian to Slack" 106 | 400: 107 | description: "Contains error description" 108 | /auth: 109 | get: 110 | summary: "Not UI related. Handles Comedian distribution into other Slack Teams." 111 | responses: 112 | 200: 113 | description: "Renders Comedian login page" 114 | /v1/bots/{id}: 115 | get: 116 | security: 117 | - Auth: [] 118 | tags: 119 | - "bots" 120 | summary: "Retrieve bot from db" 121 | consumes: 122 | - "application/json" 123 | produces: 124 | - "application/json" 125 | parameters: 126 | - name: "id" 127 | in: "path" 128 | description: "id of the bot" 129 | required: true 130 | type: "integer" 131 | responses: 132 | 200: 133 | description: "bot" 134 | schema: 135 | $ref: "#/definitions/Bot" 136 | 400: 137 | description: "Incorrect value for bot id, must be integer" 138 | 401: 139 | description: "Missing/incorrect Bot Access Token or trying to access resource from another workspace" 140 | 404: 141 | description: "Entity does not yet exist" 142 | patch: 143 | security: 144 | - Auth: [] 145 | tags: 146 | - "bots" 147 | summary: "Updates a bot in the database with form data" 148 | description: "Update language, notifier_interval, reminder_repeat_max and reminder_time of the bot" 149 | consumes: 150 | - "application/json" 151 | produces: 152 | - "application/json" 153 | parameters: 154 | - name: "id" 155 | in: "path" 156 | description: "id of bot that needs to be updated" 157 | required: true 158 | type: "integer" 159 | - in: body 160 | name: body 161 | required: true 162 | description: Bot params that needs to be updated 163 | schema: 164 | $ref: '#/definitions/Bot' 165 | responses: 166 | 200: 167 | description: "successful operation" 168 | schema: 169 | $ref: "#/definitions/Bot" 170 | 400: 171 | description: "Incorrect value for bot id, must be integer or incorrect payload for bot entity" 172 | 401: 173 | description: "Missing/incorrect Bot Access Token or trying to access resource from another workspace" 174 | 404: 175 | description: "Entity does not yet exist" 176 | /v1/channels: 177 | get: 178 | security: 179 | - Auth: [] 180 | tags: 181 | - "channels" 182 | summary: "Returns all channels" 183 | description: "Returns a map of channel objects" 184 | produces: 185 | - "application/json" 186 | parameters: [] 187 | responses: 188 | 200: 189 | description: "successful operation" 190 | schema: 191 | type: "array" 192 | items: 193 | $ref: "#/definitions/Channel" 194 | 401: 195 | description: "Missing/incorrect Bot Access Token" 196 | 500: 197 | description: "unexpected error occured, need to report to maintainers" 198 | /v1/channels/{id}: 199 | patch: 200 | security: 201 | - Auth: [] 202 | tags: 203 | - "channels" 204 | summary: "Updates a channel in the database" 205 | consumes: 206 | - "application/json" 207 | produces: 208 | - "application/json" 209 | parameters: 210 | - name: "id" 211 | in: "path" 212 | description: "id of channel that needs to be updated" 213 | required: true 214 | type: "integer" 215 | - in: body 216 | name: body 217 | required: true 218 | description: Channel params that needs to be updated. Currently only standup time can be modified 219 | schema: 220 | $ref: '#/definitions/Channel' 221 | responses: 222 | 200: 223 | description: "successful operation" 224 | schema: 225 | $ref: "#/definitions/Channel" 226 | 400: 227 | description: "Incorrect value for channel id, must be integer or incorrect payload for channel entity" 228 | 401: 229 | description: "Missing/incorrect Bot Access Token or trying to access resource from another workspace" 230 | 404: 231 | description: "Entity does not yet exist" 232 | /v1/standupers: 233 | get: 234 | security: 235 | - Auth: [] 236 | tags: 237 | - "standupers" 238 | summary: "Returns all standupers" 239 | description: "Returns a map of standuper objects" 240 | produces: 241 | - "application/json" 242 | parameters: [] 243 | responses: 244 | 200: 245 | description: "successful operation" 246 | schema: 247 | type: "array" 248 | items: 249 | $ref: "#/definitions/Standuper" 250 | 401: 251 | description: "Missing/incorrect Bot Access Token" 252 | 500: 253 | description: "unexpected error occured, need to report to maintainers" 254 | /v1/standupers/{id}: 255 | patch: 256 | security: 257 | - Auth: [] 258 | tags: 259 | - "standupers" 260 | summary: "Updates a standuper in the database with form data" 261 | consumes: 262 | - "application/json" 263 | produces: 264 | - "application/json" 265 | parameters: 266 | - name: "id" 267 | in: "path" 268 | description: "id of standuper that needs to be updated" 269 | required: true 270 | type: "integer" 271 | - in: body 272 | name: body 273 | required: true 274 | description: Standuper params that needs to be updated. Currently only role can be modified 275 | schema: 276 | $ref: '#/definitions/Standuper' 277 | responses: 278 | 200: 279 | description: "successful operation" 280 | schema: 281 | $ref: "#/definitions/Standuper" 282 | 400: 283 | description: "Incorrect value for standuper id, must be integer or incorrect payload for standuper entity" 284 | 401: 285 | description: "Missing/incorrect Bot Access Token or trying to access resource from another workspace" 286 | 404: 287 | description: "Entity does not yet exist" 288 | delete: 289 | security: 290 | - Auth: [] 291 | tags: 292 | - "standupers" 293 | summary: "Deletes a standuper" 294 | description: "Untracks user in channel" 295 | produces: 296 | - "application/json" 297 | parameters: 298 | - name: "id" 299 | in: "path" 300 | description: "standuper id to delete" 301 | required: true 302 | type: "integer" 303 | format: "int" 304 | responses: 305 | 204: 306 | description: "entity was deleted, returns no content" 307 | 400: 308 | description: "Incorrect value for standuper id, must be integer or incorrect payload for standuper entity" 309 | 401: 310 | description: "Missing/incorrect Bot Access Token or trying to access resource from another workspace" 311 | 404: 312 | description: "Entity does not yet exist" 313 | 500: 314 | description: "unexpected error occured, need to report to maintainers" 315 | /v1/standups: 316 | get: 317 | security: 318 | - Auth: [] 319 | tags: 320 | - "standups" 321 | summary: "Returns all standups" 322 | description: "Returns a map of standup objects" 323 | produces: 324 | - "application/json" 325 | parameters: [] 326 | responses: 327 | 200: 328 | description: "successful operation" 329 | schema: 330 | type: "array" 331 | items: 332 | $ref: "#/definitions/Standup" 333 | 401: 334 | description: "Missing/incorrect Bot Access Token" 335 | 500: 336 | description: "unexpected error occured, need to report to maintainers" 337 | /v1/standups/{id}: 338 | get: 339 | security: 340 | - Auth: [] 341 | tags: 342 | - "standups" 343 | summary: "Find standup by id" 344 | description: "Returns a single standup" 345 | produces: 346 | - "application/json" 347 | parameters: 348 | - name: "id" 349 | in: "path" 350 | description: "id of a standup to return" 351 | required: true 352 | type: "integer" 353 | responses: 354 | 200: 355 | description: "successful operation" 356 | schema: 357 | $ref: "#/definitions/Standup" 358 | 400: 359 | description: "Invalid data format" 360 | 401: 361 | description: "Missing/incorrect Bot Access Token or trying to access resource from another workspace" 362 | 404: 363 | description: "Not found" 364 | 403: 365 | description: "No access to resource" 366 | 500: 367 | description: "Internal Error" 368 | patch: 369 | security: 370 | - Auth: [] 371 | tags: 372 | - "standups" 373 | summary: "Updates a standup in the database with form data" 374 | consumes: 375 | - "application/json" 376 | produces: 377 | - "application/json" 378 | parameters: 379 | - name: "id" 380 | in: "path" 381 | description: "id of standup that needs to be updated" 382 | required: true 383 | type: "integer" 384 | - in: body 385 | name: body 386 | required: true 387 | description: standup params that needs to be updated 388 | schema: 389 | $ref: '#/definitions/Standup' 390 | responses: 391 | 200: 392 | description: "successful operation" 393 | schema: 394 | $ref: "#/definitions/Standup" 395 | 400: 396 | description: "Incorrect value for standuper id, must be integer or incorrect payload for standuper entity" 397 | 401: 398 | description: "Missing/incorrect Bot Access Token or trying to access resource from another workspace" 399 | 404: 400 | description: "Entity does not yet exist" 401 | delete: 402 | security: 403 | - Auth: [] 404 | tags: 405 | - "standups" 406 | summary: "Deletes a standup" 407 | produces: 408 | - "application/json" 409 | parameters: 410 | - name: "id" 411 | in: "path" 412 | description: "standup id to delete" 413 | required: true 414 | type: "integer" 415 | format: "int" 416 | responses: 417 | 204: 418 | description: "entity was deleted, returns no content" 419 | 400: 420 | description: "Incorrect value for standup id, must be integer or incorrect payload for standup entity" 421 | 401: 422 | description: "Missing/incorrect Bot Access Token or trying to access resource from another workspace" 423 | 404: 424 | description: "Entity does not yet exist" 425 | 500: 426 | description: "unexpected error occured, need to report to maintainers" 427 | definitions: 428 | Login: 429 | type: "object" 430 | required: 431 | - code 432 | properties: 433 | code: 434 | type: "string" 435 | redirect_uri: 436 | type: "string" 437 | ServiceMessage: 438 | type: "object" 439 | properties: 440 | team_name: 441 | type: "string" 442 | bot_access_token: 443 | type: "string" 444 | channel: 445 | type: "string" 446 | message: 447 | type: "string" 448 | Channel: 449 | type: "object" 450 | properties: 451 | id: 452 | type: "integer" 453 | channelName: 454 | type: "string" 455 | channel_id: 456 | type: "string" 457 | channel_standup_time: 458 | type: "string" 459 | example: "11:30" 460 | Standuper: 461 | type: "object" 462 | properties: 463 | id: 464 | type: "integer" 465 | userId: 466 | type: "string" 467 | channel_id: 468 | type: "string" 469 | submitted_standup_today: 470 | type: "boolean" 471 | created: 472 | type: "string" 473 | format: date-time 474 | role_in_channel: 475 | type: "string" 476 | real_name: 477 | type: "string" 478 | channel_name: 479 | type: "string" 480 | Standup: 481 | type: "object" 482 | properties: 483 | id: 484 | type: "integer" 485 | userId: 486 | type: "string" 487 | channel_id: 488 | type: "string" 489 | comment: 490 | type: "string" 491 | format: "text" 492 | created: 493 | type: "string" 494 | modified: 495 | type: "string" 496 | message_ts: 497 | type: "string" 498 | team_id: 499 | type: "string" 500 | Bot: 501 | type: "object" 502 | properties: 503 | id: 504 | type: "integer" 505 | format: "int64" 506 | team_id: 507 | type: "string" 508 | example: "TB9KS3E13" 509 | team_name: 510 | type: "string" 511 | example: "example" 512 | language: 513 | type: "string" 514 | description: "bot language" 515 | enum: 516 | - "ru_RU" 517 | - "en_US" 518 | notifier_interval: 519 | type: "integer" 520 | format: "int64" 521 | reminder_repeat_max: 522 | type: "integer" 523 | format: "int64" 524 | reminder_time: 525 | type: "integer" 526 | format: "int64" 527 | reporting_channel: 528 | type: "string" 529 | example: "TBA234GH" 530 | reporting_time: 531 | type: "string" 532 | example: "9:00" 533 | individual_reports_on: 534 | type: "boolean" 535 | example: false 536 | User: 537 | type: "object" 538 | properties: 539 | id: 540 | type: "string" 541 | team_id: 542 | type: "string" 543 | name: 544 | type: "string" 545 | deleted: 546 | type: "boolean" 547 | color: 548 | type: "string" 549 | real_name: 550 | type: "string" 551 | tz,omitempty: 552 | type: "string" 553 | tz_label: 554 | type: "string" 555 | tz_offset: 556 | type: "integer" 557 | profile: 558 | type: "object" 559 | is_bot: 560 | type: "boolean" 561 | is_admin: 562 | type: "boolean" 563 | is_owner: 564 | type: "boolean" 565 | is_primary_owner: 566 | type: "boolean" 567 | is_restricted: 568 | type: "boolean" 569 | is_ultra_restricted: 570 | type: "boolean" 571 | is_stranger: 572 | type: "boolean" 573 | is_app_user: 574 | type: "boolean" 575 | has_2fa: 576 | type: "boolean" 577 | has_files: 578 | type: "boolean" 579 | presence: 580 | type: "string" 581 | locale: 582 | type: "string" -------------------------------------------------------------------------------- /botuser/botuser.go: -------------------------------------------------------------------------------- 1 | package botuser 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "sync" 7 | "time" 8 | 9 | "github.com/maddevsio/comedian/config" 10 | "github.com/maddevsio/comedian/model" 11 | "github.com/maddevsio/comedian/storage" 12 | "github.com/nicksnyder/go-i18n/v2/i18n" 13 | "github.com/nlopes/slack" 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | var ( 18 | typeMessage = "" 19 | typeEditMessage = "message_changed" 20 | typeDeleteMessage = "message_deleted" 21 | ) 22 | 23 | var problemKeys = []string{"issue", "мешает"} 24 | var todayPlansKeys = []string{"today", "сегодня"} 25 | var yesterdayWorkKeys = []string{"yesterday", "friday", "вчера", "пятниц"} 26 | 27 | //Message represent any message that can be send to Slack or any other destination 28 | type Message struct { 29 | Type string 30 | Channel string 31 | User string 32 | Text string 33 | Attachments []slack.Attachment 34 | } 35 | 36 | // Bot struct used for storing and communicating with slack api 37 | type Bot struct { 38 | conf *config.Config 39 | db *storage.DB 40 | localizer *i18n.Localizer 41 | workspace *model.Workspace 42 | slack *slack.Client 43 | bundle *i18n.Bundle 44 | quitChan chan struct{} 45 | } 46 | 47 | //New creates new Bot instance 48 | func New(config *config.Config, bundle *i18n.Bundle, settings model.Workspace, db *storage.DB) *Bot { 49 | bot := &Bot{ 50 | conf: config, 51 | db: db, 52 | slack: slack.New(settings.BotAccessToken), 53 | workspace: &settings, 54 | bundle: bundle, 55 | localizer: i18n.NewLocalizer(bundle, settings.Language), 56 | } 57 | bot.quitChan = make(chan struct{}) 58 | return bot 59 | } 60 | 61 | //Start updates Users list and launches notifications 62 | func (bot *Bot) Start() { 63 | var wg sync.WaitGroup 64 | 65 | log.Info("Bot started for ", bot.workspace.WorkspaceName) 66 | 67 | wg.Add(1) 68 | go func() { 69 | ticker := time.NewTicker(time.Second * 60).C 70 | for { 71 | select { 72 | case <-ticker: 73 | err := bot.notifyChannels() 74 | if err != nil { 75 | log.Error("notifyChannels failed: ", err) 76 | } 77 | err = bot.CallDisplayYesterdayTeamReport() 78 | if err != nil { 79 | log.Error("CallDisplayYesterdayTeamReport failed: ", err) 80 | } 81 | err = bot.CallDisplayWeeklyTeamReport() 82 | if err != nil { 83 | log.Error("CallDisplayWeeklyTeamReport failed: ", err) 84 | } 85 | err = bot.remindAboutWorklogs() 86 | if err != nil { 87 | log.Error("remindAboutWorklogs failed: ", err) 88 | } 89 | case <-bot.quitChan: 90 | wg.Done() 91 | return 92 | } 93 | } 94 | }() 95 | } 96 | 97 | func (bot *Bot) send(msg *Message) error { 98 | if msg.Type == "message" { 99 | err := bot.SendMessage(msg.Channel, msg.Text, msg.Attachments) 100 | if err != nil { 101 | return err 102 | } 103 | } 104 | if msg.Type == "ephemeral" { 105 | err := bot.SendEphemeralMessage(msg.Channel, msg.User, msg.Text) 106 | if err != nil { 107 | return err 108 | } 109 | } 110 | if msg.Type == "direct" { 111 | err := bot.SendUserMessage(msg.User, msg.Text) 112 | if err != nil { 113 | return err 114 | } 115 | } 116 | 117 | return nil 118 | } 119 | 120 | //Stop closes bot quitChan making bot goroutine to exit 121 | func (bot *Bot) Stop() { 122 | close(bot.quitChan) 123 | } 124 | 125 | //HandleMessage handles slack message event 126 | func (bot *Bot) HandleMessage(msg *slack.MessageEvent) error { 127 | if !strings.Contains(msg.Msg.Text, bot.workspace.BotUserID) { 128 | return nil 129 | } 130 | msg.Team = bot.workspace.WorkspaceID 131 | switch msg.SubType { 132 | case typeMessage: 133 | _, err := bot.handleNewMessage(msg) 134 | if err != nil { 135 | log.Error("NEW MESSAGE FAILED: ", err) 136 | return err 137 | } 138 | case typeEditMessage: 139 | _, err := bot.handleEditMessage(msg) 140 | if err != nil { 141 | return err 142 | } 143 | case typeDeleteMessage: 144 | _, err := bot.handleDeleteMessage(msg) 145 | if err != nil { 146 | return err 147 | } 148 | case "bot_message": 149 | return nil 150 | } 151 | return nil 152 | } 153 | 154 | func (bot *Bot) handleNewMessage(msg *slack.MessageEvent) (string, error) { 155 | 156 | problem := bot.analizeStandup(msg.Msg.Text) 157 | if problem != "" { 158 | err := bot.send(&Message{ 159 | Type: "ephemeral", 160 | Channel: msg.Channel, 161 | User: msg.User, 162 | Text: problem, 163 | }) 164 | return problem, err 165 | } 166 | 167 | _, err := bot.db.CreateStandup(model.Standup{ 168 | CreatedAt: time.Now().Unix(), 169 | WorkspaceID: msg.Team, 170 | ChannelID: msg.Channel, 171 | UserID: msg.User, 172 | Comment: msg.Msg.Text, 173 | MessageTS: msg.Msg.Timestamp, 174 | }) 175 | if err != nil { 176 | return "", err 177 | } 178 | item := slack.ItemRef{ 179 | Channel: msg.Channel, 180 | Timestamp: msg.Msg.Timestamp, 181 | File: "", 182 | Comment: "", 183 | } 184 | err = bot.slack.AddReaction("heavy_check_mark", item) 185 | if err != nil { 186 | return "", err 187 | } 188 | return "standup saved", nil 189 | } 190 | 191 | func (bot *Bot) handleEditMessage(msg *slack.MessageEvent) (string, error) { 192 | problem := bot.analizeStandup(msg.SubMessage.Text) 193 | if problem != "" { 194 | err := bot.send(&Message{ 195 | Type: "ephemeral", 196 | Channel: msg.Channel, 197 | User: msg.User, 198 | Text: problem, 199 | }) 200 | return problem, err 201 | } 202 | 203 | standup, err := bot.db.SelectStandupByMessageTS(msg.SubMessage.Timestamp) 204 | if err == nil { 205 | standup.Comment = msg.SubMessage.Text 206 | _, err := bot.db.UpdateStandup(standup) 207 | if err != nil { 208 | return "", err 209 | } 210 | return "standup updated", nil 211 | } 212 | 213 | standup, err = bot.db.CreateStandup(model.Standup{ 214 | CreatedAt: time.Now().Unix(), 215 | WorkspaceID: msg.Team, 216 | ChannelID: msg.Channel, 217 | UserID: msg.SubMessage.User, 218 | Comment: msg.SubMessage.Text, 219 | MessageTS: msg.SubMessage.Timestamp, 220 | }) 221 | if err != nil { 222 | return "", err 223 | } 224 | 225 | item := slack.ItemRef{ 226 | Channel: msg.Channel, 227 | Timestamp: msg.SubMessage.Timestamp, 228 | File: "", 229 | Comment: "", 230 | } 231 | err = bot.slack.AddReaction("heavy_check_mark", item) 232 | if err != nil { 233 | return "", err 234 | } 235 | 236 | return "standup created", nil 237 | } 238 | 239 | func (bot *Bot) handleDeleteMessage(msg *slack.MessageEvent) (string, error) { 240 | standup, err := bot.db.SelectStandupByMessageTS(msg.DeletedTimestamp) 241 | if err != nil { 242 | return "", nil 243 | } 244 | 245 | err = bot.db.DeleteStandup(standup.ID) 246 | if err != nil { 247 | return "", err 248 | } 249 | 250 | return "standup deleted", nil 251 | } 252 | 253 | func (bot *Bot) submittedStandupToday(userID, channelID string) bool { 254 | standup, err := bot.db.SelectLatestStandupByUser(userID, channelID) 255 | if err != nil { 256 | return false 257 | } 258 | 259 | userProfile, err := bot.slack.GetUserInfo(userID) 260 | if err != nil { 261 | log.Error(err) 262 | return false 263 | } 264 | 265 | loc := time.FixedZone(userProfile.TZ, userProfile.TZOffset) 266 | 267 | if time.Unix(standup.CreatedAt, 0).Day() == time.Now().UTC().In(loc).Day() { 268 | log.Info("not non reporter: ", userID) 269 | return true 270 | } 271 | return false 272 | } 273 | 274 | func (bot *Bot) analizeStandup(message string) string { 275 | errors := []string{} 276 | message = strings.ToLower(message) 277 | 278 | var mentionsYesterdayWork, mentionsTodayPlans, mentionsProblem bool 279 | 280 | for _, work := range yesterdayWorkKeys { 281 | if strings.Contains(message, work) { 282 | mentionsYesterdayWork = true 283 | } 284 | } 285 | 286 | for _, plan := range todayPlansKeys { 287 | if strings.Contains(message, plan) { 288 | mentionsTodayPlans = true 289 | } 290 | } 291 | 292 | for _, problem := range problemKeys { 293 | if strings.Contains(message, problem) { 294 | mentionsProblem = true 295 | } 296 | } 297 | 298 | if !mentionsYesterdayWork { 299 | warnings, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 300 | DefaultMessage: &i18n.Message{ 301 | ID: "noYesterdayMention", 302 | Other: "- no 'yesterday' keywords detected: {{.Keywords}}", 303 | }, 304 | TemplateData: map[string]interface{}{ 305 | "Keywords": strings.Join(yesterdayWorkKeys, ", "), 306 | }, 307 | }) 308 | if err != nil { 309 | log.Error(err) 310 | } 311 | errors = append(errors, warnings) 312 | } 313 | if !mentionsTodayPlans { 314 | warnings, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 315 | DefaultMessage: &i18n.Message{ 316 | ID: "noTodayMention", 317 | Other: "- no 'today' keywords detected: {{.Keywords}}", 318 | }, 319 | TemplateData: map[string]interface{}{ 320 | "Keywords": strings.Join(todayPlansKeys, ", "), 321 | }, 322 | }) 323 | if err != nil { 324 | log.Error(err) 325 | } 326 | errors = append(errors, warnings) 327 | } 328 | if !mentionsProblem { 329 | warnings, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 330 | DefaultMessage: &i18n.Message{ 331 | ID: "noProblemsMention", 332 | Other: "- no 'problems' keywords detected: {{.Keywords}}", 333 | }, 334 | TemplateData: map[string]interface{}{ 335 | "Keywords": strings.Join(problemKeys, ", "), 336 | }, 337 | }) 338 | if err != nil { 339 | log.Error(err) 340 | } 341 | errors = append(errors, warnings) 342 | } 343 | return strings.Join(errors, ", ") 344 | } 345 | 346 | // SendMessage posts a message in a specified channel visible for everyone 347 | func (bot *Bot) SendMessage(channel, message string, attachments []slack.Attachment) error { 348 | _, _, err := bot.slack.PostMessage(channel, message, slack.PostMessageParameters{Attachments: attachments}) 349 | return err 350 | } 351 | 352 | // SendEphemeralMessage posts a message in a specified channel which is visible only for selected user 353 | func (bot *Bot) SendEphemeralMessage(channel, user, message string) error { 354 | _, err := bot.slack.PostEphemeral(channel, user, slack.MsgOptionText(message, true)) 355 | return err 356 | } 357 | 358 | // SendUserMessage Direct Message specific user 359 | func (bot *Bot) SendUserMessage(userID, message string) error { 360 | _, _, channelID, err := bot.slack.OpenIMChannel(userID) 361 | if err != nil { 362 | return err 363 | } 364 | return bot.SendMessage(channelID, message, nil) 365 | } 366 | 367 | //HandleJoin handles comedian joining channel 368 | func (bot *Bot) HandleJoin(joinEvent *slack.MemberJoinedChannelEvent) (model.Project, error) { 369 | newChannel := model.Project{} 370 | newChannel, err := bot.db.SelectProject(joinEvent.Channel) 371 | if err == nil { 372 | err := bot.SendUserMessage(joinEvent.User, newChannel.OnbordingMessage) 373 | if err != nil { 374 | return newChannel, err 375 | } 376 | return newChannel, nil 377 | } 378 | 379 | channel, err := bot.slack.GetConversationInfo(joinEvent.Channel, true) 380 | if err != nil { 381 | return newChannel, err 382 | } 383 | newChannel, err = bot.db.CreateProject(model.Project{ 384 | CreatedAt: time.Now().Unix(), 385 | WorkspaceID: joinEvent.Team, 386 | ChannelName: channel.Name, 387 | ChannelID: channel.ID, 388 | Deadline: "", 389 | TZ: "Asia/Bishkek", 390 | OnbordingMessage: "Hello and welcome to " + channel.Name, 391 | SubmissionDays: "monday, tuesday, wednesday, thursday, friday", 392 | }) 393 | if err != nil { 394 | return newChannel, err 395 | } 396 | return newChannel, nil 397 | } 398 | 399 | //ImplementCommands implements slash commands such as adding users and managing deadlines 400 | func (bot *Bot) ImplementCommands(command slack.SlashCommand) string { 401 | log.Info("Bot to implement command: ", bot.workspace) 402 | 403 | switch command.Command { 404 | case "/start": 405 | return bot.joinCommand(command) 406 | case "/show": 407 | return bot.showCommand(command) 408 | case "/quit": 409 | return bot.quitCommand(command) 410 | case "/deadline": 411 | return bot.modifyDeadline(command) 412 | case "/tz": 413 | return bot.modifyTZ(command) 414 | case "/submittion_days": 415 | return bot.modifySubmittionDays(command) 416 | case "/onbording_message": 417 | return bot.modifyOnbordingMessage(command) 418 | default: 419 | return "" 420 | } 421 | } 422 | 423 | //Suits returns true if found desired bot workspace 424 | func (bot *Bot) Suits(team string) bool { 425 | return strings.ToLower(team) == strings.ToLower(bot.workspace.WorkspaceID) || strings.ToLower(team) == strings.ToLower(bot.workspace.WorkspaceName) 426 | } 427 | 428 | //Settings just returns bot settings 429 | func (bot *Bot) Settings() *model.Workspace { 430 | return bot.workspace 431 | } 432 | 433 | //SetProperties updates bot settings 434 | func (bot *Bot) SetProperties(settings *model.Workspace) *model.Workspace { 435 | bot.workspace = settings 436 | bot.localizer = i18n.NewLocalizer(bot.bundle, settings.Language) 437 | return bot.workspace 438 | } 439 | 440 | func (bot *Bot) remindAboutWorklogs() error { 441 | if time.Now().AddDate(0, 0, 1).Day() != 1 { 442 | return nil 443 | } 444 | 445 | if time.Now().Hour() != 10 || time.Now().Minute() != 0 { 446 | return nil 447 | } 448 | 449 | users, err := bot.slack.GetUsers() 450 | if err != nil { 451 | return err 452 | } 453 | 454 | for _, user := range users { 455 | if user.TeamID != bot.workspace.WorkspaceID { 456 | continue 457 | } 458 | 459 | standupers, err := bot.db.FindStansupersByUserID(user.ID) 460 | if err != nil { 461 | log.Error(err) 462 | continue 463 | } 464 | 465 | if len(standupers) < 1 { 466 | continue 467 | } 468 | 469 | _, _, err = bot.GetCollectorDataOnMember(standupers[0], time.Date(time.Now().Year(), time.Now().Month(), 1, 0, 0, 0, 0, time.Local), time.Now()) 470 | if err != nil { 471 | log.Error(err) 472 | continue 473 | } 474 | 475 | message := "Сегодня последний день месяца. Пожалуйста, перепроверьте ворклоги!\n" 476 | var total int 477 | 478 | for _, member := range standupers { 479 | user, userInProject, err := bot.GetCollectorDataOnMember(member, time.Date(time.Now().Year(), time.Now().Month(), 1, 0, 0, 0, 0, time.Local), time.Now()) 480 | if err != nil { 481 | log.Error(err) 482 | continue 483 | } 484 | 485 | message += fmt.Sprintf("%s залогано %.2f\n", member.ChannelName, float32(userInProject.Worklogs)/3600) 486 | total = user.Worklogs 487 | } 488 | 489 | message += fmt.Sprintf("В общем: %.2f", float32(total)/3600) 490 | 491 | err = bot.send(&Message{ 492 | Type: "direct", 493 | User: user.ID, 494 | Text: message, 495 | }) 496 | if err != nil { 497 | log.Error("send direct message failed: ", err) 498 | } 499 | } 500 | 501 | return nil 502 | } 503 | -------------------------------------------------------------------------------- /botuser/botuser_test.go: -------------------------------------------------------------------------------- 1 | package botuser 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/maddevsio/comedian/config" 7 | "github.com/maddevsio/comedian/model" 8 | "github.com/maddevsio/comedian/storage" 9 | "github.com/nicksnyder/go-i18n/v2/i18n" 10 | "github.com/stretchr/testify/assert" 11 | "golang.org/x/text/language" 12 | ) 13 | 14 | var bot = setupBot() 15 | 16 | func setupBot() *Bot { 17 | bundle := i18n.NewBundle(language.English) 18 | 19 | config, err := config.Get() 20 | if err != nil { 21 | return nil 22 | } 23 | 24 | db, err := storage.New(config.DatabaseURL, "../migrations") 25 | if err != nil { 26 | return nil 27 | } 28 | 29 | settings := model.Workspace{ 30 | WorkspaceID: "testTeam", 31 | BotAccessToken: "foo", 32 | } 33 | 34 | bot := New(config, bundle, settings, db) 35 | 36 | bot.db.CreateProject(model.Project{ 37 | WorkspaceID: "testTeam", 38 | ChannelID: "CHAN123", 39 | ChannelName: "ChannelWithNoDeadline", 40 | TZ: "Asia/Bishkek", 41 | }) 42 | 43 | bot.db.CreateProject(model.Project{ 44 | WorkspaceID: "testTeam", 45 | ChannelID: "CHAN321", 46 | ChannelName: "ChannelWithDeadline", 47 | Deadline: "12:00", 48 | TZ: "Asia/Bishkek", 49 | }) 50 | 51 | return bot 52 | } 53 | 54 | func TestAnalizeStandup(t *testing.T) { 55 | 56 | errors := bot.analizeStandup("yesterday, today, issues") 57 | assert.Equal(t, "", errors) 58 | 59 | errors = bot.analizeStandup("wrong standup") 60 | assert.Equal(t, "- no 'yesterday' keywords detected: yesterday, friday, вчера, пятниц, - no 'today' keywords detected: today, сегодня, - no 'problems' keywords detected: issue, мешает", errors) 61 | } 62 | -------------------------------------------------------------------------------- /botuser/deadlines.go: -------------------------------------------------------------------------------- 1 | package botuser 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/nicksnyder/go-i18n/v2/i18n" 7 | "github.com/nlopes/slack" 8 | "github.com/olebedev/when" 9 | "github.com/olebedev/when/rules/en" 10 | "github.com/olebedev/when/rules/ru" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | func (bot *Bot) modifyDeadline(command slack.SlashCommand) string { 15 | 16 | if command.Text == "" { 17 | return bot.removeDeadline(command) 18 | } 19 | 20 | w := when.New(nil) 21 | w.Add(en.All...) 22 | w.Add(ru.All...) 23 | 24 | wrongDeadlineFormat, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 25 | DefaultMessage: &i18n.Message{ 26 | ID: "wrongDeadlineFormat", 27 | Other: "Could not recognize deadline time. Use 1pm or 13:00 formats", 28 | }, 29 | }) 30 | if err != nil { 31 | log.Error(err) 32 | } 33 | 34 | r, err := w.Parse(command.Text, time.Now()) 35 | if err != nil { 36 | return wrongDeadlineFormat 37 | } 38 | if r == nil { 39 | return wrongDeadlineFormat 40 | } 41 | 42 | channel, err := bot.db.SelectProject(command.ChannelID) 43 | if err != nil { 44 | deadlineNotSet, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 45 | DefaultMessage: &i18n.Message{ 46 | ID: "deadlineNotSet", 47 | Other: "Could not change channel deadline", 48 | }, 49 | }) 50 | if err != nil { 51 | log.Error(err) 52 | } 53 | return deadlineNotSet 54 | } 55 | 56 | channel.Deadline = r.Text 57 | 58 | _, err = bot.db.UpdateProject(channel) 59 | if err != nil { 60 | deadlineNotSet, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 61 | DefaultMessage: &i18n.Message{ 62 | ID: "deadlineNotSet", 63 | Other: "Could not change channel deadline", 64 | }, 65 | }) 66 | if err != nil { 67 | log.Error(err) 68 | } 69 | return deadlineNotSet 70 | } 71 | 72 | addStandupTime, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 73 | DefaultMessage: &i18n.Message{ 74 | ID: "addStandupTime", 75 | Other: "Updated standup deadline to {{.Deadline}} in {{.TZ}} timezone", 76 | }, 77 | TemplateData: map[string]interface{}{ 78 | "Deadline": command.Text, 79 | "TZ": channel.TZ, 80 | }, 81 | }) 82 | if err != nil { 83 | log.Error(err) 84 | } 85 | return addStandupTime 86 | } 87 | 88 | func (bot *Bot) removeDeadline(command slack.SlashCommand) string { 89 | channel, err := bot.db.SelectProject(command.ChannelID) 90 | if err != nil { 91 | deadlineNotSet, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 92 | DefaultMessage: &i18n.Message{ 93 | ID: "deadlineNotSet", 94 | Other: "Could not change channel deadline", 95 | }, 96 | }) 97 | if err != nil { 98 | log.Error(err) 99 | } 100 | return deadlineNotSet 101 | } 102 | 103 | channel.Deadline = "" 104 | 105 | _, err = bot.db.UpdateProject(channel) 106 | if err != nil { 107 | deadlineNotSet, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 108 | DefaultMessage: &i18n.Message{ 109 | ID: "deadlineNotSet", 110 | Other: "Could not change channel deadline", 111 | }, 112 | }) 113 | if err != nil { 114 | log.Error(err) 115 | } 116 | return deadlineNotSet 117 | } 118 | thread, err := bot.db.SelectNotificationsThread(channel.ChannelID) 119 | if err != nil { 120 | log.Error("Error on executing SelectNotificatioinsThread. ", "ChannelID: ", channel.ChannelID) 121 | } 122 | if thread.ChannelID == channel.ChannelID { 123 | err = bot.db.DeleteNotificationThread(thread.ID) 124 | if err != nil { 125 | log.Error("Error on executing DeleteNotificationThread! ", "ThreadID: ", thread.ID) 126 | } 127 | } 128 | removeStandupTime, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 129 | DefaultMessage: &i18n.Message{ 130 | ID: "removeStandupTime", 131 | Other: "Standup deadline removed", 132 | }, 133 | }) 134 | if err != nil { 135 | log.Error(err) 136 | } 137 | return removeStandupTime 138 | } 139 | -------------------------------------------------------------------------------- /botuser/deadlines_test.go: -------------------------------------------------------------------------------- 1 | package botuser 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nlopes/slack" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestImplementDeadlineCommands(t *testing.T) { 11 | 12 | resp := bot.ImplementCommands(slack.SlashCommand{ 13 | Command: "/deadline", 14 | TeamID: "testTeam", 15 | UserID: "foo123", 16 | ChannelID: "CHAN123", 17 | ChannelName: "ChannelWithNoDeadline", 18 | Text: "12:00", 19 | }) 20 | assert.Equal(t, "Updated standup deadline to 12:00 in Asia/Bishkek timezone", resp) 21 | 22 | resp = bot.ImplementCommands(slack.SlashCommand{ 23 | Command: "/deadline", 24 | TeamID: "testTeam", 25 | UserID: "foo123", 26 | ChannelID: "CHAN123", 27 | ChannelName: "ChannelWithNoDeadline", 28 | Text: "12", 29 | }) 30 | assert.Equal(t, "Could not recognize deadline time. Use 1pm or 13:00 formats", resp) 31 | 32 | resp = bot.ImplementCommands(slack.SlashCommand{ 33 | Command: "/deadline", 34 | TeamID: "testTeam", 35 | UserID: "foo123", 36 | ChannelID: "CHAN123", 37 | ChannelName: "ChannelWithNoDeadline", 38 | Text: "", 39 | }) 40 | assert.Equal(t, "Standup deadline removed", resp) 41 | } 42 | -------------------------------------------------------------------------------- /botuser/notifications.go: -------------------------------------------------------------------------------- 1 | package botuser 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/maddevsio/comedian/model" 9 | "github.com/nicksnyder/go-i18n/v2/i18n" 10 | "github.com/olebedev/when" 11 | "github.com/olebedev/when/rules/en" 12 | "github.com/olebedev/when/rules/ru" 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | func (bot *Bot) notifyChannels() error { 17 | channels, err := bot.listTeamActiveChannels() 18 | if err != nil { 19 | return err 20 | } 21 | 22 | if len(channels) == 0 { 23 | return nil 24 | } 25 | 26 | for _, channel := range channels { 27 | err := bot.notify(channel) 28 | if err != nil { 29 | log.Error(err) 30 | } 31 | } 32 | 33 | return nil 34 | } 35 | 36 | func (bot *Bot) notify(channel model.Project) error { 37 | if !shouldSubmitStandupIn(&channel, time.Now()) { 38 | return nil 39 | } 40 | 41 | loc, err := time.LoadLocation(channel.TZ) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | w := when.New(nil) 47 | w.Add(en.All...) 48 | w.Add(ru.All...) 49 | 50 | //the error is ommited here since to get to this stage the channel 51 | //needs to have proper standup time 52 | r, _ := w.Parse(channel.Deadline, time.Now()) 53 | 54 | alarmtime := time.Unix(r.Time.Unix(), 0) 55 | warningTime := time.Unix(r.Time.Unix()-bot.workspace.ReminderOffset*60, 0) 56 | 57 | var message string 58 | 59 | switch { 60 | case time.Now().In(loc).Hour() == warningTime.Hour() && time.Now().In(loc).Minute() == warningTime.Minute(): 61 | nonReporters, err := bot.findChannelNonReporters(channel) 62 | if err != nil { 63 | return fmt.Errorf("could not get non reporters: %v", err) 64 | } 65 | 66 | message, err = bot.composeWarnMessage(nonReporters) 67 | if err != nil { 68 | return fmt.Errorf("could not compose Warn Message: %v", err) 69 | } 70 | 71 | case time.Now().In(loc).Hour() == alarmtime.Hour() && time.Now().In(loc).Minute() == alarmtime.Minute(): 72 | threadTime := time.Now().Unix() + bot.conf.NotificationTime*60 73 | 74 | nonReporters, err := bot.findChannelNonReporters(channel) 75 | 76 | if err != nil { 77 | return fmt.Errorf("could not get non reporters: %v", err) 78 | } 79 | 80 | if len(nonReporters) > 0 { 81 | usersNonReport := strings.Join(nonReporters, ",") 82 | 83 | _, err = bot.db.CreateNotificationThread(model.NotificationThread{ 84 | ChannelID: channel.ChannelID, 85 | UserIDs: usersNonReport, 86 | NotificationTime: threadTime, 87 | ReminderCounter: 0, 88 | }) 89 | if err != nil { 90 | log.Error("Error on executing CreateNotificationThread ", err, "ChannelID: ", channel.ChannelID) 91 | return err 92 | } 93 | } 94 | message, err = bot.composeAlarmMessage(nonReporters) 95 | if err != nil { 96 | return fmt.Errorf("could not compose Alarm Message: %v", err) 97 | } 98 | } 99 | 100 | bot.send(&Message{ 101 | Type: "message", 102 | Channel: channel.ChannelID, 103 | Text: message, 104 | }) 105 | 106 | thread, err := bot.db.SelectNotificationsThread(channel.ChannelID) 107 | if err != nil && err.Error() != "sql: no rows in result set" { 108 | log.Error("Error on executing SelectNotificationsThread! ", err, "ChannelID: ", channel.ChannelID, "ChannelName: ", channel.ChannelName) 109 | return err 110 | } 111 | 112 | var remindTime time.Time 113 | 114 | remindTime = time.Unix(thread.NotificationTime, 0) 115 | 116 | if time.Now().In(loc).Hour() != remindTime.Hour() || time.Now().In(loc).Minute() != remindTime.Minute() { 117 | return nil 118 | } 119 | 120 | if thread.ReminderCounter >= bot.workspace.MaxReminders { 121 | err = bot.db.DeleteNotificationThread(thread.ID) 122 | if err != nil { 123 | log.Error("Error on executing DeleteNotificationsThread! ", err, "Thread ID: ", thread.ID) 124 | return err 125 | } 126 | } 127 | 128 | stillNonReporters := strings.Split(thread.UserIDs, ",") 129 | 130 | var updatedNonReporters string 131 | 132 | for i, nonReport := range stillNonReporters { 133 | if bot.submittedStandupToday(nonReport, thread.ChannelID) { 134 | stillNonReporters = append(stillNonReporters[:i], stillNonReporters[i+1:]...) 135 | } 136 | } 137 | updatedNonReporters = strings.Join(stillNonReporters, ",") 138 | 139 | if len(updatedNonReporters) == 0 { 140 | err = bot.db.DeleteNotificationThread(thread.ID) 141 | if err != nil { 142 | log.Error("Error on executing DeleteNotificationsThread! ", err, "Thread ID: ", thread.ID) 143 | return err 144 | } 145 | } 146 | 147 | message, err = bot.composeRemindMessage(stillNonReporters) 148 | if err != nil { 149 | return fmt.Errorf("could not compose Remind Message: %v", err) 150 | } 151 | 152 | if message == "" { 153 | return nil 154 | } 155 | 156 | bot.send(&Message{ 157 | Type: "message", 158 | Channel: channel.ChannelID, 159 | Text: message, 160 | }) 161 | 162 | thread.NotificationTime = thread.NotificationTime + bot.conf.NotificationTime*60 163 | 164 | return bot.db.UpdateNotificationThread(thread.ID, thread.NotificationTime, updatedNonReporters) 165 | } 166 | 167 | func (bot *Bot) listTeamActiveChannels() ([]model.Project, error) { 168 | var channels []model.Project 169 | 170 | chs, err := bot.db.ListWorkspaceProjects(bot.workspace.WorkspaceID) 171 | if err != nil { 172 | return channels, err 173 | } 174 | 175 | for _, channel := range chs { 176 | if channel.Deadline == "" { 177 | continue 178 | } 179 | 180 | channels = append(channels, channel) 181 | } 182 | 183 | return channels, nil 184 | } 185 | 186 | func (bot *Bot) findChannelNonReporters(project model.Project) ([]string, error) { 187 | nonReporters := []string{} 188 | 189 | standupers, err := bot.db.ListProjectStandupers(project.ChannelID) 190 | if err != nil { 191 | return nonReporters, err 192 | } 193 | for _, standuper := range standupers { 194 | if !bot.submittedStandupToday(standuper.UserID, standuper.ChannelID) { 195 | nonReporters = append(nonReporters, standuper.UserID) 196 | } 197 | } 198 | 199 | return nonReporters, nil 200 | } 201 | 202 | func (bot *Bot) composeWarnMessage(nonReporters []string) (string, error) { 203 | if len(nonReporters) == 0 { 204 | return "", nil 205 | } 206 | 207 | for i, nr := range nonReporters { 208 | nonReporters[i] = "<@" + nr + ">" 209 | } 210 | 211 | minutes, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 212 | DefaultMessage: &i18n.Message{ 213 | ID: "minutes", 214 | One: "{{.time}} minute", 215 | Two: "{{.time}} minutes", 216 | Few: "{{.time}} minutes", 217 | Many: "{{.time}} minutes", 218 | Other: "{{.time}} minutes", 219 | }, 220 | PluralCount: int(bot.workspace.ReminderOffset), 221 | TemplateData: map[string]interface{}{"time": bot.workspace.ReminderOffset}, 222 | }) 223 | if err != nil { 224 | return "", err 225 | } 226 | 227 | warnNonReporters, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 228 | DefaultMessage: &i18n.Message{ 229 | ID: "warnNonReporters", 230 | One: "{{.user}}, you are the only one to miss standup, in {{.minutes}}, hurry up!", 231 | Two: "{{.users}} you may miss the deadline in {{.minutes}}", 232 | Few: "{{.users}} you may miss the deadline in {{.minutes}}", 233 | Many: "{{.users}} you may miss the deadline in {{.minutes}}", 234 | Other: "{{.users}} you may miss the deadline in {{.minutes}}", 235 | }, 236 | PluralCount: len(nonReporters), 237 | TemplateData: map[string]interface{}{"user": nonReporters[0], "users": strings.Join(nonReporters, ", "), "minutes": minutes}, 238 | }) 239 | if err != nil { 240 | return "", err 241 | } 242 | 243 | return warnNonReporters, nil 244 | } 245 | 246 | func (bot *Bot) composeAlarmMessage(nonReporters []string) (string, error) { 247 | if len(nonReporters) == 0 { 248 | return "", nil 249 | } 250 | 251 | for i, nr := range nonReporters { 252 | nonReporters[i] = "<@" + nr + ">" 253 | } 254 | 255 | alarmNonReporters, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 256 | DefaultMessage: &i18n.Message{ 257 | ID: "tagNonReporters", 258 | One: "{{.user}}, you are the only one missed standup, shame!", 259 | Two: "{{.users}} you have missed standup deadlines, shame!", 260 | Few: "{{.users}} you have missed standup deadlines, shame!", 261 | Many: "{{.users}} you have missed standup deadlines, shame!", 262 | Other: "{{.users}} you have missed standup deadlines, shame!", 263 | }, 264 | PluralCount: len(nonReporters), 265 | TemplateData: map[string]interface{}{"user": nonReporters[0], "users": strings.Join(nonReporters, ", ")}, 266 | }) 267 | if err != nil { 268 | return "", err 269 | } 270 | 271 | return alarmNonReporters, nil 272 | } 273 | 274 | func (bot *Bot) composeRemindMessage(nonReporters []string) (string, error) { 275 | if len(nonReporters) == 0 { 276 | return "", nil 277 | } 278 | 279 | for i, nr := range nonReporters { 280 | nonReporters[i] = "<@" + nr + ">" 281 | } 282 | 283 | remindNonReporters, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 284 | DefaultMessage: &i18n.Message{ 285 | ID: "tagStillNonReporters", 286 | One: "{{.user}}, you still haven't written a standup! Write a standup!", 287 | Two: "{{.users}} you still haven't written a standup! Write a standup!", 288 | Few: "{{.users}} you still haven't written a standup! Write a standup!", 289 | Many: "{{.users}} you still haven't written a standup! Write a standup!", 290 | Other: "{{.users}} you still haven't written a standup! Write a standup!", 291 | }, 292 | PluralCount: len(nonReporters), 293 | TemplateData: map[string]interface{}{"user": nonReporters[0], "users": strings.Join(nonReporters, ",")}, 294 | }) 295 | if err != nil { 296 | return "", err 297 | } 298 | 299 | return remindNonReporters, nil 300 | } 301 | 302 | func shouldSubmitStandupIn(channel *model.Project, t time.Time) bool { 303 | // TODO need to think of how to include translated versions 304 | if strings.Contains(channel.SubmissionDays, strings.ToLower(t.Weekday().String())) { 305 | return true 306 | } 307 | return false 308 | } 309 | -------------------------------------------------------------------------------- /botuser/notifications_test.go: -------------------------------------------------------------------------------- 1 | package botuser 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/maddevsio/comedian/model" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestFindChannelNonReporters(t *testing.T) { 12 | t.Skip("Need to fix test and only then run") 13 | nonReportes, err := bot.findChannelNonReporters(model.Project{ 14 | ChannelID: "CHAN123", 15 | }) 16 | assert.NoError(t, err) 17 | assert.Equal(t, 0, len(nonReportes)) 18 | 19 | standuper, err := bot.db.CreateStanduper(model.Standuper{ 20 | CreatedAt: time.Now().Unix(), 21 | WorkspaceID: "testTeam", 22 | ChannelID: "CHAN123", 23 | UserID: "Foo", 24 | }) 25 | 26 | assert.NoError(t, err) 27 | 28 | nonReportes, err = bot.findChannelNonReporters(model.Project{ 29 | ChannelID: "CHAN123", 30 | }) 31 | assert.NoError(t, err) 32 | assert.Equal(t, 1, len(nonReportes)) 33 | assert.Equal(t, "<@"+standuper.UserID+">", nonReportes[0]) 34 | 35 | standup, err := bot.db.CreateStandup(model.Standup{ 36 | CreatedAt: time.Now().Unix(), 37 | WorkspaceID: "testTeam", 38 | ChannelID: "CHAN123", 39 | UserID: "Foo", 40 | MessageTS: "12345", 41 | }) 42 | assert.NoError(t, err) 43 | 44 | nonReportes, err = bot.findChannelNonReporters(model.Project{ 45 | ChannelID: "CHAN123", 46 | }) 47 | assert.NoError(t, err) 48 | assert.Equal(t, 0, len(nonReportes)) 49 | 50 | assert.NoError(t, bot.db.DeleteStanduper(standuper.ID)) 51 | assert.NoError(t, bot.db.DeleteStandup(standup.ID)) 52 | } 53 | -------------------------------------------------------------------------------- /botuser/onbording_message.go: -------------------------------------------------------------------------------- 1 | package botuser 2 | 3 | import ( 4 | "github.com/nicksnyder/go-i18n/v2/i18n" 5 | "github.com/nlopes/slack" 6 | log "github.com/sirupsen/logrus" 7 | ) 8 | 9 | func (bot *Bot) modifyOnbordingMessage(command slack.SlashCommand) string { 10 | onbordingMessage := command.Text 11 | 12 | channel, err := bot.db.SelectProject(command.ChannelID) 13 | if err != nil { 14 | deadlineNotSet, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 15 | DefaultMessage: &i18n.Message{ 16 | ID: "onbordingMessageNotSet", 17 | Other: "Could not change channel onbording message", 18 | }, 19 | }) 20 | if err != nil { 21 | log.Error(err) 22 | } 23 | return deadlineNotSet 24 | } 25 | 26 | channel.OnbordingMessage = onbordingMessage 27 | 28 | _, err = bot.db.UpdateProject(channel) 29 | if err != nil { 30 | log.Error(err) 31 | msg, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 32 | DefaultMessage: &i18n.Message{ 33 | ID: "failedUpdateOnbordingMessage", 34 | Other: "Failed to update onbording message", 35 | }, 36 | }) 37 | if err != nil { 38 | log.Error(err) 39 | } 40 | return msg 41 | } 42 | 43 | msg, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 44 | DefaultMessage: &i18n.Message{ 45 | ID: "updateOnbordingMessage", 46 | Other: "Channel onbording message is updated, new message is {{.OM}}", 47 | }, 48 | TemplateData: map[string]interface{}{ 49 | "OM": onbordingMessage, 50 | }, 51 | }) 52 | if err != nil { 53 | log.Error(err) 54 | } 55 | return msg 56 | } 57 | -------------------------------------------------------------------------------- /botuser/reporting.go: -------------------------------------------------------------------------------- 1 | package botuser 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "math" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/maddevsio/comedian/model" 12 | "github.com/nicksnyder/go-i18n/v2/i18n" 13 | "github.com/nlopes/slack" 14 | "github.com/olebedev/when" 15 | "github.com/olebedev/when/rules/en" 16 | "github.com/olebedev/when/rules/ru" 17 | log "github.com/sirupsen/logrus" 18 | ) 19 | 20 | //CollectorData used to parse data on user from Collector 21 | type CollectorData struct { 22 | Commits int `json:"total_commits"` 23 | Worklogs int `json:"worklogs"` 24 | } 25 | 26 | //AttachmentItem is needed to sort attachments 27 | type AttachmentItem struct { 28 | SlackAttachment slack.Attachment 29 | Points int 30 | } 31 | 32 | // CallDisplayYesterdayTeamReport calls displayYesterdayTeamReport 33 | func (bot *Bot) CallDisplayYesterdayTeamReport() error { 34 | if bot.workspace.ReportingTime == "" { 35 | return nil 36 | } 37 | 38 | w := when.New(nil) 39 | w.Add(en.All...) 40 | w.Add(ru.All...) 41 | 42 | r, err := w.Parse(bot.workspace.ReportingTime, time.Now()) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | if time.Now().Hour() != r.Time.Hour() || time.Now().Minute() != r.Time.Minute() { 48 | return nil 49 | } 50 | 51 | _, err = bot.displayYesterdayTeamReport() 52 | return err 53 | 54 | } 55 | 56 | // CallDisplayWeeklyTeamReport calls displayWeeklyTeamReport 57 | func (bot *Bot) CallDisplayWeeklyTeamReport() error { 58 | if int(time.Now().Weekday()) != 0 { 59 | return nil 60 | } 61 | 62 | if bot.workspace.ReportingTime == "" { 63 | return nil 64 | } 65 | 66 | w := when.New(nil) 67 | w.Add(en.All...) 68 | w.Add(ru.All...) 69 | 70 | r, err := w.Parse(bot.workspace.ReportingTime, time.Now()) 71 | 72 | if time.Now().Hour() != r.Time.Hour() || time.Now().Minute() != r.Time.Minute() { 73 | return nil 74 | } 75 | 76 | _, err = bot.displayWeeklyTeamReport() 77 | return err 78 | } 79 | 80 | // displayYesterdayTeamReport generates report on users who submit standups 81 | func (bot *Bot) displayYesterdayTeamReport() (string, error) { 82 | var allReports []slack.Attachment 83 | 84 | channels, err := bot.db.ListWorkspaceProjects(bot.workspace.WorkspaceID) 85 | if err != nil { 86 | return "", err 87 | } 88 | 89 | reportHeader, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 90 | DefaultMessage: &i18n.Message{ 91 | ID: "reportHeader", 92 | Other: "", 93 | }, 94 | }) 95 | if err != nil { 96 | log.Error(err) 97 | } 98 | 99 | for _, channel := range channels { 100 | 101 | var attachments []slack.Attachment 102 | var attachmentsPull []AttachmentItem 103 | 104 | standupers, err := bot.db.ListProjectStandupers(channel.ChannelID) 105 | if err != nil { 106 | log.Errorf("ListProjectStandupers failed for channel %v: %v", channel.ChannelName, err) 107 | continue 108 | } 109 | 110 | if len(standupers) == 0 { 111 | continue 112 | } 113 | 114 | for _, standuper := range standupers { 115 | var attachment slack.Attachment 116 | var attachmentFields []slack.AttachmentField 117 | var worklogs, commits, standup string 118 | var worklogsPoints, commitsPoints, standupPoints int 119 | 120 | dataOnUser, dataOnUserInProject, collectorError := bot.GetCollectorDataOnMember(standuper, time.Now().AddDate(0, 0, -1), time.Now().AddDate(0, 0, -1)) 121 | 122 | if collectorError == nil { 123 | worklogs, worklogsPoints = bot.processWorklogs(dataOnUser.Worklogs, dataOnUserInProject.Worklogs) 124 | commits, commitsPoints = bot.processCommits(dataOnUser.Commits, dataOnUserInProject.Commits) 125 | } 126 | 127 | if standuper.Role == "pm" || standuper.Role == "designer" { 128 | commits = "" 129 | commitsPoints++ 130 | } 131 | 132 | if collectorError != nil { 133 | worklogs = "" 134 | worklogsPoints++ 135 | commits = "" 136 | commitsPoints++ 137 | } 138 | 139 | standup, standupPoints = bot.processStandup(standuper) 140 | 141 | fieldValue := worklogs + commits + standup 142 | 143 | //if there is nothing to show, do not create attachment 144 | if fieldValue == "" { 145 | log.Warningf("Nothing to show... skip standuper! %v", standuper) 146 | continue 147 | } 148 | 149 | attachmentFields = append(attachmentFields, slack.AttachmentField{ 150 | Value: fieldValue, 151 | Short: false, 152 | }) 153 | 154 | points := worklogsPoints + commitsPoints + standupPoints 155 | 156 | //attachment text will be depend on worklogsPoints,commitsPoints and standupPoints 157 | if points >= 3 { 158 | notTagStanduper, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 159 | DefaultMessage: &i18n.Message{ 160 | ID: "notTagStanduper", 161 | Other: "", 162 | }, 163 | TemplateData: map[string]interface{}{"user": standuper.RealName, "channel": channel.ChannelName}, 164 | }) 165 | if err != nil { 166 | log.Error(err) 167 | } 168 | attachment.Text = notTagStanduper 169 | } else { 170 | tagStanduper, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 171 | DefaultMessage: &i18n.Message{ 172 | ID: "tagStanduper", 173 | Other: "", 174 | }, 175 | TemplateData: map[string]interface{}{"user": standuper.UserID, "channel": channel.ChannelName}, 176 | }) 177 | if err != nil { 178 | log.Error(err) 179 | } 180 | attachment.Text = tagStanduper 181 | } 182 | 183 | switch points { 184 | case 0: 185 | attachment.Color = "danger" 186 | case 1, 2: 187 | attachment.Color = "warning" 188 | case 3: 189 | attachment.Color = "good" 190 | } 191 | 192 | if int(time.Now().Weekday()) == 0 || int(time.Now().Weekday()) == 1 { 193 | attachment.Color = "good" 194 | } 195 | 196 | attachment.Fields = attachmentFields 197 | 198 | item := AttachmentItem{ 199 | SlackAttachment: attachment, 200 | Points: dataOnUserInProject.Worklogs, 201 | } 202 | 203 | attachmentsPull = append(attachmentsPull, item) 204 | } 205 | 206 | if len(attachmentsPull) == 0 { 207 | continue 208 | } 209 | 210 | attachments = bot.sortReportEntries(attachmentsPull) 211 | if bot.workspace.ProjectsReportsEnabled { 212 | err := bot.send(&Message{ 213 | Type: "message", 214 | Channel: channel.ChannelID, 215 | Text: reportHeader, 216 | Attachments: attachments, 217 | }) 218 | if err != nil { 219 | log.Error("send message failed ", err) 220 | } 221 | } 222 | 223 | allReports = append(allReports, attachments...) 224 | } 225 | 226 | if len(allReports) == 0 { 227 | return "", nil 228 | } 229 | 230 | var reportingChannelID string 231 | 232 | for _, ch := range channels { 233 | if (ch.ChannelName == bot.workspace.ReportingChannel && ch.WorkspaceID == bot.workspace.WorkspaceID) || (ch.ChannelID == bot.workspace.ReportingChannel && ch.WorkspaceID == bot.workspace.WorkspaceID) { 234 | reportingChannelID = ch.ChannelID 235 | } 236 | } 237 | 238 | err = bot.send(&Message{ 239 | Type: "message", 240 | Channel: reportingChannelID, 241 | Text: reportHeader, 242 | Attachments: allReports, 243 | }) 244 | 245 | return fmt.Sprintf(reportHeader, allReports), err 246 | } 247 | 248 | // displayWeeklyTeamReport generates report on users who submit standups 249 | func (bot *Bot) displayWeeklyTeamReport() (string, error) { 250 | var allReports []slack.Attachment 251 | 252 | channels, err := bot.db.ListProjects() 253 | if err != nil { 254 | return "", err 255 | } 256 | 257 | reportHeaderWeekly, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 258 | DefaultMessage: &i18n.Message{ 259 | ID: "reportHeaderWeekly", 260 | Other: "", 261 | }, 262 | }) 263 | if err != nil { 264 | log.Error(err) 265 | } 266 | 267 | for _, channel := range channels { 268 | var attachmentsPull []AttachmentItem 269 | var attachments []slack.Attachment 270 | 271 | standupers, err := bot.db.ListProjectStandupers(channel.ChannelID) 272 | if err != nil { 273 | log.Errorf("ListProjectStandupers failed for channel %v: %v", channel.ChannelName, err) 274 | continue 275 | } 276 | 277 | if len(standupers) == 0 { 278 | continue 279 | } 280 | 281 | for _, standuper := range standupers { 282 | var attachment slack.Attachment 283 | var attachmentFields []slack.AttachmentField 284 | var worklogs, commits string 285 | var worklogsPoints, commitsPoints int 286 | 287 | dataOnUser, dataOnUserInProject, collectorError := bot.GetCollectorDataOnMember(standuper, time.Now().AddDate(0, 0, -7), time.Now().AddDate(0, 0, -1)) 288 | 289 | if collectorError == nil { 290 | worklogs, worklogsPoints = bot.processWeeklyWorklogs(dataOnUser.Worklogs, dataOnUserInProject.Worklogs) 291 | commits, commitsPoints = bot.processCommits(dataOnUser.Commits, dataOnUserInProject.Commits) 292 | } 293 | 294 | if standuper.Role == "pm" || standuper.Role == "designer" { 295 | commits = "" 296 | commitsPoints++ 297 | } 298 | 299 | if collectorError != nil { 300 | worklogs = "" 301 | worklogsPoints++ 302 | commits = "" 303 | commitsPoints++ 304 | } 305 | 306 | fieldValue := worklogs + commits 307 | 308 | //if there is nothing to show, do not create attachment 309 | if fieldValue == "" { 310 | continue 311 | } 312 | 313 | attachmentFields = append(attachmentFields, slack.AttachmentField{ 314 | Value: fieldValue, 315 | Short: false, 316 | }) 317 | 318 | points := worklogsPoints + commitsPoints 319 | 320 | if points >= 2 { 321 | notTagStanduper, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 322 | DefaultMessage: &i18n.Message{ 323 | ID: "notTagStanduper", 324 | Other: "", 325 | }, 326 | TemplateData: map[string]interface{}{"user": standuper.RealName, "channel": channel.ChannelName}, 327 | }) 328 | if err != nil { 329 | log.Error(err) 330 | } 331 | attachment.Text = notTagStanduper 332 | } else { 333 | tagStanduper, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 334 | DefaultMessage: &i18n.Message{ 335 | ID: "tagStanduper", 336 | Other: "", 337 | }, 338 | TemplateData: map[string]interface{}{"user": standuper.UserID, "channel": channel.ChannelName}, 339 | }) 340 | if err != nil { 341 | log.Error(err) 342 | } 343 | attachment.Text = tagStanduper 344 | } 345 | 346 | switch points { 347 | case 0: 348 | attachment.Color = "danger" 349 | case 1: 350 | attachment.Color = "warning" 351 | case 2: 352 | attachment.Color = "good" 353 | } 354 | 355 | attachment.Fields = attachmentFields 356 | 357 | item := AttachmentItem{ 358 | SlackAttachment: attachment, 359 | Points: dataOnUserInProject.Worklogs, 360 | } 361 | 362 | attachmentsPull = append(attachmentsPull, item) 363 | } 364 | 365 | if len(attachmentsPull) == 0 { 366 | continue 367 | } 368 | 369 | attachments = bot.sortReportEntries(attachmentsPull) 370 | 371 | if bot.workspace.ProjectsReportsEnabled { 372 | err := bot.send(&Message{ 373 | Type: "message", 374 | Channel: channel.ChannelID, 375 | Text: reportHeaderWeekly, 376 | Attachments: attachments, 377 | }) 378 | if err != nil { 379 | log.Error(err) 380 | } 381 | } 382 | allReports = append(allReports, attachments...) 383 | } 384 | 385 | if len(allReports) == 0 { 386 | return "", nil 387 | } 388 | 389 | var reportingChannelID string 390 | 391 | for _, ch := range channels { 392 | if (ch.ChannelName == bot.workspace.ReportingChannel && ch.WorkspaceID == bot.workspace.WorkspaceID) || (ch.ChannelID == bot.workspace.ReportingChannel && ch.WorkspaceID == bot.workspace.WorkspaceID) { 393 | reportingChannelID = ch.ChannelID 394 | } 395 | } 396 | 397 | err = bot.send(&Message{ 398 | Type: "message", 399 | Channel: reportingChannelID, 400 | Text: reportHeaderWeekly, 401 | Attachments: allReports, 402 | }) 403 | 404 | return fmt.Sprintf(reportHeaderWeekly, allReports), err 405 | } 406 | 407 | func (bot *Bot) processWorklogs(totalWorklogs, projectWorklogs int) (string, int) { 408 | 409 | var points int 410 | worklogsEmoji := "" 411 | 412 | w := totalWorklogs / 3600 413 | switch { 414 | case w < 3: 415 | worklogsEmoji = ":angry:" 416 | case w >= 3 && w < 7: 417 | worklogsEmoji = ":disappointed:" 418 | case w >= 7 && w < 9: 419 | worklogsEmoji = ":wink:" 420 | points++ 421 | case w >= 9: 422 | worklogsEmoji = ":sunglasses:" 423 | points++ 424 | } 425 | 426 | worklogsTime := SecondsToHuman(totalWorklogs) 427 | 428 | if totalWorklogs != projectWorklogs { 429 | var err error 430 | worklogsTime, err = bot.localizer.Localize(&i18n.LocalizeConfig{ 431 | DefaultMessage: &i18n.Message{ 432 | ID: "worklogsTime", 433 | Other: "", 434 | }, 435 | TemplateData: map[string]interface{}{"projectWorklogs": SecondsToHuman(projectWorklogs), "totalWorklogs": SecondsToHuman(totalWorklogs)}, 436 | }) 437 | if err != nil { 438 | log.Error(err) 439 | } 440 | } 441 | 442 | if int(time.Now().Weekday()) == 0 || int(time.Now().Weekday()) == 1 { 443 | worklogsEmoji = "" 444 | if projectWorklogs == 0 { 445 | return "", points 446 | } 447 | } 448 | 449 | worklogsTranslation, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 450 | DefaultMessage: &i18n.Message{ 451 | ID: "worklogsTranslation", 452 | Other: "", 453 | }, 454 | TemplateData: map[string]interface{}{"worklogsTime": worklogsTime, "worklogsEmoji": worklogsEmoji}, 455 | }) 456 | if err != nil { 457 | log.Error(err) 458 | } 459 | return worklogsTranslation, points 460 | } 461 | 462 | func (bot *Bot) processWeeklyWorklogs(totalWorklogs, projectWorklogs int) (string, int) { 463 | var points int 464 | worklogsEmoji := "" 465 | 466 | w := totalWorklogs / 3600 467 | switch { 468 | case w < 31: 469 | worklogsEmoji = ":disappointed:" 470 | case w >= 31 && w < 35: 471 | worklogsEmoji = ":wink:" 472 | points++ 473 | case w >= 35: 474 | worklogsEmoji = ":sunglasses:" 475 | points++ 476 | } 477 | worklogsTime := SecondsToHuman(totalWorklogs) 478 | 479 | if totalWorklogs != projectWorklogs { 480 | var err error 481 | worklogsTime, err = bot.localizer.Localize(&i18n.LocalizeConfig{ 482 | DefaultMessage: &i18n.Message{ 483 | ID: "worklogsTime", 484 | Other: "", 485 | }, 486 | TemplateData: map[string]interface{}{"projectWorklogs": SecondsToHuman(projectWorklogs), "totalWorklogs": SecondsToHuman(totalWorklogs)}, 487 | }) 488 | if err != nil { 489 | log.Error(err) 490 | } 491 | } 492 | 493 | worklogsTranslation, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 494 | DefaultMessage: &i18n.Message{ 495 | ID: "worklogsTranslation", 496 | Other: "", 497 | }, 498 | TemplateData: map[string]interface{}{"worklogsTime": worklogsTime, "worklogsEmoji": worklogsEmoji}, 499 | }) 500 | if err != nil { 501 | log.Error(err) 502 | } 503 | 504 | return worklogsTranslation, points 505 | } 506 | 507 | func (bot *Bot) processCommits(totalCommits, projectCommits int) (string, int) { 508 | var points int 509 | commitsEmoji := "" 510 | 511 | c := projectCommits 512 | switch { 513 | case c == 0: 514 | commitsEmoji = ":shit:" 515 | case c > 0: 516 | commitsEmoji = ":wink:" 517 | points++ 518 | } 519 | 520 | if int(time.Now().Weekday()) == 0 || int(time.Now().Weekday()) == 1 { 521 | commitsEmoji = "" 522 | if projectCommits == 0 { 523 | return "", points 524 | } 525 | } 526 | 527 | commitsTranslation, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 528 | DefaultMessage: &i18n.Message{ 529 | ID: "commitsTranslation", 530 | Other: "", 531 | }, 532 | TemplateData: map[string]interface{}{"projectCommits": projectCommits, "commitsEmoji": commitsEmoji}, 533 | }) 534 | if err != nil { 535 | log.Error(err) 536 | } 537 | return commitsTranslation, points 538 | } 539 | 540 | func (bot *Bot) processStandup(member model.Standuper) (string, int) { 541 | var text string 542 | var points int 543 | 544 | t := time.Now().AddDate(0, 0, -1) 545 | 546 | timeFrom := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local).Unix() 547 | timeTo := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, time.Local).Unix() 548 | 549 | channel, err := bot.db.SelectProject(member.ChannelID) 550 | if err != nil { 551 | log.Error("reporting SelectProject failed: ", err) 552 | return "", points 553 | } 554 | 555 | standup, err := bot.db.GetStandupForPeriod(member.UserID, member.ChannelID, timeFrom, timeTo) 556 | if err != nil { 557 | log.Error("GetStandupForPeriod failed: ", err) 558 | return "", points 559 | } 560 | if standup == nil { 561 | if !shouldSubmitStandupIn(&channel, t) { 562 | return "", points + 1 563 | } 564 | 565 | noStandup, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 566 | DefaultMessage: &i18n.Message{ 567 | ID: "noStandup", 568 | Other: "", 569 | }, 570 | }) 571 | if err != nil { 572 | log.Error(err) 573 | } 574 | text = noStandup 575 | } else { 576 | hasStandup, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 577 | DefaultMessage: &i18n.Message{ 578 | ID: "hasStandup", 579 | Other: "", 580 | }, 581 | }) 582 | if err != nil { 583 | log.Error(err) 584 | } 585 | text = hasStandup 586 | points++ 587 | } 588 | 589 | return text, points 590 | } 591 | 592 | func (bot *Bot) sortReportEntries(entries []AttachmentItem) []slack.Attachment { 593 | var attachments []slack.Attachment 594 | 595 | for i := 0; i < len(entries); i++ { 596 | if !sweep(entries, i) { 597 | break 598 | } 599 | } 600 | 601 | for _, item := range entries { 602 | attachments = append(attachments, item.SlackAttachment) 603 | } 604 | 605 | return attachments 606 | } 607 | 608 | func sweep(entries []AttachmentItem, prevPasses int) bool { 609 | var N = len(entries) 610 | var didSwap = false 611 | var firstIndex = 0 612 | var secondIndex = 1 613 | 614 | for secondIndex < (N - prevPasses) { 615 | 616 | var firstItem = entries[firstIndex] 617 | var secondItem = entries[secondIndex] 618 | if entries[firstIndex].Points < entries[secondIndex].Points { 619 | entries[firstIndex] = secondItem 620 | entries[secondIndex] = firstItem 621 | didSwap = true 622 | } 623 | firstIndex++ 624 | secondIndex++ 625 | } 626 | 627 | return didSwap 628 | } 629 | 630 | //GetCollectorDataOnMember sends API request to Collector endpoint and returns CollectorData type 631 | func (bot *Bot) GetCollectorDataOnMember(member model.Standuper, startDate, endDate time.Time) (CollectorData, CollectorData, error) { 632 | dateFrom := fmt.Sprintf("%d-%02d-%02d", startDate.Year(), startDate.Month(), startDate.Day()) 633 | dateTo := fmt.Sprintf("%d-%02d-%02d", endDate.Year(), endDate.Month(), endDate.Day()) 634 | 635 | project, err := bot.db.SelectProject(member.ChannelID) 636 | if err != nil { 637 | return CollectorData{}, CollectorData{}, err 638 | } 639 | 640 | dataOnUser, err := bot.GetCollectorData("users", member.UserID, dateFrom, dateTo) 641 | if err != nil { 642 | return CollectorData{}, CollectorData{}, err 643 | } 644 | 645 | userInProject := fmt.Sprintf("%v/%v", member.UserID, project.ChannelName) 646 | dataOnUserInProject, err := bot.GetCollectorData("user-in-project", userInProject, dateFrom, dateTo) 647 | if err != nil { 648 | return CollectorData{}, CollectorData{}, err 649 | } 650 | 651 | return dataOnUser, dataOnUserInProject, err 652 | } 653 | 654 | //GetCollectorData sends api request to collector servise and returns collector object 655 | func (bot *Bot) GetCollectorData(getDataOn, data, dateFrom, dateTo string) (CollectorData, error) { 656 | var collectorData CollectorData 657 | linkURL := fmt.Sprintf("%s/rest/api/v1/logger/%s/%s/%s/%s/%s/", bot.conf.CollectorURL, bot.workspace.WorkspaceID, getDataOn, data, dateFrom, dateTo) 658 | req, err := http.NewRequest("GET", linkURL, nil) 659 | if err != nil { 660 | return collectorData, err 661 | } 662 | token := bot.conf.CollectorToken 663 | req.Header.Add("Authorization", fmt.Sprintf("Token %s", token)) 664 | res, err := http.DefaultClient.Do(req) 665 | if err != nil { 666 | return collectorData, err 667 | } 668 | 669 | defer res.Body.Close() 670 | body, _ := ioutil.ReadAll(res.Body) 671 | 672 | if res.StatusCode != 200 { 673 | log.WithFields(log.Fields(map[string]interface{}{"body": string(body), "requestURL": linkURL, "res.StatusCode": res.StatusCode})).Warning("Failed to get collector data on member!") 674 | return collectorData, fmt.Errorf("failed to get collector data. %v", res.StatusCode) 675 | } 676 | json.Unmarshal(body, &collectorData) 677 | return collectorData, nil 678 | } 679 | 680 | //SecondsToHuman converts seconds (int) to HH:MM format 681 | func SecondsToHuman(input int) string { 682 | hours := math.Floor(float64(input) / 60 / 60) 683 | seconds := input % (60 * 60) 684 | minutes := math.Floor(float64(seconds) / 60) 685 | return fmt.Sprintf("%v:%02d", int(hours), int(minutes)) 686 | } 687 | -------------------------------------------------------------------------------- /botuser/standupers.go: -------------------------------------------------------------------------------- 1 | package botuser 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/maddevsio/comedian/model" 9 | "github.com/nicksnyder/go-i18n/v2/i18n" 10 | "github.com/nlopes/slack" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | func (bot *Bot) joinCommand(command slack.SlashCommand) string { 15 | _, err := bot.db.FindStansuperByUserID(command.UserID, command.ChannelID) 16 | if err == nil { 17 | youAlreadyStandup, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 18 | DefaultMessage: &i18n.Message{ 19 | ID: "youAlreadyStandup", 20 | Other: "You are already a part of standup team", 21 | }, 22 | }) 23 | if err != nil { 24 | log.Error(err) 25 | } 26 | return youAlreadyStandup 27 | } 28 | 29 | u, err := bot.slack.GetUserInfo(command.UserID) 30 | if err != nil { 31 | log.Error("joinCommand bot.slack.GetUserInfo failed: ", err) 32 | u = &slack.User{RealName: command.UserName} 33 | } 34 | 35 | ch, err := bot.slack.GetConversationInfo(command.ChannelID, true) 36 | if err != nil { 37 | log.Error("joinCommand bot.slack.GetChannelInfo failed: ", err) 38 | ch = &slack.Channel{} 39 | ch.Name = command.ChannelName 40 | } 41 | 42 | _, err = bot.db.CreateStanduper(model.Standuper{ 43 | CreatedAt: time.Now().Unix(), 44 | WorkspaceID: command.TeamID, 45 | UserID: command.UserID, 46 | ChannelID: command.ChannelID, 47 | ChannelName: ch.Name, 48 | RealName: u.RealName, 49 | Role: command.Text, 50 | }) 51 | if err != nil { 52 | createStanduperFailed, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 53 | DefaultMessage: &i18n.Message{ 54 | ID: "createStanduperFailed", 55 | Other: "Could not add you to standup team", 56 | }, 57 | }) 58 | if err != nil { 59 | log.Error(err) 60 | } 61 | log.Error("CreateStanduper failed: ", err) 62 | return createStanduperFailed 63 | } 64 | 65 | channel, err := bot.db.SelectProject(command.ChannelID) 66 | if err != nil { 67 | channel, err = bot.db.CreateProject(model.Project{ 68 | CreatedAt: time.Now().Unix(), 69 | WorkspaceID: command.TeamID, 70 | ChannelID: command.ChannelID, 71 | ChannelName: ch.Name, 72 | Deadline: "", 73 | TZ: "Asia/Bishkek", 74 | OnbordingMessage: "Hello and welcome to " + ch.Name, 75 | SubmissionDays: "monday, tuesday, wednesday, thursday, friday", 76 | }) 77 | } 78 | 79 | if channel.Deadline == "" { 80 | welcomeWithNoDeadline, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 81 | DefaultMessage: &i18n.Message{ 82 | ID: "welcomeNoDedline", 83 | Other: "Welcome to the standup team, no standup deadline has been setup yet", 84 | }, 85 | }) 86 | if err != nil { 87 | log.Error(err) 88 | } 89 | return welcomeWithNoDeadline 90 | } 91 | 92 | welcomeWithDeadline, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 93 | DefaultMessage: &i18n.Message{ 94 | ID: "welcomeWithDedline", 95 | Other: "Welcome to the standup team, please, submit your standups no later than {{.Deadline}}", 96 | }, 97 | TemplateData: map[string]interface{}{ 98 | "Deadline": channel.Deadline, 99 | }, 100 | }) 101 | if err != nil { 102 | log.Error(err) 103 | } 104 | return welcomeWithDeadline 105 | } 106 | 107 | func (bot *Bot) showCommand(command slack.SlashCommand) string { 108 | var deadline, tz, submittionDays string 109 | channel, err := bot.db.SelectProject(command.ChannelID) 110 | if err != nil { 111 | ch, err := bot.slack.GetChannelInfo(command.ChannelID) 112 | if err != nil { 113 | log.Error("Failed to GetChannelInfo in show command: ", err) 114 | ch = &slack.Channel{} 115 | ch.Name = command.ChannelName 116 | } 117 | 118 | channel, err = bot.db.CreateProject(model.Project{ 119 | CreatedAt: time.Now().Unix(), 120 | WorkspaceID: command.TeamID, 121 | ChannelID: command.ChannelID, 122 | ChannelName: ch.Name, 123 | Deadline: "", 124 | TZ: "Asia/Bishkek", 125 | OnbordingMessage: "Hello and welcome to " + ch.Name, 126 | SubmissionDays: "monday, tuesday, wednesday, thursday, friday", 127 | }) 128 | if err != nil { 129 | log.Error("Failed to create channel in show command: ", err) 130 | } 131 | } 132 | 133 | if channel.Deadline == "" { 134 | showNoStandupTime, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 135 | DefaultMessage: &i18n.Message{ 136 | ID: "showNoStandupTime", 137 | Other: "Standup deadline is not set", 138 | }, 139 | }) 140 | if err != nil { 141 | log.Error(err) 142 | } 143 | deadline = showNoStandupTime 144 | } else { 145 | showStandupTime, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 146 | DefaultMessage: &i18n.Message{ 147 | ID: "showStandupTime", 148 | Other: "Standup deadline is {{.Deadline}}", 149 | }, 150 | TemplateData: map[string]interface{}{"Deadline": channel.Deadline}, 151 | }) 152 | if err != nil { 153 | log.Error(err) 154 | } 155 | deadline = showStandupTime 156 | } 157 | 158 | tz, err = bot.localizer.Localize(&i18n.LocalizeConfig{ 159 | DefaultMessage: &i18n.Message{ 160 | ID: "showTZ", 161 | Other: "Channel Time Zone is {{.TZ}}", 162 | }, 163 | TemplateData: map[string]interface{}{"TZ": channel.TZ}, 164 | }) 165 | if err != nil { 166 | log.Error(err) 167 | } 168 | 169 | if channel.SubmissionDays == "" { 170 | submittionDays, err = bot.localizer.Localize(&i18n.LocalizeConfig{ 171 | DefaultMessage: &i18n.Message{ 172 | ID: "showNoSubmittionDays", 173 | Other: "No submittion days", 174 | }, 175 | }) 176 | if err != nil { 177 | log.Error(err) 178 | } 179 | } else { 180 | submittionDays, err = bot.localizer.Localize(&i18n.LocalizeConfig{ 181 | DefaultMessage: &i18n.Message{ 182 | ID: "showSubmittionDays", 183 | Other: "Submit standups on {{.SD}}", 184 | }, 185 | TemplateData: map[string]interface{}{"SD": channel.SubmissionDays}, 186 | }) 187 | if err != nil { 188 | log.Error(err) 189 | } 190 | } 191 | 192 | members, err := bot.db.ListProjectStandupers(command.ChannelID) 193 | if err != nil || len(members) == 0 { 194 | listNoStandupers, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 195 | DefaultMessage: &i18n.Message{ 196 | ID: "listNoStandupers", 197 | Other: "No standupers in the team, /start to start standuping. ", 198 | }, 199 | }) 200 | if err != nil { 201 | log.Error(err) 202 | } 203 | return listNoStandupers + "\n" + deadline 204 | } 205 | 206 | var list []string 207 | 208 | for _, member := range members { 209 | var role string 210 | role = member.Role 211 | 212 | if member.Role == "" { 213 | role = "developer" 214 | } 215 | list = append(list, fmt.Sprintf("%s(%s)", member.RealName, role)) 216 | } 217 | 218 | listStandupers, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 219 | DefaultMessage: &i18n.Message{ 220 | ID: "showStandupers", 221 | One: "Only {{.Standupers}} submits standups in the team, '/start' to begin. ", 222 | Two: "{{.Standupers}} submit standups in the team. ", 223 | Few: "{{.Standupers}} submit standups in the team. ", 224 | Many: "{{.Standupers}} submit standups in the team. ", 225 | Other: "{{.Standupers}} submit standups in the team. ", 226 | }, 227 | PluralCount: len(members), 228 | TemplateData: map[string]interface{}{"Standupers": strings.Join(list, ", ")}, 229 | }) 230 | if err != nil { 231 | log.Error(err) 232 | } 233 | 234 | return listStandupers + "\n" + deadline + "\n" + tz + "\n" + submittionDays 235 | } 236 | 237 | func (bot *Bot) quitCommand(command slack.SlashCommand) string { 238 | standuper, err := bot.db.FindStansuperByUserID(command.UserID, command.ChannelID) 239 | if err != nil { 240 | notStanduper, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 241 | DefaultMessage: &i18n.Message{ 242 | ID: "notStanduper", 243 | Other: "You do not standup yet", 244 | }, 245 | }) 246 | if err != nil { 247 | log.Error(err) 248 | } 249 | return notStanduper 250 | } 251 | 252 | err = bot.db.DeleteStanduper(standuper.ID) 253 | if err != nil { 254 | log.Error("DeleteStanduper failed: ", err) 255 | failedLeaveStandupers, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 256 | DefaultMessage: &i18n.Message{ 257 | ID: "failedLeaveStandupers", 258 | Other: "Could not remove you from standup team", 259 | }, 260 | }) 261 | if err != nil { 262 | log.Error(err) 263 | } 264 | return failedLeaveStandupers 265 | } 266 | 267 | leaveStanupers, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 268 | DefaultMessage: &i18n.Message{ 269 | ID: "leaveStanupers", 270 | Other: "You no longer have to submit standups, thanks for all your standups and messages", 271 | }, 272 | }) 273 | if err != nil { 274 | log.Error(err) 275 | } 276 | return leaveStanupers 277 | } 278 | -------------------------------------------------------------------------------- /botuser/standupers_test.go: -------------------------------------------------------------------------------- 1 | package botuser 2 | 3 | import ( 4 | "github.com/nlopes/slack" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestImplementStandupersCommands(t *testing.T) { 10 | 11 | resp := bot.ImplementCommands(slack.SlashCommand{ 12 | Command: "/start", 13 | TeamID: "testTeam", 14 | UserID: "foo123", 15 | ChannelID: "CHAN123", 16 | ChannelName: "ChannelWithNoDeadline", 17 | Text: "", 18 | }) 19 | assert.Equal(t, "Welcome to the standup team, no standup deadline has been setup yet", resp) 20 | 21 | resp = bot.ImplementCommands(slack.SlashCommand{ 22 | Command: "/start", 23 | TeamID: "testTeam", 24 | UserID: "foo123", 25 | ChannelID: "CHAN321", 26 | ChannelName: "ChannelWithDeadline", 27 | Text: "", 28 | }) 29 | assert.Equal(t, "Welcome to the standup team, please, submit your standups no later than 12:00", resp) 30 | 31 | resp = bot.ImplementCommands(slack.SlashCommand{ 32 | Command: "/start", 33 | TeamID: "testTeam", 34 | UserID: "foo123", 35 | ChannelID: "CHAN123", 36 | ChannelName: "ChannelWithNoDeadline", 37 | Text: "", 38 | }) 39 | assert.Equal(t, "You are already a part of standup team", resp) 40 | 41 | resp = bot.ImplementCommands(slack.SlashCommand{ 42 | Command: "/show", 43 | TeamID: "testTeam", 44 | UserID: "foo", 45 | ChannelID: "CHAN123", 46 | ChannelName: "ChannelWithNoDeadline", 47 | Text: "", 48 | }) 49 | assert.NotEqual(t, "", resp) 50 | 51 | resp = bot.ImplementCommands(slack.SlashCommand{ 52 | Command: "/quit", 53 | TeamID: "testTeam", 54 | UserID: "foo123", 55 | ChannelID: "CHAN123", 56 | ChannelName: "ChannelWithNoDeadline", 57 | Text: "", 58 | }) 59 | assert.Equal(t, "You no longer have to submit standups, thanks for all your standups and messages", resp) 60 | 61 | resp = bot.ImplementCommands(slack.SlashCommand{ 62 | Command: "/quit", 63 | TeamID: "testTeam", 64 | UserID: "foo123", 65 | ChannelID: "CHAN123", 66 | ChannelName: "ChannelWithNoDeadline", 67 | Text: "", 68 | }) 69 | assert.Equal(t, "You do not standup yet", resp) 70 | 71 | resp = bot.ImplementCommands(slack.SlashCommand{ 72 | Command: "/quit", 73 | TeamID: "testTeam", 74 | UserID: "foo123", 75 | ChannelID: "CHAN321", 76 | ChannelName: "ChannelWithDeadline", 77 | Text: "", 78 | }) 79 | assert.Equal(t, "You no longer have to submit standups, thanks for all your standups and messages", resp) 80 | 81 | resp = bot.ImplementCommands(slack.SlashCommand{ 82 | Command: "/show", 83 | TeamID: "testTeam", 84 | UserID: "foo123", 85 | ChannelID: "CHAN321", 86 | ChannelName: "ChannelWithDeadline", 87 | Text: "", 88 | }) 89 | assert.NotEqual(t, "", resp) 90 | } 91 | -------------------------------------------------------------------------------- /botuser/submittion_days.go: -------------------------------------------------------------------------------- 1 | package botuser 2 | 3 | import ( 4 | "github.com/nicksnyder/go-i18n/v2/i18n" 5 | "github.com/nlopes/slack" 6 | log "github.com/sirupsen/logrus" 7 | ) 8 | 9 | func (bot *Bot) modifySubmittionDays(command slack.SlashCommand) string { 10 | submittionDays := command.Text 11 | 12 | channel, err := bot.db.SelectProject(command.ChannelID) 13 | if err != nil { 14 | deadlineNotSet, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 15 | DefaultMessage: &i18n.Message{ 16 | ID: "submittionDaysNotSet", 17 | Other: "Could not change channel submittion days", 18 | }, 19 | }) 20 | if err != nil { 21 | log.Error(err) 22 | } 23 | return deadlineNotSet 24 | } 25 | 26 | channel.SubmissionDays = submittionDays 27 | 28 | _, err = bot.db.UpdateProject(channel) 29 | if err != nil { 30 | log.Error(err) 31 | msg, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 32 | DefaultMessage: &i18n.Message{ 33 | ID: "failedUpdateSumittionDays", 34 | Other: "Failed to update Sumittion Days", 35 | }, 36 | }) 37 | if err != nil { 38 | log.Error(err) 39 | } 40 | return msg 41 | } 42 | 43 | msg, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 44 | DefaultMessage: &i18n.Message{ 45 | ID: "updateSubmittionDays", 46 | Other: "Channel submittion days are updated, new schedule is {{.SD}}", 47 | }, 48 | TemplateData: map[string]interface{}{ 49 | "SD": submittionDays, 50 | }, 51 | }) 52 | if err != nil { 53 | log.Error(err) 54 | } 55 | return msg 56 | } 57 | -------------------------------------------------------------------------------- /botuser/tz.go: -------------------------------------------------------------------------------- 1 | package botuser 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/nicksnyder/go-i18n/v2/i18n" 8 | "github.com/nlopes/slack" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func (bot *Bot) modifyTZ(command slack.SlashCommand) string { 13 | tz := command.Text 14 | 15 | if strings.TrimSpace(tz) == "" { 16 | tz = "Asia/Bishkek" 17 | } 18 | 19 | _, err := time.LoadLocation(tz) 20 | if err != nil { 21 | msg, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 22 | DefaultMessage: &i18n.Message{ 23 | ID: "failedRecognizeTZ", 24 | Other: "Failed to recognize new TZ you entered, double check the tz name and try again", 25 | }, 26 | }) 27 | 28 | if err != nil { 29 | log.Error(err) 30 | } 31 | return msg 32 | } 33 | 34 | channel, err := bot.db.SelectProject(command.ChannelID) 35 | if err != nil { 36 | failed, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 37 | DefaultMessage: &i18n.Message{ 38 | ID: "tzNotSet", 39 | Other: "Could not change channel time zone", 40 | }, 41 | }) 42 | if err != nil { 43 | log.Error(err) 44 | } 45 | return failed 46 | } 47 | 48 | channel.TZ = tz 49 | 50 | _, err = bot.db.UpdateProject(channel) 51 | if err != nil { 52 | log.Error(err) 53 | msg, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 54 | DefaultMessage: &i18n.Message{ 55 | ID: "failedUpdateTZ", 56 | Other: "Failed to update Timezone", 57 | }, 58 | }) 59 | if err != nil { 60 | log.Error(err) 61 | } 62 | return msg 63 | } 64 | 65 | msg, err := bot.localizer.Localize(&i18n.LocalizeConfig{ 66 | DefaultMessage: &i18n.Message{ 67 | ID: "updateTZ", 68 | Other: "Channel timezone is updated, new TZ is {{.TZ}}", 69 | }, 70 | TemplateData: map[string]interface{}{ 71 | "TZ": tz, 72 | }, 73 | }) 74 | if err != nil { 75 | log.Error(err) 76 | } 77 | return msg 78 | } 79 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/kelseyhightower/envconfig" 5 | ) 6 | 7 | // Config struct used for configuration of app with env variables 8 | type Config struct { 9 | DatabaseURL string `envconfig:"DATABASE" required:"false" default:"comedian:comedian@/comedian?parseTime=true"` 10 | CollectorURL string `envconfig:"COLLECTOR_URL" required:"false" default:""` 11 | CollectorToken string `envconfig:"COLLECTOR_TOKEN" required:"false" default:""` 12 | HTTPBindAddr string `envconfig:"HTTP_BIND_ADDR" required:"false" default:"0.0.0.0:8080"` 13 | SlackClientID string `envconfig:"SLACK_CLIENT_ID" required:"false"` 14 | SlackClientSecret string `envconfig:"SLACK_CLIENT_SECRET" required:"false"` 15 | SlackVerificationToken string `envconfig:"SLACK_VERIFICATION_TOKEN" required:"false"` 16 | UIurl string `envconfig:"UI_URL" required:"false"` 17 | NotificationTime int64 `envconfig:"NOTIFICATION_TIME" default:"1"` 18 | } 19 | 20 | // Get method processes env variables and fills Config struct 21 | func Get() (*Config, error) { 22 | c := &Config{} 23 | err := envconfig.Process("", c) 24 | return c, err 25 | } 26 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestConfig(t *testing.T) { 11 | os.Clearenv() 12 | conf, err := Get() 13 | assert.NoError(t, err) 14 | 15 | os.Setenv("DATABASE", "DB") 16 | os.Setenv("HTTP_BIND_ADDR", "0.0.0.0:8080") 17 | os.Setenv("SLACK_CLIENT_ID", "ID") 18 | os.Setenv("SLACK_CLIENT_SECRET", "SECRET") 19 | 20 | conf, err = Get() 21 | assert.NoError(t, err) 22 | assert.Equal(t, conf.DatabaseURL, "DB") 23 | assert.Equal(t, conf.HTTPBindAddr, "0.0.0.0:8080") 24 | assert.Equal(t, conf.SlackClientID, "ID") 25 | assert.Equal(t, conf.SlackClientSecret, "SECRET") 26 | 27 | } 28 | -------------------------------------------------------------------------------- /docker-compose.test-setup.yml: -------------------------------------------------------------------------------- 1 | version: '2.2' 2 | 3 | networks: 4 | integration-tests: 5 | driver: bridge 6 | 7 | services: 8 | 9 | adminer: 10 | container_name: comedian_adminer 11 | image: adminer:4.7.0 12 | restart: always 13 | ports: 14 | - 8081:8080 15 | networks: 16 | - integration-tests 17 | 18 | db: 19 | image: mysql:5.7 20 | ports: 21 | - "3306:3306" 22 | environment: 23 | MYSQL_ROOT_PASSWORD: root 24 | MYSQL_USER: comedian 25 | MYSQL_PASSWORD: comedian 26 | MYSQL_DATABASE: comedian 27 | healthcheck: 28 | test: "mysql -ucomedian --password=comedian -e 'show databases;' |grep comedian" 29 | interval: 30s 30 | timeout: 20s 31 | retries: 10 32 | networks: 33 | - integration-tests 34 | 35 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '2.2' 2 | 3 | networks: 4 | integration-tests: 5 | driver: bridge 6 | 7 | services: 8 | sut: 9 | image: golang:1.11.4 10 | links: 11 | - db:db 12 | networks: 13 | - integration-tests 14 | environment: 15 | - DATABASE=comedian:comedian@tcp(db:3306)/comedian?parseTime=true 16 | depends_on: 17 | - db 18 | working_dir: /go/src/github.com/maddevsio/comedian/ 19 | volumes: 20 | - ./:/go/src/github.com/maddevsio/comedian/ 21 | command: bash -c "go test -v -cover -race -timeout 30s ./... " 22 | depends_on: 23 | db: 24 | condition: service_healthy 25 | 26 | db: 27 | image: mysql:5.7 28 | environment: 29 | MYSQL_ROOT_PASSWORD: root 30 | MYSQL_USER: comedian 31 | MYSQL_PASSWORD: comedian 32 | MYSQL_DATABASE: comedian 33 | healthcheck: 34 | test: "mysql -ucomedian --password=comedian -e 'show databases;' |grep comedian" 35 | interval: 30s 36 | timeout: 20s 37 | retries: 10 38 | networks: 39 | - integration-tests 40 | 41 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | 5 | db: 6 | image: mysql:5.7 7 | volumes: 8 | - "database:/var/lib/mysql" 9 | environment: 10 | MYSQL_ROOT_PASSWORD: root 11 | MYSQL_DATABASE: comedian 12 | MYSQL_USER: comedian 13 | MYSQL_PASSWORD: comedian 14 | 15 | comedian: 16 | image: maddevsio/comedian 17 | restart: on-failure 18 | links: 19 | - db:db 20 | ports: 21 | - 8080:8080 22 | environment: 23 | TZ: Asia/Bishkek 24 | DATABASE: comedian:comedian@tcp(db:3306)/comedian?parseTime=true 25 | HTTP_BIND_ADDR: 0.0.0.0:8080 26 | SLACK_CLIENT_ID: ${SLACK_CLIENT_ID} 27 | SLACK_CLIENT_SECRET: ${SLACK_CLIENT_SECRET} 28 | SLACK_VERIFICATION_TOKEN: ${SLACK_VERIFICATION_TOKEN} 29 | 30 | depends_on: 31 | - db 32 | 33 | volumes: 34 | database: -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maddevsio/comedian/f0e13e9a5571d1a6ec8bd48016dc7800aeeb6163/docs/logo.png -------------------------------------------------------------------------------- /docs/slack.md: -------------------------------------------------------------------------------- 1 | ## Slack configurations guidelines 2 | 3 | ### **Step 1**: Create a public HTTPS URL for Comedian 4 | Install [ngrok](https://ngrok.com/product) and create a public HTTPS URL for Comedian on your development machine by running `ngrok http 8080`. 5 | 6 | ### **Step 2**: Create Slack chatbot 7 | Create "app" in slack workspace: https://api.slack.com/apps. Obtain App Credentals in `basic info` section and export them 8 | 9 | ``` 10 | export SLACK_CLIENT_ID=383672116036.563661723157 11 | export SLACK_CLIENT_SECRET=6b0826c3b77fd072dc1ec1fc5c582743 12 | export SLACK_VERIFICATION_TOKEN=Oiwpp2x5Jup1jdQxdtnYTOWT 13 | ``` 14 | 15 | ### **Step 3**: Add bot user 16 | From the left sidebar select "Bot users". Create a bot user with any name you like. Turn on "Always show my bot online" feature. 17 | 18 | ### **Step 4**: Configure slash commands 19 | From the left sidebar select "Slash Commands". Create slash command with request URL: `http:///commands`) Mark as needed the option of `Escase channels, users and links sent to your app`. 20 | 21 | | Name | Hint | Description | 22 | | --- | --- | --- | 23 | | /start | start standuping with role | Adds a new user with selected role | 24 | | /quit | - | Removes user with selected role from standup team | 25 | | /show | - | Shows users assigned to standup in the current chat | 26 | | /show_deadline | - | Show standup time in current channel | 27 | | /deadline | - | Update or delete standup time in current channel | 28 | 29 | ### **Step 5**: Add Redirect URL in OAuth & Permissions tab 30 | Add a new redirect url `http:///auth`. Save it! This is where Slack will redirect when you install bot into a workspace 31 | 32 | ### **Step 7**: Add Event Subscriptions 33 | Run Comedian with `make run` command 34 | 35 | In Event Subscriptions tab enable events. Configure URL as follows ```http:///event```. You should receive confirmation of your endpoint. if not, check if Comedian and ngrok are up and working and you have internet access. If confirm received, add `app_uninstalled`, `message_groups`, `message_channels`, `team_join` events. 36 | 37 | ### **Step 8**: Add Comedian to your workspace 38 | Navigate to `manage distribution` tab and press `Add to Slack` button 39 | Chose which slack you want to add Comedian to and authorize it. -------------------------------------------------------------------------------- /docs/translations.md: -------------------------------------------------------------------------------- 1 | ## Translations guidelines 2 | 3 | To update translations proceed with the below workflow: 4 | ``` 5 | goi18n extract 6 | ``` 7 | create file translate.*.toml 8 | Note: * is used instead of a particular language code. for example create `translate.ru.toml` file if you want to modify Russian translations 9 | ``` 10 | goi18n merge active.*.toml translate.*.toml 11 | ``` 12 | after you translate all the message 13 | ``` 14 | goi18n merge active.*.toml translate.*.toml 15 | ``` -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | ## Usage hints 2 | 3 | 1. Invite Comedian to your Slack workspace. This will add Comedian bot to your workspace to interact with. 4 | 2. Invite Comedian bot to a channel you want to submit standups in. 5 | 3. To submit standups write a message with tag @comedian so that bot recognizes it as a standup 6 | 4. Type `/help` to see the list of all available commands and their meaning 7 | 5. Setup deadline to submit standups in the channel with `/update_deadline` command with time of the deadline (for example `/update_deadline 10am`). 8 | 6. To enable Comedian notify you about standup deadline activate `/start` to join channel standup team. 9 | 7. To see channel info (deadline, who submit standups, etc) use `/show` command 10 | 11 | 12 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/BurntSushi/toml" 5 | "github.com/maddevsio/comedian/api" 6 | "github.com/maddevsio/comedian/config" 7 | "github.com/maddevsio/comedian/storage" 8 | "github.com/nicksnyder/go-i18n/v2/i18n" 9 | log "github.com/sirupsen/logrus" 10 | "golang.org/x/text/language" 11 | ) 12 | 13 | func main() { 14 | 15 | cnf, err := config.Get() 16 | if err != nil { 17 | log.Fatal("Failed to get config : ", err) 18 | } 19 | 20 | db, err := storage.New(cnf.DatabaseURL, "migrations") 21 | if err != nil { 22 | log.Fatal("Failed to connect to db: ", err) 23 | } 24 | 25 | bundle := i18n.NewBundle(language.English) 26 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 27 | bundle.MustLoadMessageFile("active.en.toml") 28 | bundle.MustLoadMessageFile("active.ru.toml") 29 | 30 | comedian := api.New(cnf, db, bundle) 31 | 32 | if err = comedian.Start(); err != nil { 33 | log.Fatal("Failed to start Comedian API: ", err) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /migrations/001_create_table_standups.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE `standups` ( 4 | `id` INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, 5 | `created_at` INTEGER NOT NULL, 6 | `workspace_id` VARCHAR(255) NOT NULL, 7 | `channel_id` VARCHAR(255) NOT NULL, 8 | `user_id` VARCHAR(255) NOT NULL, 9 | `comment` VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL, 10 | `message_ts` VARCHAR(255) NOT NULL 11 | ); 12 | -- +goose StatementEnd 13 | 14 | -- +goose Down 15 | -- +goose StatementBegin 16 | DROP TABLE `standups`; 17 | -- +goose StatementEnd 18 | -------------------------------------------------------------------------------- /migrations/002_create_table_standupers.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE `standupers` ( 4 | `id` INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, 5 | `created_at` INTEGER NOT NULL, 6 | `workspace_id` VARCHAR(255) NOT NULL, 7 | `channel_id` VARCHAR(255) NOT NULL, 8 | `user_id` VARCHAR(255) NOT NULL, 9 | `role` VARCHAR(255) NOT NULL, 10 | `real_name` VARCHAR(255) NOT NULL, 11 | `channel_name` VARCHAR(255) NOT NULL 12 | ); 13 | -- +goose StatementEnd 14 | 15 | -- +goose Down 16 | -- +goose StatementBegin 17 | DROP TABLE `standupers`; 18 | -- +goose StatementEnd 19 | -------------------------------------------------------------------------------- /migrations/003_create_table_workspaces.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE `workspaces` ( 4 | `id` INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, 5 | `created_at` INTEGER NOT NULL, 6 | `notifier_interval` INTEGER NOT NULL, 7 | `max_reminders` INTEGER NOT NULL, 8 | `reminder_offset` INTEGER NOT NULL, 9 | `workspace_id` VARCHAR(255) NOT NULL, 10 | `workspace_name` VARCHAR(255) NOT NULL, 11 | `bot_access_token` VARCHAR(255) NOT NULL, 12 | `bot_user_id` VARCHAR(255) NOT NULL, 13 | `projects_reports_enabled` TINYINT NOT NULL, 14 | `reporting_channel` VARCHAR(255) NOT NULL, 15 | `reporting_time` VARCHAR(255) NOT NULL, 16 | `language` VARCHAR(255) NOT NULL 17 | ); 18 | -- +goose StatementEnd 19 | 20 | -- +goose Down 21 | -- +goose StatementBegin 22 | DROP TABLE `workspaces`; 23 | -- +goose StatementEnd 24 | 25 | -------------------------------------------------------------------------------- /migrations/004_create_table_projects.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE `projects` ( 4 | `id` INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, 5 | `created_at` INTEGER NOT NULL, 6 | `workspace_id` VARCHAR(255) NOT NULL, 7 | `channel_id` VARCHAR(255) NOT NULL, 8 | `channel_name` VARCHAR(255) NOT NULL, 9 | `deadline` VARCHAR(255) NOT NULL, 10 | `tz` VARCHAR(255) NOT NULL, 11 | `submission_days` VARCHAR(255) NOT NULL, 12 | `onbording_message` TEXT COLLATE utf8mb4_unicode_ci NOT NULL 13 | ); 14 | -- +goose StatementEnd 15 | 16 | -- +goose Down 17 | -- +goose StatementBegin 18 | DROP TABLE `projects`; 19 | -- +goose StatementEnd -------------------------------------------------------------------------------- /migrations/005_create_table_notification_threads.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE `notification_threads` ( 4 | `id` INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, 5 | `channel_id` VARCHAR(255) NOT NULL, 6 | `user_ids` VARCHAR(1000) NOT NULL, 7 | `notification_time` INTEGER NOT NULL, 8 | `reminder_counter` INTEGER NOT NULL 9 | ); 10 | -- +goose StatementEnd 11 | 12 | -- +goose Down 13 | -- +goose StatementBegin 14 | DROP TABLE `notification_threads`; 15 | -- +goose StatementEnd -------------------------------------------------------------------------------- /model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "time" 7 | 8 | "github.com/nlopes/slack" 9 | ) 10 | 11 | // Standup model used for serialization/deserialization stored standups 12 | type Standup struct { 13 | ID int64 `db:"id" json:"id"` 14 | CreatedAt int64 `db:"created_at" json:"created_at"` 15 | WorkspaceID string `db:"workspace_id" json:"workspace_id"` 16 | ChannelID string `db:"channel_id" json:"channel_id"` 17 | UserID string `db:"user_id" json:"user_id"` 18 | Comment string `db:"comment" json:"comment"` 19 | MessageTS string `db:"message_ts" json:"message_ts"` 20 | } 21 | 22 | // Project model used for serialization/deserialization stored Projects 23 | type Project struct { 24 | ID int64 `db:"id" json:"id"` 25 | CreatedAt int64 `db:"created_at" json:"created_at"` 26 | WorkspaceID string `db:"workspace_id" json:"workspace_id"` 27 | ChannelName string `db:"channel_name" json:"channel_name"` 28 | ChannelID string `db:"channel_id" json:"channel_id"` 29 | Deadline string `db:"deadline" json:"deadline"` 30 | TZ string `db:"tz" json:"tz"` 31 | OnbordingMessage string `db:"onbording_message" json:"onbording_message,omitempty"` 32 | SubmissionDays string `db:"submission_days" json:"submission_days,omitempty"` 33 | } 34 | 35 | // Standuper model used for serialization/deserialization stored ChannelMembers 36 | type Standuper struct { 37 | ID int64 `db:"id" json:"id"` 38 | CreatedAt int64 `db:"created_at" json:"created_at"` 39 | WorkspaceID string `db:"workspace_id" json:"workspace_id"` 40 | UserID string `db:"user_id" json:"user_id"` 41 | ChannelID string `db:"channel_id" json:"channel_id"` 42 | Role string `db:"role" json:"role"` 43 | RealName string `db:"real_name" json:"real_name"` 44 | ChannelName string `db:"channel_name" json:"channel_name"` 45 | } 46 | 47 | // Workspace is used for updating and storing different bot configuration parameters 48 | type Workspace struct { 49 | ID int64 `db:"id" json:"id"` 50 | CreatedAt int64 `db:"created_at" json:"created_at"` 51 | BotUserID string `db:"bot_user_id" json:"bot_user_id"` 52 | NotifierInterval int `db:"notifier_interval" json:"notifier_interval" ` 53 | Language string `db:"language" json:"language" ` 54 | MaxReminders int `db:"max_reminders" json:"max_reminders" ` 55 | ReminderOffset int64 `db:"reminder_offset" json:"reminder_offset" ` 56 | BotAccessToken string `db:"bot_access_token" json:"bot_access_token" ` 57 | WorkspaceID string `db:"workspace_id" json:"workspace_id" ` 58 | WorkspaceName string `db:"workspace_name" json:"workspace_name" ` 59 | ReportingChannel string `db:"reporting_channel" json:"reporting_channel"` 60 | ReportingTime string `db:"reporting_time" json:"reporting_time"` 61 | ProjectsReportsEnabled bool `db:"projects_reports_enabled" json:"projects_reports_enabled"` 62 | } 63 | 64 | // ServiceEvent event coming from services 65 | type ServiceEvent struct { 66 | TeamName string `json:"team_name"` 67 | AccessToken string `json:"bot_access_token"` 68 | Channel string `json:"channel"` 69 | Message string `json:"message"` 70 | Attachments []slack.Attachment `json:"attachments,omitempty"` 71 | } 72 | 73 | // InfoEvent event coming from services 74 | type InfoEvent struct { 75 | TeamName string `json:"team_name"` 76 | InfoType string `json:"info_type"` 77 | AccessToken string `json:"bot_access_token"` 78 | Channel string `json:"channel"` 79 | Message string `json:"message"` 80 | } 81 | 82 | //Report used to generate report structure 83 | type Report struct { 84 | ReportHead string 85 | ReportBody []ReportBodyContent 86 | } 87 | 88 | //ReportBodyContent used to generate report body content 89 | type ReportBodyContent struct { 90 | Date time.Time 91 | Text string 92 | } 93 | 94 | //AttachmentItem is needed to sort attachments 95 | type AttachmentItem struct { 96 | SlackAttachment slack.Attachment 97 | Points int 98 | } 99 | 100 | //NotificationThread ... 101 | type NotificationThread struct { 102 | ID int64 `db:"id" json:"id"` 103 | ChannelID string `db:"channel_id" json:"channel_id"` 104 | UserIDs string `db:"user_ids" json:"user_ids"` 105 | NotificationTime int64 `db:"notification_time" json:"notification_time"` 106 | ReminderCounter int `db:"reminder_counter" json:"reminder_counter"` 107 | } 108 | 109 | // Validate validates Standup struct 110 | func (st Standup) Validate() error { 111 | if st.WorkspaceID == "" { 112 | err := errors.New("workspace ID cannot be empty") 113 | return err 114 | } 115 | if st.UserID == "" { 116 | err := errors.New("user ID cannot be empty") 117 | return err 118 | } 119 | if st.ChannelID == "" { 120 | err := errors.New("channel ID cannot be empty") 121 | return err 122 | } 123 | if st.MessageTS == "" { 124 | err := errors.New("MessageTS cannot be empty") 125 | return err 126 | } 127 | return nil 128 | } 129 | 130 | // Validate validates Workspace struct 131 | func (bs Workspace) Validate() error { 132 | if bs.WorkspaceID == "" { 133 | err := errors.New("workspace ID cannot be empty") 134 | return err 135 | } 136 | 137 | if bs.WorkspaceName == "" { 138 | err := errors.New("team name cannot be empty") 139 | return err 140 | } 141 | 142 | if bs.BotAccessToken == "" { 143 | err := errors.New("accessToken cannot be empty") 144 | return err 145 | } 146 | 147 | if bs.ReminderOffset <= 0 { 148 | err := errors.New("reminder time cannot be zero or negative") 149 | return err 150 | } 151 | 152 | if bs.MaxReminders < 0 { 153 | err := errors.New("reminder repeats max cannot be negative") 154 | return err 155 | } 156 | 157 | if bs.ReportingTime == "" { 158 | err := errors.New("reporting time cannot be empty") 159 | return err 160 | } 161 | 162 | if bs.Language == "" { 163 | err := errors.New("language cannot be empty") 164 | return err 165 | } 166 | 167 | return nil 168 | } 169 | 170 | // Validate validates Project struct 171 | func (ch Project) Validate() error { 172 | if ch.WorkspaceID == "" { 173 | err := errors.New("workspace ID cannot be empty") 174 | return err 175 | } 176 | 177 | if ch.ChannelName == "" { 178 | err := errors.New("channel name cannot be empty") 179 | return err 180 | } 181 | 182 | if ch.ChannelID == "" { 183 | err := errors.New("channel ID cannot be empty") 184 | return err 185 | } 186 | 187 | return nil 188 | } 189 | 190 | // Validate validates Standuper struct 191 | func (s Standuper) Validate() error { 192 | if s.WorkspaceID == "" { 193 | err := errors.New("workspace ID cannot be empty") 194 | return err 195 | } 196 | 197 | if s.UserID == "" { 198 | err := errors.New("user ID cannot be empty") 199 | return err 200 | } 201 | 202 | if s.ChannelID == "" { 203 | err := errors.New("channel ID cannot be empty") 204 | return err 205 | } 206 | 207 | return nil 208 | } 209 | 210 | // Validate validates NotificationsThread struct 211 | func (nt NotificationThread) Validate() error { 212 | if strings.TrimSpace(nt.ChannelID) == "" { 213 | return errors.New("Field ChannelID is empty") 214 | } 215 | if strings.TrimSpace(nt.UserIDs) == "" { 216 | return errors.New("Field UserIDs is empty") 217 | } 218 | if nt.NotificationTime < 0 { 219 | return errors.New("Field NotificationTime cannot be negative") 220 | } 221 | if nt.ReminderCounter < 0 { 222 | return errors.New("Field ReminderCounter cannot be negative") 223 | } 224 | return nil 225 | } 226 | -------------------------------------------------------------------------------- /model/model_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestStandup(t *testing.T) { 11 | testCases := []struct { 12 | workspaceID string 13 | userID string 14 | channelID string 15 | messageTS string 16 | errorMessage string 17 | }{ 18 | {"", "", "", "", "workspace ID cannot be empty"}, 19 | {"workspaceID", "", "", "", "user ID cannot be empty"}, 20 | {"workspaceID", "userID", "", "", "channel ID cannot be empty"}, 21 | {"workspaceID", "userID", "channelID", "", "MessageTS cannot be empty"}, 22 | {"workspaceID", "userID", "channelID", "12345", ""}, 23 | } 24 | for _, tt := range testCases { 25 | st := Standup{ 26 | WorkspaceID: tt.workspaceID, 27 | UserID: tt.userID, 28 | ChannelID: tt.channelID, 29 | MessageTS: tt.messageTS, 30 | } 31 | err := st.Validate() 32 | if err != nil { 33 | assert.Equal(t, errors.New(tt.errorMessage), err) 34 | } 35 | } 36 | } 37 | 38 | func TestWorkspace(t *testing.T) { 39 | testCases := []struct { 40 | workspaceID string 41 | workspace string 42 | accessToken string 43 | reminderTime int64 44 | maxReminders int 45 | reportingTime string 46 | language string 47 | errorMessage string 48 | }{ 49 | {"", "", "", 1, 1, "01:00", "en_US", "workspace ID cannot be empty"}, 50 | {"tID", "", "", 1, 1, "01:00", "en_US", "team name cannot be empty"}, 51 | {"tID", "tName", "", 1, 1, "01:00", "en_US", "accessToken cannot be empty"}, 52 | {"tID", "tName", "accToken", 1, 1, "01:00", "en_US", ""}, 53 | {"tID", "tName", "accToken", 0, 1, "01:00", "en_US", "reminder time cannot be zero or negative"}, 54 | {"tID", "tName", "accToken", -1, 1, "01:00", "en_US", "reminder time cannot be zero or negative"}, 55 | {"tID", "tName", "accToken", 1, 0, "01:00", "en_US", "reminder repeats max cannot be negative"}, 56 | {"tID", "tName", "accToken", 1, -1, "01:00", "en_US", "reminder repeats max cannot be negative"}, 57 | {"tID", "tName", "accToken", 1, 1, "", "en_US", "reporting time cannot be empty"}, 58 | {"tID", "tName", "accToken", 1, 1, "01:00", "", "language cannot be empty"}, 59 | } 60 | for _, tt := range testCases { 61 | bs := Workspace{ 62 | WorkspaceID: tt.workspaceID, 63 | WorkspaceName: tt.workspace, 64 | BotAccessToken: tt.accessToken, 65 | ReminderOffset: tt.reminderTime, 66 | MaxReminders: tt.maxReminders, 67 | ReportingTime: tt.reportingTime, 68 | Language: tt.language, 69 | } 70 | err := bs.Validate() 71 | if err != nil { 72 | assert.Equal(t, errors.New(tt.errorMessage), err) 73 | } 74 | } 75 | } 76 | 77 | func TestChannel(t *testing.T) { 78 | testCases := []struct { 79 | workspaceID string 80 | channelName string 81 | channelID string 82 | errorMessage string 83 | }{ 84 | {"", "", "", "workspace ID cannot be empty"}, 85 | {"workspaceID", "", "", "channel name cannot be empty"}, 86 | {"workspaceID", "chanName", "", "channel ID cannot be empty"}, 87 | {"workspaceID", "chanName", "chanID", ""}, 88 | } 89 | for _, tt := range testCases { 90 | ch := Project{ 91 | WorkspaceID: tt.workspaceID, 92 | ChannelName: tt.channelName, 93 | ChannelID: tt.channelID, 94 | } 95 | err := ch.Validate() 96 | if err != nil { 97 | assert.Equal(t, errors.New(tt.errorMessage), err) 98 | } 99 | } 100 | } 101 | 102 | func TestStanduper(t *testing.T) { 103 | testCases := []struct { 104 | workspaceID string 105 | userID string 106 | channelID string 107 | errorMessage string 108 | }{ 109 | {"", "", "", "workspace ID cannot be empty"}, 110 | {"workspaceID", "", "", "user ID cannot be empty"}, 111 | {"workspaceID", "teamName", "", "channel ID cannot be empty"}, 112 | {"workspaceID", "userID", "accessToken", ""}, 113 | } 114 | for _, tt := range testCases { 115 | bs := Standuper{ 116 | WorkspaceID: tt.workspaceID, 117 | UserID: tt.userID, 118 | ChannelID: tt.channelID, 119 | } 120 | err := bs.Validate() 121 | if err != nil { 122 | assert.Equal(t, errors.New(tt.errorMessage), err) 123 | } 124 | } 125 | } 126 | 127 | func TestNotificationThread(t *testing.T) { 128 | testCases := []struct { 129 | channelid string 130 | userid string 131 | notificationTime int64 132 | reminderCounter int 133 | errorMessage string 134 | }{ 135 | {"", "1", int64(2), 0, "Field ChannelID is empty"}, 136 | {"12", "", int64(2), 0, "Field UserID is empty"}, 137 | {"12", "1", -1, -1, "Field NotificationTime cannot be negative"}, 138 | {"12", "1", int64(2), -1, "Field ReminderCounter cannot be negative"}, 139 | {"12", "1", int64(2), 1, ""}, 140 | } 141 | for _, e := range testCases { 142 | nt := NotificationThread{ 143 | ChannelID: e.channelid, 144 | UserIDs: e.userid, 145 | NotificationTime: e.notificationTime, 146 | ReminderCounter: e.reminderCounter, 147 | } 148 | err := nt.Validate() 149 | if err != nil { 150 | assert.Equal(t, errors.New(e.errorMessage), err) 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /storage/channels.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "github.com/maddevsio/comedian/model" 5 | ) 6 | 7 | // CreateProject creates standup entry in database 8 | func (m *DB) CreateProject(ch model.Project) (model.Project, error) { 9 | err := ch.Validate() 10 | if err != nil { 11 | return ch, err 12 | } 13 | 14 | res, err := m.db.Exec( 15 | `INSERT INTO projects ( 16 | created_at, 17 | workspace_id, 18 | channel_name, 19 | channel_id, 20 | deadline, 21 | tz, 22 | onbording_message, 23 | submission_days 24 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 25 | ch.CreatedAt, 26 | ch.WorkspaceID, 27 | ch.ChannelName, 28 | ch.ChannelID, 29 | ch.Deadline, 30 | ch.TZ, 31 | ch.OnbordingMessage, 32 | ch.SubmissionDays, 33 | ) 34 | if err != nil { 35 | return ch, err 36 | } 37 | id, err := res.LastInsertId() 38 | if err != nil { 39 | return ch, err 40 | } 41 | ch.ID = id 42 | 43 | return ch, nil 44 | } 45 | 46 | // UpdateProject updates Project entry in database 47 | func (m *DB) UpdateProject(ch model.Project) (model.Project, error) { 48 | err := ch.Validate() 49 | if err != nil { 50 | return ch, err 51 | } 52 | _, err = m.db.Exec( 53 | `UPDATE projects SET 54 | deadline=?, 55 | tz=?, 56 | onbording_message=?, 57 | submission_days=? 58 | WHERE id=?`, 59 | ch.Deadline, 60 | ch.TZ, 61 | ch.OnbordingMessage, 62 | ch.SubmissionDays, 63 | ch.ID, 64 | ) 65 | if err != nil { 66 | return ch, err 67 | } 68 | return ch, nil 69 | } 70 | 71 | //ListProjects returns list of projects 72 | func (m *DB) ListProjects() ([]model.Project, error) { 73 | projects := []model.Project{} 74 | err := m.db.Select(&projects, "SELECT * FROM `projects`") 75 | return projects, err 76 | } 77 | 78 | //ListWorkspaceProjects returns list of projects 79 | func (m *DB) ListWorkspaceProjects(ws string) ([]model.Project, error) { 80 | projects := []model.Project{} 81 | err := m.db.Select(&projects, "SELECT * FROM `projects` where workspace_id=?", ws) 82 | return projects, err 83 | } 84 | 85 | // SelectProject selects Project entry from database 86 | func (m *DB) SelectProject(channelID string) (model.Project, error) { 87 | var c model.Project 88 | err := m.db.Get(&c, "SELECT * FROM `projects` WHERE channel_id=?", channelID) 89 | if err != nil { 90 | return c, err 91 | } 92 | return c, err 93 | } 94 | 95 | // GetProject selects Project entry from database with specific id 96 | func (m *DB) GetProject(id int64) (model.Project, error) { 97 | var c model.Project 98 | err := m.db.Get(&c, "SELECT * FROM `projects` where id=?", id) 99 | if err != nil { 100 | return c, err 101 | } 102 | return c, err 103 | } 104 | 105 | // DeleteProject deletes Project entry from database 106 | func (m *DB) DeleteProject(id int64) error { 107 | _, err := m.db.Exec("DELETE FROM `projects` WHERE id=?", id) 108 | return err 109 | } 110 | -------------------------------------------------------------------------------- /storage/channels_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/maddevsio/comedian/model" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestCreateProject(t *testing.T) { 12 | _, err := db.CreateProject(model.Project{}) 13 | assert.Error(t, err) 14 | 15 | ch, err := db.CreateProject(model.Project{ 16 | CreatedAt: time.Now().Unix(), 17 | WorkspaceID: "foo", 18 | ChannelName: "bar", 19 | ChannelID: "bar12", 20 | Deadline: "", 21 | }) 22 | assert.NoError(t, err) 23 | assert.Equal(t, "foo", ch.WorkspaceID) 24 | 25 | assert.NoError(t, db.DeleteProject(ch.ID)) 26 | } 27 | 28 | func TestGetProjects(t *testing.T) { 29 | ch, err := db.CreateProject(model.Project{ 30 | WorkspaceID: "foo", 31 | ChannelName: "bar", 32 | ChannelID: "bar12", 33 | Deadline: "", 34 | }) 35 | assert.NoError(t, err) 36 | 37 | _, err = db.ListProjects() 38 | assert.NoError(t, err) 39 | 40 | _, err = db.SelectProject("") 41 | assert.Error(t, err) 42 | 43 | _, err = db.SelectProject("bar12") 44 | assert.NoError(t, err) 45 | 46 | _, err = db.GetProject(int64(0)) 47 | assert.Error(t, err) 48 | 49 | _, err = db.GetProject(ch.ID) 50 | assert.NoError(t, err) 51 | 52 | assert.NoError(t, db.DeleteProject(ch.ID)) 53 | } 54 | 55 | func TestUpdateProjects(t *testing.T) { 56 | ch, err := db.CreateProject(model.Project{ 57 | WorkspaceID: "foo", 58 | ChannelName: "bar", 59 | ChannelID: "bar12", 60 | }) 61 | assert.NoError(t, err) 62 | assert.Equal(t, "", ch.Deadline) 63 | 64 | ch.Deadline = "10:00" 65 | ch, err = db.UpdateProject(ch) 66 | assert.NoError(t, err) 67 | assert.Equal(t, "10:00", ch.Deadline) 68 | 69 | assert.NoError(t, db.DeleteProject(ch.ID)) 70 | } 71 | -------------------------------------------------------------------------------- /storage/mysql.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | 7 | "github.com/pressly/goose" 8 | 9 | // This line is must for working MySQL database 10 | _ "github.com/go-sql-driver/mysql" 11 | "github.com/jmoiron/sqlx" 12 | ) 13 | 14 | // DB provides api for work with DB database 15 | type DB struct { 16 | db *sqlx.DB 17 | } 18 | 19 | // New creates a new instance of database API 20 | func New(dbConn, migrationsPathh string) (*DB, error) { 21 | conn, err := sqlx.Connect("mysql", dbConn) 22 | if err != nil { 23 | conn, err = sqlx.Connect("mysql", "comedian:comedian@tcp(localhost:3306)/comedian?parseTime=true") 24 | if err != nil { 25 | return nil, err 26 | } 27 | } 28 | db := &DB{conn} 29 | 30 | goose.SetDialect("mysql") 31 | 32 | current, err := goose.EnsureDBVersion(conn.DB) 33 | if err != nil { 34 | return nil, fmt.Errorf("failed to EnsureDBVersion: %v", err) 35 | } 36 | 37 | files, err := ioutil.ReadDir(migrationsPathh) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | migrations, err := goose.CollectMigrations(migrationsPathh, current, int64(len(files))) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | for _, m := range migrations { 48 | err := m.Up(conn.DB) 49 | if err != nil { 50 | return nil, err 51 | } 52 | } 53 | 54 | return db, nil 55 | } 56 | -------------------------------------------------------------------------------- /storage/mysql_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/maddevsio/comedian/config" 8 | ) 9 | 10 | var db = setupDB() 11 | 12 | func setupDB() *DB { 13 | c, err := config.Get() 14 | if err != nil { 15 | log.Fatal(err) 16 | } 17 | db, err := New(c.DatabaseURL, "../migrations") 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | 22 | time.Sleep(5 * time.Second) 23 | return db 24 | } 25 | -------------------------------------------------------------------------------- /storage/notifications_thread.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "github.com/maddevsio/comedian/model" 5 | ) 6 | 7 | // CreateNotificationThread create notifications 8 | func (m *DB) CreateNotificationThread(s model.NotificationThread) (model.NotificationThread, error) { 9 | res, err := m.db.Exec( 10 | "INSERT INTO `notification_threads` (channel_id,user_ids, notification_time, reminder_counter) VALUES (?, ?, ?, ?)", 11 | s.ChannelID, s.UserIDs, s.NotificationTime, s.ReminderCounter, 12 | ) 13 | if err != nil { 14 | return s, err 15 | } 16 | id, err := res.LastInsertId() 17 | if err != nil { 18 | return s, err 19 | } 20 | s.ID = id 21 | return s, nil 22 | } 23 | 24 | // DeleteNotificationThread deletes notification entry from database 25 | func (m *DB) DeleteNotificationThread(id int64) error { 26 | _, err := m.db.Exec("DELETE FROM `notification_threads` WHERE id=?", id) 27 | return err 28 | } 29 | 30 | // SelectNotificationsThread returns array of notifications entries from database 31 | func (m *DB) SelectNotificationsThread(channelID string) (model.NotificationThread, error) { 32 | var items model.NotificationThread 33 | err := m.db.Get(&items, "SELECT * FROM `notification_threads` WHERE channel_id=?", channelID) 34 | return items, err 35 | } 36 | 37 | // UpdateNotificationThread update field reminder counter 38 | func (m *DB) UpdateNotificationThread(id int64, notificationTime int64, nonReporters string) error { 39 | _, err := m.db.Exec("UPDATE `notification_threads` SET user_ids=?, reminder_counter=reminder_counter+1, notification_time=? WHERE id=?", nonReporters, notificationTime, id) 40 | return err 41 | } 42 | -------------------------------------------------------------------------------- /storage/notifications_thread_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/maddevsio/comedian/model" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestNotification(t *testing.T) { 13 | tt := time.Now().Unix() + 30*60 14 | n := model.NotificationThread{ 15 | ChannelID: "1", 16 | UserIDs: "1", 17 | NotificationTime: tt, 18 | ReminderCounter: 0, 19 | } 20 | 21 | notification, err := db.CreateNotificationThread(n) 22 | require.NoError(t, err) 23 | assert.Equal(t, "1", notification.ChannelID) 24 | assert.Equal(t, "1", notification.UserIDs) 25 | assert.Equal(t, tt, notification.NotificationTime) 26 | assert.Equal(t, 0, notification.ReminderCounter) 27 | 28 | thread, err := db.SelectNotificationsThread(notification.ChannelID) 29 | require.NoError(t, err) 30 | assert.Equal(t, thread.ChannelID, notification.ChannelID) 31 | 32 | err = db.DeleteNotificationThread(notification.ID) 33 | require.NoError(t, err) 34 | 35 | thread, err = db.SelectNotificationsThread(notification.ChannelID) 36 | assert.Equal(t, 0, thread.ReminderCounter) 37 | assert.Equal(t, "", thread.UserIDs) 38 | assert.Equal(t, int64(0), thread.NotificationTime) 39 | assert.Equal(t, "", thread.ChannelID) 40 | 41 | n = model.NotificationThread{ 42 | ChannelID: "1", 43 | UserIDs: "User1", 44 | NotificationTime: tt, 45 | ReminderCounter: 0, 46 | } 47 | 48 | nt, err := db.CreateNotificationThread(n) 49 | require.NoError(t, err) 50 | 51 | nt.UserIDs = nt.UserIDs + ", User2" 52 | err = db.UpdateNotificationThread(nt.ID, tt, nt.UserIDs) 53 | require.NoError(t, err) 54 | 55 | thread, err = db.SelectNotificationsThread(nt.ChannelID) 56 | require.NoError(t, err) 57 | assert.Equal(t, 1, thread.ReminderCounter) 58 | assert.Equal(t, nt.UserIDs, thread.UserIDs) 59 | 60 | err = db.DeleteNotificationThread(nt.ID) 61 | require.NoError(t, err) 62 | } 63 | -------------------------------------------------------------------------------- /storage/standupers.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "github.com/maddevsio/comedian/model" 5 | ) 6 | 7 | // CreateStanduper creates comedian entry in database 8 | func (m *DB) CreateStanduper(s model.Standuper) (model.Standuper, error) { 9 | err := s.Validate() 10 | if err != nil { 11 | return s, err 12 | } 13 | res, err := m.db.Exec( 14 | `INSERT INTO standupers ( 15 | created_at, 16 | workspace_id, 17 | user_id, 18 | channel_id, 19 | role, 20 | real_name, 21 | channel_name 22 | ) VALUES (?,?,?,?,?,?,?)`, 23 | s.CreatedAt, 24 | s.WorkspaceID, 25 | s.UserID, 26 | s.ChannelID, 27 | s.Role, 28 | s.RealName, 29 | s.ChannelName, 30 | ) 31 | if err != nil { 32 | return s, err 33 | } 34 | id, err := res.LastInsertId() 35 | if err != nil { 36 | return s, err 37 | } 38 | s.ID = id 39 | 40 | return s, nil 41 | } 42 | 43 | // UpdateStanduper updates Standuper entry in database 44 | func (m *DB) UpdateStanduper(st model.Standuper) (model.Standuper, error) { 45 | err := st.Validate() 46 | if err != nil { 47 | return st, err 48 | } 49 | _, err = m.db.Exec( 50 | "UPDATE `standupers` SET role=? WHERE id=?", 51 | st.Role, st.ID, 52 | ) 53 | if err != nil { 54 | return st, err 55 | } 56 | var i model.Standuper 57 | err = m.db.Get(&i, "SELECT * FROM `standupers` WHERE id=?", st.ID) 58 | return i, err 59 | } 60 | 61 | //FindStansuperByUserID finds user in channel 62 | func (m *DB) FindStansuperByUserID(userID, channelID string) (model.Standuper, error) { 63 | var u model.Standuper 64 | err := m.db.Get(&u, "SELECT * FROM `standupers` WHERE user_id=? AND channel_id=?", userID, channelID) 65 | return u, err 66 | } 67 | 68 | //FindStansupersByUserID finds user in channel 69 | func (m *DB) FindStansupersByUserID(userID string) ([]model.Standuper, error) { 70 | var u []model.Standuper 71 | err := m.db.Select(&u, "SELECT * FROM `standupers` WHERE user_id=?", userID) 72 | return u, err 73 | } 74 | 75 | // ListStandupers returns array of standup entries from database 76 | func (m *DB) ListStandupers() ([]model.Standuper, error) { 77 | items := []model.Standuper{} 78 | err := m.db.Select(&items, "SELECT * FROM `standupers`") 79 | return items, err 80 | } 81 | 82 | // ListWorkspaceStandupers returns array of standup entries from database 83 | func (m *DB) ListWorkspaceStandupers(workspaceID string) ([]model.Standuper, error) { 84 | items := []model.Standuper{} 85 | err := m.db.Select(&items, "SELECT * FROM `standupers` where workspace_id=?", workspaceID) 86 | return items, err 87 | } 88 | 89 | //GetStanduper returns a standuper 90 | func (m *DB) GetStanduper(id int64) (model.Standuper, error) { 91 | standuper := model.Standuper{} 92 | err := m.db.Get(&standuper, "SELECT * FROM standupers where id=?", id) 93 | return standuper, err 94 | } 95 | 96 | // ListProjectStandupers returns array of standup entries from database 97 | func (m *DB) ListProjectStandupers(channelID string) ([]model.Standuper, error) { 98 | items := []model.Standuper{} 99 | err := m.db.Select(&items, "SELECT * FROM `standupers` WHERE channel_id=?", channelID) 100 | return items, err 101 | } 102 | 103 | // ListStandupersByWorkspaceID returns array of standupers which belongs to one team 104 | func (m *DB) ListStandupersByWorkspaceID(wsID string) ([]model.Standuper, error) { 105 | items := []model.Standuper{} 106 | err := m.db.Select(&items, "SELECT * FROM `standupers` WHERE workspace_id=?", wsID) 107 | return items, err 108 | } 109 | 110 | // DeleteStanduper deletes standupers entry from database 111 | func (m *DB) DeleteStanduper(id int64) error { 112 | _, err := m.db.Exec("DELETE FROM `standupers` WHERE id=?", id) 113 | return err 114 | } 115 | -------------------------------------------------------------------------------- /storage/standupers_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/maddevsio/comedian/model" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestCreateStanduper(t *testing.T) { 12 | 13 | _, err := db.CreateStanduper(model.Standuper{}) 14 | assert.Error(t, err) 15 | 16 | s, err := db.CreateStanduper(model.Standuper{ 17 | CreatedAt: time.Now().Unix(), 18 | WorkspaceID: "foo", 19 | UserID: "bar", 20 | ChannelID: "bar12", 21 | }) 22 | assert.NoError(t, err) 23 | assert.Equal(t, "foo", s.WorkspaceID) 24 | 25 | assert.NoError(t, db.DeleteStanduper(s.ID)) 26 | } 27 | 28 | func TestGetStandupers(t *testing.T) { 29 | 30 | s, err := db.CreateStanduper(model.Standuper{ 31 | CreatedAt: time.Now().Unix(), 32 | WorkspaceID: "foo", 33 | UserID: "bar", 34 | ChannelID: "bar12", 35 | }) 36 | assert.NoError(t, err) 37 | 38 | v, err := db.CreateStanduper(model.Standuper{ 39 | CreatedAt: time.Now().Unix(), 40 | WorkspaceID: "foo", 41 | UserID: "bar", 42 | ChannelID: "bar13", 43 | }) 44 | assert.NoError(t, err) 45 | 46 | _, err = db.ListStandupers() 47 | assert.NoError(t, err) 48 | 49 | res, err := db.ListStandupersByWorkspaceID("foo") 50 | assert.NoError(t, err) 51 | assert.Equal(t, 2, len(res)) 52 | 53 | _, err = db.ListProjectStandupers("") 54 | assert.NoError(t, err) 55 | 56 | _, err = db.ListProjectStandupers("bar12") 57 | assert.NoError(t, err) 58 | 59 | _, err = db.GetStanduper(int64(0)) 60 | assert.Error(t, err) 61 | 62 | _, err = db.GetStanduper(s.ID) 63 | assert.NoError(t, err) 64 | 65 | _, err = db.FindStansuperByUserID("noUser", "bar12") 66 | assert.Error(t, err) 67 | 68 | res, err = db.FindStansupersByUserID("bar") 69 | assert.NoError(t, err) 70 | assert.Equal(t, 2, len(res)) 71 | 72 | _, err = db.FindStansuperByUserID("bar", "bar12") 73 | assert.NoError(t, err) 74 | 75 | assert.NoError(t, db.DeleteStanduper(s.ID)) 76 | assert.NoError(t, db.DeleteStanduper(v.ID)) 77 | } 78 | 79 | func TestUpdateStanduper(t *testing.T) { 80 | 81 | s, err := db.CreateStanduper(model.Standuper{ 82 | CreatedAt: time.Now().Unix(), 83 | WorkspaceID: "foo", 84 | UserID: "bar", 85 | ChannelID: "bar12", 86 | }) 87 | assert.NoError(t, err) 88 | assert.Equal(t, "", s.Role) 89 | 90 | s.Role = "developer" 91 | 92 | s, err = db.UpdateStanduper(s) 93 | assert.NoError(t, err) 94 | assert.Equal(t, "developer", s.Role) 95 | 96 | assert.NoError(t, db.DeleteStanduper(s.ID)) 97 | } 98 | -------------------------------------------------------------------------------- /storage/standups.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "github.com/maddevsio/comedian/model" 5 | ) 6 | 7 | // CreateStandup creates standup entry in database 8 | func (m *DB) CreateStandup(s model.Standup) (model.Standup, error) { 9 | err := s.Validate() 10 | if err != nil { 11 | return s, err 12 | } 13 | 14 | res, err := m.db.Exec( 15 | `INSERT INTO standups ( 16 | created_at, 17 | workspace_id, 18 | channel_id, 19 | user_id, 20 | comment, 21 | message_ts 22 | ) VALUES (?, ?, ?, ?, ?, ?)`, 23 | s.CreatedAt, 24 | s.WorkspaceID, 25 | s.ChannelID, 26 | s.UserID, 27 | s.Comment, 28 | s.MessageTS, 29 | ) 30 | if err != nil { 31 | return s, err 32 | } 33 | id, err := res.LastInsertId() 34 | if err != nil { 35 | return s, err 36 | } 37 | s.ID = id 38 | 39 | return s, nil 40 | } 41 | 42 | // UpdateStandup updates standup entry in database 43 | func (m *DB) UpdateStandup(s model.Standup) (model.Standup, error) { 44 | err := s.Validate() 45 | if err != nil { 46 | return s, err 47 | } 48 | 49 | _, err = m.db.Exec( 50 | "UPDATE `standups` SET comment=?, message_ts=? WHERE id=?", 51 | s.Comment, s.MessageTS, s.ID, 52 | ) 53 | if err != nil { 54 | return s, err 55 | } 56 | var i model.Standup 57 | err = m.db.Get(&i, "SELECT * FROM `standups` WHERE id=?", s.ID) 58 | return i, err 59 | } 60 | 61 | // ListStandups returns array of standup entries from database 62 | func (m *DB) ListStandups() ([]model.Standup, error) { 63 | items := []model.Standup{} 64 | err := m.db.Select(&items, "SELECT * FROM `standups` order by id desc") 65 | return items, err 66 | } 67 | 68 | // ListTeamStandups returns array of standup entries from database 69 | func (m *DB) ListTeamStandups(teamID string) ([]model.Standup, error) { 70 | items := []model.Standup{} 71 | err := m.db.Select(&items, "SELECT * FROM `standups` where workspace_id=? order by id desc", teamID) 72 | return items, err 73 | } 74 | 75 | //GetStandup returns standup by its ID 76 | func (m *DB) GetStandup(id int64) (model.Standup, error) { 77 | var s model.Standup 78 | err := m.db.Get(&s, "SELECT * FROM `standups` WHERE id=?", id) 79 | if err != nil { 80 | return s, err 81 | } 82 | return s, nil 83 | } 84 | 85 | // SelectStandupByMessageTS selects standup entry from database filtered by MessageTS parameter 86 | func (m *DB) SelectStandupByMessageTS(messageTS string) (model.Standup, error) { 87 | var s model.Standup 88 | err := m.db.Get(&s, "SELECT * FROM `standups` WHERE message_ts=?", messageTS) 89 | if err != nil { 90 | return s, err 91 | } 92 | return s, nil 93 | } 94 | 95 | // SelectLatestStandupByUser selects standup entry from database filtered by user 96 | func (m *DB) SelectLatestStandupByUser(userID, channelID string) (model.Standup, error) { 97 | var s model.Standup 98 | err := m.db.Get(&s, 99 | `select * from standups 100 | where user_id=? and channel_id=? 101 | order by id desc limit 1`, 102 | userID, channelID, 103 | ) 104 | if err != nil { 105 | return s, err 106 | } 107 | return s, nil 108 | } 109 | 110 | // GetStandupForPeriod selects standup entry from database filtered by user 111 | func (m *DB) GetStandupForPeriod(userID, channelID string, timeFrom, timeTo int64) (*model.Standup, error) { 112 | s := &model.Standup{} 113 | err := m.db.Get(s, 114 | `select * from standups 115 | where user_id=? and channel_id=? 116 | and created_at BETWEEN ? AND ? 117 | limit 1`, 118 | userID, 119 | channelID, 120 | timeFrom, 121 | timeTo, 122 | ) 123 | if err != nil { 124 | return s, err 125 | } 126 | return s, nil 127 | } 128 | 129 | // DeleteStandup deletes standup entry from database 130 | func (m *DB) DeleteStandup(id int64) error { 131 | _, err := m.db.Exec("DELETE FROM `standups` WHERE id=?", id) 132 | return err 133 | } 134 | -------------------------------------------------------------------------------- /storage/standups_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/maddevsio/comedian/model" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestCreateStandup(t *testing.T) { 12 | 13 | _, err := db.CreateStandup(model.Standup{}) 14 | assert.Error(t, err) 15 | 16 | st, err := db.CreateStandup(model.Standup{ 17 | CreatedAt: time.Now().Unix(), 18 | WorkspaceID: "foo", 19 | UserID: "bar", 20 | ChannelID: "bar12", 21 | MessageTS: "12345", 22 | }) 23 | assert.NoError(t, err) 24 | assert.Equal(t, "foo", st.WorkspaceID) 25 | 26 | assert.NoError(t, db.DeleteStandup(st.ID)) 27 | } 28 | 29 | func TestGetStandups(t *testing.T) { 30 | 31 | st, err := db.CreateStandup(model.Standup{ 32 | CreatedAt: time.Now().Unix(), 33 | WorkspaceID: "foo", 34 | UserID: "bar", 35 | ChannelID: "bar12", 36 | MessageTS: "12345", 37 | }) 38 | assert.NoError(t, err) 39 | 40 | _, err = db.ListStandups() 41 | assert.NoError(t, err) 42 | 43 | st, err = db.SelectLatestStandupByUser("bar", "bar12") 44 | assert.NoError(t, err) 45 | assert.Equal(t, "12345", st.MessageTS) 46 | 47 | _, err = db.SelectLatestStandupByUser("foo", "bar12") 48 | assert.Error(t, err) 49 | 50 | _, err = db.SelectStandupByMessageTS("2345") 51 | assert.Error(t, err) 52 | 53 | _, err = db.SelectStandupByMessageTS("12345") 54 | assert.NoError(t, err) 55 | 56 | _, err = db.GetStandup(int64(0)) 57 | assert.Error(t, err) 58 | 59 | _, err = db.GetStandup(st.ID) 60 | assert.NoError(t, err) 61 | 62 | res, err := db.GetStandupForPeriod("bar", "bar12", time.Now().Add(10*time.Second*(-1)).Unix(), time.Now().Add(10*time.Second).Unix()) 63 | assert.NoError(t, err) 64 | assert.Equal(t, "12345", res.MessageTS) 65 | 66 | _, err = db.GetStandupForPeriod("foo", "bar12", time.Now().Add(10*time.Second*(-1)).Unix(), time.Now().Add(10*time.Second).Unix()) 67 | assert.Error(t, err) 68 | 69 | _, err = db.GetStandupForPeriod("foo", "bar12", time.Now().Add(10*time.Hour*(-1)).Unix(), time.Now().Add(10*time.Second*(-1)).Unix()) 70 | assert.Error(t, err) 71 | 72 | assert.NoError(t, db.DeleteStandup(st.ID)) 73 | } 74 | 75 | func TestUpdateStandup(t *testing.T) { 76 | 77 | st, err := db.CreateStandup(model.Standup{ 78 | CreatedAt: time.Now().Unix(), 79 | WorkspaceID: "foo", 80 | UserID: "bar", 81 | ChannelID: "bar12", 82 | MessageTS: "12345", 83 | }) 84 | assert.NoError(t, err) 85 | assert.Equal(t, "", st.Comment) 86 | assert.Equal(t, "12345", st.MessageTS) 87 | 88 | st.Comment = "yesterday, today, problems" 89 | st.MessageTS = "123456" 90 | 91 | st, err = db.UpdateStandup(st) 92 | assert.NoError(t, err) 93 | assert.Equal(t, "yesterday, today, problems", st.Comment) 94 | assert.Equal(t, "123456", st.MessageTS) 95 | 96 | assert.NoError(t, db.DeleteStandup(st.ID)) 97 | } 98 | -------------------------------------------------------------------------------- /storage/workspaces.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "github.com/maddevsio/comedian/model" 5 | ) 6 | 7 | //CreateWorkspace creates bot properties for the newly created bot 8 | func (m *DB) CreateWorkspace(bs model.Workspace) (model.Workspace, error) { 9 | err := bs.Validate() 10 | if err != nil { 11 | return bs, err 12 | } 13 | 14 | res, err := m.db.Exec( 15 | `INSERT INTO workspaces ( 16 | created_at, 17 | notifier_interval, 18 | max_reminders, 19 | reminder_offset, 20 | workspace_id, 21 | workspace_name, 22 | bot_access_token, 23 | bot_user_id, 24 | projects_reports_enabled, 25 | reporting_channel, 26 | reporting_time, 27 | language 28 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 29 | bs.CreatedAt, 30 | bs.NotifierInterval, 31 | bs.MaxReminders, 32 | bs.ReminderOffset, 33 | bs.WorkspaceID, 34 | bs.WorkspaceName, 35 | bs.BotAccessToken, 36 | bs.BotUserID, 37 | bs.ProjectsReportsEnabled, 38 | bs.ReportingChannel, 39 | bs.ReportingTime, 40 | bs.Language, 41 | ) 42 | if err != nil { 43 | return bs, err 44 | } 45 | 46 | id, err := res.LastInsertId() 47 | if err != nil { 48 | return bs, err 49 | } 50 | 51 | bs.ID = id 52 | 53 | return bs, nil 54 | } 55 | 56 | //UpdateWorkspace updates bot 57 | func (m *DB) UpdateWorkspace(settings model.Workspace) (model.Workspace, error) { 58 | err := settings.Validate() 59 | if err != nil { 60 | return settings, err 61 | } 62 | 63 | _, err = m.db.Exec( 64 | `UPDATE workspaces set 65 | notifier_interval=?, 66 | max_reminders=?, 67 | workspace_id=?, 68 | reminder_offset=?, 69 | workspace_name=?, 70 | bot_access_token=?, 71 | bot_user_id=?, 72 | projects_reports_enabled=?, 73 | reporting_channel=?, 74 | reporting_time=?, 75 | language=? 76 | where id=?`, 77 | settings.NotifierInterval, 78 | settings.MaxReminders, 79 | settings.WorkspaceID, 80 | settings.ReminderOffset, 81 | settings.WorkspaceName, 82 | settings.BotAccessToken, 83 | settings.BotUserID, 84 | settings.ProjectsReportsEnabled, 85 | settings.ReportingChannel, 86 | settings.ReportingTime, 87 | settings.Language, 88 | settings.ID, 89 | ) 90 | if err != nil { 91 | return settings, err 92 | } 93 | return settings, nil 94 | } 95 | 96 | //GetAllWorkspaces returns all workspaces stored in DB 97 | func (m *DB) GetAllWorkspaces() ([]model.Workspace, error) { 98 | bs := []model.Workspace{} 99 | err := m.db.Select(&bs, "SELECT * FROM `workspaces`") 100 | if err != nil { 101 | return bs, err 102 | } 103 | return bs, nil 104 | } 105 | 106 | //GetWorkspaceByWorkspaceID returns a particular bot 107 | func (m *DB) GetWorkspaceByWorkspaceID(workspaceID string) (model.Workspace, error) { 108 | bs := model.Workspace{} 109 | err := m.db.Get(&bs, "SELECT * FROM `workspaces` where workspace_id=?", workspaceID) 110 | if err != nil { 111 | return bs, err 112 | } 113 | return bs, nil 114 | } 115 | 116 | //GetWorkspaceByBotAccessToken returns a particular bot 117 | func (m *DB) GetWorkspaceByBotAccessToken(botAccessToken string) (model.Workspace, error) { 118 | bs := model.Workspace{} 119 | err := m.db.Get(&bs, "SELECT * FROM `workspaces` where bot_access_token=?", botAccessToken) 120 | if err != nil { 121 | return bs, err 122 | } 123 | return bs, nil 124 | } 125 | 126 | //GetWorkspace returns a particular bot 127 | func (m *DB) GetWorkspace(id int64) (model.Workspace, error) { 128 | bs := model.Workspace{} 129 | err := m.db.Get(&bs, "SELECT * FROM `workspaces` where id=?", id) 130 | if err != nil { 131 | return bs, err 132 | } 133 | return bs, nil 134 | } 135 | 136 | //DeleteWorkspaceByID deletes bot 137 | func (m *DB) DeleteWorkspaceByID(id int64) error { 138 | _, err := m.db.Exec("DELETE FROM `workspaces` WHERE id=?", id) 139 | return err 140 | } 141 | 142 | //DeleteWorkspace deletes bot 143 | func (m *DB) DeleteWorkspace(teamID string) error { 144 | _, err := m.db.Exec("DELETE FROM `workspaces` WHERE workspace_id=?", teamID) 145 | return err 146 | } 147 | -------------------------------------------------------------------------------- /storage/workspaces_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/maddevsio/comedian/model" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestCreateWorkspace(t *testing.T) { 11 | bot, err := db.CreateWorkspace(model.Workspace{}) 12 | assert.Error(t, err) 13 | assert.Equal(t, int64(0), bot.ID) 14 | 15 | bs := model.Workspace{ 16 | NotifierInterval: 30, 17 | Language: "en_US", 18 | MaxReminders: 3, 19 | ReminderOffset: int64(10), 20 | BotAccessToken: "token", 21 | BotUserID: "userID", 22 | WorkspaceID: "WorkspaceID", 23 | WorkspaceName: "foo", 24 | ReportingChannel: "", 25 | ReportingTime: "9:00", 26 | ProjectsReportsEnabled: false, 27 | } 28 | 29 | bot, err = db.CreateWorkspace(bs) 30 | assert.NoError(t, err) 31 | assert.Equal(t, "foo", bot.WorkspaceName) 32 | 33 | assert.NoError(t, db.DeleteWorkspaceByID(bot.ID)) 34 | } 35 | 36 | func TestWorkspace(t *testing.T) { 37 | 38 | _, err := db.GetAllWorkspaces() 39 | assert.NoError(t, err) 40 | 41 | bs := model.Workspace{ 42 | NotifierInterval: 30, 43 | Language: "en_US", 44 | MaxReminders: 3, 45 | ReminderOffset: int64(10), 46 | BotAccessToken: "token", 47 | BotUserID: "userID", 48 | WorkspaceID: "WorkspaceID", 49 | WorkspaceName: "foo", 50 | ReportingChannel: "", 51 | ReportingTime: "9:00", 52 | ProjectsReportsEnabled: false, 53 | } 54 | 55 | bot, err := db.CreateWorkspace(bs) 56 | assert.NoError(t, err) 57 | assert.Equal(t, "foo", bot.WorkspaceName) 58 | 59 | bot, err = db.GetWorkspace(bot.ID) 60 | assert.NoError(t, err) 61 | assert.Equal(t, "WorkspaceID", bot.WorkspaceID) 62 | 63 | bot, err = db.GetWorkspace(int64(0)) 64 | assert.Error(t, err) 65 | 66 | bot, err = db.GetWorkspaceByWorkspaceID("WorkspaceID") 67 | assert.NoError(t, err) 68 | assert.Equal(t, "WorkspaceID", bot.WorkspaceID) 69 | 70 | bot, err = db.GetWorkspaceByWorkspaceID("teamWrongID") 71 | assert.Error(t, err) 72 | 73 | assert.NoError(t, db.DeleteWorkspaceByID(bot.ID)) 74 | } 75 | 76 | func TestUpdateAndDeleteWorkspace(t *testing.T) { 77 | bs := model.Workspace{ 78 | NotifierInterval: 30, 79 | Language: "en_US", 80 | MaxReminders: 3, 81 | ReminderOffset: int64(10), 82 | BotAccessToken: "token", 83 | BotUserID: "userID", 84 | WorkspaceID: "WorkspaceID", 85 | WorkspaceName: "foo", 86 | ReportingChannel: "", 87 | ReportingTime: "9:00", 88 | ProjectsReportsEnabled: false, 89 | } 90 | 91 | bot, err := db.CreateWorkspace(bs) 92 | assert.NoError(t, err) 93 | assert.Equal(t, "foo", bot.WorkspaceName) 94 | assert.Equal(t, "en_US", bot.Language) 95 | 96 | bot.Language = "ru_RU" 97 | 98 | bot, err = db.UpdateWorkspace(bot) 99 | assert.NoError(t, err) 100 | assert.Equal(t, "ru_RU", bot.Language) 101 | 102 | assert.NoError(t, db.DeleteWorkspace(bot.WorkspaceID)) 103 | } 104 | --------------------------------------------------------------------------------