This commit is contained in:
Alex Lushpai 2018-12-11 20:35:48 +03:00
parent 6e8f020f01
commit 5c7d21345f
43 changed files with 2658 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
config.yml
config_test.yml
.idea/
/bin/*
*.xml
/vendor

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM golang:1.11-stretch
WORKDIR /
ADD ./bin/bot /
ADD ./templates/ /templates/
ADD ./static/ /static/
ADD ./translate/ /translate/
ADD ./migrations/ /migrations/
EXPOSE 3001
ENTRYPOINT ["/bot"]
CMD ["run"]

44
Makefile Normal file
View file

@ -0,0 +1,44 @@
ROOT_DIR=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
SRC_DIR=$(ROOT_DIR)/src
MIGRATIONS_DIR=$(ROOT_DIR)/migrations
CONFIG_FILE=$(ROOT_DIR)/config.yml
CONFIG_TEST_FILE=$(ROOT_DIR)/config_test.yml
BIN=$(ROOT_DIR)/bin/bot
REVISION=$(shell git describe --tags 2>/dev/null || git log --format="v0.0-%h" -n 1 || echo "v0.0-unknown")
build: deps fmt
@echo "==> Building"
@cd $(SRC_DIR) && CGO_ENABLED=0 go build -o $(BIN) -ldflags "-X common.build=${REVISION}" .
@echo $(BIN)
run: migrate
@echo "==> Running"
@${BIN} --config $(CONFIG_FILE) run
test: deps fmt
@echo "==> Running tests"
@cd $(SRC_DIR) && go test ./... -v -cpu 2 -cover -race
jenkins_test: migrate_test
@echo "==> Running tests (result in test-report.xml)"
@go get -v -u github.com/jstemmer/go-junit-report
@cd $(SRC_DIR) && go test ./... -v -cpu 2 -cover -race | go-junit-report -set-exit-code > $(ROOT_DIR)/test-report.xml
@echo "==> Cleanup dependencies"
@go mod tidy
fmt:
@echo "==> Running gofmt"
@gofmt -l -s -w $(SRC_DIR)
deps:
@echo "==> Installing dependencies"
@go mod tidy
migrate: build
${BIN} --config $(CONFIG_FILE) migrate -p $(MIGRATIONS_DIR)
migrate_test: build
@${BIN} --config $(CONFIG_TEST_FILE) migrate -p $(MIGRATIONS_DIR)
migrate_down: build
@${BIN} --config $(CONFIG_FILE) migrate -v down

19
config.yml.dist Normal file
View file

@ -0,0 +1,19 @@
version: ~
database:
connection: postgres://mg_bot:mg_bot@postgres:5432/mg_bot?sslmode=disable
http_server:
host: ~
listen: :3001
bot_info:
name: Helper
code: crm-info-bot
logo_path: /static/logo.svg
sentry_dsn: ~
log_level: 5
debug: false

19
config_test.yml.dist Normal file
View file

@ -0,0 +1,19 @@
version: ~
database:
connection: postgres://mg_bot_test:mg_bot_test@postgres_test:5432/mg_bot_test?sslmode=disable
http_server:
host: ~
listen: :3002
bot_info:
name: Helper
code: crm-info-bot
logo_path: /static/logo.svg
sentry_dsn: ~
log_level: 5
debug: false

25
docker-compose-test.yml Normal file
View file

@ -0,0 +1,25 @@
version: '2.1'
services:
postgres_test:
image: postgres:9.6
environment:
POSTGRES_USER: mg_bot_test
POSTGRES_PASSWORD: mg_bot_test
POSTGRES_DATABASE: mg_bot_test
ports:
- ${POSTGRES_ADDRESS:-127.0.0.1:5434}:${POSTGRES_PORT:-5432}
mg_bot_test:
image: golang:1.11-stretch
working_dir: /mg-bot
user: ${UID:-1000}:${GID:-1000}
environment:
GOCACHE: /go
volumes:
- ./:/mg-bot/
- ./static:/static/
links:
- postgres_test
ports:
- ${mg_bot_ADDRESS:-3002}:3002

26
docker-compose.yml Normal file
View file

@ -0,0 +1,26 @@
version: '2.1'
services:
postgres:
image: postgres:9.6
environment:
POSTGRES_USER: mg_bot
POSTGRES_PASSWORD: mg_bot
POSTGRES_DATABASE: mg_bot
ports:
- ${POSTGRES_ADDRESS:-127.0.0.1:5434}:${POSTGRES_PORT:-5432}
mg_bot:
image: golang:1.11-stretch
working_dir: /mg-bot
user: ${UID:-1000}:${GID:-1000}
environment:
GOCACHE: /go
volumes:
- ./:/mg-bot/
- ./static:/static/
links:
- postgres
ports:
- ${mg_bot_ADDRESS:-3001}:3001
command: make run

60
go.mod Normal file
View file

@ -0,0 +1,60 @@
module skillum.ru/mg-transport.crm-info-bot
require (
cloud.google.com/go v0.26.0 // indirect
github.com/Microsoft/go-winio v0.4.11 // indirect
github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/denisenkom/go-mssqldb v0.0.0-20180901172138-1eb28afdf9b6 // indirect
github.com/docker/distribution v2.6.2+incompatible // indirect
github.com/docker/docker v1.13.1 // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.3.3 // indirect
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect
github.com/getsentry/raven-go v0.0.0-20180903072508-084a9de9eb03
github.com/gin-contrib/multitemplate v0.0.0-20180827023943-5799bbbb6dce
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 // indirect
github.com/gin-gonic/gin v1.3.0
github.com/go-playground/locales v0.12.1 // indirect
github.com/go-playground/universal-translator v0.16.0 // indirect
github.com/go-sql-driver/mysql v1.4.0 // indirect
github.com/golang-migrate/migrate v3.4.0+incompatible
github.com/golang/protobuf v1.2.0 // indirect
github.com/google/go-cmp v0.2.0 // indirect
github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135 // indirect
github.com/gorilla/websocket v1.4.0
github.com/h2non/gock v1.0.9
github.com/jessevdk/go-flags v1.4.0
github.com/jinzhu/gorm v1.9.1
github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a // indirect
github.com/jinzhu/now v0.0.0-20180511015916-ed742868f2ae // indirect
github.com/joho/godotenv v1.2.0 // indirect
github.com/json-iterator/go v1.1.5 // indirect
github.com/kr/pretty v0.1.0 // indirect
github.com/lib/pq v1.0.0 // indirect
github.com/mattn/go-isatty v0.0.4 // indirect
github.com/mattn/go-sqlite3 v1.9.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 // indirect
github.com/nicksnyder/go-i18n/v2 v2.0.0-beta.5
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/pkg/errors v0.8.0
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/retailcrm/api-client-go v1.0.7
github.com/retailcrm/mg-bot-api-client-go v1.0.16
github.com/stevvooe/resumable v0.0.0-20180830230917-22b14a53ba50 // indirect
github.com/stretchr/testify v1.2.2
github.com/ugorji/go v1.1.1 // indirect
golang.org/x/crypto v0.0.0-20180830192347-182538f80094 // indirect
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d // indirect
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f // indirect
golang.org/x/sys v0.0.0-20180903190138-2b024373dcd9 // indirect
golang.org/x/text v0.3.0
google.golang.org/appengine v1.1.0 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
gopkg.in/go-playground/validator.v8 v8.18.2 // indirect
gopkg.in/go-playground/validator.v9 v9.21.0
gopkg.in/yaml.v2 v2.2.1
)

120
go.sum Normal file
View file

@ -0,0 +1,120 @@
cloud.google.com/go v0.26.0 h1:e0WKqKTd5BnrG8aKH3J3h+QvEIQtSUcf2n5UZ5ZgLtQ=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.0 h1:e1/Ivsx3Z0FVTV0NSOv/aVgbUWyQuzj7DDnFblkRvsY=
github.com/BurntSushi/toml v0.3.0/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Microsoft/go-winio v0.4.11 h1:zoIOcVf0xPN1tnMVbTtEdI+P8OofVk3NObnwOQ6nK2Q=
github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261 h1:6/yVvBsKeAw05IUj4AzvrxaCnDjN4nUqKjW9+w5wixg=
github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.0.0-20180901172138-1eb28afdf9b6 h1:BZGp1dbKFjqlGmxEpwkDpCWNxVwEYnUPoncIzLiHlPo=
github.com/denisenkom/go-mssqldb v0.0.0-20180901172138-1eb28afdf9b6/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc=
github.com/docker/distribution v2.6.2+incompatible h1:4FI6af79dfCS/CYb+RRtkSHw3q1L/bnDjG1PcPZtQhM=
github.com/docker/distribution v2.6.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v1.13.1 h1:5VBhsO6ckUxB0A8CE5LlUJdXzik9cbEbBTQ/ggeml7M=
github.com/docker/docker v1.13.1/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.3.3 h1:Xk8S3Xj5sLGlG5g67hJmYMmUgXv5N4PhkjJHHqrwnTk=
github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/getsentry/raven-go v0.0.0-20180903072508-084a9de9eb03 h1:G/9fPivTr5EiyqE9OlW65iMRUxFXMGRHgZFGo50uG8Q=
github.com/getsentry/raven-go v0.0.0-20180903072508-084a9de9eb03/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
github.com/gin-contrib/multitemplate v0.0.0-20180827023943-5799bbbb6dce h1:KqeVCdb+M2iwyF6GzdYxTazfE1cE+133RXuGaZ5Sc1E=
github.com/gin-contrib/multitemplate v0.0.0-20180827023943-5799bbbb6dce/go.mod h1:62qM8p4crGvNKE413gTzn4eMFin1VOJfMDWMRzHdvqM=
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 h1:AzN37oI0cOS+cougNAV9szl6CVoj2RYwzS3DpUQNtlY=
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-gonic/gin v1.3.0 h1:kCmZyPklC0gVdL728E6Aj20uYBJV93nj/TkwBTKhFbs=
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM=
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/golang-migrate/migrate v3.4.0+incompatible h1:9yjg5lYsbeEpWXGc80RylvPMKZ0tZEGsyO3CpYLK3jU=
github.com/golang-migrate/migrate v3.4.0+incompatible/go.mod h1:IsVUlFN5puWOmXrqjgGUfIRIbU7mr8oNBE2tyERd9Wk=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135 h1:zLTLjkaOFEFIOxY5BWLFLwh+cL8vOBW4XJ2aqLE/Tf0=
github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/h2non/gock v1.0.9 h1:17gCehSo8ZOgEsFKpQgqHiR7VLyjxdAG3lkhVvO9QZU=
github.com/h2non/gock v1.0.9/go.mod h1:CZMcB0Lg5IWnr9bF79pPMg9WeV6WumxQiUJ1UvdO1iE=
github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/gorm v1.9.1 h1:lDSDtsCt5AGGSKTs8AHlSDbbgif4G4+CKJ8ETBDVHTA=
github.com/jinzhu/gorm v1.9.1/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo=
github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a h1:eeaG9XMUvRBYXJi4pg1ZKM7nxc5AfXfojeLLW7O5J3k=
github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v0.0.0-20180511015916-ed742868f2ae h1:8bBMcboXYVuo0WYH+rPe5mB8obO89a993hdTZ3phTjc=
github.com/jinzhu/now v0.0.0-20180511015916-ed742868f2ae/go.mod h1:oHTiXerJ20+SfYcrdlBO7rzZRJWGwSTQ0iUY2jI6Gfc=
github.com/joho/godotenv v1.2.0 h1:vGTvz69FzUFp+X4/bAkb0j5BoLC+9bpqTWY8mjhA9pc=
github.com/joho/godotenv v1.2.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/json-iterator/go v1.1.5 h1:gL2yXlmiIo4+t+y32d4WGwOjKGYcGOuyrg46vadswDE=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
github.com/nicksnyder/go-i18n/v2 v2.0.0-beta.5 h1:/TjjTS4kg7vC+05gD0LE4+97f/+PRFICnK/7wJPk7kE=
github.com/nicksnyder/go-i18n/v2 v2.0.0-beta.5/go.mod h1:4Opqa6/HIv0lhG3WRAkqzO0afezkRhxXI0P8EJkqeRU=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/retailcrm/api-client-go v1.0.7 h1:j4C2PvPUDP9nAuYWDvJPnYNpkj+LDBgn71kHvxJmSPg=
github.com/retailcrm/api-client-go v1.0.7/go.mod h1:QRoPE2SM6ST7i2g0yEdqm7Iw98y7cYuq3q14Ot+6N8c=
github.com/retailcrm/mg-bot-api-client-go v1.0.16 h1:l7xzGp0IQTR+jJ//x3vDBz/jHnOG71MhNQtSGWq3rj8=
github.com/retailcrm/mg-bot-api-client-go v1.0.16/go.mod h1:lJD4+WLi9CiOk4/2GUvmJ6LG4168eoilXAbfT61yK1U=
github.com/stevvooe/resumable v0.0.0-20180830230917-22b14a53ba50 h1:4bT0pPowCpQImewr+BjzfUKcuFW+KVyB8d1OF3b6oTI=
github.com/stevvooe/resumable v0.0.0-20180830230917-22b14a53ba50/go.mod h1:1pdIZTAHUz+HDKDVZ++5xg/duPlhKAIzw9qy42CWYp4=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/ugorji/go v1.1.1 h1:gmervu+jDMvXTbcHQ0pd2wee85nEoE0BsVyEuzkfK8w=
github.com/ugorji/go v1.1.1/go.mod h1:hnLbHMwcvSihnDhEfx2/BzKp2xb0Y+ErdfYcrs9tkJQ=
golang.org/x/crypto v0.0.0-20180830192347-182538f80094 h1:rVTAlhYa4+lCfNxmAIEOGQRoD23UqP72M3+rSWVGDTg=
golang.org/x/crypto v0.0.0-20180830192347-182538f80094/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d h1:g9qWBGx4puODJTMVyoPrpoxPFgVGd+z1DZwjfRu4d0I=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180903190138-2b024373dcd9 h1:lkiLiLBHGoH3XnqSLUIaBsilGMUjI+Uy2Xu2JLUtTas=
golang.org/x/sys v0.0.0-20180903190138-2b024373dcd9/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.0.0-20171214130843-f21a4dfb5e38/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
google.golang.org/appengine v1.1.0 h1:igQkv0AAhEIvTEpD5LIpAfav2eeVO9HBTjvKHVJPRSs=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/go-playground/validator.v9 v9.21.0 h1:wSDJGBpQBYC1wLpVnGHLmshm2JicoSNdrb38Zj+8yHI=
gopkg.in/go-playground/validator.v9 v9.21.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View file

@ -0,0 +1 @@
DROP TABLE connection;

View file

@ -0,0 +1,17 @@
create table connection
(
id serial not null constraint connection_pkey primary key,
client_id varchar(70) not null,
api_key varchar(100) not null,
api_url varchar(255) not null,
mg_url varchar(255) not null,
mg_token varchar(100) not null,
commands jsonb,
created_at timestamp with time zone,
updated_at timestamp with time zone,
active boolean,
lang varchar(2) not null
);
alter table connection
add constraint connection_key unique (client_id, mg_token);

View file

@ -0,0 +1 @@
alter table connection drop column currency

View file

@ -0,0 +1 @@
alter table connection add column currency varchar(12);

64
src/config.go Normal file
View file

@ -0,0 +1,64 @@
package main
import (
"io/ioutil"
"path/filepath"
"github.com/op/go-logging"
"gopkg.in/yaml.v2"
)
// BotConfig struct
type BotConfig struct {
Version string `yaml:"version"`
LogLevel logging.Level `yaml:"log_level"`
Database DatabaseConfig `yaml:"database"`
SentryDSN string `yaml:"sentry_dsn"`
HTTPServer HTTPServerConfig `yaml:"http_server"`
Debug bool `yaml:"debug"`
BotInfo BotInfo `yaml:"bot_info"`
}
type BotInfo struct {
Name string `yaml:"name"`
Code string `yaml:"code"`
LogoPath string `yaml:"logo_path"`
}
// DatabaseConfig struct
type DatabaseConfig struct {
Connection string `yaml:"connection"`
Logging bool `yaml:"logging"`
TablePrefix string `yaml:"table_prefix"`
MaxOpenConnections int `yaml:"max_open_connections"`
MaxIdleConnections int `yaml:"max_idle_connections"`
ConnectionLifetime int `yaml:"connection_lifetime"`
}
// HTTPServerConfig struct
type HTTPServerConfig struct {
Host string `yaml:"host"`
Listen string `yaml:"listen"`
}
// LoadConfig read configuration file
func LoadConfig(path string) *BotConfig {
var err error
path, err = filepath.Abs(path)
if err != nil {
panic(err)
}
source, err := ioutil.ReadFile(path)
if err != nil {
panic(err)
}
var c BotConfig
if err = yaml.Unmarshal(source, &c); err != nil {
panic(err)
}
return &c
}

131
src/error_handler.go Normal file
View file

@ -0,0 +1,131 @@
package main
import (
"fmt"
"net/http"
"runtime/debug"
"github.com/getsentry/raven-go"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
)
type (
ErrorHandlerFunc func(recovery interface{}, c *gin.Context)
)
func ErrorHandler(handlers ...ErrorHandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
rec := recover()
for _, handler := range handlers {
handler(rec, c)
}
if rec != nil || len(c.Errors) > 0 {
c.Abort()
}
}()
c.Next()
}
}
func ErrorResponseHandler() ErrorHandlerFunc {
return func(recovery interface{}, c *gin.Context) {
publicErrors := c.Errors.ByType(gin.ErrorTypePublic)
privateLen := len(c.Errors.ByType(gin.ErrorTypePrivate))
publicLen := len(publicErrors)
if privateLen == 0 && publicLen == 0 && recovery == nil {
return
}
messagesLen := publicLen
if privateLen > 0 || recovery != nil {
messagesLen++
}
messages := make([]string, messagesLen)
index := 0
for _, err := range publicErrors {
messages[index] = err.Error()
index++
}
if privateLen > 0 || recovery != nil {
messages[index] = getLocalizedMessage("error_save")
}
c.JSON(http.StatusInternalServerError, gin.H{"error": messages})
}
}
func ErrorCaptureHandler(client *raven.Client, errorsStacktrace bool) ErrorHandlerFunc {
return func(recovery interface{}, c *gin.Context) {
tags := map[string]string{
"endpoint": c.Request.RequestURI,
}
var (
ok bool
conn Connection
)
connection, ok := c.Get("connection")
if ok {
conn = connection.(Connection)
}
if conn.APIURL != "" {
tags["crm"] = conn.APIURL
}
if conn.ClientID != "" {
tags["clientID"] = conn.ClientID
}
if recovery != nil {
stacktrace := raven.NewStacktrace(4, 3, nil)
recStr := fmt.Sprint(recovery)
err := errors.New(recStr)
go client.CaptureMessageAndWait(
recStr,
tags,
raven.NewException(err, stacktrace),
raven.NewHttp(c.Request),
)
}
for _, err := range c.Errors {
if errorsStacktrace {
stacktrace := NewRavenStackTrace(client, err.Err, 0)
go client.CaptureMessageAndWait(
err.Error(),
tags,
raven.NewException(err.Err, stacktrace),
raven.NewHttp(c.Request),
)
} else {
go client.CaptureErrorAndWait(err.Err, tags)
}
}
}
}
func PanicLogger() ErrorHandlerFunc {
return func(recovery interface{}, c *gin.Context) {
if recovery != nil {
logger.Error(c.Request.RequestURI, recovery)
debug.PrintStack()
}
}
}
func ErrorLogger() ErrorHandlerFunc {
return func(recovery interface{}, c *gin.Context) {
for _, err := range c.Errors {
logger.Error(c.Request.RequestURI, err.Err)
}
}
}

58
src/locale.go Normal file
View file

@ -0,0 +1,58 @@
package main
import (
"html/template"
"io/ioutil"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
"gopkg.in/yaml.v2"
)
var (
localizer *i18n.Localizer
bundle = &i18n.Bundle{DefaultLanguage: language.English}
matcher = language.NewMatcher([]language.Tag{
language.English,
language.Russian,
language.Spanish,
})
)
func loadTranslateFile() {
bundle.RegisterUnmarshalFunc("yml", yaml.Unmarshal)
files, err := ioutil.ReadDir("translate")
if err != nil {
panic(err)
}
for _, f := range files {
if !f.IsDir() {
bundle.MustLoadMessageFile("translate/" + f.Name())
}
}
}
func setLocale(al string) {
tag, _ := language.MatchStrings(matcher, al)
localizer = i18n.NewLocalizer(bundle, tag.String())
}
func getLocalizedMessage(messageID string) string {
return localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: messageID})
}
func getLocale() map[string]interface{} {
return map[string]interface{}{
"Version": config.Version,
"ButtonSave": getLocalizedMessage("button_save"),
"ApiKey": getLocalizedMessage("api_key"),
"TabSettings": getLocalizedMessage("tab_settings"),
"TabBots": getLocalizedMessage("tab_bots"),
"TableUrl": getLocalizedMessage("table_url"),
"TableActivity": getLocalizedMessage("table_activity"),
"Title": getLocalizedMessage("title"),
"Language": getLocalizedMessage("language"),
"CRMLink": template.HTML(getLocalizedMessage("crm_link")),
"DocLink": template.HTML(getLocalizedMessage("doc_link")),
}
}

22
src/log.go Normal file
View file

@ -0,0 +1,22 @@
package main
import (
"os"
"github.com/op/go-logging"
)
var logFormat = logging.MustStringFormatter(
`%{time:2006-01-02 15:04:05.000} %{level:.4s} => %{message}`,
)
func newLogger() *logging.Logger {
logger := logging.MustGetLogger(config.BotInfo.Code)
logBackend := logging.NewLogBackend(os.Stdout, "", 0)
formatBackend := logging.NewBackendFormatter(logBackend, logFormat)
backend1Leveled := logging.AddModuleLevel(logBackend)
backend1Leveled.SetLevel(config.LogLevel, "")
logging.SetBackend(formatBackend)
return logger
}

40
src/main.go Normal file
View file

@ -0,0 +1,40 @@
package main
import (
"os"
"github.com/jessevdk/go-flags"
"github.com/op/go-logging"
)
// Options struct
type Options struct {
Config string `short:"c" long:"config" default:"config.yml" description:"Path to configuration file"`
}
var (
config *BotConfig
orm *Orm
logger *logging.Logger
options Options
tokenCounter uint32
parser = flags.NewParser(&options, flags.Default)
currency = map[string]string{
"Российский рубль": "rub",
"Гри́вня": "uah",
"Беларускі рубель": "byr",
"Қазақстан теңгесі": "kzt",
"U.S. dollar": "usd",
"Euro": "eur",
}
)
func main() {
if _, err := parser.Parse(); err != nil {
if flagsErr, ok := err.(*flags.Error); ok && flagsErr.Type == flags.ErrHelp {
os.Exit(0)
} else {
os.Exit(1)
}
}
}

83
src/migrate.go Normal file
View file

@ -0,0 +1,83 @@
package main
import (
"errors"
"fmt"
"strconv"
"github.com/golang-migrate/migrate"
)
func init() {
parser.AddCommand("migrate",
"Migrate database to defined migrations version",
"Migrate database to defined migrations version.",
&MigrateCommand{},
)
}
// MigrateCommand struct
type MigrateCommand struct {
Version string `short:"v" long:"version" default:"up" description:"Migrate to defined migrations version. Allowed: up, down, next, prev and integer value."`
Path string `short:"p" long:"path" default:"" description:"Path to migrations files."`
}
// Execute method
func (x *MigrateCommand) Execute(args []string) error {
botConfig := LoadConfig(options.Config)
err := Migrate(botConfig.Database.Connection, x.Version, x.Path)
if err != nil && err.Error() == "no change" {
fmt.Println("No changes detected. Skipping migration.")
err = nil
}
return err
}
// Migrate function
func Migrate(database string, version string, path string) error {
m, err := migrate.New("file://"+path, database)
if err != nil {
fmt.Printf("Migrations path %s does not exist or permission denied\n", path)
return err
}
defer m.Close()
currentVersion, _, err := m.Version()
if "up" == version {
fmt.Printf("Migrating from %d to last\n", currentVersion)
return m.Up()
}
if "down" == version {
fmt.Printf("Migrating from %d to 0\n", currentVersion)
return m.Down()
}
if "next" == version {
fmt.Printf("Migrating from %d to next\n", currentVersion)
return m.Steps(1)
}
if "prev" == version {
fmt.Printf("Migrating from %d to previous\n", currentVersion)
return m.Steps(-1)
}
ver, err := strconv.ParseUint(version, 10, 32)
if err != nil {
fmt.Printf("Invalid migration version %s\n", version)
return err
}
if ver != 0 {
fmt.Printf("Migrating from %d to %d\n", currentVersion, ver)
return m.Migrate(uint(ver))
}
fmt.Printf("Migrations not found in path %s\n", path)
return errors.New("migrations not found")
}

23
src/models.go Normal file
View file

@ -0,0 +1,23 @@
package main
import (
"time"
"github.com/jinzhu/gorm/dialects/postgres"
)
// Connection model
type Connection struct {
ID int `gorm:"primary_key"`
ClientID string `gorm:"client_id type:varchar(70);not null;unique" json:"clientId,omitempty"`
APIKEY string `gorm:"api_key type:varchar(100);not null" json:"api_key,omitempty" binding:"required"`
APIURL string `gorm:"api_url type:varchar(255);not null" json:"api_url,omitempty" binding:"required,validatecrmurl"`
MGURL string `gorm:"mg_url type:varchar(255);not null;" json:"mg_url,omitempty"`
MGToken string `gorm:"mg_token type:varchar(100);not null;unique" json:"mg_token,omitempty"`
CreatedAt time.Time
UpdatedAt time.Time
Active bool `json:"active,omitempty"`
Commands postgres.Jsonb `gorm:"commands type:jsonb;" json:"commands,omitempty"`
Lang string `gorm:"lang type:varchar(2)" json:"lang,omitempty"`
Currency string `gorm:"currency type:varchar(12)" json:"currency,omitempty"`
}

37
src/orm.go Normal file
View file

@ -0,0 +1,37 @@
package main
import (
"time"
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/postgres"
)
// Orm struct
type Orm struct {
DB *gorm.DB
}
// NewDb init new database connection
func NewDb(config *BotConfig) *Orm {
db, err := gorm.Open("postgres", config.Database.Connection)
if err != nil {
panic(err)
}
db.DB().SetConnMaxLifetime(time.Duration(config.Database.ConnectionLifetime) * time.Second)
db.DB().SetMaxOpenConns(config.Database.MaxOpenConnections)
db.DB().SetMaxIdleConns(config.Database.MaxIdleConnections)
db.SingularTable(true)
db.LogMode(config.Database.Logging)
return &Orm{
DB: db,
}
}
// Close connection
func (orm *Orm) Close() {
orm.DB.Close()
}

34
src/repository.go Normal file
View file

@ -0,0 +1,34 @@
package main
func getConnection(uid string) *Connection {
var connection Connection
orm.DB.First(&connection, "client_id = ?", uid)
return &connection
}
func getConnectionByURL(urlCrm string) *Connection {
var connection Connection
orm.DB.First(&connection, "api_url = ?", urlCrm)
return &connection
}
func getActiveConnection() []*Connection {
var connection []*Connection
orm.DB.Find(&connection, "active = ?", true)
return connection
}
func (c *Connection) setConnectionActivity() error {
return orm.DB.Model(c).Where("client_id = ?", c.ClientID).Updates(map[string]interface{}{"active": c.Active, "api_url": c.APIURL}).Error
}
func (c *Connection) createConnection() error {
return orm.DB.Create(c).Error
}
func (c *Connection) saveConnection() error {
return orm.DB.Model(c).Where("client_id = ?", c.ClientID).Update(c).Error
}

240
src/routing.go Normal file
View file

@ -0,0 +1,240 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/retailcrm/api-client-go/v5"
)
func connectHandler(c *gin.Context) {
res := struct {
Conn Connection
Locale map[string]interface{}
Year int
}{
c.MustGet("account").(Connection),
getLocale(),
time.Now().Year(),
}
c.HTML(http.StatusOK, "home", &res)
}
func botSettingsHandler(c *gin.Context) {
jm := map[string]string{}
if err := c.ShouldBindJSON(&jm); err != nil {
c.Error(err)
return
}
conn := getConnection(jm["client_id"])
conn.Lang = jm["lang"]
conn.Currency = jm["currency"]
err := conn.saveConnection()
if err != nil {
c.Error(err)
return
}
wm.setWorker(conn)
c.JSON(http.StatusOK, gin.H{"msg": getLocalizedMessage("successful")})
}
func settingsHandler(c *gin.Context) {
uid := c.Param("uid")
p := getConnection(uid)
if p.ID == 0 {
c.Redirect(http.StatusFound, "/")
return
}
res := struct {
Conn *Connection
Locale map[string]interface{}
Year int
LangCode []string
CurrencyCode map[string]string
}{
p,
getLocale(),
time.Now().Year(),
[]string{"en", "ru", "es"},
currency,
}
c.HTML(200, "form", res)
}
func saveHandler(c *gin.Context) {
conn := c.MustGet("connection").(Connection)
_, err, code := getAPIClient(conn.APIURL, conn.APIKEY)
if err != nil {
if code == http.StatusInternalServerError {
c.Error(err)
} else {
c.JSON(code, gin.H{"error": err.Error()})
}
return
}
err = conn.saveConnection()
if err != nil {
c.Error(err)
return
}
wm.setWorker(&conn)
c.JSON(http.StatusOK, gin.H{"msg": getLocalizedMessage("successful")})
}
func createHandler(c *gin.Context) {
conn := c.MustGet("connection").(Connection)
cl := getConnectionByURL(conn.APIURL)
if cl.ID != 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": getLocalizedMessage("connection_already_created")})
return
}
client, err, code := getAPIClient(conn.APIURL, conn.APIKEY)
if err != nil {
if code == http.StatusInternalServerError {
c.Error(err)
} else {
c.JSON(code, gin.H{"error": err.Error()})
}
return
}
conn.ClientID = GenerateToken()
data, status, e := client.IntegrationModuleEdit(getIntegrationModule(conn.ClientID))
if e.RuntimeErr != nil {
c.Error(e.RuntimeErr)
return
}
if status >= http.StatusBadRequest {
c.JSON(http.StatusBadRequest, gin.H{"error": getLocalizedMessage("error_activity_mg")})
logger.Error(conn.APIURL, status, e.ApiErr, data)
return
}
conn.MGURL = data.Info.MgBotInfo.EndpointUrl
conn.MGToken = data.Info.MgBotInfo.Token
conn.Active = true
conn.Lang = "ru"
conn.Currency = currency["Российский рубль"]
bj, _ := json.Marshal(botCommands)
conn.Commands.RawMessage = bj
code, err = SetBotCommand(conn.MGURL, conn.MGToken)
if err != nil {
c.JSON(code, gin.H{"error": getLocalizedMessage("error_activity_mg")})
logger.Error(conn.APIURL, code, err)
return
}
err = conn.createConnection()
if err != nil {
c.Error(err)
return
}
wm.setWorker(&conn)
c.JSON(
http.StatusCreated,
gin.H{
"url": "/settings/" + conn.ClientID,
"message": getLocalizedMessage("successful"),
},
)
}
func activityHandler(c *gin.Context) {
var (
activity v5.Activity
systemUrl = c.PostForm("systemUrl")
clientId = c.PostForm("clientId")
)
conn := getConnection(clientId)
if conn.ID == 0 {
c.AbortWithStatusJSON(http.StatusBadRequest,
gin.H{
"success": false,
"error": "Wrong data",
},
)
return
}
err := json.Unmarshal([]byte(c.PostForm("activity")), &activity)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest,
gin.H{
"success": false,
"error": "Wrong data",
},
)
return
}
conn.Active = activity.Active && !activity.Freeze
if systemUrl != "" {
conn.APIURL = systemUrl
}
if err := conn.setConnectionActivity(); err != nil {
c.Error(err)
return
}
if !conn.Active {
wm.stopWorker(conn)
} else {
wm.setWorker(conn)
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func getIntegrationModule(clientId string) v5.IntegrationModule {
return v5.IntegrationModule{
Code: config.BotInfo.Code,
IntegrationCode: config.BotInfo.Code,
Active: true,
Name: config.BotInfo.Name,
ClientID: clientId,
Logo: fmt.Sprintf(
"https://%s%s",
config.HTTPServer.Host,
config.BotInfo.LogoPath,
),
BaseURL: fmt.Sprintf(
"https://%s",
config.HTTPServer.Host,
),
AccountURL: fmt.Sprintf(
"https://%s/settings/%s",
config.HTTPServer.Host,
clientId,
),
Actions: map[string]string{"activity": "/actions/activity"},
Integrations: &v5.Integrations{
MgBot: &v5.MgBot{},
},
}
}

200
src/routing_test.go Normal file
View file

@ -0,0 +1,200 @@
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/h2non/gock"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v2"
)
var (
router *gin.Engine
crmUrl = "https://test.retailcrm.ru"
clientID = "09385039f039irf039fkj309fj30jf3"
)
func init() {
os.Chdir("../")
config = LoadConfig("config_test.yml")
orm = NewDb(config)
logger = newLogger()
router = setup()
}
func TestMain(m *testing.M) {
c := Connection{
ID: 1,
ClientID: clientID,
APIKEY: "ii32if32iuf23iufn2uifnr23inf",
APIURL: crmUrl,
MGURL: "https://test.retailcrm.pro",
MGToken: "988730985u23r390rf8j3984jf32904fj",
Active: true,
}
orm.DB.Delete(Connection{}, "id > ?", 0)
c.createConnection()
retCode := m.Run()
orm.DB.Delete(Connection{}, "id > ?", 0)
os.Exit(retCode)
}
func TestRouting_connectHandler(t *testing.T) {
rr := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
router.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code,
fmt.Sprintf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK))
}
func TestRouting_settingsHandler(t *testing.T) {
req, err := http.NewRequest("GET", "/settings/"+clientID, nil)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code,
fmt.Sprintf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK))
}
func TestRouting_saveHandler(t *testing.T) {
defer gock.Off()
gock.New(crmUrl).
Get("/api/credentials").
Reply(200).
BodyString(`{"success": true, "credentials": ["/api/integration-modules/{code}", "/api/integration-modules/{code}/edit", "/api/reference/payment-types", "/api/reference/delivery-types", "/api/store/products"]}`)
req, err := http.NewRequest("POST", "/save/",
strings.NewReader(fmt.Sprintf(
`{"clientId": "%s",
"api_url": "%s",
"api_key": "test"}`,
clientID,
crmUrl,
)),
)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code,
fmt.Sprintf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK))
}
func TestRouting_activityHandler(t *testing.T) {
startWS()
if _, ok := wm.workers[clientID]; !ok {
t.Fatal("worker don`t start")
}
data := []url.Values{
{
"clientId": {clientID},
"activity": {`{"active": false, "freeze": false}`},
"systemUrl": {crmUrl},
},
{
"clientId": {clientID},
"activity": {`{"active": true, "freeze": false}`},
"systemUrl": {crmUrl},
},
{
"clientId": {clientID},
"activity": {`{"active": true, "freeze": false}`},
"systemUrl": {"http://change.retailcrm.ru"},
},
}
for _, v := range data {
req, err := http.NewRequest("POST", "/actions/activity", strings.NewReader(v.Encode()))
if err != nil {
t.Fatal(err)
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code,
fmt.Sprintf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK))
activity := make(map[string]bool)
err = json.Unmarshal([]byte(v.Get("activity")), &activity)
if err != nil {
t.Fatal(err)
}
w, ok := wm.workers[clientID]
if ok != (activity["active"] && !activity["freeze"]) {
t.Error("worker don`t stop")
}
if ok && w.connection.APIURL != v.Get("systemUrl") {
t.Error("fail update systemUrl")
}
}
}
func TestTranslate(t *testing.T) {
files, err := ioutil.ReadDir("translate")
if err != nil {
t.Fatal(err)
}
m := make(map[int]map[string]string)
i := 0
for _, f := range files {
mt := make(map[string]string)
if !f.IsDir() {
yamlFile, err := ioutil.ReadFile("translate/" + f.Name())
if err != nil {
t.Fatal(err)
}
err = yaml.Unmarshal(yamlFile, &mt)
if err != nil {
t.Fatal(err)
}
m[i] = mt
i++
}
}
for k, v := range m {
for kv := range v {
if len(m) > k+1 {
if _, ok := m[k+1][kv]; !ok {
t.Error(kv)
}
}
}
}
}

145
src/run.go Normal file
View file

@ -0,0 +1,145 @@
package main
import (
"net/http"
"os"
"os/signal"
"regexp"
"syscall"
"github.com/getsentry/raven-go"
"github.com/gin-contrib/multitemplate"
"github.com/gin-gonic/gin"
_ "github.com/golang-migrate/migrate/database/postgres"
_ "github.com/golang-migrate/migrate/source/file"
)
func init() {
parser.AddCommand("run",
"Run bot",
"Run bot.",
&RunCommand{},
)
}
var (
sentry *raven.Client
rx = regexp.MustCompile(`/+$`)
wm = NewWorkersManager()
)
// RunCommand struct
type RunCommand struct{}
// Execute command
func (x *RunCommand) Execute(args []string) error {
config = LoadConfig(options.Config)
orm = NewDb(config)
logger = newLogger()
go start()
c := make(chan os.Signal, 1)
signal.Notify(c)
for sig := range c {
switch sig {
case os.Interrupt, syscall.SIGQUIT, syscall.SIGTERM:
orm.DB.Close()
return nil
default:
}
}
return nil
}
func start() {
router := setup()
startWS()
router.Run(config.HTTPServer.Listen)
}
func setup() *gin.Engine {
loadTranslateFile()
setValidation()
if config.Debug == false {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Recovery())
if config.Debug {
r.Use(gin.Logger())
}
r.HTMLRender = createHTMLRender()
r.Static("/static", "./static")
r.Use(func(c *gin.Context) {
setLocale(c.GetHeader("Accept-Language"))
})
errorHandlers := []ErrorHandlerFunc{
PanicLogger(),
ErrorResponseHandler(),
}
sentry, _ = raven.New(config.SentryDSN)
if sentry != nil {
errorHandlers = append(errorHandlers, ErrorCaptureHandler(sentry, true))
}
r.Use(ErrorHandler(errorHandlers...))
r.GET("/", checkAccountForRequest(), connectHandler)
r.Any("/settings/:uid", settingsHandler)
r.POST("/save/", checkConnectionForRequest(), saveHandler)
r.POST("/create/", checkConnectionForRequest(), createHandler)
r.POST("/bot-settings/", botSettingsHandler)
r.POST("/actions/activity", activityHandler)
return r
}
func createHTMLRender() multitemplate.Renderer {
r := multitemplate.NewRenderer()
r.AddFromFiles("home", "templates/layout.html", "templates/home.html")
r.AddFromFiles("form", "templates/layout.html", "templates/form.html")
return r
}
func checkAccountForRequest() gin.HandlerFunc {
return func(c *gin.Context) {
ra := rx.ReplaceAllString(c.Query("account"), ``)
p := Connection{
APIURL: ra,
}
c.Set("account", p)
}
}
func checkConnectionForRequest() gin.HandlerFunc {
return func(c *gin.Context) {
var conn Connection
if err := c.ShouldBindJSON(&conn); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": getLocalizedMessage("incorrect_url_key")})
return
}
conn.APIURL = rx.ReplaceAllString(conn.APIURL, ``)
c.Set("connection", conn)
}
}
func startWS() {
res := getActiveConnection()
if len(res) > 0 {
for _, v := range res {
wm.setWorker(v)
}
}
}

89
src/stacktrace.go Normal file
View file

@ -0,0 +1,89 @@
package main
import (
"runtime"
"github.com/getsentry/raven-go"
"github.com/pkg/errors"
)
func NewRavenStackTrace(client *raven.Client, myerr error, skip int) *raven.Stacktrace {
st := getErrorStackTraceConverted(myerr, 3, client.IncludePaths())
if st == nil {
st = raven.NewStacktrace(skip, 3, client.IncludePaths())
}
return st
}
func getErrorStackTraceConverted(err error, context int, appPackagePrefixes []string) *raven.Stacktrace {
st := getErrorCauseStackTrace(err)
if st == nil {
return nil
}
return convertStackTrace(st, context, appPackagePrefixes)
}
func getErrorCauseStackTrace(err error) errors.StackTrace {
// This code is inspired by github.com/pkg/errors.Cause().
var st errors.StackTrace
for err != nil {
s := getErrorStackTrace(err)
if s != nil {
st = s
}
err = getErrorCause(err)
}
return st
}
func convertStackTrace(st errors.StackTrace, context int, appPackagePrefixes []string) *raven.Stacktrace {
// This code is borrowed from github.com/getsentry/raven-go.NewStacktrace().
var frames []*raven.StacktraceFrame
for _, f := range st {
frame := convertFrame(f, context, appPackagePrefixes)
if frame != nil {
frames = append(frames, frame)
}
}
if len(frames) == 0 {
return nil
}
for i, j := 0, len(frames)-1; i < j; i, j = i+1, j-1 {
frames[i], frames[j] = frames[j], frames[i]
}
return &raven.Stacktrace{Frames: frames}
}
func convertFrame(f errors.Frame, context int, appPackagePrefixes []string) *raven.StacktraceFrame {
// This code is borrowed from github.com/pkg/errors.Frame.
pc := uintptr(f) - 1
fn := runtime.FuncForPC(pc)
var file string
var line int
if fn != nil {
file, line = fn.FileLine(pc)
} else {
file = "unknown"
}
return raven.NewStacktraceFrame(pc, file, line, context, appPackagePrefixes)
}
func getErrorStackTrace(err error) errors.StackTrace {
ster, ok := err.(interface {
StackTrace() errors.StackTrace
})
if !ok {
return nil
}
return ster.StackTrace()
}
func getErrorCause(err error) error {
cer, ok := err.(interface {
Cause() error
})
if !ok {
return nil
}
return cer.Cause()
}

66
src/utils.go Normal file
View file

@ -0,0 +1,66 @@
package main
import (
"crypto/sha256"
"errors"
"fmt"
"net/http"
"strings"
"sync/atomic"
"time"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/retailcrm/api-client-go/v5"
)
// GenerateToken function
func GenerateToken() string {
c := atomic.AddUint32(&tokenCounter, 1)
return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d%d", time.Now().UnixNano(), c))))
}
func getAPIClient(url, key string) (*v5.Client, error, int) {
client := v5.New(url, key)
cr, _, e := client.APICredentials()
if e.RuntimeErr != nil {
return nil, e.RuntimeErr, http.StatusInternalServerError
}
if !cr.Success {
return nil, errors.New(getLocalizedMessage("incorrect_url_key")), http.StatusBadRequest
}
if res := checkCredentials(cr.Credentials); len(res) != 0 {
return nil,
errors.New(localizer.MustLocalize(&i18n.LocalizeConfig{
MessageID: "missing_credentials",
TemplateData: map[string]interface{}{
"Credentials": strings.Join(res, ", "),
},
})),
http.StatusBadRequest
}
return client, nil, 0
}
func checkCredentials(credential []string) []string {
rc := make([]string, len(botCredentials))
copy(rc, botCredentials)
for _, vc := range credential {
for kn, vn := range rc {
if vn == vc {
if len(rc) == 1 {
rc = rc[:0]
break
}
rc = append(rc[:kn], rc[kn+1:]...)
}
}
}
return rc
}

65
src/validator.go Normal file
View file

@ -0,0 +1,65 @@
package main
import (
"reflect"
"regexp"
"sync"
"github.com/gin-gonic/gin/binding"
"gopkg.in/go-playground/validator.v9"
)
var regCommandName = regexp.MustCompile(`https://?[\da-z.-]+\.(retailcrm\.(ru|pro)|ecomlogic\.com)`)
type defaultValidator struct {
once sync.Once
validate *validator.Validate
}
var _ binding.StructValidator = &defaultValidator{}
func (v *defaultValidator) ValidateStruct(obj interface{}) error {
if kindOfData(obj) == reflect.Struct {
v.lazyinit()
if err := v.validate.Struct(obj); err != nil {
return error(err)
}
}
return nil
}
func (v *defaultValidator) Engine() interface{} {
v.lazyinit()
return v.validate
}
func (v *defaultValidator) lazyinit() {
v.once.Do(func() {
v.validate = validator.New()
v.validate.SetTagName("binding")
})
}
func kindOfData(data interface{}) reflect.Kind {
value := reflect.ValueOf(data)
valueType := value.Kind()
if valueType == reflect.Ptr {
valueType = value.Elem().Kind()
}
return valueType
}
func setValidation() {
binding.Validator = new(defaultValidator)
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("validatecrmurl", validateCrmURL)
}
}
func validateCrmURL(field validator.FieldLevel) bool {
return regCommandName.Match([]byte(field.Field().Interface().(string)))
}

387
src/worker.go Normal file
View file

@ -0,0 +1,387 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/getsentry/raven-go"
"github.com/gorilla/websocket"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/op/go-logging"
"github.com/retailcrm/api-client-go/errs"
"github.com/retailcrm/api-client-go/v5"
"github.com/retailcrm/mg-bot-api-client-go/v1"
"golang.org/x/text/language"
)
const (
CommandPayment = "/payment"
CommandDelivery = "/delivery"
CommandProduct = "/product"
)
var (
events = []string{v1.WsEventMessageNew}
msgLen = 2000
emoji = []string{"0⃣ ", "1⃣ ", "2⃣ ", "3⃣ ", "4⃣ ", "5⃣ ", "6⃣ ", "7⃣ ", "8⃣ ", "9⃣ "}
botCommands = []string{CommandPayment, CommandDelivery, CommandProduct}
botCredentials = []string{
"/api/integration-modules/{code}",
"/api/integration-modules/{code}/edit",
"/api/reference/payment-types",
"/api/reference/delivery-types",
"/api/store/products",
}
)
type Worker struct {
connection *Connection
mutex sync.RWMutex
localizer *i18n.Localizer
sentry *raven.Client
logger *logging.Logger
mgClient *v1.MgClient
crmClient *v5.Client
close bool
}
func NewWorker(conn *Connection, sentry *raven.Client, logger *logging.Logger) *Worker {
crmClient := v5.New(conn.APIURL, conn.APIKEY)
mgClient := v1.New(conn.MGURL, conn.MGToken)
if config.Debug {
crmClient.Debug = true
mgClient.Debug = true
}
return &Worker{
connection: conn,
sentry: sentry,
logger: logger,
localizer: getLang(conn.Lang),
mgClient: mgClient,
crmClient: crmClient,
close: false,
}
}
func (w *Worker) UpdateWorker(conn *Connection) {
w.mutex.RLock()
defer w.mutex.RUnlock()
w.localizer = getLang(conn.Lang)
w.connection = conn
}
func (w *Worker) sendSentry(err error) {
tags := map[string]string{
"crm": w.connection.APIURL,
"active": strconv.FormatBool(w.connection.Active),
"lang": w.connection.Lang,
"currency": w.connection.Currency,
"updated_at": w.connection.UpdatedAt.String(),
}
w.logger.Errorf("ws url: %s\nmgClient: %v\nerr: %v", w.crmClient.URL, w.mgClient, err)
go w.sentry.CaptureError(err, tags)
}
type WorkersManager struct {
mutex sync.RWMutex
workers map[string]*Worker
}
func NewWorkersManager() *WorkersManager {
return &WorkersManager{
workers: map[string]*Worker{},
}
}
func (wm *WorkersManager) setWorker(conn *Connection) {
wm.mutex.Lock()
defer wm.mutex.Unlock()
if conn.Active {
worker, ok := wm.workers[conn.ClientID]
if ok {
worker.UpdateWorker(conn)
} else {
wm.workers[conn.ClientID] = NewWorker(conn, sentry, logger)
go wm.workers[conn.ClientID].UpWS()
}
}
}
func (wm *WorkersManager) stopWorker(conn *Connection) {
wm.mutex.Lock()
defer wm.mutex.Unlock()
worker, ok := wm.workers[conn.ClientID]
if ok {
worker.close = true
delete(wm.workers, conn.ClientID)
}
}
func (w *Worker) UpWS() {
data, header, err := w.mgClient.WsMeta(events)
if err != nil {
w.sendSentry(err)
return
}
ROOT:
for {
if w.close {
if config.Debug {
w.logger.Debug("stop ws:", w.connection.APIURL)
}
return
}
ws, _, err := websocket.DefaultDialer.Dial(data, header)
if err != nil {
w.sendSentry(err)
time.Sleep(1000 * time.Millisecond)
continue ROOT
}
if config.Debug {
w.logger.Info("start ws: ", w.crmClient.URL)
}
for {
var wsEvent v1.WsEvent
err = ws.ReadJSON(&wsEvent)
if err != nil {
w.sendSentry(err)
if websocket.IsUnexpectedCloseError(err) {
continue ROOT
}
continue
}
if w.close {
if config.Debug {
w.logger.Debug("stop ws:", w.connection.APIURL)
}
return
}
var eventData v1.WsEventMessageNewData
err = json.Unmarshal(wsEvent.Data, &eventData)
if err != nil {
w.sendSentry(err)
continue
}
if eventData.Message.Type != "command" {
continue
}
msg, msgProd, err := w.execCommand(eventData.Message.Content)
if err != nil {
w.sendSentry(err)
msg = w.localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "incorrect_key"})
}
msgSend := v1.MessageSendRequest{
Scope: v1.MessageScopePrivate,
ChatID: eventData.Message.ChatID,
}
if msg != "" {
msgSend.Type = v1.MsgTypeText
msgSend.Content = msg
} else if msgProd.ID != 0 {
msgSend.Type = v1.MsgTypeProduct
msgSend.Product = &msgProd
}
if msgSend.Type != "" {
d, status, err := w.mgClient.MessageSend(msgSend)
if err != nil {
w.logger.Warningf("MessageSend status: %d\nMessageSend err: %v\nMessageSend data: %v", status, err, d)
continue
}
}
}
time.Sleep(500 * time.Millisecond)
}
}
func checkErrors(err errs.Failure) error {
if err.RuntimeErr != nil {
return err.RuntimeErr
}
if err.ApiErr != "" {
return errors.New(err.ApiErr)
}
return nil
}
func parseCommand(ci string) (co string, params v5.ProductsRequest, err error) {
s := strings.Split(ci, " ")
for _, cmd := range botCommands {
if s[0] == cmd {
if len(s) > 1 && cmd == CommandProduct {
params.Filter = v5.ProductsFilter{
Name: ci[len(CommandProduct)+1:],
}
}
co = s[0]
break
}
}
return
}
func (w *Worker) execCommand(message string) (resMes string, msgProd v1.MessageProduct, err error) {
var s []string
command, params, err := parseCommand(message)
if err != nil {
return
}
switch command {
case CommandPayment:
res, _, er := w.crmClient.PaymentTypes()
err = checkErrors(er)
if err != nil {
return
}
for _, v := range res.PaymentTypes {
if v.Active {
s = append(s, v.Name)
}
}
if len(s) > 0 {
resMes = fmt.Sprintf("%s\n\n", w.localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "payment_options"}))
}
case CommandDelivery:
res, _, er := w.crmClient.DeliveryTypes()
err = checkErrors(er)
if err != nil {
return
}
for _, v := range res.DeliveryTypes {
if v.Active {
s = append(s, v.Name)
}
}
if len(s) > 0 {
resMes = fmt.Sprintf("%s\n\n", w.localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "delivery_options"}))
}
case CommandProduct:
if params.Filter.Name == "" {
resMes = w.localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "set_name_or_article"})
return
}
res, _, er := w.crmClient.Products(params)
err = checkErrors(er)
if err != nil {
return
}
if len(res.Products) > 0 {
for _, vp := range res.Products {
if vp.Active {
vo := vp.Offers[0]
msgProd = v1.MessageProduct{
ID: uint64(vo.ID),
Name: vo.Name,
Article: vo.Article,
Url: vp.URL,
Img: vp.ImageURL,
Cost: &v1.MessageOrderCost{
Value: vo.Price,
Currency: w.connection.Currency,
},
}
if vp.Quantity > 0 {
msgProd.Quantity = &v1.MessageOrderQuantity{
Value: vp.Quantity,
Unit: w.localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "pieces"}),
}
}
if len(vo.Images) > 0 {
msgProd.Img = vo.Images[0]
}
return
}
}
}
}
if len(s) == 0 {
resMes = w.localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "not_found"})
return
}
if len(s) > 1 {
for k, v := range s {
var a string
for _, iv := range strings.Split(strconv.Itoa(k+1), "") {
t, _ := strconv.Atoi(iv)
a += emoji[t]
}
s[k] = fmt.Sprintf("%v %v", a, v)
}
}
str := strings.Join(s, "\n")
resMes += str
if len(resMes) > msgLen {
resMes = resMes[:msgLen]
}
return
}
func SetBotCommand(botURL, botToken string) (code int, err error) {
var client = v1.New(botURL, botToken)
_, code, err = client.CommandEdit(v1.CommandEditRequest{
Name: getTextCommand(CommandPayment),
Description: getLocalizedMessage("get_payment"),
})
_, code, err = client.CommandEdit(v1.CommandEditRequest{
Name: getTextCommand(CommandDelivery),
Description: getLocalizedMessage("get_delivery"),
})
_, code, err = client.CommandEdit(v1.CommandEditRequest{
Name: getTextCommand(CommandProduct),
Description: getLocalizedMessage("get_product"),
})
return
}
func getTextCommand(command string) string {
return strings.Replace(command, "/", "", -1)
}
func getLang(lang string) *i18n.Localizer {
tag, _ := language.MatchStrings(matcher, lang)
return i18n.NewLocalizer(bundle, tag.String())
}

BIN
static/font.woff2 Normal file

Binary file not shown.

2
static/jquery-3.3.1.min.js vendored Normal file

File diff suppressed because one or more lines are too long

39
static/logo.svg Normal file
View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1000px" height="1001px" viewBox="0 0 1000 1001" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 51.1 (57501) - http://www.bohemiancoding.com/sketch -->
<title>bot_logo</title>
<desc>Created with Sketch.</desc>
<defs>
<linearGradient x1="47.7902943%" y1="51.8931289%" x2="-6.26300543%" y2="-13.0383941%" id="linearGradient-1">
<stop stop-color="#FFB884" offset="0%"></stop>
<stop stop-color="#FA1E64" offset="100%"></stop>
</linearGradient>
<path d="M384.58278,-2.70545947e-12 L615.41722,-2.54241624e-12 C749.145031,-2.56698164e-12 797.637967,13.9238417 846.526856,40.0699056 C895.415744,66.2159695 933.784031,104.584256 959.930094,153.473144 C986.076158,202.362033 1000,250.854969 1000,384.58278 L1000,933.995792 C1000,956.243962 997.689913,964.312497 993.348569,972.451113 C989.007225,980.589729 982.634623,986.982248 974.509595,991.34897 C966.384567,995.715691 958.32328,998.050947 936.075219,998.120377 L300.936216,1000.10248 C135.251598,1000.61954 0.518518661,866.724773 0.00146083758,701.040154 C0.000486946452,700.728083 6.25593001e-13,700.416011 6.25277607e-13,700.103938 L2.86395841e-14,384.58278 C-1.06511505e-13,250.854969 13.9238417,202.362033 40.0699056,153.473144 C66.2159695,104.584256 104.584256,66.2159695 153.473144,40.0699056 C202.362033,13.9238417 250.854969,-2.56211992e-12 384.58278,-2.70545947e-12 Z" id="path-2"></path>
<linearGradient x1="56.2288411%" y1="88.8134955%" x2="31.8408203%" y2="-2.86783915%" id="linearGradient-4">
<stop stop-color="#FFB884" offset="0%"></stop>
<stop stop-color="#FA1E64" offset="100%"></stop>
</linearGradient>
<linearGradient x1="60.9521484%" y1="76.2483887%" x2="-6.40625%" y2="15.2701855%" id="linearGradient-5">
<stop stop-color="#FFB884" offset="0%"></stop>
<stop stop-color="#FA1E64" offset="100%"></stop>
</linearGradient>
<linearGradient x1="83.8134766%" y1="87.0133649%" x2="-6.26300543%" y2="-13.0383941%" id="linearGradient-6">
<stop stop-color="#FFB884" offset="0%"></stop>
<stop stop-color="#FA1E64" offset="100%"></stop>
</linearGradient>
</defs>
<g id="logo-bot" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="bot_logo">
<mask id="mask-3" fill="white">
<use xlink:href="#path-2"></use>
</mask>
<use id="Rectangle" fill="url(#linearGradient-1)" xlink:href="#path-2"></use>
<path d="M384.58278,200 L615.41722,200 C749.145031,200 797.637967,213.923842 846.526856,240.069906 C895.415744,266.215969 933.784031,304.584256 959.930094,353.473144 C986.076158,402.362033 1000,450.854969 1000,584.58278 L1000,814.37033 C1000,947.85935 986.139477,996.27056 960.091412,1045.10226 C934.043347,1093.93395 895.80774,1132.28907 847.057571,1158.4894 C798.307403,1184.68972 749.939683,1198.70126 616.451313,1199.11784 L300.936216,1200.10248 C135.251598,1200.61954 0.518518661,1066.72477 0.00146083758,901.040154 C0.000486946452,900.728083 6.25593001e-13,900.416011 6.25277607e-13,900.103938 L2.86395841e-14,584.58278 C-1.06511505e-13,450.854969 13.9238417,402.362033 40.0699056,353.473144 C66.2159695,304.584256 104.584256,266.215969 153.473144,240.069906 C202.362033,213.923842 250.854969,200 384.58278,200 Z" id="Rectangle" fill="url(#linearGradient-4)" mask="url(#mask-3)"></path>
<path d="M634.58278,-2.70545947e-12 L865.41722,-2.54241624e-12 C999.145031,-2.56698164e-12 1047.63797,13.9238417 1096.52686,40.0699056 C1145.41574,66.2159695 1183.78403,104.584256 1209.93009,153.473144 C1236.07616,202.362033 1250,250.854969 1250,384.58278 L1250,614.37033 C1250,747.85935 1236.13948,796.27056 1210.09141,845.102256 C1184.04335,893.933951 1145.80774,932.289067 1097.05757,958.489395 C1048.3074,984.689723 999.939683,998.701257 866.451313,999.117839 L550.936216,1000.10248 C385.251598,1000.61954 250.518519,866.724773 250.001461,701.040154 C250.000487,700.728083 250,700.416011 250,700.103938 L250,384.58278 C250,250.854969 263.923842,202.362033 290.069906,153.473144 C316.215969,104.584256 354.584256,66.2159695 403.473144,40.0699056 C452.362033,13.9238417 500.854969,-2.56211992e-12 634.58278,-2.70545947e-12 Z" id="Rectangle" fill="url(#linearGradient-5)" opacity="0.6" mask="url(#mask-3)"></path>
<path d="M-48.7505533,-400 L182.083887,-400 C315.811698,-400 364.304633,-386.076158 413.193522,-359.930094 C462.082411,-333.784031 500.450697,-295.415744 526.596761,-246.526856 C552.742825,-197.637967 566.666667,-149.145031 566.666667,-15.41722 L566.666667,214.37033 C566.666667,347.85935 552.806144,396.27056 526.758079,445.102256 C500.710014,493.933951 462.474407,532.289067 413.724238,558.489395 C364.97407,584.689723 316.606349,598.701257 183.11798,599.117839 L-132.397117,600.102478 C-298.081735,600.619535 -432.814815,466.724773 -433.331872,301.040154 C-433.332846,300.728083 -433.333333,300.416011 -433.333333,300.103938 L-433.333333,-15.41722 C-433.333333,-149.145031 -419.409492,-197.637967 -393.263428,-246.526856 C-367.117364,-295.415744 -328.749078,-333.784031 -279.860189,-359.930094 C-230.9713,-386.076158 -182.478364,-400 -48.7505533,-400 Z" id="Rectangle" fill="url(#linearGradient-6)" opacity="0.5" mask="url(#mask-3)"></path>
<path d="M766.666667,366.666667 C766.666667,311.438192 721.895142,266.666667 666.666667,266.666667 L366.666667,266.666667 C311.438192,266.666667 266.666667,311.438192 266.666667,366.666667 L266.666667,560.681238 C266.829074,616.20276 311.731714,660.842784 366.959952,660.680808 L654.098378,659.83867 C665.461733,659.805343 676.645009,662.677161 686.58633,668.18144 L766.666667,712.520063 L766.666667,366.666667 Z M366.666667,200 L666.666667,200 C758.714125,200 833.333333,274.619208 833.333333,366.666667 L833.333333,797.356286 C833.333333,806.561032 825.871412,814.022953 816.666667,814.022953 C813.842374,814.022953 811.064404,813.305238 808.593559,811.937189 L654.293901,726.50505 L367.155475,727.347187 C275.108413,727.617149 200.270678,653.217108 200.000717,561.170046 C200.000398,561.061422 200.000159,496.226962 200,366.666667 C200,274.619208 274.619208,200 366.666667,200 Z" id="Rectangle" fill="#FFFFFF" fill-rule="nonzero" mask="url(#mask-3)" transform="translate(516.666667, 507.011476) scale(-1, 1) translate(-516.666667, -507.011476) "></path>
<path d="M616.666667,438.798828 C616.666667,420.389336 631.590508,405.465495 650,405.465495 C668.409492,405.465495 683.333333,420.389336 683.333333,438.798828 L683.333333,488.798828 C683.333333,507.20832 668.409492,522.132161 650,522.132161 C631.590508,522.132161 616.666667,507.20832 616.666667,488.798828 L616.666667,438.798828 Z" id="Line" fill="#FFFFFF" fill-rule="nonzero" mask="url(#mask-3)"></path>
<path d="M366.666667,438.798828 C366.666667,420.389336 381.590508,405.465495 400,405.465495 C418.409492,405.465495 433.333333,420.389336 433.333333,438.798828 L433.333333,488.798828 C433.333333,507.20832 418.409492,522.132161 400,522.132161 C381.590508,522.132161 366.666667,507.20832 366.666667,488.798828 L366.666667,438.798828 Z" id="Line" fill="#FFFFFF" fill-rule="nonzero" mask="url(#mask-3)"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

13
static/materialize.min.css vendored Normal file

File diff suppressed because one or more lines are too long

6
static/materialize.min.js vendored Normal file

File diff suppressed because one or more lines are too long

110
static/script.js Normal file
View file

@ -0,0 +1,110 @@
$('#save-crm').on("submit", function(e) {
e.preventDefault();
let formData = formDataToObj($(this).serializeArray());
$(this).find('button.btn').addClass('disabled');
$(this).find(".material-icons").addClass('animate');
$("form :input").prop("disabled", true);
send(
$(this).attr('action'),
formData,
function (data) {
sessionStorage.setItem("createdMsg", data.message);
document.location.replace(
location.protocol.concat("//").concat(window.location.host) + data.url
);
}
)
});
$("#but-settings").on("click", function(e) {
e.preventDefault();
$(this).addClass('disabled');
$(this).find(".material-icons").addClass('animate');
send(
$(this).attr('data-action'),
{
client_id: $(this).attr('data-clientID'),
lang: $("select#lang").find(":selected").text(),
currency: $("select#currency").find(":selected").val()
},
function (data) {
M.toast({
html: data.msg,
displayLength: 1000,
completeCallback: function(){
$(document).find('#but-settings').removeClass('disabled');
$(document).find(".material-icons").removeClass('animate');
}
});
}
)
});
$("#save").on("submit", function(e) {
e.preventDefault();
let formData = formDataToObj($(this).serializeArray());
$(this).find('button.btn').addClass('disabled');
$(this).find(".material-icons").addClass('animate');
$("form :input").prop("disabled", true);
send(
$(this).attr('action'),
formData,
function (data) {
M.toast({
html: data.msg,
displayLength: 1000,
completeCallback: function(){
$(document).find('button.btn').removeClass('disabled');
$(document).find(".material-icons").removeClass('animate');
$("form :input").prop("disabled", false);
}
});
}
)
});
function send(url, data, callback) {
$.ajax({
url: url,
data: JSON.stringify(data),
type: "POST",
success: callback,
error: function (res) {
if (res.status >= 400) {
M.toast({
html: res.responseJSON.error,
displayLength: 1000,
completeCallback: function(){
$(document).find('button.btn').removeClass('disabled');
$(document).find(".material-icons").removeClass('animate');
$("form :input").prop("disabled", false);
}
})
}
}
});
}
function formDataToObj(formArray) {
let obj = {};
for (let i = 0; i < formArray.length; i++){
obj[formArray[i]['name']] = formArray[i]['value'];
}
return obj;
}
$( document ).ready(function() {
$('select').formSelect();
M.Tabs.init(document.getElementById("tab"));
let createdMsg = sessionStorage.getItem("createdMsg");
if (createdMsg) {
setTimeout(function() {
M.toast({
html: createdMsg
});
sessionStorage.removeItem("createdMsg");
}, 1000);
}
});

178
static/style.css Normal file
View file

@ -0,0 +1,178 @@
/* fallback */
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(font.woff2) format('woff2');
}
.material-icons {
font-family: 'Material Icons', sans-serif;
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
body {
display: flex;
min-height: 100vh;
flex-direction: column;
}
main {
flex: 1 0 auto;
}
.indent-top {
margin-top: 2%;
}
.text-left {
text-align: right;
}
#tab {
width: 50%;
margin: 0 auto 23px;
}
.tab-el-center,
.footer-copyright {
width: 67%;
margin: 0 auto;
}
#bots .deletebot {
float: right;
}
#bots {
font-size: 12px;
}
#bots .select-wrapper input.select-dropdown,
#bots span {
font-size: 12px;
}
#msg {
height: 23px;
}
#logo {
height: 100px;
margin-bottom: 20px;
}
/* label color */
.input-field label {
color: #ef5350;
}
/* label focus color */
.input-field input[type=text]:focus + label {
color: #ef5350;
}
/* label underline focus color */
.input-field input[type=text]:focus {
border-bottom: 1px solid #ef5350;
box-shadow: 0 1px 0 0 #ef5350;
}
/* valid color */
.input-field input[type=text].valid {
border-bottom: 1px solid #ef5350;
box-shadow: 0 1px 0 0 #ef5350;
}
/* invalid color */
.input-field input[type=text].invalid {
border-bottom: 1px solid #c62828;
box-shadow: 0 1px 0 0 #c62828;
}
/* icon prefix focus color */
.input-field .prefix.active {
color: #ef5350;
}
.tabs .tab a {
color: #ef5350;
display: block;
width: 100%;
height: 100%;
padding: 0 24px;
font-size: 14px;
text-overflow: ellipsis;
overflow: hidden;
-webkit-transition: color .28s ease, background-color .28s ease;
transition: color .28s ease, background-color .28s ease;
}
.tabs .tab a:focus, .tabs .tab a:focus.active {
background-color: #e1f5fe;
outline: none;
}
.tabs .tab a:hover, .tabs .tab a.active {
background-color: transparent;
color: #ef5350;
}
.tabs .tab.disabled a,
.tabs .tab.disabled a:hover {
color: #ef5350;
cursor: default;
}
.tabs .indicator {
position: absolute;
bottom: 0;
height: 2px;
background-color: #ef5350;
will-change: left, right;
}
a.btn-floating img {
height: 40px;
width: 40px;
}
.lang-select,
.currency-select {
width: 30%;
margin: 40px auto 0;
}
.select-wrapper ul li span {
color: #ef5350;
}
.footer-copyright {
border-top: 1px solid #9e9e9e;
margin-top: 10px;
}
.footer-copyright p {
color: #9e9e9e;
}
.animate {
transition: all 0.5s ease;
animation: rotate 1s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(360deg);
}
}

