├── .circleci ├── Dockerfile.test ├── config.yml └── docker-compose-test.yml ├── .dockerignore ├── Dockerfile ├── FUNDING.yml ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── README.md ├── bots.go ├── bots_test.go ├── cmd ├── multi-process-mode │ └── main.go └── single-process-mode │ └── main.go_example ├── config.go ├── context.go ├── context_test.go ├── data.go ├── data_test.go ├── docker-compose.yml ├── encode.go ├── handlers.go ├── helpers.go ├── migrations.go ├── modules └── feedback │ └── feedback.go ├── oauth.go ├── oauth_token_store.go ├── richtext.go ├── richtext_test.go ├── services.go ├── services_test.go ├── stat.go ├── templates.go ├── tgupdates.go ├── tgupdates_test.go └── types.go /.circleci/Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM golang:1.9 AS builder 2 | 3 | RUN curl -fsSL -o /usr/local/bin/dep https://github.com/golang/dep/releases/download/v0.3.2/dep-linux-amd64 && chmod +x /usr/local/bin/dep 4 | 5 | RUN mkdir -p /go/src/github.com/requilence/integram 6 | WORKDIR /go/src/github.com/requilence/integram 7 | 8 | COPY Gopkg.toml Gopkg.lock ./ 9 | 10 | # install the dependencies without checking for go code 11 | RUN dep ensure -vendor-only 12 | 13 | COPY . ./ 14 | 15 | CMD ["go", "test", "github.com/requilence/integram"] -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: docker/compose:1.18.0 6 | 7 | environment: 8 | TEST_RESULTS: /tmp/test-results 9 | 10 | steps: 11 | - checkout 12 | - setup_remote_docker 13 | - run: mkdir -p $TEST_RESULTS 14 | - run: 15 | name: Start container and run tests 16 | command: docker-compose -f .circleci/docker-compose-test.yml up --exit-code-from sut sut | tee ${TEST_RESULTS}/test.out 17 | - store_artifacts: 18 | path: /tmp/test-results 19 | destination: raw-test-output 20 | - store_test_results: 21 | path: /tmp/test-results -------------------------------------------------------------------------------- /.circleci/docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | volumes: 3 | data-redis: {} 4 | data-mongo: {} 5 | services: 6 | mongo: 7 | restart: "no" 8 | image: mongo:3.4 9 | volumes: 10 | - data-mongo:/data/db 11 | expose: 12 | - "27017" 13 | redis: 14 | restart: "no" 15 | command: redis-server --appendonly yes 16 | image: redis:3.2 17 | expose: 18 | - "6379" 19 | volumes: 20 | - data-redis:/data 21 | sut: 22 | build: 23 | dockerfile: .circleci/Dockerfile.test 24 | context: .. 25 | restart: "no" 26 | links: 27 | - mongo 28 | - redis 29 | depends_on: 30 | - mongo 31 | - redis 32 | environment: 33 | - TZ=UTC 34 | - INTEGRAM_MONGO_URL=mongodb://mongo:27017/integram 35 | - INTEGRAM_REDIS_URL=redis:6379 36 | - INTEGRAM_INSTANCE_MODE=single 37 | - INTEGRAM_BASE_URL=https://integram.org 38 | - INTEGRAM_DEBUG=0 39 | ## required env vars 40 | - INTEGRAM_PORT 41 | - INTEGRAM_TEST_BOT_TOKEN 42 | - INTEGRAM_TEST_USER -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | vendor 2 | .env 3 | Dockerfile 4 | docker-compose* -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.11 AS builder 2 | 3 | RUN curl -fsSL -o /usr/local/bin/dep https://github.com/golang/dep/releases/download/v0.3.2/dep-linux-amd64 && chmod +x /usr/local/bin/dep 4 | 5 | RUN mkdir -p /go/src/github.com/requilence/integram 6 | WORKDIR /go/src/github.com/requilence/integram 7 | 8 | COPY Gopkg.toml Gopkg.lock ./ 9 | 10 | # install the dependencies without checking for go code 11 | RUN dep ensure -vendor-only 12 | 13 | COPY . ./ 14 | 15 | RUN CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o /go/app github.com/requilence/integram/cmd/multi-process-mode 16 | 17 | # move the builded binary into the tiny alpine linux image 18 | FROM alpine:latest 19 | RUN apk --no-cache add ca-certificates && rm -rf /var/cache/apk/* 20 | WORKDIR /app 21 | 22 | COPY --from=builder /go/app ./ 23 | COPY --from=builder /usr/local/go/lib/time/zoneinfo.zip /usr/local/go/lib/time/zoneinfo.zip 24 | CMD ["./app"] -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: requilence 2 | ko_fi: integram 3 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "master" 6 | digest = "1:899ab67e7a794d8c454b4e6088c7cb85e6b7b4ec9cf5c94d6c742eb8b2e7da26" 7 | name = "github.com/dchest/uniuri" 8 | packages = ["."] 9 | pruneopts = "" 10 | revision = "8902c56451e9b58ff940bbe5fec35d5f9c04584a" 11 | 12 | [[projects]] 13 | digest = "1:9525d0e79ccf382e32edeef466b9a91f16eb0eebdca5971a03fad1bb3be9cd89" 14 | name = "github.com/garyburd/redigo" 15 | packages = [ 16 | "internal", 17 | "redis", 18 | ] 19 | pruneopts = "" 20 | revision = "a69d19351219b6dd56f274f96d85a7014a2ec34e" 21 | version = "v1.6.0" 22 | 23 | [[projects]] 24 | branch = "master" 25 | digest = "1:1120f960f5c334f0f94bad29eefaf73d52d226893369693686148f66c1993f15" 26 | name = "github.com/gin-contrib/sse" 27 | packages = ["."] 28 | pruneopts = "" 29 | revision = "22d885f9ecc78bf4ee5d72b937e4bbcdc58e8cae" 30 | 31 | [[projects]] 32 | branch = "fix-redirect-slash" 33 | digest = "1:c51a9d5aba803d3f443ef7f6ed50cae3d57fe234e508df0fa40062a261ed6143" 34 | name = "github.com/gin-gonic/gin" 35 | packages = [ 36 | ".", 37 | "binding", 38 | "json", 39 | "render", 40 | ] 41 | pruneopts = "" 42 | revision = "588879e55f3c13099159e1f24b7b90946f31266b" 43 | source = "https://github.com/requilence/gin.git" 44 | 45 | [[projects]] 46 | digest = "1:bcb38c8fc9b21bb8682ce2d605a7d4aeb618abc7f827e3ac0b27c0371fdb23fb" 47 | name = "github.com/golang/protobuf" 48 | packages = ["proto"] 49 | pruneopts = "" 50 | revision = "925541529c1fa6821df4e44ce2723319eb2be768" 51 | version = "v1.0.0" 52 | 53 | [[projects]] 54 | digest = "1:7f6f07500a0b7d3766b00fa466040b97f2f5b5f3eef2ecabfe516e703b05119a" 55 | name = "github.com/hashicorp/golang-lru" 56 | packages = [ 57 | ".", 58 | "simplelru", 59 | ] 60 | pruneopts = "" 61 | revision = "7f827b33c0f158ec5dfbba01bb0b14a4541fd81d" 62 | version = "v0.5.3" 63 | 64 | [[projects]] 65 | digest = "1:9eab2325abbed0ebcee9d44bb3660a69d5d10e42d5ac4a0e77f7a6ea22bfce88" 66 | name = "github.com/json-iterator/go" 67 | packages = ["."] 68 | pruneopts = "" 69 | revision = "ca39e5af3ece67bbcda3d0f4f56a8e24d9f2dad4" 70 | version = "1.1.3" 71 | 72 | [[projects]] 73 | digest = "1:b60a24f942c7031ece6c48bcab0b683c7d3d6aa9fd17e21459d9ae604da258fa" 74 | name = "github.com/kelseyhightower/envconfig" 75 | packages = ["."] 76 | pruneopts = "" 77 | revision = "f611eb38b3875cc3bd991ca91c51d06446afa14c" 78 | version = "v1.3.0" 79 | 80 | [[projects]] 81 | digest = "1:d077b8c23b0017d6b34d179a85f03b5eebef699306405f7dac734ceed3b23161" 82 | name = "github.com/kennygrant/sanitize" 83 | packages = ["."] 84 | pruneopts = "" 85 | revision = "2e6820834a1f36c626bf19a253b7d3cc060e9b8b" 86 | version = "v1.2.3" 87 | 88 | [[projects]] 89 | digest = "1:78229b46ddb7434f881390029bd1af7661294af31f6802e0e1bedaad4ab0af3c" 90 | name = "github.com/mattn/go-isatty" 91 | packages = ["."] 92 | pruneopts = "" 93 | revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39" 94 | version = "v0.0.3" 95 | 96 | [[projects]] 97 | digest = "1:0c0ff2a89c1bb0d01887e1dac043ad7efbf3ec77482ef058ac423d13497e16fd" 98 | name = "github.com/modern-go/concurrent" 99 | packages = ["."] 100 | pruneopts = "" 101 | revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" 102 | version = "1.0.3" 103 | 104 | [[projects]] 105 | digest = "1:420f9231f816eeca3ff5aab070caac3ed7f27e4d37ded96ce9de3d7a7a2e31ad" 106 | name = "github.com/modern-go/reflect2" 107 | packages = ["."] 108 | pruneopts = "" 109 | revision = "1df9eeb2bb81f327b96228865c5687bc2194af3f" 110 | version = "1.0.0" 111 | 112 | [[projects]] 113 | digest = "1:2d031fefdf62e820a4e954cc20ef1cce8b296f6df7339df778117362832b4c2b" 114 | name = "github.com/mrjones/oauth" 115 | packages = ["."] 116 | pruneopts = "" 117 | revision = "3f67d9c274355678b2f9844b08d643e2f9213340" 118 | 119 | [[projects]] 120 | branch = "master" 121 | digest = "1:a0cf124e491096e935f8db7aaf1465d13c99b283bde2d28d023d9e0ef6c44a78" 122 | name = "github.com/requilence/jobs" 123 | packages = ["."] 124 | pruneopts = "" 125 | revision = "531b5ae549de0340df9ada6c1d8a512e3bc43f68" 126 | 127 | [[projects]] 128 | branch = "master" 129 | digest = "1:cb9b571b76a90fde343f13196f75c2c80fd41549ab2f7950765423282da2bd74" 130 | name = "github.com/requilence/telegram-bot-api" 131 | packages = ["."] 132 | pruneopts = "" 133 | revision = "440431af8b3c1c003c748ce1e3fbca1c836be891" 134 | 135 | [[projects]] 136 | branch = "master" 137 | digest = "1:b348faf51e9fab0a644967f4b3cccb9c742cecee8b3d0160a538b39d2c8c56cd" 138 | name = "github.com/requilence/url" 139 | packages = ["."] 140 | pruneopts = "" 141 | revision = "6fc4fc0c65da72e95d19a8e5056e222a4f12357f" 142 | 143 | [[projects]] 144 | digest = "1:8cf46b6c18a91068d446e26b67512cf16f1540b45d90b28b9533706a127f0ca6" 145 | name = "github.com/sirupsen/logrus" 146 | packages = ["."] 147 | pruneopts = "" 148 | revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc" 149 | version = "v1.0.5" 150 | 151 | [[projects]] 152 | digest = "1:3bfdafac43ceb125f775eb79b8ee8f1e976e227464434ffad40f956ac029e760" 153 | name = "github.com/technoweenie/multipartstreamer" 154 | packages = ["."] 155 | pruneopts = "" 156 | revision = "a90a01d73ae432e2611d178c18367fbaa13e0154" 157 | version = "v1.0.1" 158 | 159 | [[projects]] 160 | digest = "1:5b607d5268f75aa5ed75bb36ebdb99511607c8f5c3a9f223b04bcc3c066392ad" 161 | name = "github.com/throttled/throttled" 162 | packages = [ 163 | ".", 164 | "store/memstore", 165 | ] 166 | pruneopts = "" 167 | revision = "def5708c45a0d2b2b9a3604521f3164e227d2c83" 168 | version = "v2.2.4" 169 | 170 | [[projects]] 171 | digest = "1:2e7f653483e51243b6cd6de60ce39bde0d6927d10a3c24295ab0f82cb1efeae2" 172 | name = "github.com/ugorji/go" 173 | packages = ["codec"] 174 | pruneopts = "" 175 | revision = "b4c50a2b199d93b13dc15e78929cfb23bfdf21ab" 176 | version = "v1.1.1" 177 | 178 | [[projects]] 179 | digest = "1:63e1f34a255c19d5741dd8e019e1fb86cb0c4d954a37294010538725aa38ef9e" 180 | name = "github.com/vova616/xxhash" 181 | packages = ["."] 182 | pruneopts = "" 183 | revision = "f0a9a8b74d487f9563a527daf3bd6b4fbd3f5d00" 184 | 185 | [[projects]] 186 | digest = "1:5c2e0bcdb121d29c54cfcb0e5d0988b502fa5b3f4a2e1ee1231eec7aed1b6b13" 187 | name = "github.com/weekface/mgorus" 188 | packages = ["."] 189 | pruneopts = "" 190 | revision = "83720e22971a8301c5b2738c9364accd3d9eb13a" 191 | 192 | [[projects]] 193 | branch = "master" 194 | digest = "1:47ff8b3229cff95d3cf3738c7a8461fdeacd3f46801e54d301a62500605ce202" 195 | name = "golang.org/x/crypto" 196 | packages = ["ssh/terminal"] 197 | pruneopts = "" 198 | revision = "d6449816ce06963d9d136eee5a56fca5b0616e7e" 199 | 200 | [[projects]] 201 | branch = "master" 202 | digest = "1:40dd5a4f1e82c4d3dc3de550a96152f6a8937bcfcb20c7ee34685f8764e1bda5" 203 | name = "golang.org/x/net" 204 | packages = [ 205 | "context", 206 | "context/ctxhttp", 207 | "html", 208 | "html/atom", 209 | ] 210 | pruneopts = "" 211 | revision = "61147c48b25b599e5b561d2e9c4f3e1ef489ca41" 212 | 213 | [[projects]] 214 | branch = "master" 215 | digest = "1:217f34bc3104f3375cb368fb264fba24a300301e54a32d67a7e4b91699518e86" 216 | name = "golang.org/x/oauth2" 217 | packages = [ 218 | ".", 219 | "internal", 220 | ] 221 | pruneopts = "" 222 | revision = "921ae394b9430ed4fb549668d7b087601bd60a81" 223 | 224 | [[projects]] 225 | branch = "master" 226 | digest = "1:e9490adb0a1d5ac8f621a6430754822f87e565fd71d4cd1c9c4d90626f390624" 227 | name = "golang.org/x/sys" 228 | packages = [ 229 | "unix", 230 | "windows", 231 | ] 232 | pruneopts = "" 233 | revision = "3b87a42e500a6dc65dae1a55d0b641295971163e" 234 | 235 | [[projects]] 236 | digest = "1:934fb8966f303ede63aa405e2c8d7f0a427a05ea8df335dfdc1833dd4d40756f" 237 | name = "google.golang.org/appengine" 238 | packages = [ 239 | "internal", 240 | "internal/base", 241 | "internal/datastore", 242 | "internal/log", 243 | "internal/remote_api", 244 | "internal/urlfetch", 245 | "urlfetch", 246 | ] 247 | pruneopts = "" 248 | revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a" 249 | version = "v1.0.0" 250 | 251 | [[projects]] 252 | digest = "1:dd549e360e5a8f982a28c2bcbe667307ceffe538ed9afc7c965524f1ac285b3f" 253 | name = "gopkg.in/go-playground/validator.v8" 254 | packages = ["."] 255 | pruneopts = "" 256 | revision = "5f1438d3fca68893a817e4a66806cea46a9e4ebf" 257 | version = "v8.18.2" 258 | 259 | [[projects]] 260 | branch = "v2" 261 | digest = "1:c80894778314c7fb90d94a5ab925214900e1341afeddc953cda7398b8cdcd006" 262 | name = "gopkg.in/mgo.v2" 263 | packages = [ 264 | ".", 265 | "bson", 266 | "internal/json", 267 | "internal/sasl", 268 | "internal/scram", 269 | ] 270 | pruneopts = "" 271 | revision = "3f83fa5005286a7fe593b055f0d7771a7dce4655" 272 | 273 | [[projects]] 274 | digest = "1:f0620375dd1f6251d9973b5f2596228cc8042e887cd7f827e4220bc1ce8c30e2" 275 | name = "gopkg.in/yaml.v2" 276 | packages = ["."] 277 | pruneopts = "" 278 | revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" 279 | version = "v2.2.1" 280 | 281 | [solve-meta] 282 | analyzer-name = "dep" 283 | analyzer-version = 1 284 | input-imports = [ 285 | "github.com/gin-gonic/gin", 286 | "github.com/kelseyhightower/envconfig", 287 | "github.com/kennygrant/sanitize", 288 | "github.com/mrjones/oauth", 289 | "github.com/requilence/jobs", 290 | "github.com/requilence/telegram-bot-api", 291 | "github.com/requilence/url", 292 | "github.com/sirupsen/logrus", 293 | "github.com/throttled/throttled", 294 | "github.com/throttled/throttled/store/memstore", 295 | "github.com/vova616/xxhash", 296 | "github.com/weekface/mgorus", 297 | "golang.org/x/oauth2", 298 | "gopkg.in/mgo.v2", 299 | "gopkg.in/mgo.v2/bson", 300 | ] 301 | solver-name = "gps-cdcl" 302 | solver-version = 1 303 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | 2 | # Gopkg.toml example 3 | # 4 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 5 | # for detailed Gopkg.toml documentation. 6 | # 7 | # required = ["github.com/user/thing/cmd/thing"] 8 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 9 | # 10 | # [[constraint]] 11 | # name = "github.com/user/project" 12 | # version = "1.0.0" 13 | # 14 | # [[constraint]] 15 | # name = "github.com/user/project2" 16 | # branch = "dev" 17 | # source = "github.com/myfork/project2" 18 | # 19 | # [[override]] 20 | # name = "github.com/x/y" 21 | # version = "2.4.0" 22 | ignored = ["github.com/gin-gonic/gin/examples/*", "golang.org/x/oauth2/google", "google.golang.org/appengine"] 23 | 24 | [[constraint]] 25 | name = "github.com/requilence/jobs" 26 | branch = "master" 27 | 28 | [[constraint]] 29 | name = "github.com/requilence/url" 30 | branch = "master" 31 | 32 | [[constraint]] 33 | name = "github.com/requilence/telegram-bot-api" 34 | branch = "master" 35 | 36 | [[constraint]] 37 | branch = "master" 38 | name = "golang.org/x/oauth2" 39 | 40 | [[constraint]] 41 | branch = "v2" 42 | name = "gopkg.in/mgo.v2" 43 | 44 | [[constraint]] 45 | name = "github.com/gin-gonic/gin" 46 | branch = "fix-redirect-slash" 47 | source = "https://github.com/requilence/gin.git" 48 | 49 | [[constraint]] 50 | name = "github.com/kelseyhightower/envconfig" 51 | version = "1.3.0" 52 | 53 | [[constraint]] 54 | name = "github.com/kennygrant/sanitize" 55 | version = "1.2.3" 56 | 57 | [[constraint]] 58 | name = "github.com/sirupsen/logrus" 59 | version = "1.0.4" 60 | 61 | [[constraint]] 62 | name = "github.com/mrjones/oauth" 63 | revision = "3f67d9c274355678b2f9844b08d643e2f9213340" 64 | 65 | [[constraint]] 66 | name = "github.com/vova616/xxhash" 67 | revision = "f0a9a8b74d487f9563a527daf3bd6b4fbd3f5d00" 68 | 69 | [[constraint]] 70 | name = "github.com/weekface/mgorus" 71 | revision = "83720e22971a8301c5b2738c9364accd3d9eb13a" 72 | 73 | [[constraint]] 74 | name = "github.com/throttled/throttled" 75 | version = "2.2.4" 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Integram 2.0 2 | =========== 3 | 4 | Framework and platform to integrate services with [Telegram](https://telegram.org) using the official [Telegram Bot API](https://core.telegram.org/bots/api) 5 | 6 | ℹ️ Individual integration repos are located at https://github.com/integram-org. 7 | 8 | [![CircleCI](https://img.shields.io/circleci/project/requilence/integram.svg)](https://circleci.com/gh/requilence/integram) [![Docker Image](https://img.shields.io/docker/build/integram/integram.svg)](https://hub.docker.com/r/integram/integram/) [![GoDoc](https://godoc.org/github.com/Requilence/integram?status.svg)](https://godoc.org/github.com/requilence/integram) 9 | 10 | ![Screencast](https://st.integram.org/img/screencast4.gif) 11 | 12 | How to use Integram in Telegram (using public bots) 13 | ------------------ 14 | Just use these links to add bots to your Telegram 15 | * [Trello](https://t.me/trello_bot?start=f_github) 16 | * [Gitlab](https://t.me/gitlab_bot?start=f_github) 17 | * [Bitbucket](https://t.me/bitbucket_bot?start=f_github) 18 | * [Simple webhook bot](https://t.me/bullhorn_bot?start=f_github) 19 | 20 | * [GitHub](https://telegram.me/githubbot) – GitHub bot was developed by [Igor Zhukov](https://github.com/zhukov) and it is not part of Integram 21 | 22 | Did not find your favorite service? [🤘 Vote for it](https://telegram.me/integram_bot?start=vote) 23 | 24 | How to host Integram on your own server (using your private bots) 25 | ------------------ 26 | 27 | 🐳 Docker way 28 | ------------------ 29 | - Prerequisites : 30 | - You will need [docker](https://docs.docker.com/install/) and [docker-compose](https://docs.docker.com/compose/install/) installed 31 | - Create your Telegram bot(s) by talking to [@BotFather](https://t.me/botfather) 32 | - Clone the repo: 33 | ```bash 34 | git clone https://github.com/requilence/integram && cd integram 35 | ``` 36 | - Check the `docker-compose.yml` file for the required ENV vars for each service 37 | - E.g. in order to run the Trello integration you will need to export: 38 | - **INTEGRAM_BASE_URL** – the base URL where your Integram host will be accessible, e.g. **https://integram.org** 39 | - **INTEGRAM_PORT** – if set to 443 Integram will use ssl.key/ssl.cert at /go/.conf. 40 | - For **Let's Encrypt**: `ssl.cert` has to be `fullchain.pem`, not `cert.pem` 41 | 42 | This directory is mounted on your host machine. Just get the path and put these files inside 43 | ```bash 44 | ## Get the path of config directory on the host machine 45 | docker volume inspect -f '{{ .Mountpoint }}' integram_data-mainapp 46 | ``` 47 | - **TRELLO_BOT_TOKEN** – your bot's token you got from [@BotFather](https://t.me/botfather) 48 | - You will need to [get your own OAuth credentials from Trello](https://trello.com/app-key) 49 | - **TRELLO_OAUTH_ID** – API Key 50 | - **TRELLO_OAUTH_SECRET** – OAuth Secret 51 | 52 | - For more detailed info about other services you should check the corresponding repo at https://github.com/integram-org 53 | - Export the variables you identified in the previous step, for instance on linux this should be something like: 54 | ```bash 55 | export INTEGRAM_PORT=xxxx 56 | export ... 57 | ``` 58 | - Now you can run the services (linux: careful if you need to sudo this, the exports you just did will not be available) : 59 | ```bash 60 | docker-compose -p integram up trello gitlab ## Here you specify the services you want to run 61 | ``` 62 | - Or in background mode (add `-d`): 63 | ```bash 64 | docker-compose -p integram up -d trello gitlab 65 | ``` 66 | - You should now see Integram's startup logs in your console 67 | - In Telegram, you can now start your bots (`/start`) and follow their directions, configure them using `/settings` 68 | - Some useful commands: 69 | ```bash 70 | ## Check the containers status 71 | docker ps 72 | 73 | ## Fetch logs for main container 74 | docker logs -f $(docker ps -aqf "name=integram_integram") 75 | ``` 76 | - To update Integram to the latest version: 77 | ```bash 78 | ## Fetch last version of images 79 | docker-compose pull integram trello gitlab 80 | ## Restart containers using the new images 81 | docker-compose -p integram up -d trello gitlab 82 | ``` 83 | 84 | 85 | 🛠 Old-school way (No docker) 86 | ------------------ 87 | - First you need to install all requirements: [Go 1.9+](https://golang.org/doc/install), [Go dep](https://github.com/golang/dep#setup), [MongoDB 3.4+ (for data)](https://docs.mongodb.com/manual/administration/install-community/), [Redis 3.2+ (for jobs queue)](https://redis.io/download) 88 | 89 | - Then, using [this template](https://github.com/requilence/integram/blob/master/cmd/single-process-mode/main.go) 90 | create the `main.go` file and put it in `src/integram/` inside your preferred working directory (e.g. `/var/integram/src/integram/main.go`) 91 | 92 | ```bash 93 | ## set the GOPATH to the absolute path of directory containing 'src' directory that you have created before 94 | export GOPATH=/var/integram 95 | 96 | cd $GOPATH/src/integram 97 | ## install dependencies 98 | dep init 99 | ``` 100 | 101 | - Specify the required ENV variables – check the [Docker way section](https://github.com/requilence/integram#-docker-way) 102 | - Run it 103 | ```bash 104 | go build integram && ./integram 105 | ``` 106 | 107 | ### Dependencies 108 | 109 | Dependencies are specified in `Gopkg.toml` and fetched using [Go dep](https://github.com/golang/dep) 110 | 111 | Contributing 112 | ------------------ 113 | Feel free to send PRs. If you want to contribute new service integrations, please [create an issue](https://integram.org/issues/new) first. Just to make sure someone is not already working on it. 114 | 115 | ### Libraries used in Integram 116 | 117 | * [Telegram Bindings](https://github.com/go-telegram-bot-api/telegram-bot-api) 118 | * [Gin – HTTP router and framework](https://github.com/gin-gonic/gin) 119 | * [Mgo – MongoDB driver](https://github.com/go-mgo/mgo) 120 | * [Jobs – background jobs](https://github.com/albrow/jobs) 121 | * [Logrus – structure logging](https://github.com/sirupsen/logrus) 122 | 123 | 124 | ### License 125 | Code licensed under GPLV3 [license](https://github.com/requilence/integram/blob/master/LICENSE) 126 | 127 | ![Analytics](https://ga-beacon.appspot.com/UA-80266491-1/github_readme) 128 | -------------------------------------------------------------------------------- /cmd/multi-process-mode/main.go: -------------------------------------------------------------------------------- 1 | // This entrypoint will run only the main process of Integram 2 | // It's intended to: 3 | // 4 | // – route incoming webhooks to corresponding services 5 | // - resolve webpreviews 6 | // – send outgoing Telegram messages 7 | // 8 | // You should run services in the separate processes 9 | 10 | package main 11 | 12 | import "github.com/requilence/integram" 13 | 14 | 15 | func main(){ 16 | integram.Run() 17 | } 18 | 19 | -------------------------------------------------------------------------------- /cmd/single-process-mode/main.go_example: -------------------------------------------------------------------------------- 1 | // This entrypoint will run the single-process all-in-one instance of Integram 2 | package main 3 | 4 | import ( 5 | "github.com/requilence/integram" 6 | "github.com/integram-org/trello" 7 | "github.com/integram-org/gitlab" 8 | "github.com/kelseyhightower/envconfig" 9 | ) 10 | 11 | func main() { 12 | 13 | // You can fetch secrets from the ENV vars 14 | var trelloConfig trello.Config 15 | envconfig.MustProcess("TRELLO", &trelloConfig) 16 | 17 | integram.Register( 18 | trelloConfig, 19 | trelloConfig.BotConfig.Token, 20 | ) 21 | 22 | // or just specify them directly 23 | integram.Register( 24 | gitlab.Config{ 25 | OAuthProvider: integram.OAuthProvider{ 26 | ID: "GITLAB_APP_ID", 27 | Secret: "GITLAB_APP_SECRET", 28 | }, 29 | }, 30 | "BOT_TOKEN_PROVIDED_BY_@BOTFATHER", 31 | ) 32 | integram.Run() 33 | 34 | } -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import ( 4 | "fmt" 5 | "github.com/kelseyhightower/envconfig" 6 | "github.com/requilence/url" 7 | log "github.com/sirupsen/logrus" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | ) 12 | 13 | type Mode string 14 | 15 | const ( 16 | InstanceModeMultiProcessMain Mode = "multi-main" // run only the main worker. It will process the outgoing messages queue, route the incoming webhooks to specific services and resolve webpreviews 17 | InstanceModeMultiProcessService Mode = "multi-service" // run only one the registred services and their workers. Main instance must be running in order to the outgoing TG messages could be sent 18 | InstanceModeSingleProcess Mode = "single" // run all-in-one process – main worker and all registred services 19 | ) 20 | 21 | type BotConfig struct { 22 | Token string `envconfig:"BOT_TOKEN" required:"true"` 23 | } 24 | 25 | type config struct { 26 | BaseURL string `envconfig:"INTEGRAM_BASE_URL" required:"true"` 27 | InstanceMode Mode `envconfig:"INTEGRAM_INSTANCE_MODE" default:"single"` // please refer to the constants declaration 28 | 29 | TGPoolBatchSize int `envconfig:"INTEGRAM_TG_POOL_BATCH_SIZE" default:"100"` // Number of jobs fetching from Redis at once 30 | 31 | // service webhook ratelimiter 32 | RateLimitMemstore int `envconfig:"INTEGRAM_RATELIMIT_MEMSTORE_SIZE" default:"0"` // max number of keys to store. set 0 to disable ratelimiter 33 | RateLimitPerMinute int `envconfig:"INTEGRAM_RATELIMIT_PER_MINUTE" default:"30"` // max number of incoming requests per minute for user/chat 34 | RateLimitBurst int `envconfig:"INTEGRAM_RATELIMIT_BURST" default:"10"` // max number of requests in a row 35 | 36 | TGPool int `envconfig:"INTEGRAM_TG_POOL" default:"10"` // Maximum simultaneously message sending 37 | MongoURL string `envconfig:"INTEGRAM_MONGO_URL" default:"mongodb://localhost:27017/integram"` 38 | RedisURL string `envconfig:"INTEGRAM_REDIS_URL" default:"127.0.0.1:6379"` 39 | Port string `envconfig:"INTEGRAM_PORT" default:"7000"` 40 | Debug bool `envconfig:"INTEGRAM_DEBUG" default:"1"` 41 | MongoLogging bool `envconfig:"INTEGRAM_MONGO_LOGGING" default:"0"` 42 | MongoStatistic bool `envconfig:"INTEGRAM_MONGO_STATISTIC" default:"0"` 43 | ConfigDir string `envconfig:"INTEGRAM_CONFIG_DIR" default:"./.conf"` // default is $GOPATH/.conf 44 | 45 | // ----- 46 | // only make sense for InstanceModeMultiProcessService 47 | HealthcheckIntervalInSecond int `envconfig:"INTEGRAM_HEALTHCHECK_INTERVAL" default:"30"` // interval to ping each service instance by the main instance 48 | StandAloneServiceURL string `envconfig:"INTEGRAM_STANDALONE_SERVICE_URL"` // default will be depending on the each service's name, e.g. http://trello:7000 49 | 50 | } 51 | 52 | var Config config 53 | 54 | func (c *config) IsMainInstance() bool { 55 | if c.InstanceMode == InstanceModeMultiProcessMain { 56 | return true 57 | } 58 | return false 59 | } 60 | 61 | func (c *config) ParseBaseURL() *url.URL { 62 | u, err := url.Parse(c.BaseURL) 63 | if err != nil { 64 | panic("PANIC: can't parse INTEGRAM_BASE_URL: '" + c.BaseURL + "'") 65 | } 66 | if u.Scheme == "" { 67 | u.Scheme = "https" 68 | } 69 | return u 70 | } 71 | 72 | func (c *config) IsStandAloneServiceInstance() bool { 73 | if c.InstanceMode == InstanceModeMultiProcessService { 74 | return true 75 | } 76 | return false 77 | } 78 | 79 | func (c *config) IsSingleProcessInstance() bool { 80 | if c.InstanceMode == InstanceModeSingleProcess { 81 | return true 82 | } 83 | return false 84 | } 85 | 86 | func init() { 87 | c := make(chan os.Signal, 1) 88 | signal.Notify(c, syscall.SIGHUP) 89 | 90 | go func() { 91 | for sig := range c { 92 | println(sig) 93 | fmt.Println("Got A HUP Signal: reloading config from ENV") 94 | err := envconfig.Process("", &Config) 95 | if err != nil { 96 | log.WithError(err).Error("HUP envconfig error") 97 | } 98 | } 99 | }() 100 | 101 | envconfig.MustProcess("", &Config) 102 | 103 | Config.ParseBaseURL() 104 | } 105 | -------------------------------------------------------------------------------- /data.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | "runtime" 8 | "strings" 9 | "time" 10 | 11 | log "github.com/sirupsen/logrus" 12 | mgo "gopkg.in/mgo.v2" 13 | "gopkg.in/mgo.v2/bson" 14 | ) 15 | 16 | var ( 17 | mongoSession *mgo.Session // Session stores mongo session 18 | mongo *mgo.DialInfo // MongoDB Connection info 19 | ) 20 | 21 | type MgoChange struct { 22 | mgo.Change 23 | } 24 | 25 | func ObjectIdHex(s string) bson.ObjectId { 26 | return bson.ObjectIdHex(s) 27 | } 28 | 29 | func ensureIndexes() { 30 | db := mongoSession.DB(mongo.Database) 31 | db.C("messages").DropIndex("chatid", "botid", "msgid") 32 | 33 | db.C("messages").EnsureIndex(mgo.Index{Key: []string{"botid", "eventid"}}) 34 | db.C("messages").EnsureIndex(mgo.Index{Key: []string{"chatid", "botid", "msgid", "inlinemsgid"}, Unique: true}) 35 | db.C("messages").EnsureIndex(mgo.Index{Key: []string{"chatid", "botid", "fromid"}}) 36 | db.C("messages").EnsureIndex(mgo.Index{Key: []string{"chatid", "botid", "eventid"}}) //todo: test eventID uniqueness 37 | 38 | db.C("previews").EnsureIndex(mgo.Index{Key: []string{"hash"}, Unique: true, Sparse: true}) 39 | 40 | db.C("chats").EnsureIndex(mgo.Index{Key: []string{"hooks.token"}, Unique: true, Sparse: true}) 41 | db.C("chats").EnsureIndex(mgo.Index{Key: []string{"_id", "membersids"}, Unique: true}) 42 | 43 | db.C("users").EnsureIndex(mgo.Index{Key: []string{"hooks.token"}, Unique: true, Sparse: true}) 44 | db.C("users").DropIndex("protected") 45 | db.C("users").EnsureIndex(mgo.Index{Key: []string{"username"}}) // should be unique but what if users swap usernames... hm 46 | db.C("users").EnsureIndex(mgo.Index{Key: []string{"keyboardperchat.chatid", "_id"}, Unique: true, Sparse: true}) 47 | 48 | db.C("users_cache").EnsureIndex(mgo.Index{Key: []string{"expiresat"}, ExpireAfter: time.Second}) 49 | db.C("users_cache").EnsureIndex(mgo.Index{Key: []string{"key", "userid", "service"}, Unique: true}) 50 | 51 | db.C("services_cache").EnsureIndex(mgo.Index{Key: []string{"expiresat"}, ExpireAfter: time.Second}) 52 | db.C("services_cache").EnsureIndex(mgo.Index{Key: []string{"key", "service"}, Unique: true}) 53 | 54 | db.C("chats_cache").EnsureIndex(mgo.Index{Key: []string{"expiresat"}, ExpireAfter: time.Second}) 55 | db.C("chats_cache").EnsureIndex(mgo.Index{Key: []string{"key", "chatid", "service"}, Unique: true}) 56 | 57 | db.C("stats").EnsureIndex(mgo.Index{Key: []string{"s", "k", "d"}, Unique: true}) 58 | 59 | db.C("stats_unique").EnsureIndex(mgo.Index{Key: []string{"exp"}, ExpireAfter: time.Second}) 60 | db.C("stats_unique").EnsureIndex(mgo.Index{Key: []string{"s", "k", "d", "p"}, Unique: true}) 61 | db.C("stats_unique").EnsureIndex(mgo.Index{Key: []string{"s", "k", "d", "p", "u"}, Unique: true}) 62 | 63 | } 64 | 65 | func dbConnect() { 66 | 67 | var err error 68 | mongo, err = mgo.ParseURL(Config.MongoURL) 69 | if err != nil { 70 | log.WithError(err).WithField("url", Config.MongoURL).Panic("Can't parse MongoDB URL") 71 | panic(err.Error()) 72 | } 73 | mongoSession, err = mgo.Dial(Config.MongoURL) 74 | if err != nil { 75 | log.WithError(err).WithField("url", Config.MongoURL).Panic("Can't connect to MongoDB") 76 | panic(err.Error()) 77 | } 78 | mongoSession.SetSafe(&mgo.Safe{}) 79 | log.Infof("MongoDB connected: %s", Config.MongoURL) 80 | 81 | ensureIndexes() 82 | } 83 | 84 | func bindInterfaceToInterface(in interface{}, out interface{}, path ...string) error { 85 | // TODO: need to workaround marshal-unmarshal trick 86 | var m bson.M 87 | var err error 88 | var ok bool 89 | var inner interface{} 90 | if reflect.TypeOf(out).Kind() != reflect.Ptr { 91 | err := errors.New("bindInterfaceToInterface: out interface must be a pointer") 92 | panic(err) 93 | } 94 | if reflect.TypeOf(in).Kind() == reflect.Ptr { 95 | inner = reflect.ValueOf(in).Elem().Interface() 96 | } else { 97 | inner = in 98 | } 99 | 100 | for _, pathel := range path { 101 | m, ok = inner.(bson.M) 102 | if !ok { 103 | return fmt.Errorf("Can't assert bson.M on %v in %v", pathel, path) 104 | } 105 | inner, ok = m[pathel] 106 | if !ok { 107 | return fmt.Errorf("Can't get nested level %v in %v", pathel, path) 108 | } 109 | } 110 | innerType := reflect.TypeOf(inner).Kind() 111 | if innerType == reflect.Slice || innerType == reflect.Array || innerType == reflect.Map || innerType == reflect.Interface { 112 | var j []byte 113 | j, err = bson.Marshal(inner) 114 | if err != nil { 115 | log.Error(err) 116 | return err 117 | } 118 | err = bson.Unmarshal(j, out) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | } else { 124 | reflect.ValueOf(out).Elem().Set(reflect.ValueOf(inner)) 125 | } 126 | 127 | if err != nil { 128 | log.WithField("path", path).WithError(err).Error("can't get nested struct. decode error") 129 | return err 130 | } 131 | return nil 132 | } 133 | 134 | func findUsernameByID(db *mgo.Database, id int64) string { 135 | d := struct{ Username string }{} 136 | db.C("chats").FindId(id).Select(bson.M{"username": 1}).One(&d) 137 | return d.Username 138 | } 139 | func (c *Context) FindChat(query interface{}) (chatData, error) { 140 | chat := chatData{} 141 | serviceID := c.getServiceID() 142 | 143 | err := c.db.C("chats").Find(query).Select(bson.M{"type": 1, "firstname": 1, "lastname": 1, "username": 1, "title": 1, "settings." + serviceID: 1, "protected." + serviceID: 1, "keyboardperbot": 1, "tz": 1, "deactivated": 1, "hooks": 1}).One(&chat) 144 | if err != nil { 145 | //c.Log().WithError(err).WithField("query", query).Error("Can't find chat") 146 | return chat, err 147 | } 148 | chat.ctx = c 149 | chat.Chat.data = &chat 150 | 151 | return chat, nil 152 | } 153 | 154 | func (c *Context) FindChats(query interface{}) ([]chatData, error) { 155 | chats := []chatData{} 156 | serviceID := c.getServiceID() 157 | 158 | err := c.db.C("chats").Find(query).Select(bson.M{"type": 1, "firstname": 1, "lastname": 1, "username": 1, "title": 1, "settings." + serviceID: 1, "protected." + serviceID: 1, "keyboardperbot": 1, "tz": 1, "deactivated": 1, "hooks": 1}).All(&chats) 159 | if err != nil { 160 | //c.Log().WithError(err).WithField("query", query).Error("Can't find chat") 161 | return chats, err 162 | } 163 | for i, _ := range chats { 164 | chats[i].ctx = c 165 | chats[i].Chat.data = &chats[i] 166 | } 167 | 168 | return chats, nil 169 | } 170 | 171 | func (c *Context) FindChatsLimit(query interface{}, limit int, sort ...string) ([]chatData, error) { 172 | chats := []chatData{} 173 | serviceID := c.getServiceID() 174 | 175 | err := c.db.C("chats").Find(query).Limit(limit).Sort(sort...).Select(bson.M{"type": 1, "firstname": 1, "lastname": 1, "username": 1, "title": 1, "settings." + serviceID: 1, "protected." + serviceID: 1, "keyboardperbot": 1, "tz": 1, "deactivated": 1, "hooks": 1}).All(&chats) 176 | if err != nil { 177 | //c.Log().WithError(err).WithField("query", query).Error("Can't find chat") 178 | return chats, err 179 | } 180 | for i, _ := range chats { 181 | chats[i].ctx = c 182 | chats[i].Chat.data = &chats[i] 183 | } 184 | 185 | return chats, nil 186 | } 187 | 188 | func (c *Context) FindUser(query interface{}) (userData, error) { 189 | user := userData{} 190 | serviceID := c.getServiceID() 191 | var err error 192 | if serviceID != "" { 193 | err = c.db.C("users").Find(query).Select(bson.M{"firstname": 1, "lastname": 1, "username": 1, "settings." + serviceID: 1, "protected." + serviceID: 1, "keyboardperchat": bson.M{"$elemMatch": bson.M{"chatid": c.Chat.ID}}, "tz": 1, "hooks": 1}).One(&user) // TODO: IS it ok to lean on c.Chat.ID here? 194 | } else { 195 | err = c.db.C("users").Find(query).Select(bson.M{"firstname": 1, "lastname": 1, "username": 1, "settings": 1, "protected": 1, "keyboardperchat": bson.M{"$elemMatch": bson.M{"chatid": c.Chat.ID}}, "tz": 1, "hooks": 1}).One(&user) // TODO: IS it ok to lean on c.Chat.ID here? 196 | } 197 | user.ctx = c 198 | 199 | if err != nil { 200 | return user, err 201 | } 202 | 203 | return user, nil 204 | } 205 | 206 | func (c *Context) FindUsers(query interface{}) ([]userData, error) { 207 | users := []userData{} 208 | serviceID := c.getServiceID() 209 | var err error 210 | if serviceID != "" { 211 | err = c.db.C("users").Find(query).Select(bson.M{"firstname": 1, "lastname": 1, "username": 1, "settings." + serviceID: 1, "protected." + serviceID: 1, "keyboardperchat": bson.M{"$elemMatch": bson.M{"chatid": c.Chat.ID}}, "tz": 1, "hooks": 1}).All(&users) // TODO: IS it ok to lean on c.Chat.ID here? 212 | } else { 213 | err = c.db.C("users").Find(query).Select(bson.M{"firstname": 1, "lastname": 1, "username": 1, "settings": 1, "protected": 1, "keyboardperchat": bson.M{"$elemMatch": bson.M{"chatid": c.Chat.ID}}, "tz": 1, "hooks": 1}).All(&users) // TODO: IS it ok to lean on c.Chat.ID here? 214 | } 215 | 216 | if err != nil { 217 | return users, err 218 | } 219 | 220 | for i, _ := range users { 221 | users[i].ctx = c 222 | } 223 | 224 | return users, nil 225 | } 226 | 227 | func (c *Context) FindUsersLimit(query interface{}, limit int, sort ...string) ([]userData, error) { 228 | users := []userData{} 229 | serviceID := c.getServiceID() 230 | var err error 231 | if serviceID != "" { 232 | err = c.db.C("users").Find(query).Limit(limit).Sort(sort...).Select(bson.M{"firstname": 1, "lastname": 1, "username": 1, "settings." + serviceID: 1, "protected." + serviceID: 1, "keyboardperchat": bson.M{"$elemMatch": bson.M{"chatid": c.Chat.ID}}, "tz": 1, "hooks": 1}).All(&users) // TODO: IS it ok to lean on c.Chat.ID here? 233 | } else { 234 | err = c.db.C("users").Find(query).Limit(limit).Sort(sort...).Select(bson.M{"firstname": 1, "lastname": 1, "username": 1, "settings": 1, "protected": 1, "keyboardperchat": bson.M{"$elemMatch": bson.M{"chatid": c.Chat.ID}}, "tz": 1, "hooks": 1}).All(&users) // TODO: IS it ok to lean on c.Chat.ID here? 235 | } 236 | 237 | if err != nil { 238 | return users, err 239 | } 240 | 241 | for i, _ := range users { 242 | users[i].ctx = c 243 | } 244 | 245 | return users, nil 246 | } 247 | 248 | func (c *Context) updateCacheVal(cacheType string, key string, update interface{}, res interface{}) (exists bool) { 249 | 250 | KeyType := reflect.TypeOf("1") 251 | var ElemType reflect.Type 252 | ElemKind := reflect.ValueOf(res).Kind() 253 | 254 | if ElemKind == reflect.Interface || ElemKind == reflect.Ptr { 255 | ElemType = reflect.ValueOf(res).Elem().Type() 256 | } else { 257 | ElemType = reflect.ValueOf(res).Type() 258 | } 259 | 260 | serviceID := c.getServiceID() 261 | 262 | mi := reflect.MakeMap(reflect.MapOf(KeyType, ElemType)).Interface() 263 | var err error 264 | //var info *mgo.ChangeInfo 265 | if cacheType == "user" { 266 | _, err = c.db.C("users_cache").Find(bson.M{"userid": c.User.ID, "service": serviceID, "key": strings.ToLower(key)}).Select(bson.M{"_id": 0, "val": 1}).Limit(1).Apply(mgo.Change{Update: update, ReturnNew: true, Upsert: true}, mi) 267 | } else if cacheType == "chat" { 268 | _, err = c.db.C("chats_cache").Find(bson.M{"chatid": c.Chat.ID, "service": serviceID, "key": strings.ToLower(key)}).Select(bson.M{"_id": 0, "val": 1}).Limit(1).Apply(mgo.Change{Update: update, ReturnNew: true, Upsert: true}, mi) 269 | } else if cacheType == "service" { 270 | _, err = c.db.C("services_cache").Find(bson.M{"service": serviceID, "key": strings.ToLower(key)}).Select(bson.M{"_id": 0, "val": 1}).Limit(1).Apply(mgo.Change{Update: update, ReturnNew: true, Upsert: true}, mi) 271 | } else { 272 | panic("updateCacheVal, type " + cacheType + " not exists") 273 | } 274 | 275 | if err != nil { 276 | log.WithField("service", serviceID).WithField("key", key).WithField("user", c.User.ID).WithField("chat", c.Chat.ID).Debugf(cacheType+" cache updating error: %v", err) 277 | return false 278 | } 279 | 280 | if mi == nil { 281 | return false 282 | } 283 | 284 | // Wow. Such reflection. Much deep. 285 | if reflect.ValueOf(mi).MapIndex(reflect.ValueOf("val")).IsValid() { 286 | 287 | val := reflect.ValueOf(reflect.ValueOf(mi).MapIndex(reflect.ValueOf("val")).Interface()) 288 | 289 | if val.IsValid() { 290 | resVal := reflect.ValueOf(res) 291 | if resVal.Kind() != reflect.Ptr { 292 | log.Panic("You need to pass pointer to result interface, not an interface") 293 | return false 294 | } 295 | 296 | if !resVal.Elem().IsValid() || !resVal.Elem().CanSet() { 297 | log.WithField("key", key).Error(cacheType + " cache, can't set to res interface") 298 | return false 299 | } 300 | resVal.Elem().Set(val) 301 | return true 302 | } 303 | } 304 | return false 305 | } 306 | 307 | func (c *Context) getCacheVal(cacheType string, key string, res interface{}) (exists bool) { 308 | 309 | KeyType := reflect.TypeOf("1") 310 | 311 | var ElemType reflect.Type 312 | ElemKind := reflect.ValueOf(res).Kind() 313 | 314 | if ElemKind == reflect.Interface || ElemKind == reflect.Ptr { 315 | ElemType = reflect.ValueOf(res).Elem().Type() 316 | } else { 317 | ElemType = reflect.ValueOf(res).Type() 318 | } 319 | serviceID := c.getServiceID() 320 | if serviceID == "" { 321 | c.Log().Errorf("getCacheVal type %s, service not set", cacheType) 322 | return false 323 | } 324 | 325 | mi := reflect.MakeMap(reflect.MapOf(KeyType, ElemType)).Interface() 326 | var err error 327 | if cacheType == "user" { 328 | err = c.db.C("users_cache").Find(bson.M{"userid": c.User.ID, "service": serviceID, "key": strings.ToLower(key)}).Select(bson.M{"_id": 0, "val": 1}).One(mi) 329 | } else if cacheType == "chat" { 330 | err = c.db.C("chats_cache").Find(bson.M{"chatid": c.Chat.ID, "service": serviceID, "key": strings.ToLower(key)}).Select(bson.M{"_id": 0, "val": 1}).One(mi) 331 | } else if cacheType == "service" { 332 | err = c.db.C("services_cache").Find(bson.M{"service": serviceID, "key": strings.ToLower(key)}).Select(bson.M{"_id": 0, "val": 1}).One(mi) 333 | } else { 334 | c.Log().Panic("getCacheVal, type " + cacheType + " not exists") 335 | return false 336 | } 337 | 338 | if err != nil { 339 | return false 340 | } 341 | 342 | if mi == nil { 343 | return false 344 | } 345 | 346 | if !reflect.ValueOf(mi).MapIndex(reflect.ValueOf("val")).IsValid() { 347 | return false 348 | } 349 | // Wow. Such reflection. Much deep. 350 | val := reflect.ValueOf(reflect.ValueOf(mi).MapIndex(reflect.ValueOf("val")).Interface()) 351 | 352 | if val.IsValid() { 353 | resVal := reflect.ValueOf(res) 354 | if resVal.Kind() != reflect.Ptr { 355 | log.Panic("You need to pass pointer to result interface, not an interface") 356 | return false 357 | } 358 | 359 | if !resVal.Elem().IsValid() || !resVal.Elem().CanSet() { 360 | log.WithField("key", key).Error(cacheType + " cache, can't set to res interface") 361 | return false 362 | } 363 | resVal.Elem().Set(val) 364 | return true 365 | } 366 | 367 | return false 368 | } 369 | 370 | // Cache returns if User's cache for specific key exists and try to bind it to res 371 | func (user *User) Cache(key string, res interface{}) (exists bool) { 372 | return user.ctx.getCacheVal("user", key, res) 373 | } 374 | 375 | // Cache returns if Chat's cache for specific key exists and try to bind it to res 376 | func (chat *Chat) Cache(key string, res interface{}) (exists bool) { 377 | return chat.ctx.getCacheVal("chat", key, res) 378 | } 379 | 380 | // ServiceCache returns if Services's cache for specific key exists and try to bind it to res 381 | func (c *Context) ServiceCache(key string, res interface{}) (exists bool) { 382 | return c.getCacheVal("service", key, res) 383 | } 384 | 385 | func (user *User) Chat() Chat { 386 | return Chat{ID: user.ID, Type: "private", UserName: user.UserName, FirstName: user.FirstName, LastName: user.LastName, ctx: user.ctx} 387 | } 388 | 389 | // IsPrivateStarted indicates if user started the private dialog with a bot (e.g. pressed the start button) 390 | func (user *User) IsPrivateStarted() bool { 391 | err := user.ctx.Db().C("messages").Find(bson.M{"chatid": user.ID, "botid": user.ctx.Bot().ID, "fromid": user.ID}).Select(bson.M{"_id": 1}).One(nil) 392 | if err == nil { 393 | return true 394 | } 395 | return false 396 | } 397 | 398 | // SetCache set the User's cache with specific key and TTL 399 | func (user *User) SetCache(key string, val interface{}, ttl time.Duration) error { 400 | expiresAt := time.Now().Add(ttl) 401 | 402 | serviceID := user.ctx.getServiceID() 403 | key = strings.ToLower(key) 404 | 405 | if val == nil { 406 | err := user.ctx.db.C("users_cache").Remove(bson.M{"userid": user.ID, "service": serviceID, "key": key}) 407 | return err 408 | } 409 | _, err := user.ctx.db.C("users_cache").Upsert(bson.M{"userid": user.ID, "service": serviceID, "key": key}, bson.M{"$set": bson.M{"val": val, "expiresat": expiresAt}}) 410 | if err != nil { 411 | // workaround for WiredTiger bug: https://jira.mongodb.org/browse/SERVER-14322 412 | if mgo.IsDup(err) { 413 | return user.ctx.db.C("users_cache").Update(bson.M{"userid": user.ID, "service": serviceID, "key": key}, bson.M{"$set": bson.M{"val": val, "expiresat": expiresAt}}) 414 | } 415 | log.WithError(err).WithField("key", key).Error("Can't set user cache value") 416 | } 417 | return err 418 | } 419 | 420 | // ClearAllCacheKeys removes all User's cache keys 421 | func (user *User) ClearAllCacheKeys() error { 422 | serviceID := user.ctx.getServiceID() 423 | _, err := user.ctx.db.C("users_cache").RemoveAll(bson.M{"userid": user.ID, "service": serviceID}) 424 | return err 425 | } 426 | 427 | // UpdateCache updates the per User cache using MongoDB Update query 428 | func (user *User) UpdateCache(key string, update interface{}, res interface{}) error { 429 | 430 | exists := user.ctx.updateCacheVal("user", key, update, res) 431 | 432 | if !exists { 433 | log.WithField("key", key).Error("Can't update user cache value") 434 | } 435 | return nil 436 | } 437 | 438 | // SetCache set the Chats's cache with specific key and TTL 439 | func (chat *Chat) SetCache(key string, val interface{}, ttl time.Duration) error { 440 | expiresAt := time.Now().Add(ttl) 441 | serviceID := chat.ctx.getServiceID() 442 | key = strings.ToLower(key) 443 | 444 | if val == nil { 445 | err := chat.ctx.db.C("chats_cache").Remove(bson.M{"chatid": chat.ID, "service": serviceID, "key": key}) 446 | return err 447 | } 448 | _, err := chat.ctx.db.C("chats_cache").Upsert(bson.M{"chatid": chat.ID, "service": serviceID, "key": key}, bson.M{"$set": bson.M{"val": val, "expiresat": expiresAt}}) 449 | if err != nil { 450 | // workaround for WiredTiger bug: https://jira.mongodb.org/browse/SERVER-14322 451 | if mgo.IsDup(err) { 452 | return chat.ctx.db.C("chats_cache").Update(bson.M{"chatid": chat.ID, "service": serviceID, "key": key}, bson.M{"$set": bson.M{"val": val, "expiresat": expiresAt}}) 453 | } 454 | log.WithError(err).WithField("key", key).Error("Can't set user cache value") 455 | } 456 | return err 457 | } 458 | 459 | // ClearAllCacheKeys removes all Chat's cache keys 460 | func (chat *Chat) ClearAllCacheKeys() error { 461 | serviceID := chat.ctx.getServiceID() 462 | _, err := chat.ctx.db.C("chats_cache").RemoveAll(bson.M{"chatid": chat.ID, "service": serviceID}) 463 | return err 464 | } 465 | 466 | // UpdateCache updates the per Chat cache using MongoDB Update query (see trello service as example) 467 | func (chat *Chat) UpdateCache(key string, update interface{}, res interface{}) error { 468 | 469 | exists := chat.ctx.updateCacheVal("chat", key, update, res) 470 | 471 | if !exists { 472 | log.WithField("key", key).Error("Can't update chat cache value") 473 | } 474 | return nil 475 | } 476 | 477 | // SetServiceCache set the Services's cache with specific key and TTL 478 | func (c *Context) SetServiceCache(key string, val interface{}, ttl time.Duration) error { 479 | expiresAt := time.Now().Add(ttl) 480 | serviceID := c.getServiceID() 481 | key = strings.ToLower(key) 482 | 483 | if val == nil { 484 | err := c.db.C("services_cache").Remove(bson.M{"service": serviceID, "key": key}) 485 | return err 486 | } 487 | 488 | _, err := c.db.C("services_cache").Upsert(bson.M{"service": serviceID, "key": key}, bson.M{"$set": bson.M{"val": val, "expiresat": expiresAt}}) 489 | if err != nil { 490 | // workaround for WiredTiger bug: https://jira.mongodb.org/browse/SERVER-14322 491 | if mgo.IsDup(err) { 492 | return c.db.C("services_cache").Update(bson.M{"service": serviceID, "key": key}, bson.M{"$set": bson.M{"val": val, "expiresat": expiresAt}}) 493 | } 494 | log.WithError(err).WithField("key", key).Error("Can't set sevices cache value") 495 | } 496 | return err 497 | } 498 | 499 | // UpdateServiceCache updates the Services's cache using MongoDB Update query (see trello service as example) 500 | func (c *Context) UpdateServiceCache(key string, update interface{}, res interface{}) error { 501 | 502 | exists := c.updateCacheVal("service", key, update, res) 503 | 504 | if !exists { 505 | log.WithField("key", key).Error("Can't update sevices cache value") 506 | } 507 | return nil 508 | } 509 | 510 | func (user *User) updateData() error { 511 | _, err := user.ctx.db.C("users").UpsertId(user.ID, bson.M{"$set": user, "$setOnInsert": bson.M{"createdat": time.Now()}}) 512 | user.data.User = *user 513 | 514 | return err 515 | } 516 | 517 | func (chat *Chat) updateData() error { 518 | _, err := chat.ctx.db.C("chats").UpsertId(chat.ID, bson.M{"$set": chat, "$setOnInsert": bson.M{"createdat": time.Now()}}) 519 | chat.data.Chat = *chat 520 | return err 521 | } 522 | 523 | func (chat *Chat) getData() (*chatData, error) { 524 | 525 | if chat.ID == 0 { 526 | return nil, errors.New("chat is empty") 527 | } 528 | 529 | if chat.data != nil { 530 | return chat.data, nil 531 | } 532 | cdata, _ := chat.ctx.FindChat(bson.M{"_id": chat.ID}) 533 | chat.data = &cdata 534 | 535 | var err error 536 | if cdata.Type == "" { 537 | err = chat.updateData() 538 | } 539 | 540 | return chat.data, err 541 | 542 | } 543 | 544 | // OAuthValid checks if OAuthToken for service is set 545 | func (chat *Chat) BotWasKickedOrStopped() bool { 546 | ps, _ := chat.protectedSettings() 547 | 548 | if ps == nil { 549 | return false 550 | } 551 | 552 | if ps.BotStoppedOrKickedAt != nil { 553 | return true 554 | } 555 | return false 556 | } 557 | 558 | func (user *User) getData() (*userData, error) { 559 | 560 | if user.ID == 0 { 561 | return nil, errors.New("user is empty") 562 | } 563 | if user.data != nil { 564 | return user.data, nil 565 | } 566 | if user.ctx == nil { 567 | panic("nil user context") 568 | } 569 | 570 | udata, err := user.ctx.FindUser(bson.M{"_id": user.ID}) 571 | 572 | user.data = &udata 573 | user.Tz = user.data.Tz 574 | 575 | if user.data.FirstName == "" { 576 | err = user.updateData() 577 | } 578 | 579 | return user.data, err 580 | 581 | } 582 | func (c *Context) getServiceID() string { 583 | s := c.Service() 584 | 585 | if s == nil { 586 | return c.ServiceName 587 | } 588 | 589 | if c.ServiceBaseURL.Host == "" { 590 | return c.ServiceName 591 | } 592 | 593 | if s.DefaultBaseURL.Host == c.ServiceBaseURL.Host { 594 | return c.ServiceName 595 | } 596 | 597 | return s.Name + "_" + escapeDot(c.ServiceBaseURL.Host) 598 | 599 | } 600 | 601 | func (user *User) protectedSettings() (*userProtected, error) { 602 | 603 | data, err := user.getData() 604 | 605 | if err != nil { 606 | return nil, err 607 | } 608 | // fmt.Printf("user.getData: %+v\n%v", data, err) 609 | 610 | serviceID := user.ctx.getServiceID() 611 | 612 | if data.Protected == nil { 613 | data.Protected = make(map[string]*userProtected) 614 | } else if protected, ok := data.Protected[serviceID]; ok { 615 | return protected, nil 616 | } 617 | 618 | data.Protected[serviceID] = &userProtected{} 619 | 620 | // Not a error – just empty settings 621 | return data.Protected[serviceID], err 622 | } 623 | 624 | func (chat *Chat) protectedSettings() (*chatProtected, error) { 625 | 626 | data, err := chat.getData() 627 | 628 | if err != nil { 629 | return nil, err 630 | } 631 | // fmt.Printf("user.getData: %+v\n%v", data, err) 632 | 633 | serviceID := chat.ctx.getServiceID() 634 | 635 | if data.Protected == nil { 636 | data.Protected = make(map[string]*chatProtected) 637 | } else if protected, ok := data.Protected[serviceID]; ok { 638 | return protected, nil 639 | } 640 | 641 | data.Protected[serviceID] = &chatProtected{} 642 | 643 | // Not a error – just empty settings 644 | return data.Protected[serviceID], err 645 | } 646 | 647 | // Settings bind User's settings for service to the interface 648 | func (user *User) Settings(out interface{}) error { 649 | data, err := user.getData() 650 | 651 | if err != nil { 652 | return err 653 | } 654 | serviceID := user.ctx.getServiceID() 655 | 656 | if _, ok := data.Settings[serviceID]; ok { 657 | // TODO: workaround that creepy bindInterfaceToInterface 658 | err = bindInterfaceToInterface(data.Settings[serviceID], out) 659 | return err 660 | } 661 | 662 | // Not a error – just empty settings 663 | return nil 664 | } 665 | 666 | // Settings bind Chat's settings for service to the interface 667 | func (chat *Chat) Settings(out interface{}) error { 668 | 669 | data, err := chat.getData() 670 | 671 | if err != nil { 672 | return err 673 | } 674 | serviceID := chat.ctx.getServiceID() 675 | 676 | if _, ok := data.Settings[serviceID]; ok { 677 | // TODO: workaround that creepy bindInterfaceToInterface 678 | err = bindInterfaceToInterface(data.Settings[serviceID], out) 679 | return err 680 | } 681 | 682 | // Not a error – just empty settings 683 | return nil 684 | } 685 | 686 | // Setting returns Chat's setting for service with specific key. NOTE! Only builtin types are supported (f.e. structs will become map) 687 | func (chat *Chat) Setting(key string) (result interface{}, exists bool) { 688 | var settings map[string]interface{} 689 | 690 | err := chat.Settings(&settings) 691 | if err != nil { 692 | log.WithError(err).Error("Can't get UserSettings") 693 | return nil, false 694 | } 695 | 696 | if _, ok := settings[key]; ok { 697 | return settings[key], true 698 | } 699 | return nil, false 700 | } 701 | 702 | // Setting returns Chat's setting for service with specific key 703 | func (user *User) Setting(key string) (result interface{}, exists bool) { 704 | var settings map[string]interface{} 705 | 706 | err := user.Settings(&settings) 707 | if err != nil { 708 | log.WithError(err).Error("Can't get ChatSettings") 709 | return nil, false 710 | } 711 | 712 | if _, ok := settings[key]; ok { 713 | return settings[key], true 714 | } 715 | return nil, false 716 | } 717 | 718 | // SaveSettings save Chat's setting for service 719 | func (chat *Chat) SaveSettings(allSettings interface{}) error { 720 | 721 | serviceID := chat.ctx.getServiceID() 722 | 723 | _, err := chat.ctx.db.C("chats").UpsertId(chat.ID, bson.M{"$set": bson.M{"settings." + serviceID: allSettings}, "$setOnInsert": bson.M{"createdat": time.Now()}}) 724 | 725 | if chat.data == nil { 726 | chat.data = &chatData{} 727 | } 728 | 729 | if chat.data.Settings == nil { 730 | chat.data.Settings = make(map[string]interface{}) 731 | } 732 | 733 | chat.data.Settings[serviceID] = allSettings 734 | 735 | return err 736 | } 737 | 738 | // SaveSettings save User's setting for service 739 | func (user *User) SaveSettings(allSettings interface{}) error { 740 | 741 | serviceID := user.ctx.getServiceID() 742 | 743 | _, err := user.ctx.db.C("users").UpsertId(user.ID, bson.M{"$set": bson.M{"settings." + serviceID: allSettings}, "$setOnInsert": bson.M{"createdat": time.Now()}}) 744 | 745 | if user.data == nil { 746 | user.data = &userData{} 747 | } 748 | if user.data.Settings == nil { 749 | user.data.Settings = make(map[string]interface{}) 750 | } 751 | user.data.Settings[serviceID] = allSettings 752 | 753 | return err 754 | } 755 | 756 | func (user *User) addHook(hook serviceHook) error { 757 | _, err := user.ctx.db.C("users").UpsertId(user.ID, bson.M{"$push": bson.M{"hooks": hook}}) 758 | user.data.Hooks = append(user.data.Hooks, hook) 759 | 760 | return err 761 | } 762 | 763 | func (chat *Chat) addHook(hook serviceHook) error { 764 | _, err := chat.ctx.db.C("chats").UpsertId(chat.ID, bson.M{"$push": bson.M{"hooks": hook}}) 765 | chat.data.Hooks = append(chat.data.Hooks, hook) 766 | 767 | return err 768 | } 769 | 770 | // ServiceHookToken returns User's hook token to use in webhook handling 771 | func (user *User) ServiceHookToken() string { 772 | data, _ := user.getData() 773 | //TODO: test backward compatibility cases 774 | for _, hook := range data.Hooks { 775 | for _, service := range hook.Services { 776 | if service == user.ctx.ServiceName { 777 | return hook.Token 778 | } 779 | } 780 | } 781 | token := "u" + rndStr.Get(10) 782 | user.addHook(serviceHook{ 783 | Token: token, 784 | Services: []string{user.ctx.ServiceName}, 785 | }) 786 | return token 787 | } 788 | 789 | // ServiceHookToken returns Chats's hook token to use in webhook handling 790 | func (chat *Chat) ServiceHookToken() string { 791 | data, _ := chat.getData() 792 | //TODO: test backward compatibility cases 793 | for _, hook := range data.Hooks { 794 | for _, service := range hook.Services { 795 | if service == chat.ctx.ServiceName { 796 | return hook.Token 797 | } 798 | } 799 | } 800 | token := "c" + rndStr.Get(10) 801 | chat.addHook(serviceHook{ 802 | Token: token, 803 | Services: []string{chat.ctx.ServiceName}, 804 | }) 805 | return token 806 | } 807 | 808 | // ServiceHookURL returns User's webhook URL for service to use in webhook handling 809 | // Used in case when incoming webhooks despatching on the user behalf to chats 810 | func (user *User) ServiceHookURL() string { 811 | return Config.BaseURL + "/" + user.ctx.ServiceName + "/" + user.ServiceHookToken() 812 | } 813 | 814 | // ServiceHookURL returns Chats's webhook URL for service to use in webhook handling 815 | // Used in case when user need to put webhook URL to receive notifications to chat 816 | func (chat *Chat) ServiceHookURL() string { 817 | return Config.BaseURL + "/" + chat.ctx.ServiceName + "/" + chat.ServiceHookToken() 818 | } 819 | 820 | // AddChatToHook adds the target chat to user's existing hook 821 | func (user *User) AddChatToHook(chatID int64) error { 822 | data, _ := user.getData() 823 | token := user.ServiceHookToken() 824 | 825 | for i, hook := range data.Hooks { 826 | if hook.Token == token { 827 | for _, service := range hook.Services { 828 | if service == user.ctx.ServiceName { 829 | for _, existingChatID := range hook.Chats { 830 | if existingChatID == chatID { 831 | return nil 832 | } 833 | } 834 | data.Hooks[i].Chats = append(data.Hooks[i].Chats, chatID) 835 | err := user.ctx.db.C("users").Update(bson.M{"_id": user.ID, "hooks.services": service}, bson.M{"$addToSet": bson.M{"hooks.$.chats": chatID}}) 836 | 837 | return err 838 | } 839 | } 840 | } 841 | } 842 | err := errors.New("Can't add chat to serviceHook. Can't find a hook.") 843 | user.ctx.Log().Error(err) 844 | return err 845 | } 846 | 847 | func (user *User) saveProtectedSettings() error { 848 | 849 | if user.ID == 0 { 850 | return errors.New("saveProtectedSettings: user is empty") 851 | 852 | } 853 | 854 | if user.data.Protected == nil { 855 | return errors.New("userData.protected is nil. I won't save it") 856 | } 857 | 858 | serviceID := user.ctx.getServiceID() 859 | _, err := user.ctx.db.C("users").UpsertId(user.ID, bson.M{"$set": bson.M{"protected." + serviceID: user.data.Protected[serviceID]}, "$setOnInsert": bson.M{"createdat": time.Now()}}) 860 | 861 | return err 862 | } 863 | 864 | func (user *User) saveProtectedSetting(key string, value interface{}) error { 865 | 866 | if user.ID == 0 { 867 | return errors.New("saveProtectedSetting: user is empty") 868 | 869 | } 870 | 871 | if user.data == nil { 872 | user.getData() 873 | } 874 | 875 | if user.data.Protected == nil { 876 | user.data.Protected = make(map[string]*userProtected) 877 | } 878 | serviceID := user.ctx.getServiceID() 879 | 880 | v := reflect.ValueOf(user.data.Protected[serviceID]).Elem().FieldByName(key) 881 | if v.IsValid() { 882 | s := reflect.ValueOf(value) 883 | if s.Type() != v.Type() { 884 | return errors.New("protected setting with key " + key + " has wrong Type") 885 | } 886 | if v.CanSet() { 887 | v.Set(s) 888 | } 889 | } else { 890 | return errors.New("protected setting with key " + key + " not exists") 891 | } 892 | 893 | _, err := user.ctx.db.C("users").UpsertId(user.ID, bson.M{"$set": bson.M{"protected." + serviceID + "." + strings.ToLower(key): value}}) 894 | 895 | return err 896 | } 897 | 898 | // SaveSetting sets Chat's setting for service with specific key 899 | func (chat *Chat) SaveSetting(key string, value interface{}) error { 900 | 901 | key = strings.ToLower(key) 902 | serviceID := chat.ctx.getServiceID() 903 | var cd chatData 904 | _, err := chat.ctx.db.C("chats").FindId(chat.ID).Select(bson.M{"settings." + serviceID: 1}). 905 | Apply( 906 | mgo.Change{ 907 | Update: bson.M{ 908 | "$set": bson.M{"settings." + serviceID + "." + key: value}, 909 | "$setOnInsert": bson.M{"createdat": time.Now()}, 910 | }, 911 | Upsert: true, 912 | ReturnNew: true, 913 | }, 914 | &cd) 915 | 916 | if err == nil && chat.data != nil && chat.data.Settings != nil && cd.Settings != nil && cd.Settings[serviceID] != nil { 917 | chat.data.Settings[serviceID] = cd.Settings[serviceID] 918 | } 919 | 920 | return err 921 | } 922 | 923 | // SaveSetting sets User's setting for service with specific key 924 | func (user *User) SaveSetting(key string, value interface{}) error { 925 | 926 | if user.ID == 0 { 927 | return errors.New("SaveSetting: user is empty") 928 | } 929 | 930 | key = strings.ToLower(key) 931 | serviceID := user.ctx.getServiceID() 932 | 933 | var ud userData 934 | _, err := user.ctx.db.C("users").FindId(user.ID).Select(bson.M{"settings." + serviceID: 1}). 935 | Apply( 936 | mgo.Change{ 937 | Update: bson.M{ 938 | "$set": bson.M{"settings." + serviceID + "." + key: value}, 939 | "$setOnInsert": bson.M{"createdat": time.Now()}, 940 | }, 941 | Upsert: true, 942 | ReturnNew: true, 943 | }, 944 | &ud) 945 | 946 | if err == nil && user.data != nil && user.data.Settings != nil && ud.Settings != nil && ud.Settings[serviceID] != nil { 947 | user.data.Settings[serviceID] = ud.Settings[serviceID] 948 | } 949 | 950 | return err 951 | } 952 | 953 | 954 | func escapeDot(s string) string { 955 | return strings.Replace(s, ".", "_", -1) 956 | } 957 | 958 | // SetAfterAuthAction sets the handlerFunc and it's args that will be triggered on success user Auth. 959 | // F.e. you can use it to resume action interrupted because user didn't authed 960 | // !!! Please note that you must ommit first arg *integram.Context, because it will be automatically prepended on auth success and will contains actual action context 961 | func (user *User) SetAfterAuthAction(handlerFunc interface{}, args ...interface{}) error { 962 | err := verifyTypeMatching(handlerFunc, args...) 963 | if err != nil { 964 | log.WithError(err).Error("Can't verify SetUserAfterAuthHandler args") 965 | return err 966 | } 967 | 968 | bytes, err := encode(args) 969 | 970 | if err != nil { 971 | log.WithError(err).Error("Can't encode SetUserAfterAuthHandler args") 972 | return err 973 | } 974 | ps, _ := user.protectedSettings() 975 | 976 | ps.AfterAuthData = bytes 977 | ps.AfterAuthHandler = runtime.FuncForPC(reflect.ValueOf(handlerFunc).Pointer()).Name() 978 | 979 | user.saveProtectedSettings() 980 | 981 | return nil 982 | } 983 | 984 | // WebPreview generate fake webpreview and store it in DB. Telegram will resolve it as we need 985 | func (c *Context) WebPreview(title string, headline string, text string, serviceURL string, imageURL string) (WebPreviewURL string) { 986 | token := rndStr.Get(10) 987 | if title == "" { 988 | title = c.Service().NameToPrint 989 | c.Log().WithField("token", token).Warn("webPreview: title is empty") 990 | } 991 | 992 | if headline == "" { 993 | c.Log().WithField("token", token).Warn("webPreview: headline is empty") 994 | headline = "-" 995 | 996 | } 997 | wp := webPreview{ 998 | title, 999 | headline, 1000 | text, 1001 | serviceURL, 1002 | imageURL, 1003 | token, 1004 | "", 1005 | 0, 1006 | time.Now(), 1007 | } 1008 | 1009 | wp.Hash = wp.CalculateHash() 1010 | 1011 | var wpExists webPreview 1012 | c.db.C("previews").Find(bson.M{"hash": wp.Hash}).One(&wpExists) 1013 | 1014 | if wpExists.Token != "" { 1015 | wp = wpExists 1016 | } else { 1017 | err := c.db.C("previews").Insert(wp) 1018 | 1019 | if err != nil { 1020 | // Wow! So jackpot! Much collision 1021 | wp.Token = rndStr.Get(10) 1022 | err = c.db.C("previews").Insert(wp) 1023 | c.Log().WithError(err).Error("Can't add webpreview") 1024 | 1025 | } 1026 | } 1027 | 1028 | return Config.BaseURL + "/a/" + wp.Token 1029 | 1030 | } 1031 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | volumes: 3 | data-redis: {} 4 | data-mongo: {} 5 | data-mainapp: {} 6 | services: 7 | mongo: 8 | restart: always 9 | image: mongo:3.4 10 | volumes: 11 | - data-mongo:/data/db 12 | expose: 13 | - "27017" 14 | redis: 15 | restart: always 16 | command: redis-server --appendonly yes 17 | image: redis:3.2 18 | expose: 19 | - "6379" 20 | volumes: 21 | - data-redis:/data 22 | integram: 23 | image: integram/integram:latest 24 | restart: always 25 | volumes: 26 | - data-mainapp:/app/.conf 27 | links: 28 | - mongo 29 | - redis 30 | depends_on: 31 | - mongo 32 | - redis 33 | ports: 34 | - ${INTEGRAM_PORT}:${INTEGRAM_PORT} 35 | environment: 36 | - TZ=UTC 37 | - INTEGRAM_MONGO_URL=mongodb://mongo:27017/integram 38 | - INTEGRAM_REDIS_URL=redis:6379 39 | - INTEGRAM_INSTANCE_MODE=multi-main 40 | - INTEGRAM_CONFIG_DIR=/app/.conf 41 | ## required ENV vars 42 | - INTEGRAM_PORT 43 | - INTEGRAM_BASE_URL 44 | trello: 45 | image: integram/trello:latest 46 | restart: always 47 | links: 48 | - mongo 49 | - redis 50 | depends_on: 51 | - integram 52 | environment: 53 | - TZ=UTC 54 | - INTEGRAM_PORT=7000 55 | - INTEGRAM_MONGO_URL=mongodb://mongo:27017/integram 56 | - INTEGRAM_REDIS_URL=redis:6379 57 | - INTEGRAM_INSTANCE_MODE=multi-service 58 | 59 | ## required ENV vars 60 | - INTEGRAM_BASE_URL 61 | - TRELLO_BOT_TOKEN 62 | - TRELLO_OAUTH_ID 63 | - TRELLO_OAUTH_SECRET 64 | gitlab: 65 | image: integram/gitlab 66 | restart: always 67 | links: 68 | - mongo 69 | - redis 70 | depends_on: 71 | - integram 72 | environment: 73 | - TZ=UTC 74 | - INTEGRAM_PORT=7000 75 | - INTEGRAM_MONGO_URL=mongodb://mongo:27017/integram 76 | - INTEGRAM_REDIS_URL=redis:6379 77 | - INTEGRAM_INSTANCE_MODE=multi-service 78 | 79 | ## required ENV vars 80 | - INTEGRAM_BASE_URL 81 | - GITLAB_BOT_TOKEN 82 | - GITLAB_OAUTH_ID 83 | - GITLAB_OAUTH_SECRET 84 | bitbucket: 85 | image: integram/bitbucket 86 | restart: always 87 | links: 88 | - mongo 89 | - redis 90 | depends_on: 91 | - integram 92 | environment: 93 | - TZ=UTC 94 | - INTEGRAM_PORT=7000 95 | - INTEGRAM_MONGO_URL=mongodb://mongo:27017/integram 96 | - INTEGRAM_REDIS_URL=redis:6379 97 | - INTEGRAM_INSTANCE_MODE=multi-service 98 | 99 | ## required ENV vars 100 | - INTEGRAM_BASE_URL 101 | - BITBUCKET_BOT_TOKEN 102 | - BITBUCKET_OAUTH_ID 103 | - BITBUCKET_OAUTH_SECRET 104 | webhook: 105 | image: integram/webhook 106 | restart: always 107 | links: 108 | - mongo 109 | - redis 110 | depends_on: 111 | - integram 112 | environment: 113 | - TZ=UTC 114 | - INTEGRAM_PORT=7000 115 | - INTEGRAM_MONGO_URL=mongodb://mongo:27017/integram 116 | - INTEGRAM_REDIS_URL=redis:6379 117 | - INTEGRAM_INSTANCE_MODE=multi-service 118 | 119 | ## required ENV vars 120 | - INTEGRAM_BASE_URL 121 | - WEBHOOK_BOT_TOKEN 122 | -------------------------------------------------------------------------------- /encode.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import ( 4 | "bytes" 5 | "encoding/gob" 6 | "fmt" 7 | "reflect" 8 | ) 9 | 10 | var errorType = reflect.TypeOf(make([]error, 1)).Elem() 11 | var contextType = reflect.TypeOf(&Context{}) 12 | 13 | func typeIsError(typ reflect.Type) bool { 14 | return typ.Implements(errorType) 15 | } 16 | 17 | // decode decodes a slice of bytes and scans the value into dest using the gob package. 18 | // All types are supported except recursive data structures and functions. 19 | func decode(reply []byte, dest interface{}) error { 20 | // Check the type of dest and make sure it is a pointer to something, 21 | // otherwise we can't set its value in any meaningful way. 22 | val := reflect.ValueOf(dest) 23 | if val.Kind() != reflect.Ptr { 24 | return fmt.Errorf("Argument to decode must be pointer. Got %T", dest) 25 | } 26 | 27 | // Use the gob package to decode the reply and write the result into 28 | // dest. 29 | buf := bytes.NewBuffer(reply) 30 | dec := gob.NewDecoder(buf) 31 | if err := dec.DecodeValue(val.Elem()); err != nil { 32 | return err 33 | } 34 | return nil 35 | } 36 | 37 | // encode encodes data into a slice of bytes using the gob package. 38 | // All types are supported except recursive data structures and functions. 39 | func encode(data interface{}) ([]byte, error) { 40 | if data == nil { 41 | return nil, nil 42 | } 43 | buf := bytes.NewBuffer([]byte{}) 44 | enc := gob.NewEncoder(buf) 45 | if err := enc.Encode(data); err != nil { 46 | return nil, err 47 | } 48 | return buf.Bytes(), nil 49 | } 50 | 51 | func verifyTypeMatching(handlerFunc interface{}, args ...interface{}) error { 52 | // Check the type of data 53 | // Make sure handler is a function 54 | handlerType := reflect.TypeOf(handlerFunc) 55 | if handlerType.Kind() != reflect.Func { 56 | return fmt.Errorf("handler must be a function. Got %T", handlerFunc) 57 | } 58 | 59 | if handlerType.NumIn() == 0 { 60 | return fmt.Errorf("handler first arg must be a %s. ", contextType.String()) 61 | } 62 | 63 | if handlerType.In(0) != contextType { 64 | return fmt.Errorf("handler first arg must be a %s. Got %s", contextType.String(), handlerType.In(0).String()) 65 | } 66 | 67 | if handlerType.NumIn() != (len(args) + 1) { 68 | return fmt.Errorf("handler have %d args, you must call it with %d args (ommit the first %s). Instead got %d args", handlerType.NumIn(), handlerType.NumIn()-1, contextType.String(), len(args)) 69 | } 70 | 71 | if handlerType.NumOut() != 1 { 72 | return fmt.Errorf("handler must have exactly one return value. Got %d", handlerType.NumOut()) 73 | } 74 | if !typeIsError(handlerType.Out(0)) { 75 | return fmt.Errorf("handler must return an error. Got return value of type %s", handlerType.Out(0).String()) 76 | } 77 | 78 | for argIndex, arg := range args { 79 | handlerArgType := handlerType.In(argIndex + 1) 80 | argType := reflect.TypeOf(arg) 81 | if handlerArgType != argType { 82 | return fmt.Errorf("provided data was not of the correct type.\nExpected %s, but got %s", handlerArgType.String(), argType.String()) 83 | } 84 | 85 | if reflect.Zero(argType) == reflect.ValueOf(arg) { 86 | return fmt.Errorf("You can't send zero-valued arguments, received zero %v arg[%d]", argType.String(), argIndex) 87 | } 88 | } 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /handlers.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "html/template" 8 | stdlog "log" 9 | "net/http" 10 | "net/http/httputil" 11 | nativeurl "net/url" 12 | "os" 13 | "os/signal" 14 | "path" 15 | "runtime" 16 | "strconv" 17 | "strings" 18 | "sync" 19 | "syscall" 20 | "time" 21 | 22 | "github.com/gin-gonic/gin" 23 | "github.com/requilence/url" 24 | log "github.com/sirupsen/logrus" 25 | "github.com/throttled/throttled" 26 | "github.com/throttled/throttled/store/memstore" 27 | 28 | "github.com/weekface/mgorus" 29 | "golang.org/x/oauth2" 30 | mgo "gopkg.in/mgo.v2" 31 | "gopkg.in/mgo.v2/bson" 32 | 33 | "github.com/requilence/jobs" 34 | ) 35 | 36 | var startedAt time.Time 37 | var webhookRateLimiter *throttled.GCRARateLimiter 38 | 39 | func getCurrentDir() string { 40 | _, filename, _, _ := runtime.Caller(1) 41 | return path.Dir(filename) 42 | } 43 | 44 | func init() { 45 | 46 | if Config.Debug { 47 | mgo.SetDebug(true) 48 | gin.SetMode(gin.DebugMode) 49 | log.SetLevel(log.DebugLevel) 50 | } else { 51 | gin.SetMode(gin.ReleaseMode) 52 | log.SetLevel(log.InfoLevel) 53 | } 54 | if Config.InstanceMode != InstanceModeMultiProcessService && Config.InstanceMode != InstanceModeMultiProcessMain && Config.InstanceMode != InstanceModeSingleProcess { 55 | panic("WRONG InstanceMode " + Config.InstanceMode) 56 | } 57 | log.Infof("Integram mode: %s", Config.InstanceMode) 58 | 59 | if _, err := os.Stat(Config.ConfigDir); err != nil { 60 | if os.IsNotExist(err) { 61 | err = os.MkdirAll(Config.ConfigDir, os.ModePerm) 62 | if err != nil { 63 | log.WithError(err).Errorf("Failed to create the missing ConfigDir at '%s'", Config.ConfigDir) 64 | } 65 | } 66 | } 67 | 68 | startedAt = time.Now() 69 | 70 | dbConnect() 71 | } 72 | 73 | func cloneMiddleware(c *gin.Context) { 74 | s := mongoSession.Clone() 75 | 76 | defer s.Close() 77 | 78 | c.Set("db", s.DB(mongo.Database)) 79 | c.Next() 80 | } 81 | 82 | func ginLogger(c *gin.Context) { 83 | statusCode := c.Writer.Status() 84 | if statusCode < 200 || statusCode > 299 && statusCode != 404 { 85 | log.WithFields(log.Fields{ 86 | "path": c.Request.URL.Path, 87 | "ip": c.ClientIP(), 88 | "method": c.Request.Method, 89 | "ua": c.Request.UserAgent(), 90 | "code": statusCode, 91 | }).Error(c.Errors.ByType(gin.ErrorTypePrivate).String()) 92 | } 93 | c.Next() 94 | } 95 | func ginRecovery(c *gin.Context) { 96 | defer func() { 97 | if err := recover(); err != nil { 98 | stack := stack(3) 99 | log.WithFields(log.Fields{ 100 | "path": c.Request.URL.Path, 101 | "ip": c.ClientIP(), 102 | "method": c.Request.Method, 103 | "ua": c.Request.UserAgent(), 104 | "code": 500, 105 | }).Errorf("Panic recovery -> %s\n%s\n", err, stack) 106 | c.String(500, "Oops. Something not good.") 107 | } 108 | }() 109 | c.Next() 110 | } 111 | 112 | func ReverseProxy(target *url.URL) gin.HandlerFunc { 113 | proxy := httputil.NewSingleHostReverseProxy((*nativeurl.URL)(target)) 114 | return func(c *gin.Context) { 115 | proxy.ServeHTTP(c.Writer, c.Request) 116 | } 117 | } 118 | 119 | func gracefulShutdownJobPools() { 120 | sigs := make(chan os.Signal, 1) 121 | done := make(chan bool, 1) 122 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 123 | exitCode := 0 124 | 125 | go func() { 126 | sig := <-sigs 127 | fmt.Printf("Got '%s' signal\n", sig.String()) 128 | for name, pool := range jobs.Pools { 129 | fmt.Printf("Shutdown '%s' jobs pool...\n", name) 130 | pool.Close() 131 | err := pool.Wait() 132 | if err != nil { 133 | exitCode = 1 134 | fmt.Printf("Error while waiting for pool shutdown: %s\n", err.Error()) 135 | } 136 | } 137 | fmt.Printf("All jobs pool finished\n") 138 | 139 | done <- true 140 | }() 141 | <-done 142 | syscall.Exit(exitCode) 143 | } 144 | 145 | // Run initiates Integram to listen webhooks, TG updates and start the workers pool 146 | func Run() { 147 | if Config.Debug { 148 | gin.SetMode(gin.DebugMode) 149 | log.SetLevel(log.DebugLevel) 150 | } else { 151 | gin.SetMode(gin.ReleaseMode) 152 | log.SetLevel(log.InfoLevel) 153 | } 154 | 155 | if Config.MongoLogging { 156 | mongoURIParsed, _ := url.Parse(Config.MongoURL) 157 | 158 | hooker, err := mgorus.NewHooker(mongoURIParsed.Host, mongoURIParsed.Path[1:], "logs") 159 | 160 | if err == nil { 161 | log.AddHook(hooker) 162 | } 163 | } 164 | 165 | // This will test TG tokens and creates API 166 | time.Sleep(time.Second * 1) 167 | initBots() 168 | 169 | for _, s := range services { 170 | if Config.IsStandAloneServiceInstance() { 171 | // save the service info as a job to Redis. The MAIN instance will process it 172 | serviceURL := Config.StandAloneServiceURL 173 | if serviceURL == "" { 174 | serviceURL = fmt.Sprintf("http://%s:%s", s.Name, Config.Port) 175 | } 176 | _, err := ensureStandAloneServiceJob.Schedule(1, time.Now(), s.Name, serviceURL, s.Bot().tgToken()) 177 | 178 | if err != nil { 179 | log.WithError(err).Panic("ensureStandAloneServiceJob error") 180 | } 181 | } 182 | } 183 | 184 | if Config.RateLimitMemstore > 0 { 185 | rateLimiterStore, err := memstore.New(262144) 186 | if err != nil { 187 | log.WithError(err).Fatalf("rateLimiter memstore error") 188 | } 189 | 190 | quota := throttled.RateQuota{throttled.PerMin(Config.RateLimitPerMinute), Config.RateLimitBurst} 191 | webhookRateLimiter, err = throttled.NewGCRARateLimiter(rateLimiterStore, quota) 192 | if err != nil { 193 | log.WithError(err).Fatalf("rateLimiter NewGCRARateLimiter error") 194 | } 195 | } 196 | 197 | // Configure 198 | router := gin.New() 199 | 200 | // register some HTML templates 201 | templ := template.Must(template.New("webpreview").Parse(htmlTemplateWebpreview)) 202 | template.Must(templ.New("determineTZ").Parse(htmlTemplateDetermineTZ)) 203 | 204 | router.SetHTMLTemplate(templ) 205 | 206 | // Middlewares 207 | router.Use(cloneMiddleware) 208 | router.Use(ginRecovery) 209 | router.Use(ginLogger) 210 | 211 | if Config.Debug { 212 | router.Use(gin.Logger()) 213 | } 214 | 215 | if Config.IsMainInstance() || Config.IsSingleProcessInstance() { 216 | router.StaticFile("/", "index.html") 217 | } 218 | 219 | router.NoRoute(func(c *gin.Context) { 220 | // todo: good 404 221 | if len(c.Request.RequestURI) > 10 && (c.Request.RequestURI[1:2] == "c" || c.Request.RequestURI[1:2] == "u" || c.Request.RequestURI[1:2] == "h") { 222 | c.String(404, "Hi here!! This link isn't working in a browser. Please follow the instructions in the chat") 223 | } 224 | }) 225 | 226 | /* 227 | Possible URLs 228 | 229 | Service webhooks: 230 | 231 | token resolving handled by framework: 232 | /service_name/token 233 | /token - DEPRECATED, to be removed, auto-detect the service 234 | 235 | token resolving handled by service 236 | /service_name/service 237 | /service_name 238 | 239 | OAuth: 240 | /oauth1/service_name/auth_temp_id – OAuth1 initial redirect 241 | 242 | /auth/service_name - OAuth2 redirect URL 243 | /auth/service_name/provider_id - adds provider_id for the custom OAuth provider (e.g. self-hosted instance) 244 | 245 | 246 | WebPreview resolving: 247 | /a/token 248 | */ 249 | 250 | router.HEAD("/:param1/:param2/:param3", serviceHookHandler) 251 | router.GET("/:param1/:param2/:param3", serviceHookHandler) 252 | router.POST("/:param1/:param2/:param3", serviceHookHandler) 253 | 254 | router.HEAD("/:param1/:param2", serviceHookHandler) 255 | router.GET("/:param1/:param2", serviceHookHandler) 256 | router.POST("/:param1/:param2", serviceHookHandler) 257 | 258 | router.HEAD("/:param1", serviceHookHandler) 259 | router.GET("/:param1", serviceHookHandler) 260 | router.POST("/:param1", serviceHookHandler) 261 | 262 | // Start listening 263 | 264 | var err error 265 | 266 | go gracefulShutdownJobPools() 267 | 268 | if Config.Port == "443" || Config.Port == "1443" { 269 | if _, err := os.Stat(Config.ConfigDir + string(os.PathSeparator) + "ssl.crt"); !os.IsNotExist(err) { 270 | log.Infof("SSL: Using ssl.key/ssl.crt") 271 | err = router.RunTLS(":"+Config.Port, Config.ConfigDir+string(os.PathSeparator)+"ssl.crt", Config.ConfigDir+string(os.PathSeparator)+"ssl.key") 272 | } else { 273 | log.Fatalf("INTEGRAM_PORT set to 443, but ssl.crt and ssl.key files not found at '%s'", Config.ConfigDir) 274 | } 275 | 276 | } else { 277 | if Config.IsMainInstance() || Config.IsSingleProcessInstance() { 278 | log.Warnf("WARNING! It is recommended to use Integram with a SSL.\n"+ 279 | "Set the INTEGRAM_PORT to 443 and put integram.crt & integram.key files at '%s'", Config.ConfigDir) 280 | } 281 | err = router.Run(":" + Config.Port) 282 | } 283 | 284 | if err != nil { 285 | log.WithError(err).Fatal("Can't start the router") 286 | } 287 | } 288 | 289 | func webPreviewHandler(c *gin.Context, token string) { 290 | db := c.MustGet("db").(*mgo.Database) 291 | wp := webPreview{} 292 | 293 | err := db.C("previews").Find(bson.M{"_id": token}).One(&wp) 294 | 295 | if err != nil { 296 | c.String(http.StatusNotFound, "Not found") 297 | return 298 | } 299 | 300 | if !strings.Contains(c.Request.UserAgent(), "TelegramBot") { 301 | db.C("previews").UpdateId(wp.Token, bson.M{"$inc": bson.M{"redirects": 1}}) 302 | c.Redirect(http.StatusMovedPermanently, wp.URL) 303 | return 304 | } 305 | if wp.Text == "" && wp.ImageURL == "" { 306 | wp.ImageURL = "http://fakeurlaaaaaaa.com/fake/url" 307 | } 308 | 309 | p := gin.H{"title": wp.Title, "headline": wp.Headline, "text": wp.Text, "imageURL": wp.ImageURL} 310 | 311 | log.WithFields(log.Fields(p)).Debug("WP") 312 | 313 | c.HTML(http.StatusOK, "webpreview", p) 314 | 315 | } 316 | 317 | // TriggerEventHandler perform search query and trigger EventHandler in context of each chat/user 318 | func (s *Service) TriggerEventHandler(queryChat bool, bsonQuery map[string]interface{}, data interface{}) error { 319 | 320 | if s.EventHandler == nil { 321 | return fmt.Errorf("EventHandler missed for %s service", s.Name) 322 | } 323 | 324 | if bsonQuery == nil { 325 | return nil 326 | } 327 | 328 | db := mongoSession.Clone().DB(mongo.Database) 329 | defer db.Session.Close() 330 | 331 | ctx := &Context{db: db, ServiceName: s.Name} 332 | atLeastOneWasHandled := false 333 | 334 | if queryChat { 335 | chats, err := ctx.FindChats(bsonQuery) 336 | 337 | if err != nil { 338 | s.Log().WithError(err).Error("FindChats error") 339 | } 340 | for _, chat := range chats { 341 | if chat.Deactivated || chat.BotWasKickedOrStopped() { 342 | continue 343 | } 344 | ctx.Chat = chat.Chat 345 | err := s.EventHandler(ctx, data) 346 | 347 | if err != nil { 348 | ctx.Log().WithError(err).Error("EventHandler returned error") 349 | } else { 350 | atLeastOneWasHandled = true 351 | } 352 | } 353 | } else { 354 | users, err := ctx.FindUsers(bsonQuery) 355 | 356 | if err != nil { 357 | s.Log().WithError(err).Error("findUsers error") 358 | } 359 | 360 | for _, user := range users { 361 | ctx.User = user.User 362 | ctx.User.ctx = ctx 363 | ctx.Chat = Chat{ID: user.ID, ctx: ctx} 364 | err := s.EventHandler(ctx, data) 365 | 366 | if err != nil { 367 | ctx.Log().WithError(err).Error("EventHandler returned error") 368 | } else { 369 | atLeastOneWasHandled = true 370 | } 371 | } 372 | } 373 | 374 | if !atLeastOneWasHandled { 375 | return errors.New("No single chat was handled") 376 | } 377 | return nil 378 | } 379 | 380 | var reverseProxiesMap = map[string]*httputil.ReverseProxy{} 381 | var reverseProxiesMapMutex = sync.RWMutex{} 382 | 383 | func reverseProxyForService(service string) *httputil.ReverseProxy { 384 | reverseProxiesMapMutex.RLock() 385 | 386 | if rp, exists := reverseProxiesMap[service]; exists { 387 | reverseProxiesMapMutex.RUnlock() 388 | return rp 389 | } 390 | 391 | reverseProxiesMapMutex.RUnlock() 392 | 393 | s, _ := serviceByName(service) 394 | 395 | if s == nil { 396 | return nil 397 | } 398 | 399 | u, _ := nativeurl.Parse(s.machineURL) 400 | reverseProxiesMapMutex.Lock() 401 | defer reverseProxiesMapMutex.Unlock() 402 | rp := httputil.NewSingleHostReverseProxy(u) 403 | 404 | buf := new(bytes.Buffer) 405 | rp.ErrorLog = stdlog.New(buf, "reverseProxy ", stdlog.LUTC) 406 | 407 | reverseProxiesMap[service] = rp 408 | 409 | return rp 410 | } 411 | 412 | func rateLimitAndSetHeaders(c *gin.Context, key string) (limited bool) { 413 | if webhookRateLimiter != nil { 414 | rateLimited, rateLimitedResult, err := webhookRateLimiter.RateLimit(key, 1) 415 | if err != nil { 416 | log.WithError(err).Errorf("ratelimit error: %s", err.Error()) 417 | } else { 418 | c.Header("X-RateLimit-Limit", strconv.Itoa(Config.RateLimitPerMinute)) 419 | c.Header("X-RateLimit-Remaining", strconv.Itoa(rateLimitedResult.Remaining)) 420 | c.Header("X-RateLimit-Reset", strconv.Itoa(int(rateLimitedResult.ResetAfter.Seconds()))) 421 | 422 | if rateLimited { 423 | c.Header("Retry-After", strconv.Itoa(int(rateLimitedResult.RetryAfter.Seconds()))) 424 | c.String(http.StatusTooManyRequests, fmt.Sprintf("Too many requests, retry after %.0fs", rateLimitedResult.RetryAfter.Seconds())) 425 | return true 426 | } 427 | } 428 | } 429 | 430 | return false 431 | } 432 | 433 | func serviceHookHandler(c *gin.Context) { 434 | 435 | // temp ugly routing before deprecating hook URL without service name 436 | 437 | var service string 438 | var webhookToken string 439 | 440 | var s *Service 441 | p1 := c.Param("param1") 442 | p2 := c.Param("param2") 443 | p3 := c.Param("param3") 444 | 445 | switch p1 { 446 | // webpreview handler 447 | case "a": 448 | webPreviewHandler(c, p2) 449 | return 450 | 451 | // determine user's TZ and redirect (only withing baseURL) 452 | case "tz": 453 | c.HTML(http.StatusOK, "determineTZ", gin.H{"redirectURL": Config.BaseURL + c.Query("r")}) 454 | return 455 | 456 | // /oauth1/service_name 457 | // /auth/service_name 458 | case "auth", "oauth1": 459 | service = p2 460 | 461 | default: 462 | 463 | if p2 != "" { 464 | // service known 465 | // 466 | // /service/token 467 | service = p1 468 | webhookToken = p2 469 | } else { 470 | // service unknown - to be determined 471 | // 472 | // /token 473 | webhookToken = p1 474 | } 475 | } 476 | 477 | if s, _ = serviceByName(service); service != "" && service != "healthcheck" && s == nil { 478 | c.String(404, "Service not found") 479 | return 480 | } 481 | 482 | // in case of multi-process mode redirect from the main process to the corresponding service 483 | if Config.IsMainInstance() && s != nil { 484 | proxy := reverseProxyForService(s.Name) 485 | proxy.ServeHTTP(c.Writer, c.Request) 486 | return 487 | } 488 | 489 | if p1 == "oauth1" { 490 | // /oauth1/service_name/auth_temp_id 491 | oAuthInitRedirect(c, p2, p3) 492 | 493 | return 494 | } else if p1 == "auth" { 495 | 496 | if p3 == "" { 497 | /* 498 | For the default(usually means non self-hosted) service's OAuth 499 | /auth/service_name == /auth/service_name/service_name 500 | */ 501 | p3 = p2 502 | } 503 | 504 | // /auth/service_name/provider_id 505 | oAuthCallback(c, p3) 506 | return 507 | } 508 | 509 | db := c.MustGet("db").(*mgo.Database) 510 | 511 | if p1 == "healthcheck" || p2 == "healthcheck" { 512 | err := healthCheck(db) 513 | if err != nil { 514 | c.String(500, err.Error()) 515 | return 516 | } 517 | 518 | c.String(200, "OK") 519 | return 520 | } 521 | 522 | ctx := &Context{db: db, gin: c} 523 | 524 | if s != nil { 525 | ctx.ServiceName = s.Name 526 | } 527 | 528 | var hooks []serviceHook 529 | 530 | wctx := &WebhookContext{gin: c, requestID: rndStr.Get(10)} 531 | 532 | // if service has its own TokenHandler use it to resolve the URL query and get the user/chat db Query 533 | if s != nil && s.TokenHandler != nil { 534 | 535 | if c.Request.Method == "HEAD" { 536 | c.Status(http.StatusNoContent) 537 | return 538 | } 539 | 540 | queryChat, query, err := s.TokenHandler(ctx, wctx) 541 | 542 | if err != nil { 543 | log.WithFields(log.Fields{"token": webhookToken}).WithError(err).Error("TokenHandler error") 544 | } 545 | 546 | if query == nil { 547 | ctx.StatInc(StatWebhookProcessingError) 548 | c.Status(http.StatusNoContent) 549 | return 550 | } 551 | 552 | if queryChat { 553 | chats, err := ctx.FindChats(query) 554 | 555 | if err != nil { 556 | log.WithFields(log.Fields{"token": webhookToken}).WithError(err).Error("FindChats error") 557 | } 558 | 559 | if len(chats) == 0 { 560 | c.String(http.StatusAccepted, "Webhook accepted but no associated chats found") 561 | return 562 | } 563 | 564 | for _, chat := range chats { 565 | if chat.Deactivated || chat.BotWasKickedOrStopped() { 566 | continue 567 | } 568 | ctxCopy := *ctx 569 | ctxCopy.Chat = chat.Chat 570 | ctxCopy.Chat.ctx = &ctxCopy 571 | err := s.WebhookHandler(&ctxCopy, wctx) 572 | 573 | if err != nil { 574 | ctxCopy.StatIncChat(StatWebhookProcessingError) 575 | if err == ErrorFlood { 576 | c.String(http.StatusTooManyRequests, err.Error()) 577 | return 578 | } else if strings.HasPrefix(err.Error(), ErrorBadRequstPrefix) { 579 | c.String(http.StatusBadRequest, err.Error()) 580 | return 581 | } else { 582 | ctx.Log().WithFields(log.Fields{"token": webhookToken}).WithError(err).Error("WebhookHandler returned error") 583 | } 584 | } else { 585 | ctxCopy.StatIncChat(StatWebhookHandled) 586 | } 587 | } 588 | 589 | } else { 590 | users, err := ctx.FindUsers(query) 591 | 592 | if err != nil { 593 | log.WithFields(log.Fields{"token": webhookToken}).WithError(err).Error("findUsers error") 594 | } 595 | 596 | if len(users) == 0 { 597 | c.String(http.StatusAccepted, "Webhook accepted but no associated chats found") 598 | return 599 | } 600 | 601 | for _, user := range users { 602 | ctxCopy := *ctx 603 | ctxCopy.User = user.User 604 | ctxCopy.User.ctx = &ctxCopy 605 | ctxCopy.Chat = Chat{ID: user.ID, ctx: &ctxCopy} 606 | err := s.WebhookHandler(&ctxCopy, wctx) 607 | 608 | if err != nil { 609 | ctxCopy.StatIncUser(StatWebhookProcessingError) 610 | 611 | if err == ErrorFlood { 612 | c.String(http.StatusTooManyRequests, err.Error()) 613 | return 614 | } else if strings.HasPrefix(err.Error(), ErrorBadRequstPrefix) { 615 | c.String(http.StatusBadRequest, err.Error()) 616 | return 617 | } else { 618 | ctxCopy.Log().WithFields(log.Fields{"token": webhookToken}).WithError(err).Error("WebhookHandler returned error") 619 | } 620 | } else { 621 | ctxCopy.StatIncUser(StatWebhookHandled) 622 | } 623 | } 624 | 625 | } 626 | c.AbortWithStatus(http.StatusAccepted) 627 | return 628 | } else if webhookToken[0:1] == "u" { 629 | // Here is some trick 630 | // If token starts with u - this is notification with TG User behavior (id >0) 631 | // User can set which groups will receive notifications on this webhook 632 | // 1 notification can be mirrored to multiple chats 633 | 634 | // If token starts with c - this is notification with TG Chat behavior 635 | // So just one chat will receive this notification 636 | user, err := ctx.FindUser(bson.M{"hooks.token": webhookToken}) 637 | // todo: improve this part 638 | 639 | if !(err == nil && user.ID != 0) { 640 | c.String(http.StatusNotFound, "Unknown user token") 641 | return 642 | } else { 643 | if c.Request.Method == "GET" { 644 | c.String(200, "Hi here! This link isn't working in a browser. Please follow the instructions in the chat") 645 | return 646 | } 647 | 648 | if c.Request.Method == "HEAD" { 649 | c.Status(200) 650 | return 651 | } 652 | } 653 | 654 | if limited := rateLimitAndSetHeaders(c, webhookToken); limited { 655 | return 656 | } 657 | 658 | for i, hook := range user.Hooks { 659 | if hook.Token == webhookToken { 660 | user.Hooks = user.Hooks[i : i+1] 661 | if len(hook.Services) == 1 { 662 | ctx.ServiceName = hook.Services[0] 663 | } 664 | for serviceName := range user.Protected { 665 | if !SliceContainsString(hook.Services, serviceName) { 666 | delete(user.Protected, serviceName) 667 | } 668 | } 669 | 670 | for serviceName := range user.Settings { 671 | if !SliceContainsString(hook.Services, serviceName) { 672 | delete(user.Settings, serviceName) 673 | } 674 | } 675 | 676 | break 677 | } 678 | } 679 | 680 | ctx.User = user.User 681 | ctx.User.ctx = ctx 682 | 683 | hooks = user.Hooks 684 | } else if webhookToken[0:1] == "c" || webhookToken[0:1] == "h" { 685 | chat, err := ctx.FindChat(bson.M{"hooks.token": webhookToken}) 686 | 687 | if !(err == nil && chat.ID != 0) { 688 | 689 | c.String(http.StatusNotFound, "Сhat not found") 690 | 691 | return 692 | } else if chat.Deactivated { 693 | c.String(http.StatusGone, "TG chat was deactivated") 694 | 695 | return 696 | } else if chat.BotWasKickedOrStopped() { 697 | c.String(http.StatusGone, "Bot was kicked or stopped in the TG chat") 698 | 699 | return 700 | } else { 701 | if c.Request.Method == "GET" { 702 | c.String(200, "Hi here! This link isn't working in a browser. Please follow the instructions in the chat") 703 | return 704 | } 705 | 706 | if c.Request.Method == "HEAD" { 707 | c.Status(200) 708 | return 709 | } 710 | } 711 | 712 | if !chat.IgnoreRateLimit { 713 | if limited := rateLimitAndSetHeaders(c, webhookToken); limited { 714 | return 715 | } 716 | } 717 | 718 | hooks = chat.Hooks 719 | ctx.Chat = chat.Chat 720 | ctx.Chat.ctx = ctx 721 | } else { 722 | c.String(http.StatusNotFound, "Unknown token format") 723 | return 724 | } 725 | atLeastOneChatProcessedWithoutErrors := false 726 | 727 | for _, hook := range hooks { 728 | if hook.Token != webhookToken { 729 | continue 730 | } 731 | 732 | if len(hook.Services) > 1 && Config.IsMainInstance() { 733 | if s == nil { 734 | sName := "" 735 | 736 | // temp hack for deprecated multi-service webhooks 737 | if c.Request.Header.Get("X-Event-Key") != "" { 738 | sName = "bitbucket" 739 | } else if c.Request.Header.Get("X-Gitlab-Event") != "" { 740 | sName = "gitlab" 741 | } else if c.Request.Header.Get("X-GitHub-Event") != "" { 742 | sName = "github" 743 | } else { 744 | sName = "webhook" 745 | } 746 | 747 | ctx.Log().Errorf("Deprecated multi-service hook: detected %s", sName) 748 | s, _ = serviceByName(sName) 749 | 750 | } 751 | } 752 | 753 | for _, serviceName := range hook.Services { 754 | // in case this requests contains serviceMame skip the others 755 | if s != nil && s.Name != serviceName { 756 | continue 757 | } 758 | 759 | s, _ = serviceByName(serviceName) 760 | 761 | if s == nil { 762 | continue 763 | } 764 | 765 | if Config.IsMainInstance() { 766 | proxy := reverseProxyForService(serviceName) 767 | proxy.ServeHTTP(c.Writer, c.Request) 768 | return 769 | } 770 | 771 | ctx.ServiceName = serviceName 772 | 773 | if len(hook.Chats) == 0 { 774 | if ctx.Chat.ID != 0 { 775 | hook.Chats = []int64{ctx.Chat.ID} 776 | } else { 777 | // Some services(f.e. Trello) removes webhook after received 410 HTTP Gone 778 | // In this case we can safely answer 410 code because we know that DB is up (token was found) 779 | c.String(http.StatusGone, "No TG chats associated with this webhook URL") 780 | return 781 | } 782 | } 783 | 784 | // todo: if bot kicked or stopped in all chats – need to remove the webhook? 785 | 786 | for _, chatID := range hook.Chats { 787 | ctxCopy := *ctx 788 | ctxCopy.Chat = Chat{ID: chatID, ctx: &ctxCopy} 789 | 790 | if ctx.Chat.ID == chatID { 791 | if ctx.Chat.BotWasKickedOrStopped() || ctx.Chat.data.Deactivated { 792 | continue 793 | } 794 | } else if d, _ := ctxCopy.Chat.getData(); d != nil && (d.BotWasKickedOrStopped() || d.Deactivated) { 795 | continue 796 | } 797 | err := s.WebhookHandler(&ctxCopy, wctx) 798 | 799 | if err != nil { 800 | if err == ErrorFlood { 801 | c.String(http.StatusTooManyRequests, err.Error()) 802 | return 803 | } else if strings.HasPrefix(err.Error(), ErrorBadRequstPrefix) { 804 | c.String(http.StatusBadRequest, err.Error()) 805 | return 806 | } else { 807 | ctxCopy.Log().WithFields(log.Fields{"token": webhookToken}).WithError(err).Error("WebhookHandler returned error") 808 | } 809 | } else { 810 | 811 | if ctxCopy.messageAnsweredAt != nil { 812 | ctxCopy.StatIncChat(StatWebhookProducedMessageToChat) 813 | } 814 | atLeastOneChatProcessedWithoutErrors = true 815 | } 816 | } 817 | } 818 | 819 | if atLeastOneChatProcessedWithoutErrors { 820 | ctx.StatIncUser(StatWebhookHandled) 821 | c.AbortWithStatus(200) 822 | } else { 823 | ctx.StatIncUser(StatWebhookProcessingError) 824 | log.WithField("token", webhookToken).Warn("Hook not handled") 825 | 826 | // need to answer 2xx otherwise we webhook will be retried and the error will reappear 827 | // todo: maybe throw 500 if error because of DB fault etc. 828 | c.AbortWithStatus(http.StatusAccepted) 829 | } 830 | return 831 | 832 | } 833 | c.AbortWithError(404, errors.New("No hooks")) 834 | } 835 | 836 | func oAuthInitRedirect(c *gin.Context, service string, authTempID string) { 837 | 838 | if Config.IsMainInstance() && service != "" { 839 | s, _ := serviceByName(service) 840 | 841 | if s != nil { 842 | proxy := reverseProxyForService(s.Name) 843 | proxy.ServeHTTP(c.Writer, c.Request) 844 | return 845 | } else { 846 | log.Errorf("oAuthInitRedirect reverse proxy failed. Service unknown: %s", service) 847 | } 848 | } 849 | 850 | db := c.MustGet("db").(*mgo.Database) 851 | 852 | val := oAuthIDCache{} 853 | 854 | err := db.C("users_cache").Find(bson.M{"key": "auth_" + authTempID}).One(&val) 855 | 856 | if !(err == nil && val.UserID > 0) { 857 | err := errors.New("Unknown auth token") 858 | 859 | log.WithFields(log.Fields{"token": authTempID}).Error(err) 860 | c.AbortWithError(http.StatusForbidden, errors.New("can't find user")) 861 | return 862 | } 863 | 864 | s, _ := serviceByName(val.Service) 865 | 866 | // user's TZ provided 867 | tzName := c.Request.URL.Query().Get("tz") 868 | 869 | if tzName != "" { 870 | l, err := time.LoadLocation(tzName) 871 | if err == nil && l != nil { 872 | db.C("users").Update(bson.M{"_id": val.UserID}, bson.M{"$set": bson.M{"tz": tzName}}) 873 | } else { 874 | log.WithError(err).Errorf("oAuthInitRedirect: Bad TZ: %s", tzName) 875 | } 876 | } 877 | 878 | if s.DefaultOAuth1 != nil { 879 | 880 | u, _ := url.Parse(val.Val.BaseURL) 881 | 882 | if u == nil { 883 | log.WithField("oauthID", authTempID).WithError(err).Error("BaseURL empty") 884 | c.String(http.StatusInternalServerError, "Error occurred") 885 | return 886 | } 887 | // Todo: Self-hosted services not implemented for OAuth1 888 | ctx := &Context{ServiceName: val.Service, ServiceBaseURL: *u, gin: c} 889 | o := ctx.OAuthProvider() 890 | requestToken, oauthURL, err := o.OAuth1Client(ctx).GetRequestTokenAndUrl(fmt.Sprintf("%s/auth/%s/%s/?state=%s", Config.BaseURL, s.Name, o.internalID(), authTempID)) 891 | if err != nil { 892 | log.WithField("oauthID", authTempID).WithError(err).Error("Error getting OAuth request URL") 893 | c.String(http.StatusServiceUnavailable, "Error getting OAuth request URL") 894 | return 895 | } 896 | err = db.C("users_cache").Update(bson.M{"key": "auth_" + authTempID}, bson.M{"$set": bson.M{"val.requesttoken": requestToken}}) 897 | 898 | if err != nil { 899 | ctx.Log().WithError(err).Error("oAuthInitRedirect error updating authTempID") 900 | } 901 | 902 | c.Redirect(303, oauthURL) 903 | fmt.Println("HTML") 904 | } else { 905 | c.String(http.StatusNotImplemented, "Redirect is for OAuth1 only") 906 | return 907 | } 908 | } 909 | 910 | func oAuthCallback(c *gin.Context, oauthProviderID string) { 911 | 912 | db := c.MustGet("db").(*mgo.Database) 913 | 914 | authTempID := c.Query("u") 915 | 916 | if authTempID == "" { 917 | authTempID = c.Query("state") 918 | } 919 | 920 | val := oAuthIDCache{} 921 | err := db.C("users_cache").Find(bson.M{"key": "auth_" + authTempID}).One(&val) 922 | 923 | if !(err == nil && val.UserID > 0) { 924 | err := errors.New("Unknown auth token") 925 | 926 | log.WithFields(log.Fields{"token": authTempID}).Error(err) 927 | c.String(http.StatusForbidden, "This OAuth is not associated with any user") 928 | return 929 | } 930 | 931 | oap, err := findOauthProviderByID(db, oauthProviderID) 932 | 933 | if err != nil { 934 | log.WithError(err).WithField("OauthProviderID", oauthProviderID).Error("Can't get OauthProvider") 935 | c.String(http.StatusInternalServerError, "Error occured") 936 | return 937 | } 938 | 939 | ctx := &Context{ServiceBaseURL: oap.BaseURL, ServiceName: oap.Service, db: db, gin: c} 940 | 941 | userData, _ := ctx.FindUser(bson.M{"_id": val.UserID}) 942 | s := ctx.Service() 943 | 944 | ctx.User = userData.User 945 | ctx.User.data = &userData 946 | ctx.User.ctx = ctx 947 | 948 | ctx.Chat = ctx.User.Chat() 949 | 950 | accessToken := "" 951 | refreshToken := "" 952 | var expiresAt *time.Time 953 | 954 | if s.DefaultOAuth2 != nil { 955 | if s.DefaultOAuth2.AccessTokenReceiver != nil { 956 | accessToken, expiresAt, refreshToken, err = s.DefaultOAuth2.AccessTokenReceiver(ctx, c.Request) 957 | } else { 958 | code := c.Request.FormValue("code") 959 | 960 | if code == "" { 961 | ctx.Log().Error("OAuth2 code is empty") 962 | return 963 | } 964 | 965 | var otoken *oauth2.Token 966 | otoken, err = ctx.OAuthProvider().OAuth2Client(ctx).Exchange(oauth2.NoContext, code) 967 | if otoken != nil { 968 | accessToken = otoken.AccessToken 969 | refreshToken = otoken.RefreshToken 970 | expiresAt = &otoken.Expiry 971 | } 972 | } 973 | 974 | } else if s.DefaultOAuth1 != nil { 975 | accessToken, err = s.DefaultOAuth1.AccessTokenReceiver(ctx, c.Request, &val.Val.RequestToken) 976 | } 977 | 978 | if accessToken == "" { 979 | log.WithError(err).WithFields(log.Fields{"oauthID": oauthProviderID}).Error("Can't verify OAuth token") 980 | 981 | c.String(http.StatusForbidden, err.Error()) 982 | return 983 | } 984 | 985 | err = oauthTokenStore.SetOAuthAccessToken(&ctx.User, accessToken, expiresAt) 986 | if err != nil { 987 | log.WithError(err).WithFields(log.Fields{"oauthID": oauthProviderID}).Error("Can't save OAuth token to store") 988 | 989 | c.String(http.StatusInternalServerError, "failed to save OAuth access token to token store") 990 | return 991 | } 992 | if refreshToken != "" { 993 | oauthTokenStore.SetOAuthRefreshToken(&ctx.User, refreshToken) 994 | } 995 | 996 | ctx.StatIncUser(StatOAuthSuccess) 997 | 998 | if s.OAuthSuccessful != nil { 999 | s.DoJob(s.OAuthSuccessful, ctx) 1000 | } 1001 | 1002 | c.Redirect(302, "https://telegram.me/"+s.Bot().Username) 1003 | } 1004 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "encoding/base64" 7 | "encoding/binary" 8 | "fmt" 9 | "github.com/requilence/url" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/vova616/xxhash" 12 | "io/ioutil" 13 | "math/rand" 14 | "regexp" 15 | "runtime" 16 | "strings" 17 | "time" 18 | ) 19 | 20 | const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789" 21 | const ( 22 | letterIdxBits = 6 // 6 bits to represent a letter index 23 | letterIdxMask = 1<= len(lines) { 59 | return dunno 60 | } 61 | return bytes.TrimSpace(lines[n]) 62 | } 63 | 64 | // function returns, if possible, the name of the function containing the PC. 65 | func function(pc uintptr) []byte { 66 | fn := runtime.FuncForPC(pc) 67 | if fn == nil { 68 | return dunno 69 | } 70 | name := []byte(fn.Name()) 71 | // The name includes the path name to the package, which is unnecessary 72 | // since the file name is already included. Plus, it has center dots. 73 | // That is, we see 74 | // runtime/debug.*T·ptrmethod 75 | // and want 76 | // *T.ptrmethod 77 | // Also the package path might contains dot (e.g. code.google.com/...), 78 | // so first eliminate the path prefix 79 | if lastslash := bytes.LastIndex(name, slash); lastslash >= 0 { 80 | name = name[lastslash+1:] 81 | } 82 | if period := bytes.Index(name, dot); period >= 0 { 83 | name = name[period+1:] 84 | } 85 | name = bytes.Replace(name, centerDot, dot, -1) 86 | return name 87 | } 88 | 89 | // stack returns a nicely formated stack frame, skipping skip frames 90 | func stack(skip int) []byte { 91 | buf := new(bytes.Buffer) // the returned data 92 | // As we loop, we open files and read them. These variables record the currently 93 | // loaded file. 94 | var lines [][]byte 95 | var lastFile string 96 | for i := skip; ; i++ { // Skip the expected number of frames 97 | pc, file, line, ok := runtime.Caller(i) 98 | if !ok { 99 | break 100 | } 101 | // Print this much at least. If we can't find the source, it won't show. 102 | fmt.Fprintf(buf, "%s:%d (0x%x)\n", file, line, pc) 103 | if file != lastFile { 104 | data, err := ioutil.ReadFile(file) 105 | if err != nil { 106 | continue 107 | } 108 | lines = bytes.Split(data, []byte{'\n'}) 109 | lastFile = file 110 | } 111 | fmt.Fprintf(buf, "\t%s: %s\n", function(pc), source(lines, line)) 112 | } 113 | return buf.Bytes() 114 | } 115 | 116 | func randomInRange(min, max int) int { 117 | rand.Seed(time.Now().UnixNano()) 118 | return rand.Intn(max-min) + min 119 | } 120 | 121 | // SliceContainsString returns true if []string contains string 122 | func SliceContainsString(s []string, e string) bool { 123 | for _, a := range s { 124 | if a == e { 125 | return true 126 | } 127 | } 128 | return false 129 | } 130 | func compactHash(s string) string { 131 | a := md5.Sum([]byte(s)) 132 | return base64.RawURLEncoding.EncodeToString(a[:]) 133 | } 134 | 135 | func checksumString(s string) string { 136 | b := make([]byte, 4) 137 | cs := xxhash.Checksum32([]byte(s)) 138 | 139 | binary.LittleEndian.PutUint32(b, cs) 140 | return base64.RawURLEncoding.EncodeToString(b) 141 | } 142 | 143 | type strGenerator interface { 144 | Get(n int) string 145 | } 146 | 147 | type rndStrGenerator struct { 148 | } 149 | 150 | func (r rndStrGenerator) Get(n int) string { 151 | rand.Seed(time.Now().UTC().UnixNano()) 152 | b := make([]byte, n) 153 | for i := 0; i < n; { 154 | if idx := int(rand.Int63() & letterIdxMask); idx < len(letterBytes) { 155 | b[i] = letterBytes[idx] 156 | i++ 157 | } 158 | } 159 | return string(b) 160 | } 161 | 162 | var rndStr = strGenerator(rndStrGenerator{}) 163 | 164 | // URLMustParse returns url.URL from static string. Don't use it with a dynamic param 165 | func URLMustParse(s string) *url.URL { 166 | u, err := url.Parse(s) 167 | if err != nil { 168 | log.Errorf("Expected URL to parse: %q, got error: %v", s, err) 169 | } 170 | return u 171 | } 172 | 173 | 174 | func getBaseURL(s string) (*url.URL, error) { 175 | u, err := url.Parse(s) 176 | if err != nil { 177 | return nil, err 178 | } 179 | 180 | return &url.URL{Scheme: u.Scheme, Host: u.Host}, nil 181 | } 182 | func getHostFromURL(s string) string { 183 | h := strings.SplitAfterN(s, "://", 2) 184 | if len(h) > 1 { 185 | m := strings.SplitN(h[1], "/", 2) 186 | return m[0] 187 | } 188 | 189 | return "" 190 | } 191 | 192 | func Logger() *log.Logger { 193 | return log.StandardLogger() 194 | } 195 | 196 | var currentGitHead string 197 | var refRE = regexp.MustCompile(`ref: ([^\n^\s]+)`) 198 | 199 | // GetVersion returns the current HEAD git commit if .git exists 200 | func GetVersion() string { 201 | if currentGitHead == "" { 202 | b, err := ioutil.ReadFile(".git/HEAD") 203 | 204 | if err != nil { 205 | currentGitHead = "unknown" 206 | return currentGitHead 207 | } 208 | 209 | p := refRE.FindStringSubmatch(string(b)) 210 | 211 | if len(p) < 2 { 212 | currentGitHead = string(b) 213 | return currentGitHead 214 | } 215 | 216 | b, err = ioutil.ReadFile(".git/" + p[1]) 217 | if err != nil { 218 | currentGitHead = p[1] 219 | return currentGitHead 220 | } 221 | 222 | currentGitHead = string(b) 223 | } 224 | return currentGitHead 225 | } 226 | 227 | // GetVersion returns the current HEAD git commit(first 7 symbols) if .git exists 228 | func GetShortVersion() string { 229 | v := GetVersion() 230 | if len(v) > 7 { 231 | return v[0:7] 232 | } 233 | return v 234 | } 235 | -------------------------------------------------------------------------------- /migrations.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import ( 4 | "time" 5 | 6 | "gopkg.in/mgo.v2" 7 | "gopkg.in/mgo.v2/bson" 8 | ) 9 | 10 | func migrations(db *mgo.Database, serviceName string) error { 11 | err := migrateMissingOAuthStores(db, serviceName) 12 | if err != nil { 13 | return err 14 | } 15 | 16 | return nil 17 | } 18 | 19 | func migrateMissingOAuthStores(db *mgo.Database, serviceName string) error { 20 | name := "MissingOAuthStores" 21 | n, _ := db.C("migrations").FindId(serviceName + "_" + name).Count() 22 | if n > 0 { 23 | return nil 24 | } 25 | 26 | info, err := db.C("users").UpdateAll(bson.M{ 27 | "protected." + serviceName + ".oauthtoken": bson.M{"$exists": true, "$ne": ""}, 28 | "$or": []bson.M{ 29 | {"protected." + serviceName + ".oauthstore": bson.M{"$exists": false}}, 30 | {"protected." + serviceName + ".oauthstore": ""}, 31 | }, 32 | }, 33 | bson.M{"$set": bson.M{ 34 | "protected." + serviceName + ".oauthstore": "default", 35 | "protected." + serviceName + ".oauthvalid": true, 36 | 37 | }}) 38 | 39 | if err != nil && err != mgo.ErrNotFound { 40 | return err 41 | } 42 | 43 | return db.C("migrations").Insert(bson.M{"_id": serviceName + "_" + name, "date": time.Now(), "migrated": info.Updated}) 44 | } 45 | -------------------------------------------------------------------------------- /modules/feedback/feedback.go: -------------------------------------------------------------------------------- 1 | // ability to add the feedback 2 | 3 | package feedback 4 | 5 | import ( 6 | "fmt" 7 | "github.com/requilence/integram" 8 | "time" 9 | 10 | "github.com/kelseyhightower/envconfig" 11 | "math/rand" 12 | "errors" 13 | ) 14 | 15 | var FeedbackModule = integram.Module{ 16 | Actions: []interface{}{ 17 | askForFeedbackReplied, 18 | feedbackEdited, 19 | }, 20 | } 21 | 22 | var m = integram.HTMLRichText{} 23 | 24 | const ( 25 | langFeedbackCmdText = "Tell what we can improve to make this Youtube bot better" 26 | langFeedbackOkText = "Thanks for your feedback 👍 It was forwarded to developers. If you have something to add you can just edit your message" 27 | langFeedbackOnlyTextSupportedText = "For now only the text feedback is accepted. If you want to send some screenshots, please note this in the text" 28 | ) 29 | 30 | var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz1234567890-_") 31 | 32 | type FeedbackConfig struct { 33 | ChatID int64 `envconfig:"CHAT_ID" required:"true"` 34 | } 35 | 36 | var config FeedbackConfig 37 | 38 | func init() { 39 | envconfig.Process("FEEDBACK", &config) 40 | } 41 | 42 | func randStr(n int) string { 43 | b := make([]rune, n) 44 | for i := range b { 45 | b[i] = letterRunes[rand.Intn(len(letterRunes))] 46 | } 47 | return string(b) 48 | } 49 | 50 | type feedbackMsg struct { 51 | ChatID int64 52 | MsgID int 53 | 54 | Text string 55 | } 56 | 57 | func feedbackEdited(c *integram.Context, feedbackID string) error { 58 | // handle feedback message edited 59 | if feedbackID == "" { 60 | return nil 61 | } 62 | 63 | var texts []feedbackMsg 64 | 65 | c.User.Cache(feedbackID, &texts) 66 | joinedText := "" 67 | found := false 68 | 69 | for i, msg := range texts { 70 | if msg.MsgID == c.Message.MsgID && msg.ChatID == c.Message.ChatID { 71 | msg.Text = c.Message.Text 72 | texts[i] = msg 73 | found = true 74 | } 75 | joinedText += msg.Text + "\n" 76 | } 77 | 78 | if found { 79 | c.User.SetCache(feedbackID, texts, time.Hour*48) 80 | _, err := c.EditMessagesTextWithEventID("fb_"+feedbackID, formatFeedbackMessage(joinedText, c)) 81 | 82 | return err 83 | } 84 | 85 | return nil 86 | 87 | } 88 | 89 | func formatFeedbackMessage(text string, ctx *integram.Context) string { 90 | var userMention string 91 | 92 | if ctx.User.UserName != "" { 93 | userMention = "@" + ctx.User.UserName 94 | } else { 95 | userMention = fmt.Sprintf(`%s`, ctx.User.ID, ctx.User.FirstName+" "+ctx.User.LastName) 96 | } 97 | 98 | text = m.EncodeEntities(text) 99 | 100 | suffix := fmt.Sprintf("\n#%s • by %s • ver #%s", ctx.ServiceName, userMention, integram.GetShortVersion()) 101 | 102 | if len(text) > (4096 - len(suffix)) { 103 | text = text[0:(4096-len(suffix)-3)] + "..." 104 | } 105 | return text + suffix 106 | } 107 | 108 | func askForFeedbackReplied(c *integram.Context) error { 109 | 110 | if c.Message.Text == "" { 111 | return c.NewMessage(). 112 | SetReplyToMsgID(c.Message.MsgID). 113 | SetText(langFeedbackOnlyTextSupportedText). 114 | EnableForceReply(). 115 | SetReplyAction(askForFeedbackReplied). 116 | Send() 117 | } 118 | 119 | feedbackID := "" 120 | 121 | c.User.Cache("feedbackID", &feedbackID) 122 | 123 | var texts = []feedbackMsg{} 124 | if feedbackID == "" { 125 | feedbackID = randStr(10) 126 | c.User.SetCache("feedbackID", feedbackID, time.Hour*24) 127 | } else { 128 | c.User.Cache(feedbackID, &texts) 129 | } 130 | 131 | texts = append(texts, feedbackMsg{c.Chat.ID, c.Message.MsgID, c.Message.Text}) 132 | c.User.SetCache(feedbackID, texts, time.Hour*48) 133 | 134 | joinedText := "" 135 | 136 | for _, msg := range texts { 137 | joinedText += msg.Text + "\n" 138 | } 139 | 140 | if len(texts) > 1 { 141 | _, err := c.EditMessagesTextWithEventID("fb_"+feedbackID, formatFeedbackMessage(joinedText, c)) 142 | 143 | if err != nil { 144 | c.Log().WithError(err).Error("askForFeedbackReplied EditMessagesTextWithEventID error") 145 | } 146 | } 147 | 148 | c.NewMessage().SetReplyToMsgID(c.Message.MsgID).SetText(langFeedbackOkText).Send() 149 | c.Message.SetEditAction(feedbackEdited, feedbackID) 150 | 151 | if len(texts) == 1 { 152 | return c.NewMessage(). 153 | AddEventID("fb_" + feedbackID). 154 | SetChat(config.ChatID). 155 | EnableHTML(). 156 | SetText(formatFeedbackMessage(joinedText, c)). 157 | Send() 158 | } 159 | 160 | return nil 161 | } 162 | 163 | func SendAskForFeedbackMessage(c *integram.Context) error { 164 | if config.ChatID == 0 { 165 | return errors.New("Received /feedback but env FEEDBACK_CHAT_ID not set") 166 | } 167 | return c.NewMessage().SetText(langFeedbackCmdText).EnableForceReply().SetReplyAction(askForFeedbackReplied).Send() 168 | } 169 | -------------------------------------------------------------------------------- /oauth.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "golang.org/x/oauth2" 10 | "gopkg.in/mgo.v2" 11 | "gopkg.in/mgo.v2/bson" 12 | ) 13 | 14 | type OAuthTokenSource struct { 15 | user *User 16 | last oauth2.Token 17 | } 18 | 19 | func (tsw *OAuthTokenSource) Token() (*oauth2.Token, error) { 20 | lastToken := tsw.last 21 | ts := tsw.user.ctx.OAuthProvider().OAuth2Client(tsw.user.ctx).TokenSource(oauth2.NoContext, &lastToken) 22 | token, err := ts.Token() 23 | if err != nil { 24 | if strings.Contains(err.Error(), "revoked") || strings.Contains(err.Error(), "invalid_grant") { 25 | _ = tsw.user.saveProtectedSetting("OAuthValid", false) 26 | 27 | //todo: provide revoked callback 28 | } 29 | tsw.user.ctx.Log().Errorf("OAuth token refresh failed, token OAuthValid set to false: %s", err.Error()) 30 | return nil, err 31 | } 32 | 33 | if token != nil { 34 | if token.AccessToken != lastToken.AccessToken || !token.Expiry.Equal(lastToken.Expiry) { 35 | ps, _ := tsw.user.protectedSettings() 36 | if ps != nil && !ps.OAuthValid { 37 | ps.OAuthValid = true 38 | _ = tsw.user.saveProtectedSetting("OAuthValid", true) 39 | } 40 | 41 | err = oauthTokenStore.SetOAuthAccessToken(tsw.user, token.AccessToken, &token.Expiry) 42 | if err != nil { 43 | tsw.user.ctx.Log().Errorf("failed to set OAuth Access token in store: %s", err.Error()) 44 | } 45 | } 46 | if token.RefreshToken != "" && token.RefreshToken != lastToken.RefreshToken { 47 | err = oauthTokenStore.SetOAuthRefreshToken(tsw.user, token.RefreshToken) 48 | if err != nil { 49 | tsw.user.ctx.Log().Errorf("failed to set OAuth Access token in store: %s", err.Error()) 50 | } 51 | } 52 | } 53 | 54 | return token, nil 55 | } 56 | 57 | // OAuthHTTPClient returns HTTP client with Bearer authorization headers 58 | func (user *User) OAuthHTTPClient() *http.Client { 59 | if user.ctx.Service().DefaultOAuth2 != nil { 60 | ts, err := user.OAuthTokenSource() 61 | if err != nil { 62 | user.ctx.Log().Errorf("OAuthTokenSource got error: %s", err.Error()) 63 | return nil 64 | } 65 | 66 | return oauth2.NewClient(oauth2.NoContext, ts) 67 | } else if user.ctx.Service().DefaultOAuth1 != nil { 68 | //todo make a correct httpclient 69 | return http.DefaultClient 70 | } 71 | return nil 72 | } 73 | 74 | // OAuthValid checks if OAuthToken for service is set 75 | func (user *User) OAuthValid() bool { 76 | token, _, _ := oauthTokenStore.GetOAuthAccessToken(user) 77 | return token != "" 78 | } 79 | 80 | // OAuthTokenStore returns current OAuthTokenStore name used to get/set access and refresh tokens 81 | func (user *User) OAuthTokenStore() string { 82 | ps, _ := user.protectedSettings() 83 | if ps == nil { 84 | return "" 85 | } 86 | 87 | return ps.OAuthStore 88 | } 89 | 90 | // SetOAuthTokenStore stores the new name for OAuth Store to get/set access and refresh tokens 91 | func (user *User) SetOAuthTokenStore(storeName string) error { 92 | return user.saveProtectedSetting("OAuthStore", storeName) 93 | } 94 | 95 | // OAuthTokenSource returns OAuthTokenSource to use within http client to get OAuthToken 96 | func (user *User) OAuthTokenSource() (oauth2.TokenSource, error) { 97 | if user.ctx.Service().DefaultOAuth2 == nil { 98 | return nil, fmt.Errorf("DefaultOAuth2 config not set for the service") 99 | } 100 | 101 | accessToken, expireDate, err := oauthTokenStore.GetOAuthAccessToken(user) 102 | if err != nil { 103 | user.ctx.Log().Errorf("can't create OAuthTokenSource: oauthTokenStore.GetOAuthAccessToken got error: %s", err.Error()) 104 | return nil, err 105 | } 106 | 107 | refreshToken, err := oauthTokenStore.GetOAuthRefreshToken(user) 108 | if err != nil { 109 | user.ctx.Log().Errorf("can't create OAuthTokenSource: oauthTokenStore.GetOAuthRefreshToken got error: %s", err.Error()) 110 | return nil, err 111 | } 112 | 113 | otoken := oauth2.Token{AccessToken: accessToken, RefreshToken: refreshToken, TokenType: "Bearer"} 114 | if expireDate != nil { 115 | otoken.Expiry = *expireDate 116 | } 117 | 118 | return &OAuthTokenSource{ 119 | user: user, 120 | last: otoken, 121 | }, nil 122 | } 123 | 124 | // OAuthToken returns OAuthToken for service 125 | func (user *User) OAuthToken() string { 126 | // todo: oauthtoken per host? 127 | /* 128 | host := user.ctx.ServiceBaseURL.Host 129 | 130 | if host == "" { 131 | host = user.ctx.Service().DefaultBaseURL.Host 132 | } 133 | */ 134 | ts, err := user.OAuthTokenSource() 135 | if err != nil { 136 | user.ctx.Log().Errorf("OAuthTokenSource got error: %s", err.Error()) 137 | return "" 138 | } 139 | 140 | token, err := ts.Token() 141 | if err != nil { 142 | user.ctx.Log().Errorf("OAuthToken got tokensource error: %s", err.Error()) 143 | return "" 144 | } 145 | 146 | return token.AccessToken 147 | } 148 | 149 | // ResetOAuthToken reset OAuthToken for service 150 | func (user *User) ResetOAuthToken() error { 151 | err := oauthTokenStore.SetOAuthAccessToken(user, "", nil) 152 | if err != nil { 153 | user.ctx.Log().WithError(err).Error("ResetOAuthToken error") 154 | } 155 | return err 156 | } 157 | 158 | // OauthRedirectURL used in OAuth process as returning URL 159 | func (user *User) OauthRedirectURL() string { 160 | providerID := user.ctx.OAuthProvider().internalID() 161 | if providerID == user.ctx.ServiceName { 162 | return fmt.Sprintf("%s/auth/%s", Config.BaseURL, user.ctx.ServiceName) 163 | } 164 | 165 | return fmt.Sprintf("%s/auth/%s/%s", Config.BaseURL, user.ctx.ServiceName, providerID) 166 | } 167 | 168 | // OauthInitURL used in OAuth process as returning URL 169 | func (user *User) OauthInitURL() string { 170 | authTempToken := user.AuthTempToken() 171 | s := user.ctx.Service() 172 | if authTempToken == "" { 173 | user.ctx.Log().Error("authTempToken is empty") 174 | return "" 175 | } 176 | if s.DefaultOAuth2 != nil { 177 | provider := user.ctx.OAuthProvider() 178 | 179 | return provider.OAuth2Client(user.ctx).AuthCodeURL(authTempToken, oauth2.AccessTypeOffline) 180 | } 181 | if s.DefaultOAuth1 != nil { 182 | return fmt.Sprintf("%s/oauth1/%s/%s", Config.BaseURL, s.Name, authTempToken) 183 | } 184 | return "" 185 | } 186 | 187 | // AuthTempToken returns Auth token used in OAuth process to associate TG user with OAuth creditianals 188 | func (user *User) AuthTempToken() string { 189 | 190 | host := user.ctx.ServiceBaseURL.Host 191 | if host == "" { 192 | host = user.ctx.Service().DefaultBaseURL.Host 193 | } 194 | 195 | serviceBaseURL := user.ctx.ServiceBaseURL.String() 196 | if serviceBaseURL == "" { 197 | serviceBaseURL = user.ctx.Service().DefaultBaseURL.String() 198 | } 199 | 200 | ps, _ := user.protectedSettings() 201 | cacheTime := user.ctx.Service().DefaultOAuth2.AuthTempTokenCacheTime 202 | 203 | if cacheTime == 0 { 204 | cacheTime = time.Hour * 24 * 30 205 | } 206 | 207 | if ps.AuthTempToken != "" { 208 | oAuthIDCacheFound := oAuthIDCacheVal{BaseURL: serviceBaseURL} 209 | user.SetCache("auth_"+ps.AuthTempToken, oAuthIDCacheFound, cacheTime) 210 | 211 | return ps.AuthTempToken 212 | } 213 | 214 | rnd := strings.ToLower(rndStr.Get(16)) 215 | user.SetCache("auth_"+rnd, oAuthIDCacheVal{BaseURL: serviceBaseURL}, cacheTime) 216 | 217 | err := user.saveProtectedSetting("AuthTempToken", rnd) 218 | 219 | if err != nil { 220 | user.ctx.Log().WithError(err).Error("Error saving AuthTempToken") 221 | } 222 | return rnd 223 | } 224 | 225 | func findOauthProviderByID(db *mgo.Database, id string) (*OAuthProvider, error) { 226 | oap := OAuthProvider{} 227 | 228 | if s, _ := serviceByName(id); s != nil { 229 | return s.DefaultOAuthProvider(), nil 230 | } 231 | 232 | err := db.C("oauth_providers").FindId(id).One(&oap) 233 | if err != nil { 234 | return nil, err 235 | } 236 | 237 | return &oap, nil 238 | } 239 | 240 | func findOauthProviderByHost(db *mgo.Database, host string) (*OAuthProvider, error) { 241 | oap := OAuthProvider{} 242 | err := db.C("oauth_providers").Find(bson.M{"baseurl.host": strings.ToLower(host)}).One(&oap) 243 | if err != nil { 244 | return nil, err 245 | } 246 | 247 | return &oap, nil 248 | } 249 | -------------------------------------------------------------------------------- /oauth_token_store.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "gopkg.in/mgo.v2/bson" 8 | ) 9 | 10 | type OAuthTokenStore interface { 11 | Name() string 12 | GetOAuthAccessToken(user *User) (token string, expireDate *time.Time, err error) 13 | SetOAuthAccessToken(user *User, token string, expireDate *time.Time) error 14 | GetOAuthRefreshToken(user *User) (string, error) 15 | SetOAuthRefreshToken(user *User, token string) error 16 | } 17 | 18 | type DefaultOAuthTokenMongoStore struct { 19 | } 20 | 21 | var oauthTokenStore OAuthTokenStore = &DefaultOAuthTokenMongoStore{} 22 | 23 | func SetOAuthTokenStore(store OAuthTokenStore) { 24 | oauthTokenStore = store 25 | } 26 | 27 | func MigrateOAuthFromTo(c *Context, oldTS OAuthTokenStore, newTS OAuthTokenStore, onlyValid bool) (total int, migrated int, expired int, err error) { 28 | keyPrefix := "protected." + c.ServiceName 29 | 30 | query := bson.M{ 31 | keyPrefix + ".oauthstore": oldTS.Name(), 32 | } 33 | 34 | if onlyValid { 35 | query[keyPrefix+".oauthvalid"] = true 36 | } 37 | 38 | users, err := c.FindUsers(query) 39 | if err != nil { 40 | return 41 | } 42 | 43 | total = len(users) 44 | expiredOlderThan := time.Now().Add((-1) * time.Hour * 24 * 30) 45 | for i, userData := range users { 46 | ctxCopy := *userData.ctx 47 | ctxCopy.User = userData.User 48 | ctxCopy.User.ctx = &ctxCopy 49 | ctxCopy.Chat = Chat{ID: userData.ID, ctx: &ctxCopy} 50 | userData.ctx = &ctxCopy 51 | user := userData.User 52 | user.data = &userData 53 | 54 | if i%100 == 0 { 55 | fmt.Printf("MigrateOAuthFromTo: %d/%d users transfered\n", i, len(users)) 56 | } 57 | 58 | token, expiry, err := oldTS.GetOAuthAccessToken(&user) 59 | if err != nil { 60 | c.Log().Errorf("MigrateOAuthFromTo got error on GetOAuthAccessToken: %s", err.Error()) 61 | continue 62 | } 63 | 64 | if onlyValid && token == "" { 65 | expired++ 66 | continue 67 | } 68 | 69 | if onlyValid && expiry != nil && expiry.Before(expiredOlderThan) { 70 | expired++ 71 | continue 72 | } 73 | 74 | err = newTS.SetOAuthAccessToken(&user, token, expiry) 75 | if err != nil { 76 | c.Log().Errorf("MigrateOAuthFromTo got error on SetOAuthAccessToken: %s", err.Error()) 77 | continue 78 | } 79 | 80 | refreshToken, err := oldTS.GetOAuthRefreshToken(&user) 81 | if err != nil { 82 | c.Log().Errorf("MigrateOAuthFromTo got error on GetOAuthRefreshToken: %s", err.Error()) 83 | continue 84 | } 85 | 86 | err = newTS.SetOAuthRefreshToken(&user, refreshToken) 87 | if err != nil { 88 | c.Log().Errorf("MigrateOAuthFromTo got error on SetOAuthRefreshToken: %s", err.Error()) 89 | continue 90 | } 91 | 92 | err = c.db.C("users").UpdateId(user.ID, bson.M{"$set": bson.M{keyPrefix + ".oauthstore": newTS.Name(), keyPrefix + ".oauthvalid": true}}) 93 | if err != nil { 94 | c.Log().Errorf("MigrateOAuthFromTo got error: %s", err.Error()) 95 | continue 96 | } 97 | 98 | migrated++ 99 | } 100 | 101 | fmt.Printf("MigrateOAuthFromTo: %d/%d users transfered\n", len(users), len(users)) 102 | 103 | return 104 | } 105 | 106 | func (d *DefaultOAuthTokenMongoStore) GetOAuthAccessToken(user *User) (token string, expireDate *time.Time, err error) { 107 | ps, err := user.protectedSettings() 108 | if err != nil { 109 | return "", nil, err 110 | } 111 | 112 | return ps.OAuthToken, ps.OAuthExpireDate, nil 113 | } 114 | 115 | func (d *DefaultOAuthTokenMongoStore) GetOAuthRefreshToken(user *User) (string, error) { 116 | ps, err := user.protectedSettings() 117 | 118 | if err != nil { 119 | return "", err 120 | } 121 | 122 | return ps.OAuthRefreshToken, nil 123 | } 124 | 125 | func (d *DefaultOAuthTokenMongoStore) SetOAuthAccessToken(user *User, token string, expireDate *time.Time) error { 126 | ps, err := user.protectedSettings() 127 | if err != nil { 128 | return err 129 | } 130 | 131 | ps.OAuthStore = d.Name() 132 | ps.OAuthToken = token 133 | ps.OAuthExpireDate = expireDate 134 | 135 | return user.saveProtectedSettings() 136 | } 137 | 138 | func (d *DefaultOAuthTokenMongoStore) SetOAuthRefreshToken(user *User, refreshToken string) error { 139 | ps, err := user.protectedSettings() 140 | if err != nil { 141 | return err 142 | } 143 | 144 | ps.OAuthRefreshToken = refreshToken 145 | 146 | return user.saveProtectedSetting("OAuthRefreshToken", refreshToken) 147 | } 148 | 149 | func (d *DefaultOAuthTokenMongoStore) Name() string { 150 | return "default" 151 | } 152 | -------------------------------------------------------------------------------- /richtext.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // MarkdownRichText produce Markdown that can be sent to Telegram. Not recommended to use because of tricky escaping 9 | // Use HTMLRichText instead 10 | type MarkdownRichText struct{} 11 | 12 | // HTMLRichText produce HTML that can be sent to Telegram 13 | type HTMLRichText struct{} 14 | 15 | // Pre generates
text
16 | func (hrt HTMLRichText) Pre(s string) string { 17 | return "
" + hrt.EncodeEntities(s) + "
" 18 | } 19 | 20 | // Fixed generates text 21 | func (hrt HTMLRichText) Fixed(s string) string { 22 | return "" + hrt.EncodeEntities(s) + "" 23 | } 24 | 25 | // EncodeEntities encodes '<', '>' 26 | func (hrt HTMLRichText) EncodeEntities(s string) string { 27 | repalcer := strings.NewReplacer("<", "<", ">", ">") 28 | return repalcer.Replace(s) 29 | } 30 | 31 | // URL generates %s", url, hrt.EncodeEntities(text)) 34 | return text 35 | } 36 | 37 | // Bold generates text 38 | func (hrt HTMLRichText) Bold(text string) string { 39 | if text == "" { 40 | return "" 41 | } 42 | 43 | text = fmt.Sprintf("%s", hrt.EncodeEntities(text)) 44 | return text 45 | } 46 | 47 | // Italic generates text 48 | func (hrt HTMLRichText) Italic(text string) string { 49 | if text == "" { 50 | return "" 51 | } 52 | 53 | text = fmt.Sprintf("%s", hrt.EncodeEntities(text)) 54 | return text 55 | } 56 | 57 | // Pre generates```text``` 58 | func (mrt MarkdownRichText) Pre(text string) string { 59 | if text == "" { 60 | return "" 61 | } 62 | repalcer := strings.NewReplacer("`", "‛") 63 | return "```\n" + repalcer.Replace(text) + "\n```" 64 | } 65 | 66 | // Fixed generates`text` 67 | func (mrt MarkdownRichText) Fixed(text string) string { 68 | if text == "" { 69 | return "" 70 | } 71 | repalcer := strings.NewReplacer("`", "‛") 72 | return "`" + repalcer.Replace(text) + "`" 73 | } 74 | 75 | // Esc escapes '[', ']', '(', ')', "`", "_", "*" with \ 76 | func (mrt MarkdownRichText) Esc(s string) string { 77 | repalcer := strings.NewReplacer("[", "\\[", "]", "\\]", "(", "\\(", ")", "\\)", "`", "\\`", "_", "\\_", "*", "\\*") 78 | return repalcer.Replace(s) 79 | } 80 | 81 | // URL generates [text](URL) 82 | func (mrt MarkdownRichText) URL(text string, url string) string { 83 | repalcer := strings.NewReplacer("[", "⟦", "]", "⟧", "(", "❨", ")", "❩") 84 | text = fmt.Sprintf("[%s](%s)", repalcer.Replace(text), url) 85 | return text 86 | } 87 | 88 | // Bold generates *text* 89 | func (mrt MarkdownRichText) Bold(text string) string { 90 | if text == "" { 91 | return "" 92 | } 93 | repalcer := strings.NewReplacer("*", "∗") 94 | return "*" + repalcer.Replace(text) + "*" 95 | } 96 | 97 | // Italic generates _text_ 98 | func (mrt MarkdownRichText) Italic(text string) string { 99 | if text == "" { 100 | return "" 101 | } 102 | repalcer := strings.NewReplacer("_", "_") 103 | return "_" + repalcer.Replace(text) + "_" 104 | } 105 | -------------------------------------------------------------------------------- /richtext_test.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import "testing" 4 | 5 | func TestHTMLRichText_Pre(t *testing.T) { 6 | type args struct { 7 | s string 8 | } 9 | tests := []struct { 10 | name string 11 | hrt HTMLRichText 12 | args args 13 | want string 14 | }{ 15 | {"test1", HTMLRichText{}, args{"text here"}, "
text here
"}, 16 | {"test2", HTMLRichText{}, args{"text here"}, "
</a>text here
"}, 17 | } 18 | for _, tt := range tests { 19 | hrt := HTMLRichText{} 20 | if got := hrt.Pre(tt.args.s); got != tt.want { 21 | t.Errorf("%q. HTMLRichText.Pre() = %v, want %v", tt.name, got, tt.want) 22 | } 23 | } 24 | } 25 | 26 | func TestHTMLRichText_Fixed(t *testing.T) { 27 | type args struct { 28 | s string 29 | } 30 | tests := []struct { 31 | name string 32 | hrt HTMLRichText 33 | args args 34 | want string 35 | }{ 36 | {"test1", HTMLRichText{}, args{"text here"}, "text here"}, 37 | {"test2", HTMLRichText{}, args{"text here"}, "</a>text here"}, 38 | } 39 | for _, tt := range tests { 40 | hrt := HTMLRichText{} 41 | if got := hrt.Fixed(tt.args.s); got != tt.want { 42 | t.Errorf("%q. HTMLRichText.Fixed() = %v, want %v", tt.name, got, tt.want) 43 | } 44 | } 45 | } 46 | 47 | func TestHTMLRichText_EncodeEntities(t *testing.T) { 48 | type args struct { 49 | s string 50 | } 51 | tests := []struct { 52 | name string 53 | hrt HTMLRichText 54 | args args 55 | want string 56 | }{ 57 | {"test1", HTMLRichText{}, args{"abc"}, "<a href=\"index.html?a=1&b=2\">abc</a>"}, 58 | } 59 | for _, tt := range tests { 60 | hrt := HTMLRichText{} 61 | if got := hrt.EncodeEntities(tt.args.s); got != tt.want { 62 | t.Errorf("%q. HTMLRichText.EncodeEntities() = %v, want %v", tt.name, got, tt.want) 63 | } 64 | } 65 | } 66 | 67 | func TestHTMLRichText_URL(t *testing.T) { 68 | type args struct { 69 | text string 70 | url string 71 | } 72 | tests := []struct { 73 | name string 74 | hrt HTMLRichText 75 | args args 76 | want string 77 | }{ 78 | {"test1", HTMLRichText{}, args{"text here", "https://integram.org"}, "text here"}, 79 | {"test2", HTMLRichText{}, args{"text here", "https://integram.org/?a=1&b=2"}, "</a>text here"}, 80 | } 81 | for _, tt := range tests { 82 | hrt := HTMLRichText{} 83 | if got := hrt.URL(tt.args.text, tt.args.url); got != tt.want { 84 | t.Errorf("%q. HTMLRichText.URL() = %v, want %v", tt.name, got, tt.want) 85 | } 86 | } 87 | } 88 | 89 | func TestHTMLRichText_Bold(t *testing.T) { 90 | type args struct { 91 | text string 92 | } 93 | tests := []struct { 94 | name string 95 | hrt HTMLRichText 96 | args args 97 | want string 98 | }{ 99 | {"test1", HTMLRichText{}, args{"text here"}, "text here"}, 100 | {"test2", HTMLRichText{}, args{"text here"}, "</a>text here"}, 101 | } 102 | for _, tt := range tests { 103 | hrt := HTMLRichText{} 104 | if got := hrt.Bold(tt.args.text); got != tt.want { 105 | t.Errorf("%q. HTMLRichText.Bold() = %v, want %v", tt.name, got, tt.want) 106 | } 107 | } 108 | } 109 | 110 | func TestHTMLRichText_Italic(t *testing.T) { 111 | type args struct { 112 | text string 113 | } 114 | tests := []struct { 115 | name string 116 | hrt HTMLRichText 117 | args args 118 | want string 119 | }{ 120 | {"test1", HTMLRichText{}, args{"text here"}, "text here"}, 121 | {"test2", HTMLRichText{}, args{"text here"}, "</a>text here"}, 122 | } 123 | for _, tt := range tests { 124 | hrt := HTMLRichText{} 125 | if got := hrt.Italic(tt.args.text); got != tt.want { 126 | t.Errorf("%q. HTMLRichText.Italic() = %v, want %v", tt.name, got, tt.want) 127 | } 128 | } 129 | } 130 | 131 | func TestMarkdownRichText_Pre(t *testing.T) { 132 | type args struct { 133 | s string 134 | } 135 | tests := []struct { 136 | name string 137 | mrt MarkdownRichText 138 | args args 139 | want string 140 | }{ 141 | {"test1", MarkdownRichText{}, args{"text here"}, "```\ntext here\n```"}, 142 | {"test2", MarkdownRichText{}, args{"```text here"}, "```\n‛‛‛text here\n```"}, 143 | } 144 | for _, tt := range tests { 145 | mrt := MarkdownRichText{} 146 | if got := mrt.Pre(tt.args.s); got != tt.want { 147 | t.Errorf("%q. MarkdownRichText.Pre() = %v, want %v", tt.name, got, tt.want) 148 | } 149 | } 150 | } 151 | 152 | func TestMarkdownRichText_Fixed(t *testing.T) { 153 | type args struct { 154 | s string 155 | } 156 | tests := []struct { 157 | name string 158 | mrt MarkdownRichText 159 | args args 160 | want string 161 | }{ 162 | {"test1", MarkdownRichText{}, args{"text here"}, "`text here`"}, 163 | {"test2", MarkdownRichText{}, args{"`text here"}, "`‛text here`"}, 164 | } 165 | for _, tt := range tests { 166 | mrt := MarkdownRichText{} 167 | if got := mrt.Fixed(tt.args.s); got != tt.want { 168 | t.Errorf("%q. MarkdownRichText.Fixed() = %v, want %v", tt.name, got, tt.want) 169 | } 170 | } 171 | } 172 | 173 | func TestMarkdownRichText_Esc(t *testing.T) { 174 | type args struct { 175 | s string 176 | } 177 | tests := []struct { 178 | name string 179 | mrt MarkdownRichText 180 | args args 181 | want string 182 | }{ 183 | {"test1", MarkdownRichText{}, args{"[a](b)"}, "\\[a\\]\\(b\\)"}, 184 | {"test2", MarkdownRichText{}, args{"`here is the*_text"}, "\\`here is the\\*\\_text"}, 185 | } 186 | for _, tt := range tests { 187 | mrt := MarkdownRichText{} 188 | if got := mrt.Esc(tt.args.s); got != tt.want { 189 | t.Errorf("%q. MarkdownRichText.Esc() = %v, want %v", tt.name, got, tt.want) 190 | } 191 | } 192 | } 193 | 194 | func TestMarkdownRichText_URL(t *testing.T) { 195 | type args struct { 196 | text string 197 | url string 198 | } 199 | tests := []struct { 200 | name string 201 | mrt MarkdownRichText 202 | args args 203 | want string 204 | }{ 205 | {"test1", MarkdownRichText{}, args{"text here", "https://integram.org"}, "[text here](https://integram.org)"}, 206 | {"test2", MarkdownRichText{}, args{"(text here[])", "https://integram.org/?a=1&b=2"}, "[❨text here⟦⟧❩](https://integram.org/?a=1&b=2)"}, 207 | } 208 | for _, tt := range tests { 209 | mrt := MarkdownRichText{} 210 | if got := mrt.URL(tt.args.text, tt.args.url); got != tt.want { 211 | t.Errorf("%q. MarkdownRichText.URL() = %v, want %v", tt.name, got, tt.want) 212 | } 213 | } 214 | } 215 | 216 | func TestMarkdownRichText_Bold(t *testing.T) { 217 | type args struct { 218 | text string 219 | } 220 | tests := []struct { 221 | name string 222 | mrt MarkdownRichText 223 | args args 224 | want string 225 | }{ 226 | {"test1", MarkdownRichText{}, args{"text here"}, "*text here*"}, 227 | {"test2", MarkdownRichText{}, args{"*text here"}, "*∗text here*"}, 228 | } 229 | for _, tt := range tests { 230 | mrt := MarkdownRichText{} 231 | if got := mrt.Bold(tt.args.text); got != tt.want { 232 | t.Errorf("%q. MarkdownRichText.Bold() = %v, want %v", tt.name, got, tt.want) 233 | } 234 | } 235 | } 236 | 237 | func TestMarkdownRichText_Italic(t *testing.T) { 238 | type args struct { 239 | text string 240 | } 241 | tests := []struct { 242 | name string 243 | mrt MarkdownRichText 244 | args args 245 | want string 246 | }{ 247 | {"test1", MarkdownRichText{}, args{"text here"}, "_text here_"}, 248 | {"test2", MarkdownRichText{}, args{"_text here"}, "__text here_"}} 249 | for _, tt := range tests { 250 | mrt := MarkdownRichText{} 251 | if got := mrt.Italic(tt.args.text); got != tt.want { 252 | t.Errorf("%q. MarkdownRichText.Italic() = %v, want %v", tt.name, got, tt.want) 253 | } 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /services.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import ( 4 | "encoding/gob" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | "os" 11 | "reflect" 12 | "runtime" 13 | "strings" 14 | "sync" 15 | "time" 16 | 17 | "github.com/mrjones/oauth" 18 | "github.com/requilence/jobs" 19 | "github.com/requilence/url" 20 | log "github.com/sirupsen/logrus" 21 | "golang.org/x/oauth2" 22 | 23 | "gopkg.in/mgo.v2" 24 | ) 25 | 26 | const standAloneServicesFileName = "standAloneServices.json" 27 | // Map of Services configs per name. See Register func 28 | var serviceMapMutex = sync.RWMutex{} 29 | var services = make(map[string]*Service) 30 | 31 | // Mapping job.Type by job alias names specified in service's config 32 | type jobTypePerJobName map[string]*jobs.Type 33 | 34 | var jobsPerService = make(map[string]jobTypePerJobName) 35 | 36 | // Map of replyHandlers names to funcs. Use service's config to specify it 37 | var actionFuncs = make(map[string]interface{}) 38 | 39 | // Channel that use to recover tgUpadates reader after panic inside it 40 | var tgUpdatesRevoltChan = make(chan *Bot) 41 | 42 | type Module struct { 43 | Jobs []Job 44 | Actions []interface{} 45 | } 46 | 47 | // Service configuration 48 | type Service struct { 49 | Name string // Service lowercase name 50 | NameToPrint string // Service print name 51 | ImageURL string // Service thumb image to use in WebPreview if there is no image specified in message. Useful for non-interactive integrations that uses main Telegram's bot. 52 | 53 | DefaultBaseURL url.URL // Cloud(not self-hosted) URL 54 | DefaultOAuth1 *DefaultOAuth1 // Cloud(not self-hosted) app data 55 | DefaultOAuth2 *DefaultOAuth2 // Cloud(not self-hosted) app data 56 | OAuthRequired bool // Is OAuth required in order to receive webhook updates 57 | 58 | JobsPool int // Worker pool to be created for service. Default to 1 worker. Workers will be inited only if jobs types are available 59 | 60 | JobOldPrefix string 61 | Jobs []Job // Job types that can be scheduled 62 | 63 | Modules []Module // you can inject modules and use it across different services 64 | 65 | // Functions that can be triggered after message reply, inline button press or Auth success f.e. API query to comment the card on replying. 66 | // Please note that first argument must be an *integram.Context. Because all actions is triggered in some context. 67 | // F.e. when using action with onReply triggered with context of replied message (user, chat, bot). 68 | Actions []interface{} 69 | 70 | // Handler to produce the user/chat search query based on the http request. Set queryChat to true to perform chat search 71 | TokenHandler func(ctx *Context, request *WebhookContext) (queryChat bool, bsonQuery map[string]interface{}, err error) 72 | 73 | // Handler to receive webhooks from outside 74 | WebhookHandler func(ctx *Context, request *WebhookContext) error 75 | 76 | // Handler to receive already prepared data. Useful for manual interval grabbing jobs 77 | EventHandler func(ctx *Context, data interface{}) error 78 | 79 | // Worker wil be run in goroutine after service and framework started. In case of error or crash it will be restarted 80 | Worker func(ctx *Context) error 81 | 82 | // Handler to receive new messages from Telegram 83 | TGNewMessageHandler func(ctx *Context) error 84 | 85 | // Handler to receive new messages from Telegram 86 | TGEditMessageHandler func(ctx *Context) error 87 | 88 | // Handler to receive inline queries from Telegram 89 | TGInlineQueryHandler func(ctx *Context) error 90 | 91 | // Handler to receive chosen inline results from Telegram 92 | TGChosenInlineResultHandler func(ctx *Context) error 93 | 94 | OAuthSuccessful func(ctx *Context) error 95 | // Can be used for services with tiny load 96 | UseWebhookInsteadOfLongPolling bool 97 | 98 | // Can be used to automatically clean up old messages metadata from database 99 | RemoveMessagesOlderThan *time.Duration 100 | 101 | machineURL string // in case of multi-instance mode URL is used to talk with the service 102 | 103 | rootPackagePath string 104 | } 105 | 106 | const ( 107 | // JobRetryLinear specify jobs retry politic as retry after fail 108 | JobRetryLinear = iota 109 | // JobRetryFibonacci specify jobs retry politic as delay after fail using fibonacci sequence 110 | JobRetryFibonacci 111 | ) 112 | 113 | // Job 's handler that may be used when scheduling 114 | type Job struct { 115 | HandlerFunc interface{} // Must be a func. 116 | Retries uint // Number of retries before fail 117 | RetryType int // JobRetryLinear or JobRetryFibonacci 118 | } 119 | 120 | // DefaultOAuth1 is the default OAuth1 config for the service 121 | type DefaultOAuth1 struct { 122 | Key string 123 | Secret string 124 | RequestTokenURL string 125 | AuthorizeTokenURL string 126 | AccessTokenURL string 127 | AdditionalAuthorizationURLParams map[string]string 128 | HTTPMethod string 129 | AccessTokenReceiver func(serviceContext *Context, r *http.Request, requestToken *oauth.RequestToken) (token string, err error) 130 | } 131 | 132 | // DefaultOAuth2 is the default OAuth2 config for the service 133 | type DefaultOAuth2 struct { 134 | oauth2.Config 135 | AccessTokenReceiver func(serviceContext *Context, r *http.Request) (token string, expiresAt *time.Time, refreshToken string, err error) 136 | 137 | // duration to cache temp token to associate with user 138 | // default(when zero) will be set to 30 days 139 | AuthTempTokenCacheTime time.Duration 140 | } 141 | 142 | func servicesHealthChecker() { 143 | 144 | defer func() { 145 | if r := recover(); r != nil { 146 | log.Errorf("HealthChecker panic recovered %v", r) 147 | servicesHealthChecker() 148 | } 149 | }() 150 | s := mongoSession.Clone() 151 | defer s.Close() 152 | 153 | for true { 154 | 155 | err := healthCheck(s.DB(mongo.Database)) 156 | 157 | if err != nil { 158 | log.Errorf("HealthChecker main, error: %s", err.Error()) 159 | } 160 | 161 | if Config.IsMainInstance() { 162 | 163 | for serviceName, _ := range services { 164 | 165 | resp, err := http.Get(fmt.Sprintf("%s/%s/healthcheck", Config.BaseURL, serviceName)) 166 | 167 | if err != nil { 168 | log.Errorf("HealthChecker %s, network error: %s", serviceName, err.Error()) 169 | continue 170 | } 171 | 172 | // always close the response-body, even if content is not required 173 | defer resp.Body.Close() 174 | 175 | if resp.StatusCode != 200 { 176 | b, err := ioutil.ReadAll(resp.Body) 177 | 178 | if err != nil { 179 | log.Errorf("HealthChecker %s, status %d, read error: %s", serviceName, resp.StatusCode, err.Error()) 180 | } 181 | 182 | log.Errorf("HealthChecker %s, status %d, error: %s", serviceName, resp.StatusCode, string(b)) 183 | } 184 | 185 | } 186 | } 187 | 188 | time.Sleep(time.Second * time.Duration(Config.HealthcheckIntervalInSecond)) 189 | } 190 | } 191 | 192 | func healthCheck(db *mgo.Database) error { 193 | 194 | err := db.Session.Ping() 195 | if err != nil { 196 | return fmt.Errorf("DB fault: %s", err) 197 | 198 | } 199 | 200 | _, err = jobs.FindById("fake") 201 | if err != nil { 202 | if _, isThisNotFoundError := err.(jobs.ErrorJobNotFound); !isThisNotFoundError { 203 | return fmt.Errorf("Redis fault: %s", err) 204 | } 205 | } 206 | 207 | if int(time.Now().Sub(startedAt).Seconds()) < Config.HealthcheckIntervalInSecond { 208 | return fmt.Errorf("Instance was restarted") 209 | } 210 | 211 | return nil 212 | } 213 | 214 | func init() { 215 | 216 | jobs.Config.Db.Address = Config.RedisURL 217 | if Config.IsMainInstance() { 218 | err := loadStandAloneServicesFromFile() 219 | if err != nil { 220 | log.WithError(err).Error("loadStandAloneServicesFromFile error") 221 | } 222 | go servicesHealthChecker() 223 | 224 | } else { 225 | go func() { 226 | var b *Bot 227 | for { 228 | b = <-tgUpdatesRevoltChan 229 | log.Debugf("tgUpdatesRevoltChan received, bot %+v\n", b) 230 | 231 | b.listen() 232 | } 233 | }() 234 | } 235 | } 236 | 237 | func afterJob(job *jobs.Job) { 238 | // remove successed tasks from Redis 239 | err := job.Error() 240 | 241 | if err == nil { 242 | log.WithFields(log.Fields{"jobID": job.Id(), "jobType": job.TypeName(), "poolId": job.PoolId()}).WithError(err).Infof("Job succeed after %.2f sec", job.Duration().Seconds()) 243 | job.Destroy() 244 | } else if err != nil && job.Retries() == 0 { 245 | log.WithFields(log.Fields{"jobID": job.Id(), "jobType": job.TypeName(), "poolId": job.PoolId()}).WithError(err).Errorf("Job failed after %.2f sec", job.Duration().Seconds()) 246 | job.Destroy() 247 | } else if err != nil && job.Retries() > 0 { 248 | log.WithFields(log.Fields{"jobID": job.Id(), "jobType": job.TypeName(), "poolId": job.PoolId()}).WithError(err).Errorf("Job failed after %.2f sec, %d retries left", job.Duration().Seconds(), job.Retries()) 249 | job.Destroy() 250 | } 251 | } 252 | 253 | func beforeJob(ch chan bool, job *jobs.Job, args *[]reflect.Value) { 254 | s := mongoSession.Clone() 255 | 256 | for i := 0; i < len(*args); i++ { 257 | 258 | if (*args)[i].Kind() == reflect.Ptr && (*args)[i].Type().String() == "*integram.Context" { 259 | ctx := (*args)[i].Interface().(*Context) 260 | 261 | ctx.db = s.DB(mongo.Database) 262 | ctx.User.ctx = ctx 263 | ctx.Chat.ctx = ctx 264 | 265 | break 266 | } 267 | } 268 | 269 | ch <- true 270 | <-ch 271 | s.Close() 272 | } 273 | 274 | // Servicer is interface to match service's config from which the service itself can be produced 275 | type Servicer interface { 276 | Service() *Service 277 | } 278 | 279 | func ensureStandAloneService(serviceName string, machineURL string, botToken string) error { 280 | 281 | log.Infof("Service '%s' discovered: %s", serviceName, machineURL) 282 | s, _ := serviceByName(serviceName) 283 | 284 | serviceMapMutex.Lock() 285 | defer serviceMapMutex.Unlock() 286 | 287 | if s == nil { 288 | s = &Service{Name: serviceName} 289 | services[serviceName] = s 290 | } 291 | 292 | s.machineURL = machineURL 293 | reverseProxiesMapMutex.Lock() 294 | _, ok := reverseProxiesMap[serviceName] 295 | if ok { 296 | delete(reverseProxiesMap, serviceName) 297 | } 298 | reverseProxiesMapMutex.Unlock() 299 | 300 | err := s.registerBot(botToken) 301 | 302 | if err != nil { 303 | log.WithError(err).Error("Service Ensure: registerBot error") 304 | } 305 | 306 | err = saveStandAloneServicesToFile() 307 | 308 | //if err != nil{ 309 | // log.WithError(err).Error("Service Ensure: saveServicesBotsTokensToCache error") 310 | //} 311 | return err 312 | } 313 | 314 | func loadStandAloneServicesFromFile() error { 315 | b, err := ioutil.ReadFile(Config.ConfigDir + string(os.PathSeparator) + standAloneServicesFileName) 316 | 317 | if err != nil { 318 | return err 319 | } 320 | var m map[string]externalService 321 | 322 | err = json.Unmarshal(b, &m) 323 | 324 | if err != nil { 325 | return err 326 | } 327 | 328 | for serviceName, es := range m { 329 | s, _ := serviceByName(serviceName) 330 | if s == nil { 331 | s = &Service{Name: serviceName, machineURL: es.URL} 332 | serviceMapMutex.Lock() 333 | services[serviceName] = s 334 | serviceMapMutex.Unlock() 335 | } 336 | 337 | err := s.registerBot(es.BotToken) 338 | 339 | if err != nil { 340 | log.WithError(err).Error("loadStandAloneServicesFromFile: registerBot error") 341 | } 342 | } 343 | return nil 344 | 345 | } 346 | 347 | type externalService struct { 348 | BotToken string 349 | URL string 350 | } 351 | 352 | func saveStandAloneServicesToFile() error { 353 | m := map[string]externalService{} 354 | for _, s := range services { 355 | m[s.Name] = externalService{BotToken: s.Bot().tgToken(), URL: s.machineURL} 356 | } 357 | 358 | jsonData, err := json.Marshal(m) 359 | 360 | if err != nil { 361 | return err 362 | } 363 | 364 | return ioutil.WriteFile(Config.ConfigDir + string(os.PathSeparator) + standAloneServicesFileName, jsonData, 0655) 365 | } 366 | 367 | func (s *Service) getShortFuncPath(actionFunc interface{}) string { 368 | fullPath := runtime.FuncForPC(reflect.ValueOf(actionFunc).Pointer()).Name() 369 | if fullPath == "" { 370 | panic("getShortFuncPath") 371 | } 372 | return s.trimFuncPath(fullPath) 373 | } 374 | 375 | func (s *Service) trimFuncPath(fullPath string) string{ 376 | // Trim funcPath for a specific service name and determined service's rootPackagePath 377 | // trello, github.com/requilence/integram/services/trello, github.com/requilence/integram/services/trello.cardReplied -> trello.cardReplied 378 | // trello, github.com/requilence/integram/services/Trello, github.com/requilence/integram/services/Trello.cardReplied -> trello.cardReplied 379 | // trello, github.com/requilence/trelloRepo, _/var/integram/trello.cardReplied -> trello.cardReplied 380 | // trello, github.com/requilence/trelloRepo, _/var/integram/another.cardReplied -> trello.cardReplied 381 | // trello, github.com/requilence/integram/services/trello, github.com/requilence/integram/services/trello/another.action -> trello/another.action 382 | // trello, github.com/requilence/integram/services/trello, _/var/integram/trello.cardReplied -> trello.cardReplied 383 | // trello, trello.cardReplied, github.com/requilence/integram/services/trello.cardReplied -> trello.cardReplied 384 | if s.rootPackagePath != "" && strings.HasPrefix(fullPath, s.rootPackagePath) { 385 | internalFuncPath := strings.TrimPrefix(fullPath, s.rootPackagePath) 386 | return s.Name + internalFuncPath 387 | } else if strings.HasPrefix(fullPath, s.Name+".") { 388 | return fullPath 389 | } 390 | funcPos := strings.LastIndex(fullPath, s.Name+".") 391 | if funcPos > -1 { 392 | return fullPath[funcPos:] 393 | } 394 | 395 | funcPos = strings.LastIndex(fullPath, ".") 396 | if funcPos > -1 { 397 | return s.Name + fullPath[funcPos:] 398 | } 399 | 400 | return fullPath 401 | } 402 | 403 | // Register the service's config and corresponding botToken 404 | func Register(servicer Servicer, botToken string) { 405 | //jobs.Config.Db.Address="192.168.1.101:6379" 406 | db := mongoSession.Clone().DB(mongo.Database) 407 | service := servicer.Service() 408 | err := migrations(db, service.Name) 409 | if err != nil { 410 | log.Fatalf("failed to apply migrations: %s", err.Error()) 411 | } 412 | 413 | if service.DefaultOAuth1 != nil { 414 | if service.DefaultOAuth1.AccessTokenReceiver == nil { 415 | err := errors.New("OAuth1 need an AccessTokenReceiver func to be specified\n") 416 | panic(err.Error()) 417 | } 418 | service.DefaultBaseURL = *URLMustParse(service.DefaultOAuth1.AccessTokenURL) 419 | 420 | //mongoSession.DB(mongo.Database).C("users").EnsureIndex(mgo.Index{Key: []string{"settings." + service.Name + ".oauth_redirect_token"}}) 421 | } else if service.DefaultOAuth2 != nil { 422 | service.DefaultBaseURL = *URLMustParse(service.DefaultOAuth2.Endpoint.AuthURL) 423 | } 424 | service.DefaultBaseURL.Path = "" 425 | service.DefaultBaseURL.RawPath = "" 426 | service.DefaultBaseURL.RawQuery = "" 427 | 428 | services[service.Name] = service 429 | 430 | if len(service.Jobs) > 0 || service.OAuthSuccessful != nil { 431 | if service.JobsPool == 0 { 432 | service.JobsPool = 1 433 | } 434 | pool, err := jobs.NewPool(&jobs.PoolConfig{ 435 | Key: "_" + service.Name, 436 | NumWorkers: service.JobsPool, 437 | BatchSize: Config.TGPoolBatchSize, 438 | }) 439 | if err != nil { 440 | log.Panicf("Can't create jobs pool: %v\n", err) 441 | } else { 442 | pool.SetMiddleware(beforeJob) 443 | pool.SetAfterFunc(afterJob) 444 | } 445 | 446 | //log.Infof("%s: workers pool [%d] is ready", service.Name, service.JobsPool) 447 | 448 | jobsPerService[service.Name] = make(map[string]*jobs.Type) 449 | 450 | if service.OAuthSuccessful != nil { 451 | service.Jobs = append(service.Jobs, Job{ 452 | service.OAuthSuccessful, 10, JobRetryFibonacci, 453 | }) 454 | } 455 | 456 | if len(service.Modules) > 0 { 457 | for _, module := range service.Modules { 458 | service.Actions = append(service.Actions, module.Actions...) 459 | service.Jobs = append(service.Jobs, module.Jobs...) 460 | } 461 | } 462 | 463 | for _, job := range service.Jobs { 464 | handlerType := reflect.TypeOf(job.HandlerFunc) 465 | m := make([]interface{}, handlerType.NumIn()) 466 | 467 | for i := 0; i < handlerType.NumIn(); i++ { 468 | argType := handlerType.In(i) 469 | if argType.Kind() == reflect.Ptr { 470 | //argType = argType.Elem() 471 | } 472 | 473 | if argType.Kind() == reflect.Interface { 474 | gob.Register(reflect.Zero(argType)) 475 | } else { 476 | gob.Register(reflect.Zero(argType).Interface()) 477 | } 478 | m[i] = reflect.Zero(argType) 479 | } 480 | gob.Register(m) 481 | 482 | jobName := service.getShortFuncPath(job.HandlerFunc) 483 | 484 | jobType, err := jobs.RegisterTypeWithPoolKey(jobName, "_"+service.Name, job.Retries, job.HandlerFunc) 485 | if err != nil { 486 | fmt.Errorf("RegisterTypeWithPoolKey '%s', for %s: %s", jobName, service.Name, err.Error() ) 487 | } else { 488 | jobsPerService[service.Name][jobName] = jobType 489 | } 490 | 491 | } 492 | 493 | rootPackagePath := reflect.TypeOf(servicer).PkgPath() 494 | service.rootPackagePath = rootPackagePath 495 | 496 | log.Debugf("RootPackagePath of %s is %s", service.Name, rootPackagePath) 497 | 498 | go func(pool *jobs.Pool, service *Service) { 499 | time.Sleep(time.Second * 5) 500 | 501 | err = pool.Start() 502 | 503 | if err != nil { 504 | log.Panicf("Can't start jobs pool: %v\n", err) 505 | } 506 | log.Infof("%s service: workers pool [%d] started", service.Name, service.JobsPool) 507 | 508 | }(pool, service) 509 | 510 | } 511 | 512 | if len(service.Actions) > 0 { 513 | for _, actionFunc := range service.Actions { 514 | actionFuncType := reflect.TypeOf(actionFunc) 515 | m := make([]interface{}, actionFuncType.NumIn()) 516 | 517 | for i := 0; i < actionFuncType.NumIn(); i++ { 518 | argType := actionFuncType.In(i) 519 | if argType.Kind() == reflect.Ptr { 520 | //argType = argType.Elem() 521 | } 522 | 523 | gob.Register(reflect.Zero(argType).Interface()) 524 | } 525 | gob.Register(m) 526 | actionFuncs[service.getShortFuncPath(actionFunc)] = actionFunc 527 | } 528 | } 529 | if botToken == "" { 530 | return 531 | } 532 | 533 | err = service.registerBot(botToken) 534 | if err != nil { 535 | log.WithError(err).WithField("token", botToken).Panic("Can't register the bot") 536 | } 537 | go ServiceWorkerAutorespawnGoroutine(service) 538 | 539 | if service.Worker != nil { 540 | go ServiceWorkerAutorespawnGoroutine(service) 541 | } 542 | 543 | // todo: here is possible bug if service just want to use inline keyboard callbacks via setCallbackAction 544 | if service.TGNewMessageHandler == nil && service.TGInlineQueryHandler == nil { 545 | return 546 | } 547 | 548 | } 549 | 550 | func ServiceWorkerAutorespawnGoroutine(s *Service) { 551 | 552 | c := s.EmptyContext() 553 | defer func() { 554 | if r := recover(); r != nil { 555 | stack := stack(3) 556 | log.Errorf("Panic recovery at ServiceWorkerAutorespawnGoroutine -> %s\n%s\n", r, stack) 557 | } 558 | go ServiceWorkerAutorespawnGoroutine(s) // restart 559 | }() 560 | 561 | err := s.Worker(c) 562 | if err != nil { 563 | s.Log().WithError(err).Error("Worker return error") 564 | } 565 | } 566 | 567 | // Bot returns corresponding bot for the service 568 | func (s *Service) Bot() *Bot { 569 | if bot, exists := botPerService[s.Name]; exists { 570 | return bot 571 | } 572 | log.WithField("service", s.Name).Error("Can't get bot for service") 573 | return nil 574 | } 575 | 576 | // DefaultOAuthProvider returns default(means cloud-based) OAuth client 577 | func (s *Service) DefaultOAuthProvider() *OAuthProvider { 578 | oap := OAuthProvider{} 579 | oap.BaseURL = s.DefaultBaseURL 580 | oap.Service = s.Name 581 | if s.DefaultOAuth2 != nil { 582 | oap.ID = s.DefaultOAuth2.ClientID 583 | oap.Secret = s.DefaultOAuth2.ClientSecret 584 | } else if s.DefaultOAuth1 != nil { 585 | oap.ID = s.DefaultOAuth1.Key 586 | oap.Secret = s.DefaultOAuth1.Secret 587 | } else { 588 | s.Log().Error("Can't get OAuth client") 589 | } 590 | return &oap 591 | } 592 | 593 | // DoJob queues the job to run. The job must be registred in Service's config (Jobs field). Arguments must be identically types with hudlerFunc's input args 594 | func (s *Service) DoJob(handlerFunc interface{}, data ...interface{}) (*jobs.Job, error) { 595 | return s.SheduleJob(handlerFunc, 0, time.Now(), data...) 596 | } 597 | 598 | // SheduleJob schedules the job for specific time with specific priority. The job must be registred in Service's config (Jobs field). Arguments must be identically types with hudlerFunc's input args 599 | func (s *Service) SheduleJob(handlerFunc interface{}, priority int, time time.Time, data ...interface{}) (*jobs.Job, error) { 600 | if jobsPerName, ok := jobsPerService[s.Name]; ok { 601 | if jobType, ok := jobsPerName[s.getShortFuncPath(handlerFunc)]; ok { 602 | return jobType.Schedule(priority, time, data...) 603 | } 604 | panic("SheduleJob: Job type not found") 605 | } 606 | panic("SheduleJob: Service pool not found") 607 | } 608 | 609 | // EmptyContext returns context on behalf of service without user/chat relation 610 | func (s *Service) EmptyContext() *Context { 611 | db := mongoSession.Clone().DB(mongo.Database) 612 | 613 | ctx := &Context{db: db, ServiceName: s.Name} 614 | return ctx 615 | } 616 | 617 | func serviceByName(name string) (*Service, error) { 618 | serviceMapMutex.RLock() 619 | defer serviceMapMutex.RUnlock() 620 | if val, ok := services[name]; ok { 621 | return val, nil 622 | } 623 | 624 | return nil, fmt.Errorf("Can't find service with name %s", name) 625 | } 626 | 627 | // Log returns logrus instance with related context info attached 628 | func (s *Service) Log() *log.Entry { 629 | return log.WithField("service", s.Name) 630 | } 631 | -------------------------------------------------------------------------------- /services_test.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | log "github.com/sirupsen/logrus" 7 | "github.com/mrjones/oauth" 8 | "golang.org/x/oauth2" 9 | "gopkg.in/mgo.v2" 10 | "net/http" 11 | "os" 12 | "strings" 13 | "testing" 14 | "time" 15 | ) 16 | 17 | type serviceTestConfig struct { 18 | service *Service 19 | } 20 | 21 | var totalServices int 22 | 23 | // Service returns *integram.Service 24 | func (sc serviceTestConfig) Service() *Service { 25 | return sc.service 26 | } 27 | 28 | func dumbFuncWithParam(a bool) error { 29 | if a { 30 | a = false 31 | } 32 | return nil 33 | } 34 | 35 | var dCounter = 0 36 | 37 | func dumbFuncWithError(es string) error { 38 | dCounter++ 39 | return errors.New(es) 40 | } 41 | 42 | type dumbStruct struct { 43 | A float64 44 | B struct { 45 | A string 46 | B int 47 | } 48 | } 49 | 50 | func dumbFuncWithParams(a, b int, d dumbStruct) error { 51 | if a != b { 52 | a = b 53 | } 54 | if d.B.A == "" { 55 | d.A = 0 56 | } 57 | dCounter++ 58 | return nil 59 | } 60 | 61 | func dumbFuncWithContext(c *Context) error { 62 | dCounter++ 63 | return nil 64 | } 65 | 66 | func dumbFuncWithContextAndParam(c *Context, a bool) error { 67 | if a { 68 | a = false 69 | } 70 | dCounter++ 71 | return nil 72 | } 73 | 74 | func dumbFuncWithContextAndParams(c *Context, a, b int) error { 75 | if a != b { 76 | a = b 77 | } 78 | dCounter++ 79 | return nil 80 | } 81 | 82 | var db *mgo.Database 83 | 84 | func TestMain(t *testing.M) { 85 | db = mongoSession.Clone().DB(mongo.Database) 86 | defer db.Session.Close() 87 | 88 | registerServices() 89 | 90 | code := t.Run() 91 | clearData() 92 | os.Exit(code) 93 | } 94 | 95 | func TestRegister(t *testing.T) { 96 | if totalServices > len(services) { 97 | t.Errorf("Register() = len(services)==%d, want %d", len(services), totalServices) 98 | } 99 | } 100 | 101 | func registerServices() { 102 | 103 | if len(services) == 0 { 104 | log.SetLevel(log.DebugLevel) 105 | servicesToRegister := []struct { 106 | service Servicer 107 | botToken string 108 | }{ 109 | {serviceTestConfig{&Service{ 110 | Name: "servicewithoauth1", 111 | NameToPrint: "ServiceWithOAuth1", 112 | DefaultOAuth1: &DefaultOAuth1{ 113 | Key: "ID", 114 | Secret: "SECRET", 115 | 116 | RequestTokenURL: "https://sub.example.com/1/OAuthGetRequestToken", 117 | AuthorizeTokenURL: "https://sub.example.com/1/OAuthAuthorizeToken", 118 | AccessTokenURL: "https://sub.example.com/1/OAuthGetAccessToken", 119 | 120 | AdditionalAuthorizationURLParams: map[string]string{ 121 | "name": "Integram", 122 | "expiration": "never", 123 | "scope": "read,write", 124 | }, 125 | 126 | AccessTokenReceiver: func(serviceContext *Context, r *http.Request, requestToken *oauth.RequestToken) (token string, err error) { 127 | return "token", nil 128 | }, 129 | }, 130 | }}, ""}, 131 | {serviceTestConfig{&Service{ 132 | Name: "servicewithoauth2", 133 | NameToPrint: "ServiceWithOAuth2", 134 | DefaultOAuth2: &DefaultOAuth2{ 135 | Config: oauth2.Config{ 136 | ClientID: "ID", 137 | ClientSecret: "SECRET", 138 | Endpoint: oauth2.Endpoint{ 139 | AuthURL: "https://sub.example.com/oauth/authorize", 140 | TokenURL: "https://sub.example.com/oauth/token", 141 | }, 142 | }, 143 | }, 144 | }}, ""}, 145 | {serviceTestConfig{&Service{ 146 | Name: "servicewithjobs", 147 | NameToPrint: "ServiceWithJobs", 148 | Jobs: []Job{ 149 | {dumbFuncWithParam, 10, JobRetryFibonacci}, 150 | {dumbFuncWithParams, 10, JobRetryLinear}, 151 | {dumbFuncWithError, 3, JobRetryFibonacci}, 152 | }, 153 | }}, ""}, 154 | {serviceTestConfig{&Service{ 155 | Name: "servicewithactions", 156 | NameToPrint: "ServiceWithActions", 157 | Actions: []interface{}{ 158 | dumbFuncWithContext, 159 | dumbFuncWithContextAndParam, 160 | dumbFuncWithContextAndParams, 161 | }, 162 | }}, ""}, 163 | {serviceTestConfig{&Service{ 164 | Name: "servicewithbottoken", 165 | NameToPrint: "ServiceWithBotToken", 166 | }}, os.Getenv("INTEGRAM_TEST_BOT_TOKEN")}, 167 | } 168 | 169 | for _, s := range servicesToRegister { 170 | Register(s.service, s.botToken) 171 | } 172 | totalServices = len(servicesToRegister) 173 | } 174 | 175 | go func() { Run() }() 176 | time.Sleep(time.Second * 3) 177 | 178 | } 179 | 180 | func TestService_Bot(t *testing.T) { 181 | 182 | s, err := serviceByName("servicewithbottoken") 183 | if err != nil || s == nil { 184 | t.Errorf("TestService_Bot() 'servicewithbottoken' not found") 185 | return 186 | } 187 | 188 | bt := strings.Split(os.Getenv("INTEGRAM_TEST_BOT_TOKEN"), ":") 189 | bot := s.Bot() 190 | if bot == nil || fmt.Sprintf("%d", bot.ID) != bt[0] || len(bot.services) == 0 || bot.services[0].Name != "servicewithbottoken" || bot.token != bt[1] { 191 | t.Errorf("TestService_Bot() bad bot returned for services") 192 | } 193 | } 194 | 195 | func TestService_DefaultOAuthProvider(t *testing.T) { 196 | 197 | servicesWithOAP := []string{"servicewithoauth1", "servicewithoauth2"} 198 | for _, sn := range servicesWithOAP { 199 | s, err := serviceByName(sn) 200 | if err != nil || s == nil { 201 | t.Errorf("TestService_DefaultOAuthProvider() '%s' not found", sn) 202 | return 203 | } 204 | 205 | oap := s.DefaultOAuthProvider() 206 | if oap == nil || oap.ID == "" || oap.Service != sn || oap.BaseURL.String() != "https://sub.example.com" { 207 | t.Errorf("TestService_DefaultOAuthProvider() bad DefaultOAuthProvider returned for '%s'", sn) 208 | } 209 | } 210 | 211 | } 212 | 213 | func TestService_DoJob(t *testing.T) { 214 | s, err := serviceByName("servicewithjobs") 215 | if err != nil || s == nil { 216 | t.Error("TestService_DoJob 'servicewithjobs' not found") 217 | return 218 | } 219 | 220 | dCounter = 0 221 | job, err := s.DoJob(dumbFuncWithParams, 1, 2, dumbStruct{A: 1.0, B: struct { 222 | A string 223 | B int 224 | }{A: "_", 225 | B: 1.0}}) 226 | if err != nil || job == nil || job.Id() == "" { 227 | t.Error("TestService_DoJob failed") 228 | } 229 | maxTimeToFinishJob := time.Second * 5 230 | 231 | for dCounter == 0 && maxTimeToFinishJob > 0 { 232 | time.Sleep(time.Millisecond * 50) 233 | maxTimeToFinishJob = maxTimeToFinishJob - time.Millisecond*50 234 | } 235 | 236 | if maxTimeToFinishJob <= 0 { 237 | t.Errorf("TestService_DoJob 'dumbFuncWithParams' not finished after maxTimeToFinishJob: instead got status: %s", job.Status()) 238 | } 239 | 240 | // reset global retries counter 241 | dCounter = 0 242 | 243 | job, err = s.DoJob(dumbFuncWithError, "errText") 244 | if err != nil || job == nil || job.Id() == "" { 245 | t.Errorf("TestService_DoJob failed with err: %v", err) 246 | } 247 | err = job.Refresh() 248 | if err != nil { 249 | t.Error(err) 250 | } 251 | 252 | maxTimeToFinishRetries := time.Second * 6 253 | prevTime := job.Time() 254 | fmt.Println(prevTime) 255 | prevdCounter := 0 256 | for dCounter < 4 && maxTimeToFinishJob > 0 { 257 | maxTimeToFinishRetries = maxTimeToFinishRetries - time.Millisecond*100 258 | time.Sleep(time.Millisecond * 100) 259 | if dCounter > prevdCounter { 260 | fmt.Printf("dCounter: %d\n", dCounter) 261 | job.Refresh() 262 | fmt.Printf("time: %d\n", job.Time()) 263 | jt := job.Time() 264 | if dCounter < 4 && jt <= prevTime { 265 | t.Error("TestService_DoJob next try job's1 time must be greater than on prev attempt") 266 | } 267 | prevTime = jt 268 | prevdCounter = dCounter 269 | } 270 | } 271 | 272 | if maxTimeToFinishJob <= 0 { 273 | t.Errorf("TestService_DoJob 'dumbFuncWithError' not enough retries after 6 secs. want 4, got %d instead", dCounter) 274 | } 275 | 276 | } 277 | -------------------------------------------------------------------------------- /stat.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import ( 4 | "fmt" 5 | "gopkg.in/mgo.v2/bson" 6 | "time" 7 | ) 8 | 9 | type StatKey string 10 | 11 | const ( 12 | StatInlineQueryAnswered StatKey = "iq_answered" 13 | StatInlineQueryNotAnswered StatKey = "iq_not_answered" 14 | StatInlineQueryTimeouted StatKey = "iq_timeouted" 15 | StatInlineQueryCanceled StatKey = "iq_canceled" 16 | StatInlineQueryChosen StatKey = "iq_chosen" 17 | StatInlineQueryProcessingError StatKey = "iq_error" 18 | 19 | 20 | StatWebhookHandled StatKey = "wh_handled" 21 | StatWebhookProducedMessageToChat StatKey = "wh_message" 22 | StatWebhookProcessingError StatKey = "wh_error" 23 | 24 | StatIncomingMessageAnswered StatKey = "im_replied" 25 | StatIncomingMessageNotAnswered StatKey = "im_not_replied" 26 | 27 | StatOAuthSuccess StatKey = "oauth_success" 28 | ) 29 | 30 | type stat struct { 31 | Service string `bson:"s"` 32 | DayN uint16 `bson:"d"` // Days since unix epoch (Unix TS)/(24*60*60) 33 | Key StatKey `bson:"k"` 34 | Counter uint32 `bson:"v"` 35 | UniqueCounter uint32 `bson:"uv"` 36 | 37 | Series5m map[string]uint32 `bson:"v5m"` 38 | UniqueSeries5m map[string]uint32 `bson:"uv5m"` 39 | } 40 | 41 | // count only once per user/chat per period 42 | type uniqueStat struct { 43 | Service string `bson:"s"` 44 | DayN uint16 // Days since unix epoch (Unix TS)/(24*60*60) 45 | Key StatKey `bson:"k"` 46 | PeriodN uint16 `bson:"p"` // -1 for the whole day period 47 | IDS []int64 `bson:"u"` 48 | 49 | ExpiresAt time.Time `bson:"exp"` 50 | } 51 | 52 | func (c *Context) StatIncBy(key StatKey, uniqueID int64, inc int) error { 53 | 54 | if !Config.MongoStatistic { 55 | return nil 56 | } 57 | 58 | n := time.Now() 59 | unixDay := uint16(n.Unix() / (24 * 60 * 60)) 60 | 61 | //id := generateStatID(c.Bot().ID, key, unixDay) 62 | 63 | requesterID := c.User.ID 64 | if requesterID == 0 { 65 | requesterID = c.Chat.ID 66 | } 67 | 68 | startOfTheDayTS := int64(unixDay) * 24 * 60 * 60 69 | 70 | periodN := int((n.Unix() - startOfTheDayTS) / (60 * 5)) // number of 5min period within a day 71 | 72 | updateInc := bson.M{ 73 | "v": inc, 74 | fmt.Sprintf("v5m.%d", periodN): inc, 75 | } 76 | 77 | if uniqueID != 0 { 78 | // check the ID uniqueness for the current day 79 | 80 | ci, err := c.Db().C("stats_unique").Upsert( 81 | bson.M{"s": c.ServiceName, "k": key, "d": unixDay, "p": -1, "u": bson.M{"$ne": uniqueID}}, 82 | bson.M{ 83 | "$push": bson.M{ 84 | "u": uniqueID, 85 | }, 86 | 87 | "$setOnInsert": bson.M{"exp": time.Unix(startOfTheDayTS+24*60*60, 0)}, 88 | }) 89 | 90 | if err == nil && (ci.Matched > 0 || ci.UpsertedId != nil) { 91 | // this ID wasn't found for the current day. So we can add to both overall and 5min period 92 | updateInc["uv"] = 1 93 | } 94 | 95 | // check the ID uniqueness for the current 5min period 96 | ci, err = c.Db().C("stats_unique").Upsert( 97 | bson.M{"s": c.ServiceName, "k": key, "d": unixDay, "p": periodN, "u": bson.M{"$ne": uniqueID}}, 98 | bson.M{ 99 | "$push": bson.M{ 100 | "u": requesterID, 101 | }, 102 | 103 | "$setOnInsert": bson.M{"exp": time.Unix(startOfTheDayTS+24*60*60, 0)}, 104 | }) 105 | 106 | if err == nil && (ci.Matched > 0 || ci.UpsertedId != nil) { 107 | // this ID wasn't found for the current day. So we can add to both overall and 5min period 108 | updateInc[fmt.Sprintf("uv5m.%d", periodN)] = 1 109 | } 110 | } 111 | 112 | _, err := c.Db().C("stats").Upsert(bson.M{"s": c.ServiceName, "k": key, "d": unixDay}, bson.M{ 113 | "$inc": updateInc, 114 | "$setOnInsert": bson.M{"s": c.ServiceName, "d": unixDay, "k": key}, 115 | }) 116 | return err 117 | } 118 | 119 | func (c *Context) StatInc(key StatKey) error { 120 | return c.StatIncBy(key, 0, 1) 121 | } 122 | 123 | func (c *Context) StatIncChat(key StatKey) error { 124 | return c.StatIncBy(key, c.Chat.ID, 1) 125 | } 126 | 127 | func (c *Context) StatIncUser(key StatKey) error { 128 | return c.StatIncBy(key, c.User.ID, 1) 129 | } 130 | -------------------------------------------------------------------------------- /templates.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | const htmlTemplateDetermineTZ = ` 4 | 5 | 6 | 7 | 13 | 14 | 15 | 16 | 17 | 18 | ` 19 | 20 | const htmlTemplateWebpreview = ` 21 | 22 | 23 | 24 | 25 | 26 | {{if .text}} 27 | 28 | {{end}} 29 | 30 | {{if .imageURL}} 31 | 32 | 33 | {{end}} 34 | 35 | 36 | 37 | 38 | 39 | 40 | ` -------------------------------------------------------------------------------- /tgupdates.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | tg "github.com/requilence/telegram-bot-api" 14 | log "github.com/sirupsen/logrus" 15 | mgo "gopkg.in/mgo.v2" 16 | "gopkg.in/mgo.v2/bson" 17 | ) 18 | 19 | var updateMapMutex = &sync.RWMutex{} 20 | var updateMutexPerBotPerChat = make(map[string]*sync.Mutex) 21 | 22 | type msgInfo struct { 23 | TS time.Time 24 | ID int 25 | InlineID string 26 | BotID int64 27 | ChatID int64 28 | } 29 | 30 | func updateRoutine(b *Bot, u *tg.Update) { 31 | 32 | if !Config.Debug { 33 | defer func() { 34 | if r := recover(); r != nil { 35 | stack := stack(3) 36 | log.Errorf("Panic recovery at updateRoutine -> %s\n%s\n", r, stack) 37 | 38 | } 39 | }() 40 | } 41 | updateReceivedAt := time.Now() 42 | 43 | var chatID int64 44 | if u.Message != nil { 45 | chatID = u.Message.Chat.ID 46 | } else if u.CallbackQuery != nil { 47 | if u.CallbackQuery.Message != nil { 48 | chatID = u.CallbackQuery.Message.Chat.ID 49 | } else { 50 | chatID = u.CallbackQuery.From.ID 51 | } 52 | } else if u.EditedMessage != nil { 53 | chatID = u.EditedMessage.Chat.ID 54 | } else if u.ChosenInlineResult != nil { 55 | chatID = u.ChosenInlineResult.From.ID 56 | } else if u.InlineQuery != nil { 57 | chatID = u.InlineQuery.From.ID 58 | } 59 | mutexID := fmt.Sprintf("%d_%d", b.ID, chatID) 60 | 61 | updateMapMutex.Lock() 62 | m, exists := updateMutexPerBotPerChat[mutexID] 63 | updateMapMutex.Unlock() 64 | 65 | if exists { 66 | m.Lock() 67 | } else { 68 | m = &sync.Mutex{} 69 | m.Lock() 70 | 71 | updateMapMutex.Lock() 72 | updateMutexPerBotPerChat[mutexID] = m 73 | updateMapMutex.Unlock() 74 | 75 | } 76 | 77 | db := mongoSession.Clone().DB(mongo.Database) 78 | 79 | defer func() { 80 | m.Unlock() 81 | 82 | defer func() { 83 | if r := recover(); r != nil { 84 | fmt.Println(r) 85 | } 86 | }() 87 | 88 | db.Session.Close() 89 | }() 90 | 91 | service, context := tgUpdateHandler(u, b, db) 92 | 93 | if service == nil || context == nil { 94 | return 95 | } 96 | 97 | if context.Message != nil && !context.MessageEdited { 98 | 99 | replyActionProcessed := false 100 | if context.Message.ReplyToMessage != nil { 101 | rm := context.Message.ReplyToMessage 102 | log.Debugf("Received reply for message %d", rm.MsgID) 103 | // TODO: detect service by ReplyHandler 104 | if rm.OnReplyAction != "" { 105 | log.Debugf("ReplyHandler found %s", rm.OnReplyAction) 106 | 107 | if handler, ok := actionFuncs[service.trimFuncPath(rm.OnReplyAction)]; ok { 108 | handlerType := reflect.TypeOf(handler) 109 | log.Debugf("handler %v: %v %v\n", rm.OnReplyAction, handlerType.String(), handlerType.Kind().String()) 110 | handlerArgsInterfaces := make([]interface{}, handlerType.NumIn()-1) 111 | handlerArgs := make([]reflect.Value, handlerType.NumIn()) 112 | 113 | for i := 1; i < handlerType.NumIn(); i++ { 114 | dataVal := reflect.New(handlerType.In(i)) 115 | handlerArgsInterfaces[i-1] = dataVal.Interface() 116 | } 117 | if err := decode(rm.OnReplyData, &handlerArgsInterfaces); err != nil { 118 | log.WithField("handler", rm.OnReplyAction).WithError(err).Error("Can't decode replyHandler's args") 119 | } 120 | handlerArgs[0] = reflect.ValueOf(context) 121 | for i := 0; i < len(handlerArgsInterfaces); i++ { 122 | handlerArgs[i+1] = reflect.ValueOf(handlerArgsInterfaces[i]) 123 | } 124 | 125 | if len(handlerArgs) > 0 { 126 | handlerVal := reflect.ValueOf(handler) 127 | returnVals := handlerVal.Call(handlerArgs) 128 | 129 | if !returnVals[0].IsNil() { 130 | err := returnVals[0].Interface().(error) 131 | // NOTE: panics will be caught by the recover statement above 132 | log.WithField("handler", rm.OnReplyAction).WithError(err).Error("replyHandler failed") 133 | } 134 | 135 | replyActionProcessed = true 136 | } 137 | } else { 138 | log.WithField("handler", rm.OnReplyAction).Error("Reply handler not registered") 139 | } 140 | 141 | } 142 | 143 | } 144 | 145 | if !replyActionProcessed { 146 | if service.TGNewMessageHandler == nil { 147 | context.Log().Warn("Received Message but TGNewMessageHandler not set for service") 148 | return 149 | } 150 | 151 | err := service.TGNewMessageHandler(context) 152 | if err != nil { 153 | context.Log().WithError(err).Error("BotUpdateHandler error") 154 | } 155 | } 156 | 157 | // Save incoming message metadata(text and files are excluded) in case it has onReply/onEdit actions or have associated event 158 | if context.Message.OnEditAction != "" || context.Message.OnReplyAction != "" || len(context.Message.EventID) > 0 { 159 | err := context.Message.Message.saveToDB(db) 160 | if err != nil { 161 | log.WithError(err).Error("can't add incoming message to db") 162 | } 163 | } 164 | 165 | if context.messageAnsweredAt != nil { 166 | context.StatIncChat(StatIncomingMessageAnswered) 167 | } else { 168 | context.StatIncChat(StatIncomingMessageNotAnswered) 169 | } 170 | } else if context.InlineQuery != nil { 171 | if service.TGInlineQueryHandler == nil { 172 | context.Log().Warn("Received InlineQuery but TGInlineQueryHandler not set for service") 173 | return 174 | } 175 | 176 | queryHandlerStarted := time.Now() 177 | err := service.TGInlineQueryHandler(context) 178 | if err != nil { 179 | if strings.Contains(err.Error(), "QUERY_ID_INVALID") { 180 | context.StatIncUser(StatInlineQueryTimeouted) 181 | } else if !strings.Contains(err.Error(), "context canceled") { 182 | context.StatIncUser(StatInlineQueryCanceled) 183 | } 184 | context.Log().WithError(err).WithField("secSpent", time.Now().Sub(queryHandlerStarted).Seconds()).WithField("secSpentSinceUpdate", time.Now().Sub(updateReceivedAt).Seconds()).Error("BotUpdateHandler InlineQuery error") 185 | context.StatIncUser(StatInlineQueryProcessingError) 186 | } else { 187 | if context.inlineQueryAnsweredAt == nil { 188 | context.StatIncUser(StatInlineQueryNotAnswered) 189 | 190 | context.Log().WithError(err).Error("BotUpdateHandler InlineQuery not answered") 191 | } else { 192 | context.StatIncUser(StatInlineQueryAnswered) 193 | 194 | secsSpent := context.inlineQueryAnsweredAt.Sub(updateReceivedAt).Seconds() 195 | if secsSpent > 10 { 196 | context.Log().WithError(err).Errorf("BotUpdateHandler InlineQuery 10 sec exceeded: %.1f sec spent after update, %.1f sec after the handle", secsSpent, time.Now().Sub(queryHandlerStarted).Seconds()) 197 | } 198 | } 199 | } 200 | return 201 | } else if context.ChosenInlineResult != nil { 202 | 203 | if service.TGChosenInlineResultHandler == nil { 204 | context.Log().Warn("Received ChosenInlineResult but TGChosenInlineResultHandler not set for service") 205 | return 206 | } 207 | 208 | err := service.TGChosenInlineResultHandler(context) 209 | context.StatIncUser(StatInlineQueryChosen) 210 | 211 | if err != nil { 212 | context.Log().WithError(err).Error("BotUpdateHandler error") 213 | } 214 | return 215 | } 216 | 217 | // If this message is keyboard answer - remove that keyboard from user/chat 218 | if context.Message.ReplyToMessage != nil && context.Message.ReplyToMessage.om != nil && context.Message.ReplyToMessage.om.OneTimeKeyboard { 219 | key, _ := context.KeyboardAnswer() 220 | 221 | if key != "" { 222 | var err error 223 | // if received reply is result of button pressed 224 | // TODO: fix for non-selective one_time_keyboard in group chat 225 | _, err = db.C("users").UpdateAll(bson.M{}, bson.M{"$pull": bson.M{"keyboardperchat": bson.M{"chatid": context.Chat.ID, "msgid": context.Message.ReplyToMessage.ID}}}) 226 | if err != nil { 227 | log.WithError(err).Debugf("can't remove onetime keyboard from user") 228 | } 229 | 230 | if context.Chat.IsGroup() { 231 | err = db.C("chats").Update(bson.M{"_id": context.Chat.ID, "keyboard.msgid": context.Message.ReplyToMessage.ID}, bson.M{"$unset": bson.M{"keyboard": true}}) 232 | } else { 233 | _, err = db.C("chats").UpdateAll(bson.M{"_id": context.Chat.ID}, bson.M{"$pull": bson.M{"keyboardperbot": bson.M{"botid": context.Message.BotID, "msgid": context.Message.ReplyToMessage.ID}}}) 234 | } 235 | if err != nil { 236 | log.WithError(err).Debugf("can't remove onetime keyboard from chat") 237 | } 238 | } 239 | 240 | } 241 | 242 | } 243 | 244 | func (bot *Bot) listen() { 245 | api := bot.API 246 | if bot.updatesChan == nil { 247 | var err error 248 | bot.updatesChan, err = api.GetUpdatesChan(tg.UpdateConfig{Timeout: randomInRange(10, 20), Limit: 100}) 249 | if err != nil { 250 | log.WithField("bot", bot.ID).WithError(err).Panic("Can't get updatesChan") 251 | } 252 | } 253 | go func(c <-chan tg.Update, b *Bot) { 254 | var context Context 255 | 256 | defer func() { 257 | fmt.Println("Stop listening for updates on bot " + b.Username) 258 | 259 | if r := recover(); r != nil { 260 | stack := stack(3) 261 | context.Log().Errorf("Panic recovery at updatesChan -> %s\n%s\n", r, stack) 262 | tgUpdatesRevoltChan <- b 263 | } 264 | }() 265 | 266 | for { 267 | u := <-c 268 | go updateRoutine(b, &u) 269 | } 270 | 271 | }(bot.updatesChan, bot) 272 | } 273 | 274 | func tgUserPointer(u *tg.User) *User { 275 | if u == nil { 276 | return nil 277 | } 278 | return &User{ID: u.ID, FirstName: u.FirstName, LastName: u.LastName, UserName: u.UserName, Lang: u.LanguageCode} 279 | } 280 | 281 | func tgChatPointer(c *tg.Chat) *Chat { 282 | if c == nil { 283 | return nil 284 | } 285 | return &Chat{ID: c.ID, FirstName: c.FirstName, LastName: c.LastName, UserName: c.UserName, Title: c.Title, Type: c.Type} 286 | } 287 | 288 | func tgUser(u *tg.User) User { 289 | if u == nil { 290 | return User{} 291 | } 292 | return User{ID: u.ID, FirstName: u.FirstName, LastName: u.LastName, UserName: u.UserName, Lang: u.LanguageCode} 293 | } 294 | 295 | func tgChat(chat *tg.Chat) Chat { 296 | if chat == nil { 297 | return Chat{} 298 | } 299 | return Chat{ID: chat.ID, Title: chat.Title, Type: chat.Type, FirstName: chat.FirstName, LastName: chat.LastName, UserName: chat.UserName} 300 | } 301 | 302 | func incomingMessageFromTGMessage(m *tg.Message) IncomingMessage { 303 | im := IncomingMessage{} 304 | 305 | // Base message struct 306 | im.MsgID = m.MessageID 307 | if m.From != nil { 308 | im.FromID = m.From.ID 309 | } 310 | im.InlineMsgID = m.InlineMessageID 311 | 312 | im.ChatID = m.Chat.ID 313 | im.Date = time.Unix(int64(m.Date), 0) 314 | im.Text = m.Text 315 | 316 | if m.From != nil { 317 | im.From = tgUser(m.From) 318 | } 319 | im.Chat = Chat{ID: m.Chat.ID, Type: m.Chat.Type, FirstName: m.Chat.FirstName, LastName: m.Chat.LastName, UserName: m.Chat.UserName, Title: m.Chat.Title} 320 | 321 | im.ForwardFrom = tgUserPointer(m.ForwardFrom) 322 | im.ForwardFromChat = tgChatPointer(m.ForwardFromChat) 323 | im.ForwardDate = time.Unix(int64(m.ForwardDate), 0) 324 | im.ForwardFromMessageID = int64(m.ForwardFromMessageID) 325 | if m.ReplyToMessage != nil { 326 | rm := m.ReplyToMessage 327 | im.ReplyToMessage = &Message{MsgID: rm.MessageID, Date: time.Unix(int64(rm.Date), 0), Text: rm.Text} 328 | if rm.Chat != nil { 329 | im.ReplyToMessage.ChatID = rm.Chat.ID 330 | } 331 | 332 | if rm.From != nil { 333 | im.ReplyToMessage.FromID = rm.From.ID 334 | } 335 | } 336 | 337 | im.Caption = m.Caption 338 | 339 | if m.NewChatMembers != nil { 340 | for _, newMember := range *m.NewChatMembers { 341 | n := newMember 342 | im.NewChatMembers = append(im.NewChatMembers, tgUserPointer(&n)) 343 | } 344 | } 345 | im.LeftChatMember = tgUserPointer(m.LeftChatMember) 346 | 347 | im.Audio = m.Audio 348 | im.Document = m.Document 349 | im.Photo = m.Photo 350 | im.Sticker = m.Sticker 351 | im.Video = m.Video 352 | im.Voice = m.Voice 353 | im.Contact = m.Contact 354 | im.Location = m.Location 355 | im.NewChatTitle = m.NewChatTitle 356 | im.NewChatPhoto = m.NewChatPhoto 357 | im.DeleteChatPhoto = m.DeleteChatPhoto 358 | im.GroupChatCreated = m.GroupChatCreated 359 | return im 360 | 361 | } 362 | func tgCallbackHandler(u *tg.Update, b *Bot, db *mgo.Database) (*Service, *Context) { 363 | var rm *Message 364 | var err error 365 | if u.CallbackQuery.Message != nil { 366 | rm, err = findMessage(db, u.CallbackQuery.Message.Chat.ID, b.ID, u.CallbackQuery.Message.MessageID) 367 | if err != nil { 368 | log.WithError(err).WithField("bot_id", b.ID).WithField("msg_id", u.CallbackQuery.Message.MessageID).Error("tgCallbackHandler can't find source message") 369 | } 370 | } else { 371 | rm, err = findInlineMessage(db, b.ID, u.CallbackQuery.InlineMessageID) 372 | if err != nil { 373 | log.WithError(err).WithField("bot_id", b.ID).WithField("msg_id", u.CallbackQuery.InlineMessageID).Error("tgCallbackHandler can't find source message") 374 | } 375 | } 376 | 377 | if rm != nil { 378 | service, err := detectServiceByBot(b.ID) 379 | 380 | if err != nil { 381 | log.WithError(err).WithField("bot", b.ID).Error("Can't detect service") 382 | } 383 | cbData := u.CallbackQuery.Data 384 | cbState := 0 385 | if []byte(cbData)[0] == inlineButtonStateKeyword { 386 | log.Debug("INLINE_BUTTON_STATE_KEYWORD found") 387 | cbState, err = strconv.Atoi(cbData[1:2]) 388 | cbData = cbData[2:] 389 | if err != nil { 390 | log.WithError(err).Errorf("INLINE_BUTTON_STATE_KEYWORD found but next symbol is %s", cbData[1:2]) 391 | } 392 | } 393 | ctx := &Context{ 394 | db: db, 395 | ServiceName: service.Name, 396 | User: tgUser(u.CallbackQuery.From), 397 | Callback: &callback{ID: u.CallbackQuery.ID, Data: cbData, Message: rm.om, State: cbState}} 398 | var chat Chat 399 | if u.CallbackQuery.InlineMessageID != "" && rm.ChatID != 0 { 400 | chatData, err := ctx.FindChat(bson.M{"_id": rm.ChatID}) 401 | if err != nil { 402 | ctx.Log().WithError(err).WithField("chatID", rm.ChatID).Error("find chat for inline msg' callback") 403 | chat = Chat{ID: rm.ChatID} 404 | } else { 405 | chat = chatData.Chat 406 | } 407 | 408 | } else if u.CallbackQuery.Message != nil { 409 | chat = tgChat(u.CallbackQuery.Message.Chat) 410 | } else { 411 | chat = tgChat(&tg.Chat{ID: u.CallbackQuery.From.ID, LastName: u.CallbackQuery.From.LastName, UserName: u.CallbackQuery.From.UserName, Type: "private", FirstName: u.CallbackQuery.From.FirstName}) 412 | } 413 | ctx.Chat = chat 414 | ctx.User.ctx = ctx 415 | ctx.Chat.ctx = ctx 416 | 417 | if rm.OnCallbackAction != "" { 418 | log.Debugf("CallbackAction found %s", rm.OnCallbackAction) 419 | // Instantiate a new variable to hold this argument 420 | if handler, ok := actionFuncs[service.trimFuncPath(rm.OnCallbackAction)]; ok { 421 | handlerType := reflect.TypeOf(handler) 422 | log.Debugf("handler %v: %v %v\n", rm.OnCallbackAction, handlerType.String(), handlerType.Kind().String()) 423 | handlerArgsInterfaces := make([]interface{}, handlerType.NumIn()-1) 424 | handlerArgs := make([]reflect.Value, handlerType.NumIn()) 425 | 426 | for i := 1; i < handlerType.NumIn(); i++ { 427 | dataVal := reflect.New(handlerType.In(i)) 428 | handlerArgsInterfaces[i-1] = dataVal.Interface() 429 | } 430 | if err := decode(rm.OnCallbackData, &handlerArgsInterfaces); err != nil { 431 | ctx.Log().WithField("handler", rm.OnCallbackAction).WithError(err).Error("Can't decode replyHandler's args") 432 | } 433 | handlerArgs[0] = reflect.ValueOf(ctx) 434 | for i := 0; i < len(handlerArgsInterfaces); i++ { 435 | handlerArgs[i+1] = reflect.ValueOf(handlerArgsInterfaces[i]) 436 | } 437 | 438 | if len(handlerArgs) > 0 { 439 | handlerVal := reflect.ValueOf(handler) 440 | returnVals := handlerVal.Call(handlerArgs) 441 | 442 | if !returnVals[0].IsNil() { 443 | err := returnVals[0].Interface().(error) 444 | // NOTE: panics will be caught by the recover statement above 445 | ctx.Log().WithField("handler", rm.OnCallbackAction).WithError(err).Error("callbackAction failed") 446 | ctx.AnswerCallbackQuery("Oops! Please try again", false) 447 | } else { 448 | if ctx.Callback.AnsweredAt == nil { 449 | ctx.AnswerCallbackQuery("", false) 450 | } 451 | } 452 | } 453 | } else { 454 | ctx.Log().WithField("handler", rm.OnCallbackAction).Error("Callback handler not registered") 455 | } 456 | 457 | } 458 | return nil, ctx 459 | } 460 | 461 | return nil, nil 462 | 463 | } 464 | func tgInlineQueryHandler(u *tg.Update, b *Bot, db *mgo.Database) (*Service, *Context) { 465 | service, err := detectServiceByBot(b.ID) 466 | if err != nil { 467 | log.WithError(err).WithField("bot", b.ID).Error("Can't detect service") 468 | } 469 | user := tgUser(u.InlineQuery.From) 470 | ctx := &Context{ServiceName: service.Name, User: user, db: db, InlineQuery: u.InlineQuery} 471 | ctx.User.ctx = ctx 472 | 473 | return service, ctx 474 | } 475 | func tgChosenInlineResultHandler(u *tg.Update, b *Bot, db *mgo.Database) (*Service, *Context) { 476 | 477 | service, err := detectServiceByBot(b.ID) 478 | if err != nil { 479 | log.WithError(err).WithField("bot", b.ID).Error("Can't detect service") 480 | } 481 | 482 | user := tgUser(u.ChosenInlineResult.From) 483 | ctx := &Context{ServiceName: service.Name, User: user, db: db, ChosenInlineResult: &chosenInlineResult{ChosenInlineResult: *u.ChosenInlineResult}} 484 | ctx.User.ctx = ctx 485 | if u.Message != nil { 486 | // in case we corellated chosen update and chat message 487 | ctx.Chat = tgChat(u.Message.Chat) 488 | } 489 | ctx.Chat.ctx = ctx 490 | 491 | /*chatID:=0 492 | for _,hook:=range ctx.User.data.Hooks{ 493 | if SliceContainsString(hook.Services,service.Name) { 494 | for _, chat := range hook.Chats { 495 | 496 | } 497 | } 498 | }*/ 499 | log.Debug("InlineMessageID: ", u.ChosenInlineResult.InlineMessageID) 500 | msg := OutgoingMessage{ 501 | ParseMode: "HTML", 502 | WebPreview: true, 503 | Message: Message{ 504 | ID: bson.NewObjectId(), 505 | InlineMsgID: u.ChosenInlineResult.InlineMessageID, 506 | Text: u.ChosenInlineResult.Query, // Todo: thats a lie. The actual message content is known while producing inline results 507 | FromID: u.ChosenInlineResult.From.ID, 508 | BotID: b.ID, 509 | ChatID: ctx.Chat.ID, 510 | Date: time.Now(), 511 | }} 512 | 513 | if u.Message != nil { 514 | msg.MsgID = u.Message.MessageID 515 | } 516 | // we need to save this message! 517 | err = db.C("messages").Insert(&msg) 518 | 519 | if err != nil { 520 | ctx.Log().WithError(err).Error("tgChosenInlineResultHandler: msg insert") 521 | } 522 | ctx.ChosenInlineResult.Message = &msg 523 | ctx.Log().WithField("message", &msg).Debug("tgChosenInlineResultHandler") 524 | return service, ctx 525 | } 526 | 527 | func tgEditedMessageHandler(u *tg.Update, b *Bot, db *mgo.Database) (*Service, *Context) { 528 | im := incomingMessageFromTGMessage(u.EditedMessage) 529 | im.BotID = b.ID 530 | service, err := detectServiceByBot(b.ID) 531 | 532 | if err != nil { 533 | log.WithError(err).WithField("bot", b.ID).Error("Can't detect service") 534 | } 535 | ctx := &Context{ServiceName: service.Name, Chat: im.Chat, db: db} 536 | if im.From.ID != 0 { 537 | ctx.User = im.From 538 | ctx.User.ctx = ctx 539 | } 540 | ctx.Chat.ctx = ctx 541 | 542 | ctx.Message = &im 543 | ctx.MessageEdited = true 544 | rm, _ := findMessage(db, im.ChatID, b.ID, im.MsgID) 545 | if rm != nil { 546 | log.Debugf("Received edit for message %d", rm.MsgID) 547 | 548 | if rm.OnEditAction != "" { 549 | log.Debugf("onEditHandler found %s", rm.OnEditAction) 550 | // Instantiate a new variable to hold this argument 551 | if handler, ok := actionFuncs[service.trimFuncPath(rm.OnEditAction)]; ok { 552 | handlerType := reflect.TypeOf(handler) 553 | log.Debugf("handler %v: %v %v\n", rm.OnEditAction, handlerType.String(), handlerType.Kind().String()) 554 | handlerArgsInterfaces := make([]interface{}, handlerType.NumIn()-1) 555 | handlerArgs := make([]reflect.Value, handlerType.NumIn()) 556 | 557 | for i := 1; i < handlerType.NumIn(); i++ { 558 | dataVal := reflect.New(handlerType.In(i)) 559 | handlerArgsInterfaces[i-1] = dataVal.Interface() 560 | } 561 | if err := decode(rm.OnEditData, &handlerArgsInterfaces); err != nil { 562 | log.WithField("handler", rm.OnEditAction).WithError(err).Error("Can't decode editHandler's args") 563 | } 564 | handlerArgs[0] = reflect.ValueOf(ctx) 565 | for i := 0; i < len(handlerArgsInterfaces); i++ { 566 | handlerArgs[i+1] = reflect.ValueOf(handlerArgsInterfaces[i]) 567 | } 568 | 569 | if len(handlerArgs) > 0 { 570 | handlerVal := reflect.ValueOf(handler) 571 | returnVals := handlerVal.Call(handlerArgs) 572 | 573 | if !returnVals[0].IsNil() { 574 | err := returnVals[0].Interface().(error) 575 | // NOTE: panics will be caught by the recover statement above 576 | log.WithField("handler", rm.OnEditAction).WithError(err).Error("editHandler failed") 577 | } 578 | } 579 | } else { 580 | log.WithField("handler", rm.OnEditAction).Error("Edit handler not registered") 581 | } 582 | 583 | } 584 | 585 | } 586 | 587 | return service, ctx 588 | } 589 | 590 | func tgIncomingMessageHandler(u *tg.Update, b *Bot, db *mgo.Database) (*Service, *Context) { 591 | im := incomingMessageFromTGMessage(u.Message) 592 | im.BotID = b.ID 593 | 594 | service, err := detectServiceByBot(b.ID) 595 | 596 | if err != nil { 597 | log.WithError(err).WithField("bot", b.ID).Error("Can't detect service") 598 | } 599 | ctx := &Context{ServiceName: service.Name, Chat: im.Chat, db: db} 600 | if im.From.ID != 0 { 601 | ctx.User = im.From 602 | ctx.User.ctx = ctx 603 | } 604 | ctx.Chat.ctx = ctx 605 | 606 | var rm *Message 607 | if im.ReplyToMessage != nil && im.ReplyToMessage.MsgID != 0 { 608 | rm, _ = findMessage(db, im.ReplyToMessage.ChatID, b.ID, im.ReplyToMessage.MsgID) 609 | im.ReplyToMessage = rm 610 | if rm != nil { 611 | im.ReplyToMessage.BotID = b.ID 612 | im.ReplyToMsgID = im.ReplyToMessage.MsgID 613 | } 614 | 615 | } else if im.Chat.IsPrivate() { 616 | // For private chat all messages is reply for the last message. We need to parse message for /commands first 617 | cmd, _ := im.GetCommand() 618 | if cmd == "" { 619 | // If there is active keyboard – received message is reply for the source message 620 | kb, _ := ctx.keyboard() 621 | if kb.MsgID > 0 { 622 | rm, err = findMessage(db, im.Chat.ID, b.ID, kb.MsgID) 623 | if rm == nil { 624 | ctx.Log().WithError(err).WithField("msgid", kb.MsgID).WithField("botid", b.ID).Error("Keyboard message source not found") 625 | } 626 | } 627 | 628 | if rm == nil { 629 | rm, err = findLastMessageInChat(db, b.ID, im.ChatID) 630 | 631 | if err != nil && err.Error() != "not found" { 632 | ctx.Log().WithError(err).Error("Error on findLastOutgoingMessageInChat") 633 | } else if rm != nil { 634 | // assume user's message as the reply for the last message sent by the bot if bot's message has Reply handler and ForceReply set 635 | if rm.FromID != rm.BotID || !rm.om.ForceReply || rm.om.OnReplyAction == "" { 636 | rm = nil 637 | } 638 | } 639 | } 640 | 641 | // Leave ReplyToMessage empty to avoid unnecessary db request in case we don't need prev message. 642 | im.ReplyToMessage = rm 643 | if rm != nil { 644 | im.ReplyToMessage.BotID = b.ID 645 | im.ReplyToMsgID = im.ReplyToMessage.MsgID 646 | } 647 | } 648 | } 649 | 650 | ctx.Message = &im 651 | 652 | // unset botstoppedorkickedat in case it was previosly set 653 | // E.g. bot was stopped by user and now restarted 654 | // Or it was kicked from a group chat and now it is invited again 655 | if ctx != nil && ctx.Message != nil { 656 | key := "protected." + ctx.ServiceName + ".botstoppedorkickedat" 657 | db.C("chats").Update(bson.M{"_id": ctx.Chat.ID, key: bson.M{"$exists": true}}, bson.M{"$unset": bson.M{key: ""}}) 658 | } 659 | 660 | return service, ctx 661 | 662 | } 663 | 664 | func removeHooksForChat(db *mgo.Database, serviceName string, chatID int64) { 665 | err := db.C("users").Update(bson.M{"hooks.services": []string{serviceName}, "hooks.chats": chatID}, bson.M{"$pull": bson.M{"hooks.$.chats": chatID}}) 666 | if err != nil { 667 | if err != nil { 668 | log.WithError(err).Error("removeHooksForChat remove outdated hook chats") 669 | } 670 | } 671 | } 672 | 673 | func migrateToSuperGroup(db *mgo.Database, fromChatID int64, toChatID int64) { 674 | if fromChatID == toChatID { 675 | return 676 | } 677 | 678 | var chat chatData 679 | _, err := db.C("chats").FindId(fromChatID).Apply(mgo.Change{ 680 | Update: bson.M{"$set": bson.M{"migratedtochatid": toChatID}, "$unset": bson.M{"hooks": "", "membersids": ""}}, 681 | }, &chat) 682 | if err != nil { 683 | log.WithError(err).Error("migrateToSuperGroup remove") 684 | } 685 | 686 | if chat.ID != 0 { 687 | chat.ID = toChatID 688 | chat.Type = "supergroup" 689 | chat.MigratedFromChatID = toChatID 690 | 691 | _, err := db.C("chats").Upsert(bson.M{"_id": toChatID, "migratedfromchatid": bson.M{"$exists": false}}, chat) 692 | 693 | if err != nil { 694 | log.WithError(err).Error("migrateToSuperGroup ID insert error") 695 | } 696 | } 697 | 698 | err = db.C("users").Update(bson.M{"hooks.chats": fromChatID}, bson.M{"$addToSet": bson.M{"hooks.$.chats": toChatID}}) 699 | if err != nil { 700 | log.WithError(err).Error("migrateToSuperGroup add new hook chats") 701 | } 702 | err = db.C("users").Update(bson.M{"hooks.chats": toChatID}, bson.M{"$pull": bson.M{"hooks.$.chats": fromChatID}}) 703 | if err != nil { 704 | log.WithError(err).Error("migrateToSuperGroup remove outdated hook chats") 705 | } 706 | } 707 | func tgUpdateHandler(u *tg.Update, b *Bot, db *mgo.Database) (*Service, *Context) { 708 | 709 | if u.Message != nil && u.ChosenInlineResult == nil { 710 | if u.Message.LeftChatMember != nil { 711 | db.C("chats").UpdateId(u.Message.Chat.ID, bson.M{"$pull": bson.M{"membersids": u.Message.From.ID}}) 712 | return nil, nil 713 | } else if u.Message.MigrateToChatID != 0 { 714 | log.Infof("Group %v migrated to supergroup %v", u.Message.Chat.ID, u.Message.MigrateToChatID) 715 | migrateToSuperGroup(db, u.Message.Chat.ID, u.Message.MigrateToChatID) 716 | return nil, nil 717 | } 718 | 719 | return tgIncomingMessageHandler(u, b, db) 720 | } else if u.CallbackQuery != nil { 721 | if u.CallbackQuery.Message != nil && (u.CallbackQuery.Message.Chat.IsGroup() || u.CallbackQuery.Message.Chat.IsSuperGroup()) { 722 | db.C("chats").UpdateId(u.CallbackQuery.Message.Chat.ID, bson.M{"$addToSet": bson.M{"membersids": u.CallbackQuery.From.ID}}) 723 | } 724 | return tgCallbackHandler(u, b, db) 725 | } else if u.InlineQuery != nil { 726 | 727 | return tgInlineQueryHandler(u, b, db) 728 | } else if u.ChosenInlineResult != nil { 729 | 730 | return tgChosenInlineResultHandler(u, b, db) 731 | } else if u.EditedMessage != nil { 732 | 733 | return tgEditedMessageHandler(u, b, db) 734 | } else if u.ChannelPost != nil { 735 | u.Message = u.ChannelPost 736 | return tgIncomingMessageHandler(u, b, db) 737 | } else if u.EditedChannelPost != nil { 738 | u.EditedMessage = u.EditedChannelPost 739 | return tgEditedMessageHandler(u, b, db) 740 | } 741 | 742 | return nil, nil 743 | 744 | } 745 | 746 | /*func (im *IncomingMessage) ButtonAnswer(db *mgo.Database) (key string, text string) { 747 | if im.Message.ReplyToMsgID == 0 { 748 | return 749 | } 750 | 751 | om, err := im.ReplyToMessage.FindOutgoingMessage(db) 752 | if err != nil || om == nil { 753 | return 754 | } 755 | if val, ok := om.Buttons[im.Text]; ok { 756 | return val, im.Text 757 | } 758 | return 759 | }*/ 760 | 761 | // saveToDB stores incoming message metadata to the database 762 | func (m *Message) saveToDB(db *mgo.Database) error { 763 | // text is excluded, instead saving textHash 764 | m.TextHash = m.GetTextHash() 765 | return db.C("messages").Insert(m) 766 | } 767 | 768 | // IsEventBotAddedToGroup returns true if user created a new group with bot as member or add the bot to existing group 769 | func (m *IncomingMessage) IsEventBotAddedToGroup() bool { 770 | if (len(m.NewChatMembers) > 0 && m.NewChatMembers[0].ID == m.BotID) || m.GroupChatCreated || m.SuperGroupChatCreated { 771 | return true 772 | } 773 | return false 774 | } 775 | 776 | // GetCommand parses received message text for bot command. Returns the command and after command text if presented 777 | func (m *IncomingMessage) GetCommand() (string, string) { 778 | text := m.Text 779 | 780 | if !strings.HasPrefix(text, "/") { 781 | return "", text 782 | } 783 | r, _ := regexp.Compile("^/([a-zA-Z0-9_]+)(?:@[a-zA-Z0-9_]+)?.?(.*)?$") 784 | match := r.FindStringSubmatch(text) 785 | if len(match) == 3 { 786 | return match[1], match[2] 787 | } else if len(match) == 2 { 788 | return match[1], "" 789 | } 790 | return "", "" 791 | 792 | } 793 | 794 | func detectServiceByBot(botID int64) (*Service, error) { 795 | serviceName := "" 796 | if botID > 0 { 797 | if bot := botByID(botID); bot != nil { 798 | if len(bot.services) == 1 { 799 | serviceName = bot.services[0].Name 800 | } 801 | } 802 | } 803 | if serviceName == "" { 804 | return &Service{}, fmt.Errorf("Can't determine active service for bot with ID=%d. No messages found.", botID) 805 | } 806 | if val, ok := services[serviceName]; ok { 807 | return val, nil 808 | } 809 | return &Service{}, errors.New("Unknown service: " + serviceName) 810 | 811 | } 812 | func (m *Message) detectService(db *mgo.Database) (*Service, error) { 813 | serviceName := "" 814 | 815 | if m.BotID > 0 { 816 | if bot := botByID(m.BotID); bot != nil { 817 | if len(bot.services) == 1 { 818 | serviceName = bot.services[0].Name 819 | } 820 | } 821 | } 822 | 823 | if serviceName == "" { 824 | return &Service{}, fmt.Errorf("Can't determine active service for bot with ID=%d. No messages found.", m.BotID) 825 | } 826 | if val, ok := services[serviceName]; ok { 827 | return val, nil 828 | } 829 | return &Service{}, errors.New("Unknown service: " + serviceName) 830 | } 831 | -------------------------------------------------------------------------------- /tgupdates_test.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | tg "github.com/requilence/telegram-bot-api" 8 | ) 9 | 10 | func TestIncomingMessage_IsEventBotAddedToGroup(t *testing.T) { 11 | type fields struct { 12 | Message Message 13 | From User 14 | Chat Chat 15 | ForwardFrom *User 16 | ForwardDate time.Time 17 | ReplyToMessage *Message 18 | ForwardFromChat *Chat 19 | EditDate int 20 | Entities *[]tg.MessageEntity 21 | Audio *tg.Audio 22 | Document *tg.Document 23 | Photo *[]tg.PhotoSize 24 | Sticker *tg.Sticker 25 | Video *tg.Video 26 | Voice *tg.Voice 27 | Caption string 28 | Contact *tg.Contact 29 | Location *tg.Location 30 | Venue *tg.Venue 31 | NewChatMember []*User 32 | LeftChatMember *User 33 | NewChatTitle string 34 | NewChatPhoto *[]tg.PhotoSize 35 | DeleteChatPhoto bool 36 | GroupChatCreated bool 37 | SuperGroupChatCreated bool 38 | ChannelChatCreated bool 39 | MigrateToChatID int64 40 | MigrateFromChatID int64 41 | PinnedMessage *Message 42 | needToUpdateDB bool 43 | } 44 | tests := []struct { 45 | name string 46 | fields fields 47 | want bool 48 | }{ 49 | {"NewChatMember with user equal to bot", fields{Message: Message{BotID: 12345}, NewChatMember: []*User{&User{ID: 12345}}}, true}, 50 | {"NewChatMember with user not equal to bot", fields{Message: Message{BotID: 123456}, NewChatMember: []*User{&User{ID: 12345}}}, false}, 51 | {"GroupChatCreated with user not equal to bot", fields{Message: Message{BotID: 123456}, GroupChatCreated: true}, true}, 52 | {"NewChatMember with user not equal to bot", fields{Message: Message{BotID: 123456}, SuperGroupChatCreated: true}, true}, 53 | } 54 | for _, tt := range tests { 55 | m := &IncomingMessage{ 56 | Message: tt.fields.Message, 57 | From: tt.fields.From, 58 | Chat: tt.fields.Chat, 59 | ForwardFrom: tt.fields.ForwardFrom, 60 | ForwardDate: tt.fields.ForwardDate, 61 | ReplyToMessage: tt.fields.ReplyToMessage, 62 | ForwardFromChat: tt.fields.ForwardFromChat, 63 | EditDate: tt.fields.EditDate, 64 | Entities: tt.fields.Entities, 65 | Audio: tt.fields.Audio, 66 | Document: tt.fields.Document, 67 | Photo: tt.fields.Photo, 68 | Sticker: tt.fields.Sticker, 69 | Video: tt.fields.Video, 70 | Voice: tt.fields.Voice, 71 | Caption: tt.fields.Caption, 72 | Contact: tt.fields.Contact, 73 | Location: tt.fields.Location, 74 | Venue: tt.fields.Venue, 75 | NewChatMembers: tt.fields.NewChatMember, 76 | LeftChatMember: tt.fields.LeftChatMember, 77 | NewChatTitle: tt.fields.NewChatTitle, 78 | NewChatPhoto: tt.fields.NewChatPhoto, 79 | DeleteChatPhoto: tt.fields.DeleteChatPhoto, 80 | GroupChatCreated: tt.fields.GroupChatCreated, 81 | SuperGroupChatCreated: tt.fields.SuperGroupChatCreated, 82 | ChannelChatCreated: tt.fields.ChannelChatCreated, 83 | MigrateToChatID: tt.fields.MigrateToChatID, 84 | MigrateFromChatID: tt.fields.MigrateFromChatID, 85 | PinnedMessage: tt.fields.PinnedMessage, 86 | needToUpdateDB: tt.fields.needToUpdateDB, 87 | } 88 | if got := m.IsEventBotAddedToGroup(); got != tt.want { 89 | t.Errorf("%q. IncomingMessage.IsEventBotAddedToGroup() = %v, want %v", tt.name, got, tt.want) 90 | } 91 | } 92 | } 93 | 94 | func TestIncomingMessage_GetCommand(t *testing.T) { 95 | type fields struct { 96 | Message Message 97 | From User 98 | Chat Chat 99 | ForwardFrom *User 100 | ForwardDate time.Time 101 | ReplyToMessage *Message 102 | ForwardFromChat *Chat 103 | EditDate int 104 | Entities *[]tg.MessageEntity 105 | Audio *tg.Audio 106 | Document *tg.Document 107 | Photo *[]tg.PhotoSize 108 | Sticker *tg.Sticker 109 | Video *tg.Video 110 | Voice *tg.Voice 111 | Caption string 112 | Contact *tg.Contact 113 | Location *tg.Location 114 | Venue *tg.Venue 115 | NewChatMembers []*User 116 | LeftChatMember *User 117 | NewChatTitle string 118 | NewChatPhoto *[]tg.PhotoSize 119 | DeleteChatPhoto bool 120 | GroupChatCreated bool 121 | SuperGroupChatCreated bool 122 | ChannelChatCreated bool 123 | MigrateToChatID int64 124 | MigrateFromChatID int64 125 | PinnedMessage *Message 126 | needToUpdateDB bool 127 | } 128 | tests := []struct { 129 | name string 130 | fields fields 131 | want string 132 | want1 string 133 | }{ 134 | {"just command", fields{Message: Message{Text: "/command123"}}, "command123", ""}, 135 | {"command with param", fields{Message: Message{Text: "/command123 param"}}, "command123", "param"}, 136 | {"command with bot name", fields{Message: Message{Text: "/command123@bot"}}, "command123", ""}, 137 | {"command with bot name and param", fields{Message: Message{Text: "/command123@bot param"}}, "command123", "param"}, 138 | } 139 | for _, tt := range tests { 140 | m := &IncomingMessage{ 141 | Message: tt.fields.Message, 142 | From: tt.fields.From, 143 | Chat: tt.fields.Chat, 144 | ForwardFrom: tt.fields.ForwardFrom, 145 | ForwardDate: tt.fields.ForwardDate, 146 | ReplyToMessage: tt.fields.ReplyToMessage, 147 | ForwardFromChat: tt.fields.ForwardFromChat, 148 | EditDate: tt.fields.EditDate, 149 | Entities: tt.fields.Entities, 150 | Audio: tt.fields.Audio, 151 | Document: tt.fields.Document, 152 | Photo: tt.fields.Photo, 153 | Sticker: tt.fields.Sticker, 154 | Video: tt.fields.Video, 155 | Voice: tt.fields.Voice, 156 | Caption: tt.fields.Caption, 157 | Contact: tt.fields.Contact, 158 | Location: tt.fields.Location, 159 | Venue: tt.fields.Venue, 160 | NewChatMembers: tt.fields.NewChatMembers, 161 | LeftChatMember: tt.fields.LeftChatMember, 162 | NewChatTitle: tt.fields.NewChatTitle, 163 | NewChatPhoto: tt.fields.NewChatPhoto, 164 | DeleteChatPhoto: tt.fields.DeleteChatPhoto, 165 | GroupChatCreated: tt.fields.GroupChatCreated, 166 | SuperGroupChatCreated: tt.fields.SuperGroupChatCreated, 167 | ChannelChatCreated: tt.fields.ChannelChatCreated, 168 | MigrateToChatID: tt.fields.MigrateToChatID, 169 | MigrateFromChatID: tt.fields.MigrateFromChatID, 170 | PinnedMessage: tt.fields.PinnedMessage, 171 | needToUpdateDB: tt.fields.needToUpdateDB, 172 | } 173 | got, got1 := m.GetCommand() 174 | if got != tt.want { 175 | t.Errorf("%q. IncomingMessage.GetCommand() got = %v, want %v", tt.name, got, tt.want) 176 | } 177 | if got1 != tt.want1 { 178 | t.Errorf("%q. IncomingMessage.GetCommand() got1 = %v, want %v", tt.name, got1, tt.want1) 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package integram 2 | 3 | import ( 4 | "time" 5 | 6 | "crypto/md5" 7 | "encoding/base64" 8 | "github.com/mrjones/oauth" 9 | "github.com/requilence/url" 10 | log "github.com/sirupsen/logrus" 11 | "gopkg.in/mgo.v2/bson" 12 | ) 13 | 14 | // User information initiated from TG 15 | type User struct { 16 | ID int64 `bson:"_id"` 17 | FirstName string 18 | LastName string `bson:",omitempty"` 19 | UserName string `bson:",omitempty"` 20 | Tz string 21 | Lang string 22 | 23 | ctx *Context // provide pointer to Context for convenient nesting and DB quering 24 | data *userData 25 | } 26 | 27 | // Chat information initiated from TG 28 | type Chat struct { 29 | ID int64 `bson:"_id"` 30 | Type string `bson:",omitempty"` 31 | FirstName string 32 | LastName string `bson:",omitempty"` 33 | UserName string `bson:",omitempty"` 34 | Title string `bson:",omitempty"` 35 | Tz string `bson:",omitempty"` 36 | 37 | ctx *Context // provide pointer to Context for convenient nesting and DB quering 38 | data *chatData 39 | } 40 | 41 | // OAuthProvider contains OAuth application info 42 | type OAuthProvider struct { 43 | Service string // Service name 44 | BaseURL url.URL `envconfig:"OAUTH_BASEURL"` // Scheme + Host. Default https://{service.DefaultHost} 45 | ID string `envconfig:"OAUTH_ID" required:"true"` // OAuth ID 46 | Secret string `envconfig:"OAUTH_SECRET" required:"true"` // OAuth Secret 47 | } 48 | 49 | // workaround to save urls as struct 50 | func (o *OAuthProvider) toBson() bson.M { 51 | return bson.M{"baseurl": struct { 52 | Scheme string 53 | Host string 54 | Path string 55 | }{o.BaseURL.Scheme, o.BaseURL.Host, o.BaseURL.Path}, 56 | "id": o.ID, 57 | "secret": o.Secret, 58 | "service": o.Service} 59 | 60 | } 61 | 62 | func (o *OAuthProvider) internalID() string { 63 | if o == nil { 64 | log.Errorf("OAuthProvider is empty") 65 | } 66 | s, _ := serviceByName(o.Service) 67 | 68 | if s != nil && o.BaseURL.Host == s.DefaultBaseURL.Host { 69 | return s.Name 70 | } 71 | 72 | return checksumString(s.Name + o.BaseURL.Host) 73 | } 74 | 75 | // RedirectURL returns impersonal Redirect URL, useful when setting up the OAuth Client 76 | func (o *OAuthProvider) RedirectURL() string { 77 | return Config.BaseURL + "/auth/" + o.internalID() 78 | 79 | } 80 | 81 | // IsSetup returns true if OAuth provider(app,client) has ID and Secret. Should be always true for service's default provider 82 | func (o *OAuthProvider) IsSetup() bool { 83 | if o == nil { 84 | return false 85 | } 86 | 87 | return o.ID != "" && o.Secret != "" 88 | } 89 | 90 | type oAuthIDCache struct { 91 | UserID int 92 | Service string 93 | Val oAuthIDCacheVal 94 | } 95 | type oAuthIDCacheVal struct { 96 | oauth.RequestToken `bson:",omitempty"` 97 | BaseURL string 98 | } 99 | 100 | // Webhook token for service 101 | type serviceHook struct { 102 | Token string 103 | Services []string // For backward compatibility with universal hook 104 | Chats []int64 `bson:",omitempty"` // Chats that will receive notifications on this hook 105 | } 106 | 107 | // Struct for user's data. Used to store in MongoDB 108 | type userData struct { 109 | User `bson:",inline"` 110 | KeyboardPerChat []chatKeyboard // stored map for Telegram Bot's keyboard 111 | Protected map[string]*userProtected // Protected settings used for some core functional 112 | Settings map[string]interface{} 113 | Hooks []serviceHook 114 | } 115 | 116 | // Core settings for Telegram User behavior per Service 117 | type userProtected struct { 118 | OAuthToken string // Oauth token. Used to perform API queries 119 | OAuthExpireDate *time.Time 120 | OAuthRefreshToken string 121 | AuthTempToken string // Temp token for redirect to time-limited Oauth URL to authorize the user (F.e. Trello) 122 | OAuthValid bool // used for stat purposes 123 | OAuthStore string // to detect whether non-standard store used 124 | 125 | AfterAuthHandler string // Used to store function that will be called after successful auth. F.e. in case of interactive reply in chat for non-authed user 126 | AfterAuthData []byte // Gob encoded arg's 127 | } 128 | 129 | // Core settings for Telegram Chat behavior per Service 130 | type chatProtected struct { 131 | BotStoppedOrKickedAt *time.Time `bson:",omitempty"` // when we informed that bot was stopped by user 132 | } 133 | 134 | // Struct for chat's data. Used to store in MongoDB 135 | type chatData struct { 136 | Chat `bson:",inline"` 137 | KeyboardPerBot []chatKeyboard `bson:",omitempty"` 138 | Settings map[string]interface{} 139 | Protected map[string]*chatProtected 140 | 141 | Hooks []serviceHook 142 | MembersIDs []int64 143 | Deactivated bool `bson:",omitempty"` 144 | IgnoreRateLimit bool `bson:",omitempty"` 145 | MigratedToChatID int64 `bson:",omitempty"` 146 | MigratedFromChatID int64 `bson:",omitempty"` 147 | } 148 | 149 | type chatKeyboard struct { 150 | MsgID int // ID of message sent with this keyboard 151 | ChatID int64 `bson:",minsize"` // ID of chat where this keyboard shown 152 | BotID int64 `bson:",minsize"` // ID of bot who sent this keyboard 153 | Date time.Time // Date when keyboard was sent 154 | Keyboard map[string]string // Keyboard's md5(text):key map 155 | } 156 | 157 | // Struct used to store WebPreview redirection trick in MongoDB 158 | type webPreview struct { 159 | Title string 160 | Headline string 161 | Text string 162 | URL string 163 | ImageURL string 164 | Token string `bson:"_id"` 165 | Hash string 166 | Redirects int 167 | Created time.Time 168 | } 169 | 170 | func (wp *webPreview) CalculateHash() string { 171 | md5Hash := md5.Sum([]byte(wp.Title + wp.Headline + wp.Text + wp.URL + wp.ImageURL)) 172 | 173 | return base64.URLEncoding.EncodeToString(md5Hash[:]) 174 | } 175 | 176 | // Mention returns @username if available. First + Last name otherwise 177 | func (u *User) Mention() string { 178 | 179 | if u.UserName != "" { 180 | return "@" + u.UserName 181 | } 182 | 183 | name := u.FirstName 184 | if u.LastName != "" { 185 | name += " " + u.LastName 186 | } 187 | 188 | return name 189 | } 190 | 191 | // Method to implement String interface 192 | func (u *User) String() string { 193 | if u.UserName != "" { 194 | return u.UserName 195 | } 196 | 197 | name := u.FirstName 198 | if u.LastName != "" { 199 | name += " " + u.LastName 200 | } 201 | 202 | return name 203 | } 204 | 205 | // TzLocation retrieve User's timezone if stored in DB 206 | func (u *User) TzLocation() *time.Location { 207 | return tzLocation(u.Tz) 208 | } 209 | 210 | // IsGroup returns true if chat is a group chat 211 | func (c *Chat) IsGroup() bool { 212 | if c.ID < 0 { 213 | return true 214 | } 215 | return false 216 | 217 | } 218 | 219 | // IsPrivate returns true if chat is a private chat 220 | func (c *Chat) IsPrivate() bool { 221 | if c.ID > 0 { 222 | return true 223 | } 224 | return false 225 | } 226 | --------------------------------------------------------------------------------