From 66f61408a753c1933c076e671a36ba6387f731bc Mon Sep 17 00:00:00 2001 From: Ilham Fadhilah Date: Thu, 9 May 2024 21:00:14 +0700 Subject: [PATCH] feat: Event listener, docker support and development environment Event listener can be processed and sent as a webhook Adding docker support for further development and scaling development --- .air.toml | 44 ++++++ .gitignore | 7 +- Makefile | 4 + README.md | 4 +- dev/docker/.env | 48 ++++++ dev/docker/.env.docker.example | 2 + Dockerfile => dev/docker/Dockerfile | 3 +- dev/docker/air/.air.toml | 44 ++++++ dev/docker/docker-compose.override.yaml | 20 +++ dev/docker/docker-compose.production.yaml | 22 +++ dev/docker/docker-compose.yaml | 75 ++++++++++ dev/docker/nginx/nginx.conf | 19 +++ docker-compose.yaml => docker-compose.yml | 2 +- docs/docs.go | 49 ++++--- docs/swagger.json | 44 +++--- docs/swagger.yaml | 44 +++--- internal/startup.go | 1 + pkg/app/app.go | 76 ++++++++++ pkg/app/database/container.go | 57 ++++++++ pkg/app/database/model.go | 10 ++ pkg/app/database/upgrade.go | 112 ++++++++++++++ pkg/app/drivers.go | 6 + pkg/app/http/request.go | 169 ++++++++++++++++++++++ pkg/utils/json.go | 19 +++ pkg/whatsapp/whatsapp.go | 28 ++++ 25 files changed, 835 insertions(+), 74 deletions(-) create mode 100644 .air.toml create mode 100644 dev/docker/.env create mode 100644 dev/docker/.env.docker.example rename Dockerfile => dev/docker/Dockerfile (98%) create mode 100644 dev/docker/air/.air.toml create mode 100644 dev/docker/docker-compose.override.yaml create mode 100644 dev/docker/docker-compose.production.yaml create mode 100644 dev/docker/docker-compose.yaml create mode 100644 dev/docker/nginx/nginx.conf rename docker-compose.yaml => docker-compose.yml (93%) create mode 100644 pkg/app/app.go create mode 100644 pkg/app/database/container.go create mode 100644 pkg/app/database/model.go create mode 100644 pkg/app/database/upgrade.go create mode 100644 pkg/app/drivers.go create mode 100644 pkg/app/http/request.go create mode 100644 pkg/utils/json.go diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..0fb22c5 --- /dev/null +++ b/.air.toml @@ -0,0 +1,44 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ./cmd/main/main.go" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata", "volumes", "dbs", "node_modules", "misc"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "10s" + log = "build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true \ No newline at end of file diff --git a/.gitignore b/.gitignore index e82245c..7cb6db1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,9 @@ dbs/* .bin/ .env *.DS_Store -!*.gitkeep \ No newline at end of file +!*.gitkeep +dev/docker/volumes +dev/docker/.env.docker +!dev/docker/.env +tmp +node_modules \ No newline at end of file diff --git a/Makefile b/Makefile index f1c20a6..41f5709 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,10 @@ run: make vendor go run cmd/main/*.go +run-dev: + make vendor + air -c .air.toml || echo "Please ensure 'air' is installed and '.air.toml' exists." + gen-docs: rm -rf docs/* swag init -g cmd/main/main.go --output docs diff --git a/README.md b/README.md index a6ec06f..e4e52e3 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,9 @@ make vendor 5) Link or copy environment variables file ```sh -ln -sf .env.development .env +ln -sf .env.example .env # - OR - -cp .env.development .env +cp .env.example .env ``` 6) Until this step you already can run this code by using this command diff --git a/dev/docker/.env b/dev/docker/.env new file mode 100644 index 0000000..a204b42 --- /dev/null +++ b/dev/docker/.env @@ -0,0 +1,48 @@ +# ----------------------------------- +# Server Configuration +# ----------------------------------- +SERVER_ADDRESS=0.0.0.0 +SERVER_PORT=1321 + +# ----------------------------------- +# HTTP Configuration +# ----------------------------------- +HTTP_BASE_URL=/api/v1/whatsapp + +# HTTP_CORS_ORIGIN=* +# HTTP_BODY_LIMIT_SIZE=8m +# HTTP_GZIP_LEVEL=1 + +# HTTP_CACHE_CAPACITY=100 +# HTTP_CACHE_TTL_SECONDS=5 + +# ----------------------------------- +# Authentication Configuration +# ----------------------------------- +AUTH_BASIC_USERNAME=root +AUTH_BASIC_PASSWORD=root + +AUTH_JWT_SECRET=ThisIsJWTSecret +AUTH_JWT_EXPIRED_HOUR=24 + +# ----------------------------------- +# WhatsApp Configuration +# ----------------------------------- +WHATSAPP_DATASTORE_TYPE=postgres +WHATSAPP_DATASTORE_URI=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable +# WHATSAPP_DATASTORE_TYPE=sqlite +# WHATSAPP_DATASTORE_URI=file:dbs/WhatsApp.db?_pragma=foreign_keys(1) + +WHATSAPP_CLIENT_PROXY_URL="" + +WHATSAPP_MEDIA_IMAGE_COMPRESSION=true +WHATSAPP_MEDIA_IMAGE_CONVERT_WEBP=true + +# WHATSAPP_VERSION_MAJOR=2 +# WHATSAPP_VERSION_MINOR=2411 +# WHATSAPP_VERSION_PATCH=2 + +# ----------------------------------- +# 3rd Party Configuration +# ----------------------------------- +LIBWEBP_VERSION=0.6.1 diff --git a/dev/docker/.env.docker.example b/dev/docker/.env.docker.example new file mode 100644 index 0000000..64204ad --- /dev/null +++ b/dev/docker/.env.docker.example @@ -0,0 +1,2 @@ +APP_WEBHOOK_URL_TARGET= +APP_WEBHOOK_BASIC_AUTH= diff --git a/Dockerfile b/dev/docker/Dockerfile similarity index 98% rename from Dockerfile rename to dev/docker/Dockerfile index 9447815..35c2f1f 100644 --- a/Dockerfile +++ b/dev/docker/Dockerfile @@ -27,7 +27,6 @@ RUN mkdir -p {.bin/webp,dbs} \ COPY --from=go-builder /usr/src/app/.env.example ./.env COPY --from=go-builder /usr/src/app/main ./main -EXPOSE 3000 - VOLUME ["/usr/app/${SERVICE_NAME}/dbs"] CMD ["main"] + \ No newline at end of file diff --git a/dev/docker/air/.air.toml b/dev/docker/air/.air.toml new file mode 100644 index 0000000..450589c --- /dev/null +++ b/dev/docker/air/.air.toml @@ -0,0 +1,44 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "/app/src/tmp/main" + cmd = "go build -o /app/src/tmp/main /app/src/cmd/main/main.go" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata", "volumes", "dbs", "node_modules", "misc"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "10s" + log = "build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true \ No newline at end of file diff --git a/dev/docker/docker-compose.override.yaml b/dev/docker/docker-compose.override.yaml new file mode 100644 index 0000000..64819c1 --- /dev/null +++ b/dev/docker/docker-compose.override.yaml @@ -0,0 +1,20 @@ +x-app: &whatsapp-rest + image: 'cosmtrek/air:v1.61.7' + # entrypoint: ['tail', '-f', '/dev/null'] + command: ['-c', '/app/src/misc/docker/air/.air.toml'] + depends_on: + - postgres + volumes: + - ../../:/app/src + - ./.env:/app/src/.env + - ./volumes/dbs:/app/src/dbs + +services: + whatsapp-rest-1: + <<: *whatsapp-rest + + # whatsapp-rest-2: + # <<: *whatsapp-rest + + # whatsapp-rest-3: + # <<: *whatsapp-rest diff --git a/dev/docker/docker-compose.production.yaml b/dev/docker/docker-compose.production.yaml new file mode 100644 index 0000000..f10363a --- /dev/null +++ b/dev/docker/docker-compose.production.yaml @@ -0,0 +1,22 @@ +x-app: &whatsapp-rest + build: + context: ../.. + dockerfile: ./misc/docker/Dockerfile + image: 'ilhamfadhilah/whatsapp-rest:local' + depends_on: + - postgres + env_file: + - ./.env + - ./.env.docker + volumes: + - ./volumes/dbs:/usr/app/go-whatsapp-multidevice-rest/dbs + +services: + whatsapp-rest-1: + <<: *whatsapp-rest + + # whatsapp-rest-2: + # <<: *whatsapp-rest + + # whatsapp-rest-3: + # <<: *whatsapp-rest diff --git a/dev/docker/docker-compose.yaml b/dev/docker/docker-compose.yaml new file mode 100644 index 0000000..7deb2fe --- /dev/null +++ b/dev/docker/docker-compose.yaml @@ -0,0 +1,75 @@ +x-app: &whatsapp-rest + depends_on: + - postgres + +services: + whatsapp-rest-1: + <<: *whatsapp-rest + + # whatsapp-rest-2: + # <<: *whatsapp-rest + + # whatsapp-rest-3: + # <<: *whatsapp-rest + + postgres: + image: postgres:16.2 + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + volumes: + - './volumes/postgres:/var/lib/postgresql/data' + ports: + - "0.0.0.0:5432:5432" + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres -d postgres'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + networks: + default: + pgadmin: + image: dpage/pgadmin4 + restart: always + ports: + - '8888:80' + depends_on: + - postgres + environment: + PGADMIN_DEFAULT_EMAIL: admin@test.net + PGADMIN_DEFAULT_PASSWORD: 123456 + volumes: + - ./volumes/pgadmin:/var/lib/pgadmin + networks: + default: + nginx: + image: nginx:latest + container_name: nginx + ports: + - "1321:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - whatsapp-rest-1 + # - whatsapp-rest-2 + # - whatsapp-rest-3 + webhook-tester: + image: tarampampam/webhook-tester + command: serve --port 8080 --storage-driver redis --pubsub-driver redis --redis-dsn redis://redis:6379/0 + ports: + - 8080:8080 + depends_on: + redis: + condition: service_healthy + redis: + image: redis:7-alpine + volumes: + - ./volumes/redis:/data + ports: + - 6379:6379 + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 1s \ No newline at end of file diff --git a/dev/docker/nginx/nginx.conf b/dev/docker/nginx/nginx.conf new file mode 100644 index 0000000..b57f44c --- /dev/null +++ b/dev/docker/nginx/nginx.conf @@ -0,0 +1,19 @@ +events {} + +http { + upstream backend_servers { + server whatsapp-rest-1:1321; + # server whatsapp-rest-2:1321; + # server whatsapp-rest-3:1321; + } + + server { + listen 80; + + location / { + proxy_pass http://backend_servers; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + } +} diff --git a/docker-compose.yaml b/docker-compose.yml similarity index 93% rename from docker-compose.yaml rename to docker-compose.yml index 43d8a66..3e772e6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yml @@ -19,4 +19,4 @@ services: - ./.env volumes: - ./dbs:/usr/app/go-whatsapp-multidevice-rest/dbs - restart: unless-stopped + restart: unless-stopped \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index fb72c30..6a0f876 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1,5 +1,4 @@ -// Package docs GENERATED BY SWAG; DO NOT EDIT -// This file was generated by swaggo/swag +// Package docs Code generated by swaggo/swag. DO NOT EDIT package docs import "github.com/swaggo/swag" @@ -32,7 +31,7 @@ const docTemplate = `{ "summary": "Show The Status of The Server", "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -54,7 +53,7 @@ const docTemplate = `{ "summary": "Generate Authentication Token", "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -76,7 +75,7 @@ const docTemplate = `{ "summary": "Get Joined Groups Information", "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -107,7 +106,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -138,7 +137,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -177,7 +176,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -202,7 +201,7 @@ const docTemplate = `{ "summary": "Pair Phone for WhatsApp Multi-Device Login", "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -224,7 +223,7 @@ const docTemplate = `{ "summary": "Logout Device from WhatsApp Multi-Device", "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -265,7 +264,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -313,7 +312,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -361,7 +360,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -392,7 +391,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -433,7 +432,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -481,7 +480,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -522,7 +521,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -577,7 +576,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -624,7 +623,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -672,7 +671,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -727,7 +726,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -768,7 +767,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -809,7 +808,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -864,7 +863,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -892,6 +891,8 @@ var SwaggerInfo = &swag.Spec{ Description: "This is WhatsApp Multi-Device Implementation in Go REST API", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", } func init() { diff --git a/docs/swagger.json b/docs/swagger.json index 99cd3eb..9c0242b 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -23,7 +23,7 @@ "summary": "Show The Status of The Server", "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -45,7 +45,7 @@ "summary": "Generate Authentication Token", "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -67,7 +67,7 @@ "summary": "Get Joined Groups Information", "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -98,7 +98,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -129,7 +129,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -168,7 +168,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -193,7 +193,7 @@ "summary": "Pair Phone for WhatsApp Multi-Device Login", "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -215,7 +215,7 @@ "summary": "Logout Device from WhatsApp Multi-Device", "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -256,7 +256,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -304,7 +304,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -352,7 +352,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -383,7 +383,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -424,7 +424,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -472,7 +472,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -513,7 +513,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -568,7 +568,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -615,7 +615,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -663,7 +663,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -718,7 +718,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -759,7 +759,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -800,7 +800,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -855,7 +855,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d1db7c8..c80c5f7 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -14,7 +14,7 @@ paths: - application/json responses: "200": - description: "" + description: OK summary: Show The Status of The Server tags: - Root @@ -25,7 +25,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BasicAuth: [] summary: Generate Authentication Token @@ -38,7 +38,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Get Joined Groups Information @@ -57,7 +57,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Join Group From Invitation Link @@ -76,7 +76,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Leave Group By Group ID @@ -101,7 +101,7 @@ paths: - text/html responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Generate QR Code for WhatsApp Multi-Device Login @@ -116,7 +116,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Pair Phone for WhatsApp Multi-Device Login @@ -129,7 +129,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Logout Device from WhatsApp Multi-Device @@ -155,7 +155,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Delete Message @@ -186,7 +186,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Update Message @@ -217,7 +217,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: React Message @@ -236,7 +236,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Check If WhatsApp Personal ID is Registered @@ -262,7 +262,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Send Audio Message @@ -294,7 +294,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Send Contact Message @@ -321,7 +321,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Send Document Message @@ -357,7 +357,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Send Image Message @@ -387,7 +387,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Send Link Message @@ -419,7 +419,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Send Location Message @@ -455,7 +455,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Send Poll @@ -482,7 +482,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Send Sticker Message @@ -508,7 +508,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Send Text Message @@ -544,7 +544,7 @@ paths: - application/json responses: "200": - description: "" + description: OK security: - BearerAuth: [] summary: Send Video Message diff --git a/internal/startup.go b/internal/startup.go index f821d82..d376b83 100644 --- a/internal/startup.go +++ b/internal/startup.go @@ -1,6 +1,7 @@ package internal import ( + _ "github.com/dimaskiddo/go-whatsapp-multidevice-rest/pkg/app" "github.com/dimaskiddo/go-whatsapp-multidevice-rest/pkg/log" pkgWhatsApp "github.com/dimaskiddo/go-whatsapp-multidevice-rest/pkg/whatsapp" ) diff --git a/pkg/app/app.go b/pkg/app/app.go new file mode 100644 index 0000000..1733d67 --- /dev/null +++ b/pkg/app/app.go @@ -0,0 +1,76 @@ +package app + +import ( + "log" + "time" + + "github.com/dimaskiddo/go-whatsapp-multidevice-rest/pkg/app/database" + "github.com/dimaskiddo/go-whatsapp-multidevice-rest/pkg/app/http" + "github.com/dimaskiddo/go-whatsapp-multidevice-rest/pkg/env" +) + +var ( + AppWebhookURL string + AppWebhookBasicAuth string + AppDatabase *database.DatabaseContainer + AppRequest *http.HttpClient +) + +func init() { + var err error + + dbType, err := env.GetEnvString("WHATSAPP_DATASTORE_TYPE") + if err != nil { + log.Fatal("Error Parse Environment Variable for Application Datastore Type") + } + + dbURI, err := env.GetEnvString("WHATSAPP_DATASTORE_URI") + if err != nil { + log.Fatal("Error Parse Environment Variable for Application Datastore URI") + } + + // Initialize App Client Datastore + initDB(dbType, dbURI) + + appWebhookUrl, err := env.GetEnvString("APP_WEBHOOK_URL_TARGET") + if err != nil { + log.Fatal("Error Parse Environment Variable for App Webhook URL Target") + } + AppWebhookURL = appWebhookUrl + + appWebhookBasicAuth, err := env.GetEnvString("APP_WEBHOOK_BASIC_AUTH") + if err != nil { + AppWebhookBasicAuth = "" + } + AppWebhookBasicAuth = appWebhookBasicAuth + + // Initialize App HTTP Request + initHttpRequest() +} + +func initDB(dbType string, dbURI string) { + // Initialize App Client Datastore + appDb, err := database.New(dbType, dbURI) + if err != nil { + log.Fatal("Error Connect Application Datastore: ", err) + } + AppDatabase = appDb +} + +func initHttpRequest() { + // Initialize App HTTP Request + headers := map[string]string{ + "Content-Type": "application/json", + } + + if AppWebhookBasicAuth != "" { + headers["Authorization"] = "Basic " + AppWebhookBasicAuth + } + + client := http.NewHttpClient(http.HttpClientOptions{ + Timeout: 30 * time.Second, + Headers: headers, + }) + + AppRequest = client +} diff --git a/pkg/app/database/container.go b/pkg/app/database/container.go new file mode 100644 index 0000000..43e1696 --- /dev/null +++ b/pkg/app/database/container.go @@ -0,0 +1,57 @@ +package database + +import ( + "database/sql" + "fmt" +) + +type DatabaseContainer struct { + db *sql.DB + dialect string +} + +// New connects to the given SQL database and wraps it in a Container. +// +// Only SQLite and Postgres are currently fully supported. +// +// The logger can be nil and will default to a no-op logger. +// +// When using SQLite, it's strongly recommended to enable foreign keys by adding `?_foreign_keys=true`: +// +// container, err := sqlstore.New("sqlite3", "file:yoursqlitefile.db?_foreign_keys=on", nil) +func New(dialect, address string) (*DatabaseContainer, error) { + db, err := sql.Open(dialect, address) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + container := NewWithDB(db, dialect) + err = container.Upgrade() + if err != nil { + return nil, fmt.Errorf("failed to upgrade database: %w", err) + } + return container, nil +} + +func NewWithDB(db *sql.DB, dialect string) *DatabaseContainer { + return &DatabaseContainer{ + db: db, + dialect: dialect, + } +} + +const ( + insertWebhookResponseQuery = ` + INSERT INTO app_callback_responses (callback_url,status,response,error_message) + VALUES ($1, $2, $3, $4) + ` +) + +func (c *DatabaseContainer) StoreResponse(res *AppWebhookResponse) error { + _, err := c.db.Exec(insertWebhookResponseQuery, + res.CallbackUrl, + res.Status, + res.Response, + res.ErrorMessage, + ) + return err +} diff --git a/pkg/app/database/model.go b/pkg/app/database/model.go new file mode 100644 index 0000000..0a4632c --- /dev/null +++ b/pkg/app/database/model.go @@ -0,0 +1,10 @@ +package database + +import "encoding/json" + +type AppWebhookResponse struct { + CallbackUrl string `json:"callback_url"` + Status string `json:"status"` + Response json.RawMessage `json:"response"` + ErrorMessage string `json:"error_message"` +} diff --git a/pkg/app/database/upgrade.go b/pkg/app/database/upgrade.go new file mode 100644 index 0000000..2146511 --- /dev/null +++ b/pkg/app/database/upgrade.go @@ -0,0 +1,112 @@ +package database + +import ( + "database/sql" + "fmt" +) + +type upgradeFunc func(*sql.Tx, *DatabaseContainer) error + +// Upgrades is a list of functions that will upgrade a database to the latest version. +// +// This may be of use if you want to manage the database fully manually, but in most cases you +// should just call DatabaseContainer.Upgrade to let the library handle everything. +var Upgrades = [...]upgradeFunc{upgradeV1} + +const ( + AppMigrationTable = "app_migration_version" +) + +func (c *DatabaseContainer) getVersion() (int, error) { + _, err := c.db.Exec(fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (version INTEGER)", AppMigrationTable)) + if err != nil { + return -1, err + } + + version := 0 + row := c.db.QueryRow(fmt.Sprintf("SELECT version FROM %s LIMIT 1", AppMigrationTable)) + if row != nil { + _ = row.Scan(&version) + } + return version, nil +} + +func (c *DatabaseContainer) setVersion(tx *sql.Tx, version int) error { + _, err := tx.Exec(fmt.Sprintf("DELETE FROM %s", AppMigrationTable)) + if err != nil { + return err + } + _, err = tx.Exec(fmt.Sprintf("INSERT INTO %s (version) VALUES ($1)", AppMigrationTable), version) + return err +} + +func (c *DatabaseContainer) Upgrade() error { + if c.dialect == "sqlite" { + var foreignKeysEnabled bool + err := c.db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeysEnabled) + if err != nil { + return fmt.Errorf("failed to check if foreign keys are enabled: %w", err) + } else if !foreignKeysEnabled { + return fmt.Errorf("foreign keys are not enabled") + } + } + + version, err := c.getVersion() + if err != nil { + return err + } + + for ; version < len(Upgrades); version++ { + var tx *sql.Tx + tx, err = c.db.Begin() + if err != nil { + return err + } + + migrateFunc := Upgrades[version] + err = migrateFunc(tx, c) + if err != nil { + _ = tx.Rollback() + return err + } + + if err = c.setVersion(tx, version+1); err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return err + } + } + + return nil +} + +func upgradeV1(tx *sql.Tx, c *DatabaseContainer) error { + var err error + postgresAppCallback := `CREATE TABLE app_callback_responses ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + callback_url TEXT NOT NULL, + status VARCHAR(50) NOT NULL, + response JSONB, + error_message TEXT, + timestamp TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + );` + sqliteAppCallback := `CREATE TABLE app_callback_responses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + callback_url TEXT NOT NULL, + status TEXT NOT NULL, + response TEXT, + error_message TEXT, + timestamp TEXT DEFAULT CURRENT_TIMESTAMP + );` + if c.dialect == "postgres" { + _, err = tx.Exec(postgresAppCallback) + } else if c.dialect == "sqlite" { + _, err = tx.Exec(sqliteAppCallback) + } + if err != nil { + return err + } + return nil +} diff --git a/pkg/app/drivers.go b/pkg/app/drivers.go new file mode 100644 index 0000000..27067a9 --- /dev/null +++ b/pkg/app/drivers.go @@ -0,0 +1,6 @@ +package app + +import ( + _ "github.com/lib/pq" + _ "modernc.org/sqlite" +) diff --git a/pkg/app/http/request.go b/pkg/app/http/request.go new file mode 100644 index 0000000..b736dfe --- /dev/null +++ b/pkg/app/http/request.go @@ -0,0 +1,169 @@ +package http + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// HttpClient wraps an http.Client and provides methods for GET and POST +type HttpClient struct { + Client *http.Client + Headers map[string]string + BaseURL string +} + +type HttpClientOptions struct { + Timeout time.Duration + Headers map[string]string + BaseURL string +} + +type HttpResponse struct { + Status string + StatusCode int + Headers http.Header + Body []byte +} + +type HttpError struct { + Response *HttpResponse + Message string +} + +func (e *HttpError) Error() string { + return fmt.Sprintf("%s (status code %d)", e.Message, e.Response.StatusCode) +} + +func NewHttpClient(option ...HttpClientOptions) *HttpClient { + var timeout time.Duration = 30 * time.Second + var headers map[string]string + var baseURL string + + if len(option) > 0 { + if option[0].Timeout > 0 { + timeout = option[0].Timeout + } + headers = option[0].Headers + baseURL = option[0].BaseURL + } + + return &HttpClient{ + Client: &http.Client{Timeout: timeout}, + Headers: headers, + BaseURL: baseURL, + } +} + +// internal method to apply headers +func (h *HttpClient) applyHeaders(req *http.Request) { + for key, value := range h.Headers { + req.Header.Set(key, value) + } +} + +// internal method to validate absolute URL +func validateAbsoluteURL(rawURL string) (bool, error) { + parsed, err := url.Parse(rawURL) + if err != nil { + return false, err + } + if !parsed.IsAbs() { + return false, nil + } + return true, nil +} + +func (h *HttpClient) httpRequest(method, url string, body io.Reader) (*http.Request, error) { + // Validate URL + isAbsolute, _ := validateAbsoluteURL(url) + if !isAbsolute { + if h.BaseURL != "" { + url = h.BaseURL + url + } + } + req, err := http.NewRequest(method, url, body) + return req, err +} + +// Get performs a GET request and returns full response info +func (h *HttpClient) Get(url string) (*HttpResponse, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + h.applyHeaders(req) + + resp, err := h.Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + response := &HttpResponse{ + Status: resp.Status, + StatusCode: resp.StatusCode, + Headers: resp.Header, + Body: bodyBytes, + } + + // Return an error if status code is 4xx or 5xx + if resp.StatusCode >= 400 { + return response, &HttpError{ + Response: response, + Message: "unexpected HTTP status", + } + } + + return response, nil +} + +// Post performs a POST request and returns full response info +func (h *HttpClient) Post(url string, data any) (*HttpResponse, error) { + jsonBytes, err := json.Marshal(data) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer([]byte(jsonBytes))) + if err != nil { + return nil, err + } + h.applyHeaders(req) + + resp, err := h.Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + response := &HttpResponse{ + Status: resp.Status, + StatusCode: resp.StatusCode, + Headers: resp.Header, + Body: bodyBytes, + } + + // Return an error if status code is 4xx or 5xx + if resp.StatusCode >= 400 { + return response, &HttpError{ + Response: response, + Message: "unexpected HTTP status", + } + } + + return response, nil +} diff --git a/pkg/utils/json.go b/pkg/utils/json.go new file mode 100644 index 0000000..89daa89 --- /dev/null +++ b/pkg/utils/json.go @@ -0,0 +1,19 @@ +package utils + +import "encoding/json" + +func ObjectToJson(o interface{}) string { + res, err := json.Marshal(o) + if err != nil { + return "" + } + return string(res) +} + +func JsonToObject(data string, o interface{}) error { + err := json.Unmarshal([]byte(data), o) + if err != nil { + return err + } + return nil +} diff --git a/pkg/whatsapp/whatsapp.go b/pkg/whatsapp/whatsapp.go index b770380..98a972f 100644 --- a/pkg/whatsapp/whatsapp.go +++ b/pkg/whatsapp/whatsapp.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/base64" + "encoding/json" "errors" "fmt" "net/http" @@ -28,7 +29,10 @@ import ( "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + "github.com/dimaskiddo/go-whatsapp-multidevice-rest/pkg/app" + "github.com/dimaskiddo/go-whatsapp-multidevice-rest/pkg/app/database" "github.com/dimaskiddo/go-whatsapp-multidevice-rest/pkg/env" "github.com/dimaskiddo/go-whatsapp-multidevice-rest/pkg/log" ) @@ -106,6 +110,11 @@ func WhatsAppInitClient(device *store.Device, jid string) { // Set WhatsApp Client Auto Trust Identity WhatsAppClient[jid].AutoTrustIdentity = true + + // Set WhatsApp Client Event Handler + WhatsAppClient[jid].AddEventHandler(func(evt interface{}) { + whatsAppEventHandler(evt) + }) } } @@ -1342,3 +1351,22 @@ func WhatsAppGroupLeave(jid string, gjid string) error { // Return Error WhatsApp Client is not Valid return errors.New("WhatsApp Client is not Valid") } + +func whatsAppEventHandler(evt interface{}) { + switch v := evt.(type) { + case *events.Message: + res, err := app.AppRequest.Post(app.AppWebhookURL, v) + respBody := database.AppWebhookResponse{ + CallbackUrl: app.AppWebhookURL, + Status: res.Status, + Response: json.RawMessage(res.Body), + } + if err != nil { + respBody.ErrorMessage = err.Error() + } + err = app.AppDatabase.StoreResponse(&respBody) + if err != nil { + fmt.Println("Error storing webhook response:", err) + } + } +}