1
static/style.min.css vendored Normal file
View file

@ -0,0 +1 @@
@font-face{font-family:'Material Icons';font-style:normal;font-weight:400;src:url(font.woff2) format('woff2')}.material-icons{font-family:'Material Icons',sans-serif;font-weight:normal;font-style:normal;font-size:24px;line-height:1;letter-spacing:normal;text-transform:none;display:inline-block;white-space:nowrap;word-wrap:normal;direction:ltr;-webkit-font-feature-settings:'liga';-webkit-font-smoothing:antialiased}body{display:flex;min-height:100vh;flex-direction:column}main{flex:1 0 auto}.indent-top{margin-top:2%}.text-left{text-align:right}#tab{width:50%;margin:0 auto 23px}.tab-el-center,.footer-copyright{width:67%;margin:0 auto}#bots .deletebot{float:right}#bots{font-size:12px}#bots .select-wrapper input.select-dropdown,#bots span{font-size:12px}#msg{height:23px}#logo{height:100px;margin-bottom:20px}.input-field label{color:#ef5350}.input-field input[type=text]:focus+label{color:#ef5350}.input-field input[type=text]:focus{border-bottom:1px solid #ef5350;box-shadow:0 1px 0 0 #ef5350}.input-field input[type=text].valid{border-bottom:1px solid #ef5350;box-shadow:0 1px 0 0 #ef5350}.input-field input[type=text].invalid{border-bottom:1px solid #c62828;box-shadow:0 1px 0 0 #c62828}.input-field .prefix.active{color:#ef5350}.tabs .tab a{color:#ef5350;display:block;width:100%;height:100%;padding:0 24px;font-size:14px;text-overflow:ellipsis;overflow:hidden;-webkit-transition:color .28s ease,background-color .28s ease;transition:color .28s ease,background-color .28s ease}.tabs .tab a:focus,.tabs .tab a:focus.active{background-color:#e1f5fe;outline:0}.tabs .tab a:hover,.tabs .tab a.active{background-color:transparent;color:#ef5350}.tabs .tab.disabled a,.tabs .tab.disabled a:hover{color:#ef5350;cursor:default}.tabs .indicator{position:absolute;bottom:0;height:2px;background-color:#ef5350;will-change:left,right}a.btn-floating img{height:40px;width:40px}.lang-select,.currency-select{width:30%;margin:40px auto 0}.select-wrapper ul li span{color:#ef5350}.footer-copyright{border-top:1px solid #9e9e9e;margin-top:10px}.footer-copyright p{color:#9e9e9e}.animate{transition:all .5s ease;animation:rotate 1s linear infinite}@keyframes rotate{from{transform:rotate(360deg)}}

68
templates/form.html Normal file
View file

@ -0,0 +1,68 @@
{{define "body"}}
<div class="row indent-top">
<div class="col s12">
<ul class="tabs" id="tab">
<li class="tab col s6"><a class="active" href="#tab1">{{.Locale.TabSettings}}</a></li>
<li class="tab col s6"><a class="" href="#tab2">{{.Locale.TabBots}}</a></li>
</ul>
</div>
<div id="tab1" class="col s12">
<div class="row indent-top">
<form id="save" class="tab-el-center" action="/save/" method="POST">
<input name="clientId" type="hidden" value="{{.Conn.ClientID}}">
<div class="row">
<div class="input-field col s12">
<input placeholder="{{.Locale.ApiUrl}}" id="api_url" name="api_url" type="text" class="validate" value="{{.Conn.APIURL}}">
</div>
</div>
<div class="row">
<div class="input-field col s12">
<input placeholder="{{.Locale.ApiKey}}" id="api_key" name="api_key" type="text" class="validate" value="{{.Conn.APIKEY}}">
</div>
</div>
<div class="row">
<div class="input-field col s12 center-align">
<button class="btn waves-effect waves-light red lighten-1" type="submit" name="action">
{{.Locale.ButtonSave}}
<i class="material-icons right">sync</i>
</button>
</div>
</div>
</form>
</div>
</div>
<div id="tab2" class="col s12">
<div class="row indent-top">
<div class="lang-select">
{{$LangCode := .LangCode}}
{{$lang := .Conn.Lang}}
<label>{{.Locale.Language}}</label>
<select id="lang">
{{range $key, $value := $LangCode}}
<option value="{{$value}}" {{if eq $value $lang}}selected{{end}}>{{$value}}</option>
{{end}}
</select>
</div>
<div class="currency-select">
{{$CurrencyCode := .CurrencyCode}}
{{$currency := .Conn.Currency}}
<label>Валюта</label>
<select id="currency">
{{range $key, $value := $CurrencyCode}}
<option value="{{$value}}" {{if eq $value $currency}}selected{{end}}>{{$key}}</option>
{{end}}
</select>
</div>
</div>
<div class="row">
<div class="input-field col s12 center-align">
<button id="but-settings" class="btn waves-effect waves-light red lighten-1" type="submit" name="action"
data-clientID="{{.Conn.ClientID}}" data-action="/bot-settings/">
{{.Locale.ButtonSave}}
<i class="material-icons right">sync</i>
</button>
</div>
</div>
</div>
</div>
{{end}}

26
templates/home.html Normal file
View file

@ -0,0 +1,26 @@
{{define "body"}}
<div class="row indent-top home">
<form class="tab-el-center" method="POST" id="save-crm" action="/create/">
<div id="msg"></div>
<div class="row">
<div class="input-field col s12">
<input placeholder="CRM Url" id="api_url" name="api_url" type="text" class="validate"
{{if .Conn.APIURL}} value="{{.Conn.APIURL}}" {{end}}>
</div>
</div>
<div class="row">
<div class="input-field col s12">
<input placeholder="{{.Locale.ApiKey}}" id="api_key" name="api_key" type="text" class="validate">
</div>
</div>
<div class="row">
<div class="input-field col s12 center-align">
<button class="btn waves-effect waves-light red lighten-1" type="submit" name="action">
{{.Locale.ButtonSave}}
<i class="material-icons right">sync</i>
</button>
</div>
</div>
</form>
</div>
{{end}}

34
templates/layout.html Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,48 @@
button_save: Save
tab_settings: CRM settings
tab_bots: Bot
table_url: URL
table_token: Token
table_activity: Activity mark
api_key: API key
api_url: URL of CRM
add_bot: Add a bot
title: Module of connecting mg-bot to retailCRM
successful: Data was updated successfully
language: Language
no_bot_token: Enter token
no_bot_url: Enter URL
wrong_data: Wrong data
set_method: Set POST method
bot_already_created: Bot is already created
not_found_account: Account is not found, contact technical support
error_activating_channel: Error when activating a channel
error_deactivating_channel: Error when deactivating a channel
incorrect_url_key: Enter the correct URL or API key
error_creating_integration: Error when integrating
error_creating_connection: Error when establishing a connection
connection_already_created: Connection is already established
missing_url_key: URL and API key are missing
incorrect_url: Enter the correct URL of CRM
incorrect_key: "[Enter the correct apiKey]"
incorrect_token: Create the correct token
error_creating_webhook: Error when creating a webhook
error_adding_bot: Error when adding a bot
error_save: Error while saving, contact technical support
error_delete: Error while deleting, contact technical support
missing_credentials: "Required methods: {{.Credentials}}"
error_activity_mg: Check if the integration with retailCRM Chat is enabled in CRM settings
crm_link: "<a href='//www.retailcrm.pro' title='retailCRM'>retailCRM</a>"
doc_link: "<a href='//www.retailcrm.pro/docs' target='_blank'>documentation</a>"
set_name_or_article: Enter product name or article number
product_response: "Name: {{.Name}}\nStocks: {{.Quantity}} pcs\nPrice: {{.Price}} {{.Currency}}\nImage: {{.ImageURL}}"
not_found: Nothing is found for the specified parameters
get_payment: Get available payment types
get_delivery: Get available types of deliveries
get_product: Get product by article or name
payment_options: "Payment options:"
delivery_options: "Delivery options:"
pieces: "pcs."

View file

@ -0,0 +1,48 @@
button_save: Guardar
tab_settings: Ajustes CRM
tab_bots: Bot
table_url: URL
table_token: Token
table_activity: Marca de actividad
api_key: API key
api_url: URL de CRM
add_bot: Añadir un bot
title: Módulo de conexión de mg-bot con retailCRM
successful: Datos actualizados con éxito
language: Idioma
no_bot_token: Introducir token
no_bot_url: Introducir URL
wrong_data: Datos erróneos
set_method: Establecer método POST
bot_already_created: El bot está creado
not_found_account: Cuenta no encontrada, contacte con el soporte técnico
error_activating_channel: Error al activar un canal
error_deactivating_channel: Error al desactivar un canal
incorrect_url_key: Introduzca la URL correcta o el API key
error_creating_integration: Error al integrar
error_creating_connection: Error al establecer la conexión
connection_already_created: Conexión establecida con éxito
missing_url_key: Faltan la URL y API key
incorrect_url: Introduzca la URL correcta de CRM
incorrect_key: "[Introduzca la API key correcta]"
incorrect_token: Cree el token correcto
error_creating_webhook: Error al crear el webhook
error_adding_bot: Error al añadir un bot
error_save: Error al guardar, contacte con el soporte técnico
error_delete: Error al eliminar, póngase en contacto con el soporte técnico
missing_credentials: "Required methods: {{.Credentials}}"
error_activity_mg: Compruebe que la integración con retailCRM Chat está habilitada en Ajustes de CRM
crm_link: "<a href='//www.retailcrm.pro' title='retailCRM'>retailCRM</a>"
doc_link: "<a href='//www.retailcrm.pro/docs' target='_blank'>documentación</a>"
set_name_or_article: Indique el nombre o el número de artículo
product_response: "Nombre: {{.Name}}\nStock: {{.Quantity}} piezas\nPrecio: {{.Price}} {{.Currency}}\nImagen: {{.ImageURL}}"
not_found: No se encontró nada con los parámetros especificados
get_payment: Obtener tipos de pago disponibles
get_delivery: Obtener tipos de entrega disponibles
get_product: Recibir los productos por el artículo o el nombre
payment_options: "Opciones de pago:"
delivery_options: "Opciones de entrega:"
pieces: "piezas"

View file

@ -0,0 +1,48 @@
button_save: Сохранить
tab_settings: Настройки CRM
tab_bots: Бот
table_url: URL
table_token: Токен
table_activity: Активность
api_key: API Ключ
api_url: Адрес CRM
add_bot: Добавить бота
title: Модуль подключения mg-bot к retailCRM
successful: Данные успешно обновлены
language: Язык
no_bot_token: Введите токен
no_bot_url: Введите URL
wrong_data: Неверные данные
set_method: Установить метод POST
bot_already_created: Бот уже создан
not_found_account: Не удалось найти учетную запись, обратитесь в службу технической поддержки
error_activating_channel: Ошибка при активации канала
error_deactivating_channel: Ошибка при отключении канала
incorrect_url_key: Введите корректный URL или apiKey
error_creating_integration: Ошибка при создании интеграции
error_creating_connection: Ошибка при создании соединения
connection_already_created: Соединение уже создано
missing_url_key: Отсутствует URL или apiKey
incorrect_url: Введите корректный URL CRM
incorrect_key: "[Введите корректный apiKey]"
incorrect_token: Установите корректный токен
error_creating_webhook: Ошибка при создании webhook
error_adding_bot: Ошибка при добавлении бота
error_save: Ошибка при сохранении, обратитесь в службу технической поддержки
error_delete: Ошибка при удалении, обратитесь в службу технической поддержки
missing_credentials: "Необходимые методы: {{.Credentials}}"
error_activity_mg: Проверьте активность интеграции с retailCRM Chat в настройках CRM
crm_link: "<a href='//www.retailcrm.ru' title='retailCRM'>retailCRM</a>"
doc_link: "<a href='//www.retailcrm.ru/docs' target='_blank'>документация</a>"
set_name_or_article: Укажите название или артикул товара
product_response: "Название: {{.Name}}\nОстаток: {{.Quantity}} шт\nЦена: {{.Price}} {{.Currency}}\nИзображение: {{.ImageURL}}"
not_found: По указанным параметрам ничего не найдено
get_payment: Получить доступные типы оплат
get_delivery: Получить доступные типы доставок
get_product: Получить товар по артикулу или наименованию
payment_options: "Варианты оплаты:"
delivery_options: "Варианты доставки:"
pieces: "шт."