From ca1a409610cf523ceebb2ec83b6d8b6aac09d8bf Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 4 Aug 2026 23:00:01 +0700 Subject: [PATCH] Clear and rewrite Retire the Go port; history continues from the JavaScript implementation (store-scraper-bot). --- .dockerignore | 43 ---- .env.example | 18 -- .gitignore | 32 --- Dockerfile | 31 --- LICENSE | 201 ------------------ README.md | 50 ----- cmd/bot/main.go | 76 ------- docker-compose.dev.yml | 56 ----- docker-compose.yml | 39 ---- go.mod | 24 --- go.sum | 66 ------ internal/api/apple/apple_scraper.go | 111 ---------- .../api/apple/request/apple_app_request.go | 18 -- internal/api/google/google_scraper.go | 114 ---------- .../api/google/request/google_app_request.go | 14 -- internal/bot/bot.go | 127 ----------- internal/bot/command/add_apple_app.go | 64 ------ internal/bot/command/add_google_app.go | 54 ----- internal/bot/command/add_group.go | 55 ----- internal/bot/command/args.go | 13 -- internal/bot/command/check_app.go | 120 ----------- internal/bot/command/check_app_scores.go | 100 --------- internal/bot/command/command.go | 39 ---- internal/bot/command/delete_apple_app.go | 39 ---- internal/bot/command/delete_google_app.go | 39 ---- internal/bot/command/delete_group.go | 50 ----- internal/bot/command/info.go | 22 -- internal/bot/command/list_app.go | 61 ------ internal/bot/command/list_group.go | 45 ---- internal/bot/command/raw_apple_app.go | 48 ----- internal/bot/command/raw_google_app.go | 40 ---- internal/config/config.go | 178 ---------------- internal/model/abstract_model.go | 8 - internal/model/admin.go | 43 ---- internal/model/apple_app.go | 59 ----- internal/model/google_app.go | 91 -------- internal/model/group.go | 80 ------- internal/model/non_updated_app.go | 14 -- internal/repository/admin_repository.go | 108 ---------- internal/repository/apple_app_repository.go | 61 ------ internal/repository/google_app_repository.go | 58 ----- internal/repository/group_repository.go | 131 ------------ internal/repository/mongodb.go | 59 ----- internal/scheduler/scheduler.go | 189 ---------------- internal/util/table.go | 95 --------- 45 files changed, 2983 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .env.example delete mode 100644 .gitignore delete mode 100644 Dockerfile delete mode 100644 LICENSE delete mode 100644 README.md delete mode 100644 cmd/bot/main.go delete mode 100644 docker-compose.dev.yml delete mode 100644 docker-compose.yml delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 internal/api/apple/apple_scraper.go delete mode 100644 internal/api/apple/request/apple_app_request.go delete mode 100644 internal/api/google/google_scraper.go delete mode 100644 internal/api/google/request/google_app_request.go delete mode 100644 internal/bot/bot.go delete mode 100644 internal/bot/command/add_apple_app.go delete mode 100644 internal/bot/command/add_google_app.go delete mode 100644 internal/bot/command/add_group.go delete mode 100644 internal/bot/command/args.go delete mode 100644 internal/bot/command/check_app.go delete mode 100644 internal/bot/command/check_app_scores.go delete mode 100644 internal/bot/command/command.go delete mode 100644 internal/bot/command/delete_apple_app.go delete mode 100644 internal/bot/command/delete_google_app.go delete mode 100644 internal/bot/command/delete_group.go delete mode 100644 internal/bot/command/info.go delete mode 100644 internal/bot/command/list_app.go delete mode 100644 internal/bot/command/list_group.go delete mode 100644 internal/bot/command/raw_apple_app.go delete mode 100644 internal/bot/command/raw_google_app.go delete mode 100644 internal/config/config.go delete mode 100644 internal/model/abstract_model.go delete mode 100644 internal/model/admin.go delete mode 100644 internal/model/apple_app.go delete mode 100644 internal/model/google_app.go delete mode 100644 internal/model/group.go delete mode 100644 internal/model/non_updated_app.go delete mode 100644 internal/repository/admin_repository.go delete mode 100644 internal/repository/apple_app_repository.go delete mode 100644 internal/repository/google_app_repository.go delete mode 100644 internal/repository/group_repository.go delete mode 100644 internal/repository/mongodb.go delete mode 100644 internal/scheduler/scheduler.go delete mode 100644 internal/util/table.go diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 803350a..0000000 --- a/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# Binaries -*.exe -*.exe~ -*.dll -*.so -*.dylib -bot - -# Test files -*_test.go -*.test - -# Build artifacts -*.out - -# IDE -.idea/ -.vscode/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Git -.git/ -.gitignore - -# Docker -docker-compose.yml -docker-compose.dev.yml -Dockerfile -.dockerignore - -# Documentation -*.md -LICENSE - -# Environment -.env -.env.* diff --git a/.env.example b/.env.example deleted file mode 100644 index 2dcb141..0000000 --- a/.env.example +++ /dev/null @@ -1,18 +0,0 @@ -# Telegram Configuration -TELEGRAM_BOT_TOKEN=your_bot_token_here -TELEGRAM_BOT_USERNAME=your_bot_username - -# MongoDB Configuration -MONGO_URI=mongodb://localhost:27017 -MONGO_DATABASE=store_scraper_bot -MONGO_TIMEOUT_SECONDS=10 - -# Application Configuration -ENV=DEVELOPMENT -ADMIN_IDS=123456789,987654321 -SOURCE_COMMIT=unknown - -# Optional: Override default constants -APP_CACHE_SECONDS=600 -NUM_DAYS_WARNING_NOT_UPDATED=30 -SCHEDULE_CHECK_APP_TIME=0 7 * * * diff --git a/.gitignore b/.gitignore deleted file mode 100644 index aaadf73..0000000 --- a/.gitignore +++ /dev/null @@ -1,32 +0,0 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Code coverage profiles and other test artifacts -*.out -coverage.* -*.coverprofile -profile.cov - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work -go.work.sum - -# env file -.env - -# Editor/IDE -# .idea/ -# .vscode/ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 9638606..0000000 --- a/Dockerfile +++ /dev/null @@ -1,31 +0,0 @@ -# Build stage -FROM golang:1.21-alpine AS builder - -WORKDIR /app - -# Install build dependencies -RUN apk add --no-cache git - -# Copy go mod files -COPY go.mod go.sum ./ -RUN go mod download - -# Copy source code -COPY . . - -# Build the application -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o bot ./cmd/bot - -# Final stage -FROM alpine:latest - -RUN apk --no-cache add ca-certificates tzdata - -WORKDIR /root/ - -# Copy the binary from builder -COPY --from=builder /app/bot . - -# Expose no ports (bot uses long polling) - -CMD ["./bot"] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 261eeb9..0000000 --- a/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/README.md b/README.md deleted file mode 100644 index d18e617..0000000 --- a/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# go-store-scraper-bot - -Go port of [java-store-scraper-bot](https://github.com/tiennm99/java-store-scraper-bot). - -> ⚠️ **Preview / unstable — use at your own risk.** -> This port was produced largely with AI assistance and has **not** been tested -> end-to-end against a live Telegram bot or the upstream Java implementation. -> Behavior parity is intended but unverified. Expect bugs, schema mismatches, -> or runtime failures. Do not run against a production database. - -The Java version remains the reference implementation. This Go version is -under development; the plan is to replace the Java version on the original -repo's `feature/go` branch once it stabilizes. - -## Status - -- Builds cleanly (`go build ./...`, `go vet ./...`). -- Mongo schema is intended to match Java (collections: `common`, `group`, - `apple_app`, `google_app`; string `_id`; `class` discriminator field). -- Telegram command identifiers match Java exactly (`/info`, `/addgroup`, - `/delgroup`, `/listgroup`, `/addapple`, `/delapple`, `/addgoogle`, - `/delgoogle`, `/listapp`, `/checkapp`, `/checkappscore`, `/rawappleapp`, - `/rawgoogleapp`). -- HTML parse mode; weekend-silent daily report; 10-minute API cache. - -## Configuration - -Required env vars (see `.env.example`): - -| Name | Notes | -|---|---| -| `TELEGRAM_BOT_TOKEN` | Telegram bot token | -| `TELEGRAM_BOT_USERNAME` | Bot username | -| `MONGODB_CONNECTION_STRING` | Preferred (Java parity); falls back to `MONGO_URI` | -| `ADMIN_IDS` | Comma-separated Telegram user IDs | -| `ENV` | `DEVELOPMENT` or `PRODUCTION` | -| `SOURCE_COMMIT` | Optional; shown on startup | - -## Run - -```sh -go build -o bot ./cmd/bot -./bot -``` - -Or via Docker Compose: - -```sh -docker compose up --build -``` diff --git a/cmd/bot/main.go b/cmd/bot/main.go deleted file mode 100644 index 2d48a88..0000000 --- a/cmd/bot/main.go +++ /dev/null @@ -1,76 +0,0 @@ -package main - -import ( - "log" - "os" - "os/signal" - "syscall" - - "github.com/miti99/store-scraper-bot-go/internal/api/apple" - "github.com/miti99/store-scraper-bot-go/internal/api/google" - "github.com/miti99/store-scraper-bot-go/internal/bot" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/repository" - "github.com/miti99/store-scraper-bot-go/internal/scheduler" - "go.uber.org/zap" -) - -func main() { - // Load configuration - cfg, err := config.Load() - if err != nil { - log.Fatalf("Failed to load configuration: %v", err) - } - defer cfg.Logger.Sync() - - cfg.Logger.Info("Starting Store Scraper Bot", - zap.String("env", string(cfg.Env)), - zap.String("commit", cfg.SourceCommit)) - - // Initialize MongoDB - if err := repository.InitMongoDB(cfg); err != nil { - cfg.Logger.Fatal("Failed to initialize MongoDB", zap.Error(err)) - } - defer repository.Close() - - // Initialize repositories - adminRepo := repository.NewAdminRepository() - groupRepo := repository.NewGroupRepository() - appleAppRepo := repository.NewAppleAppRepository() - googleAppRepo := repository.NewGoogleAppRepository() - - // Java parity: ensure the singleton "common/admin" document exists. - if err := adminRepo.Init(); err != nil { - cfg.Logger.Fatal("Failed to init admin singleton", zap.Error(err)) - } - - // Initialize scrapers - appleScraper := apple.NewAppleScraper(appleAppRepo, cfg) - googleScraper := google.NewGoogleScraper(googleAppRepo, cfg) - - // Initialize bot - telegramBot, err := bot.NewBot(cfg, adminRepo, groupRepo, appleScraper, googleScraper) - if err != nil { - cfg.Logger.Fatal("Failed to initialize bot", zap.Error(err)) - } - - // Initialize and start scheduler - sched := scheduler.NewScheduler(cfg, telegramBot, adminRepo, groupRepo, appleScraper, googleScraper) - if err := sched.Start(); err != nil { - cfg.Logger.Fatal("Failed to start scheduler", zap.Error(err)) - } - defer sched.Stop() - - // Start bot in a goroutine - go func() { - cfg.Logger.Info("Starting Telegram bot polling") - telegramBot.Start() - }() - - // Wait for interrupt signal - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - - <-sigChan - cfg.Logger.Info("Received shutdown signal, stopping bot...") -} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index 1dae233..0000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,56 +0,0 @@ -version: '3.8' - -services: - bot: - build: . - container_name: store-scraper-bot-go-dev - restart: unless-stopped - environment: - - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} - - TELEGRAM_BOT_USERNAME=${TELEGRAM_BOT_USERNAME} - - MONGO_URI=mongodb://mongodb:27017 - - MONGO_DATABASE=store_scraper_bot_dev - - ENV=DEVELOPMENT - - ADMIN_IDS=${ADMIN_IDS} - - SOURCE_COMMIT=${SOURCE_COMMIT:-dev} - depends_on: - - mongodb - networks: - - bot-network - volumes: - - .:/app - - mongodb: - image: mongo:7.0 - container_name: store-scraper-mongodb-dev - restart: unless-stopped - environment: - - MONGO_INITDB_DATABASE=store_scraper_bot_dev - volumes: - - mongodb_data_dev:/data/db - networks: - - bot-network - ports: - - "27017:27017" - - mongo-express: - image: mongo-express:latest - container_name: mongo-express-dev - restart: unless-stopped - environment: - - ME_CONFIG_MONGODB_URL=mongodb://mongodb:27017 - - ME_CONFIG_BASICAUTH_USERNAME=admin - - ME_CONFIG_BASICAUTH_PASSWORD=admin - depends_on: - - mongodb - networks: - - bot-network - ports: - - "8081:8081" - -networks: - bot-network: - driver: bridge - -volumes: - mongodb_data_dev: diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 1c9bb1f..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,39 +0,0 @@ -version: '3.8' - -services: - bot: - build: . - container_name: store-scraper-bot-go - restart: unless-stopped - environment: - - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} - - TELEGRAM_BOT_USERNAME=${TELEGRAM_BOT_USERNAME} - - MONGO_URI=mongodb://mongodb:27017 - - MONGO_DATABASE=store_scraper_bot - - ENV=${ENV:-PRODUCTION} - - ADMIN_IDS=${ADMIN_IDS} - - SOURCE_COMMIT=${SOURCE_COMMIT:-unknown} - depends_on: - - mongodb - networks: - - bot-network - - mongodb: - image: mongo:7.0 - container_name: store-scraper-mongodb - restart: unless-stopped - environment: - - MONGO_INITDB_DATABASE=store_scraper_bot - volumes: - - mongodb_data:/data/db - networks: - - bot-network - ports: - - "27017:27017" - -networks: - bot-network: - driver: bridge - -volumes: - mongodb_data: diff --git a/go.mod b/go.mod deleted file mode 100644 index 873d862..0000000 --- a/go.mod +++ /dev/null @@ -1,24 +0,0 @@ -module github.com/miti99/store-scraper-bot-go - -go 1.24.0 - -require ( - github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 - github.com/robfig/cron/v3 v3.0.1 - go.mongodb.org/mongo-driver v1.17.6 - go.uber.org/zap v1.27.0 -) - -require ( - github.com/golang/snappy v0.0.4 // indirect - github.com/klauspost/compress v1.16.7 // indirect - github.com/montanaflynn/stats v0.7.1 // indirect - github.com/xdg-go/pbkdf2 v1.0.0 // indirect - github.com/xdg-go/scram v1.1.2 // indirect - github.com/xdg-go/stringprep v1.0.4 // indirect - github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - go.uber.org/multierr v1.10.0 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/text v0.31.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 4666012..0000000 --- a/go.sum +++ /dev/null @@ -1,66 +0,0 @@ -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/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= -github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= -github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= -github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= -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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= -github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= -github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= -github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= -github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= -go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/apple/apple_scraper.go b/internal/api/apple/apple_scraper.go deleted file mode 100644 index 3300cd2..0000000 --- a/internal/api/apple/apple_scraper.go +++ /dev/null @@ -1,111 +0,0 @@ -package apple - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/miti99/store-scraper-bot-go/internal/api/apple/request" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/model" - "github.com/miti99/store-scraper-bot-go/internal/repository" - "go.uber.org/zap" -) - -// BaseURL mirrors Java AppStoreScraper (api/apple/AppStoreScraper.java). -const BaseURL = "https://store-scraper.vercel.app/apple" - -type AppleScraper struct { - repo *repository.AppleAppRepository - cfg *config.Config - client *http.Client - logger *zap.Logger -} - -func NewAppleScraper(repo *repository.AppleAppRepository, cfg *config.Config) *AppleScraper { - return &AppleScraper{ - repo: repo, - cfg: cfg, - client: &http.Client{Timeout: 30 * time.Second}, - logger: cfg.Logger, - } -} - -// RawApp posts the request and returns the raw JSON body. -func (s *AppleScraper) RawApp(req request.AppleAppRequest) (string, error) { - body, err := json.Marshal(req) - if err != nil { - return "", fmt.Errorf("marshal apple request: %w", err) - } - httpReq, err := http.NewRequestWithContext(context.Background(), http.MethodPost, BaseURL+"/app", bytes.NewReader(body)) - if err != nil { - return "", fmt.Errorf("build apple request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - - resp, err := s.client.Do(httpReq) - if err != nil { - return "", fmt.Errorf("apple HTTP error: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("apple HTTP status %d", resp.StatusCode) - } - raw, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("read apple body: %w", err) - } - return string(raw), nil -} - -// App posts the request and decodes the response. -func (s *AppleScraper) App(req request.AppleAppRequest) (*model.AppleAppResponse, error) { - raw, err := s.RawApp(req) - if err != nil { - return nil, err - } - out := &model.AppleAppResponse{} - if err := json.Unmarshal([]byte(raw), out); err != nil { - return nil, fmt.Errorf("decode apple response: %w", err) - } - return out, nil -} - -// GetApp returns a cached response (if fresh) or fetches by bundleId and caches. -func (s *AppleScraper) GetApp(appID, country string) (*model.AppleAppResponse, error) { - if cached, _ := s.repo.GetCached(appID); cached != nil { - return &cached.App, nil - } - resp, err := s.App(request.ByBundleID(appID, country)) - if err != nil { - return nil, err - } - s.cache(resp) - return resp, nil -} - -// FetchAndCache fetches by an arbitrary request (track ID or bundle ID). -func (s *AppleScraper) FetchAndCache(req request.AppleAppRequest) (*model.AppleAppResponse, error) { - resp, err := s.App(req) - if err != nil { - return nil, err - } - s.cache(resp) - return resp, nil -} - -func (s *AppleScraper) cache(resp *model.AppleAppResponse) { - if resp == nil || resp.AppID == "" { - return - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - entry := model.NewAppleApp(resp.AppID, *resp, time.Now().UnixMilli()) - if err := s.repo.Save(ctx, entry); err != nil { - s.logger.Warn("failed to cache apple app", zap.String("appId", resp.AppID), zap.Error(err)) - } -} diff --git a/internal/api/apple/request/apple_app_request.go b/internal/api/apple/request/apple_app_request.go deleted file mode 100644 index 183e0bb..0000000 --- a/internal/api/apple/request/apple_app_request.go +++ /dev/null @@ -1,18 +0,0 @@ -package request - -// AppleAppRequest mirrors Java AppleAppRequest record. Either ID (iTunes -// trackId) or AppID (bundleId) is set; the other is omitted from JSON. -type AppleAppRequest struct { - ID *int64 `json:"id,omitempty"` - AppID *string `json:"appId,omitempty"` - Country string `json:"country"` - Ratings bool `json:"ratings"` -} - -func ByTrackID(id int64, country string) AppleAppRequest { - return AppleAppRequest{ID: &id, Country: country, Ratings: true} -} - -func ByBundleID(appID, country string) AppleAppRequest { - return AppleAppRequest{AppID: &appID, Country: country, Ratings: true} -} diff --git a/internal/api/google/google_scraper.go b/internal/api/google/google_scraper.go deleted file mode 100644 index 644ff62..0000000 --- a/internal/api/google/google_scraper.go +++ /dev/null @@ -1,114 +0,0 @@ -package google - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/miti99/store-scraper-bot-go/internal/api/google/request" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/model" - "github.com/miti99/store-scraper-bot-go/internal/repository" - "go.uber.org/zap" -) - -// BaseURL mirrors Java GooglePlayScraper (api/google/GooglePlayScraper.java). -const BaseURL = "https://store-scraper.vercel.app/google" - -type GoogleScraper struct { - repo *repository.GoogleAppRepository - cfg *config.Config - client *http.Client - logger *zap.Logger -} - -func NewGoogleScraper(repo *repository.GoogleAppRepository, cfg *config.Config) *GoogleScraper { - return &GoogleScraper{ - repo: repo, - cfg: cfg, - client: &http.Client{Timeout: 30 * time.Second}, - logger: cfg.Logger, - } -} - -func (s *GoogleScraper) RawApp(req request.GoogleAppRequest) (string, error) { - body, err := json.Marshal(req) - if err != nil { - return "", fmt.Errorf("marshal google request: %w", err) - } - httpReq, err := http.NewRequestWithContext(context.Background(), http.MethodPost, BaseURL+"/app", bytes.NewReader(body)) - if err != nil { - return "", fmt.Errorf("build google request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - - resp, err := s.client.Do(httpReq) - if err != nil { - return "", fmt.Errorf("google HTTP error: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("google HTTP status %d", resp.StatusCode) - } - raw, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("read google body: %w", err) - } - return string(raw), nil -} - -func (s *GoogleScraper) App(req request.GoogleAppRequest) (*model.GoogleAppResponse, error) { - raw, err := s.RawApp(req) - if err != nil { - return nil, err - } - out := &model.GoogleAppResponse{} - if err := json.Unmarshal([]byte(raw), out); err != nil { - return nil, fmt.Errorf("decode google response: %w", err) - } - return out, nil -} - -func (s *GoogleScraper) GetApp(appID, country string) (*model.GoogleAppResponse, error) { - if cached, _ := s.repo.GetCached(appID); cached != nil { - return &cached.App, nil - } - resp, err := s.App(request.New(appID, country)) - if err != nil { - return nil, err - } - s.cache(resp, appID) - return resp, nil -} - -func (s *GoogleScraper) FetchAndCache(req request.GoogleAppRequest) (*model.GoogleAppResponse, error) { - resp, err := s.App(req) - if err != nil { - return nil, err - } - s.cache(resp, req.AppID) - return resp, nil -} - -func (s *GoogleScraper) cache(resp *model.GoogleAppResponse, fallbackID string) { - if resp == nil { - return - } - id := resp.AppID - if id == "" { - id = fallbackID - } - if id == "" { - return - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - entry := model.NewGoogleApp(id, *resp, time.Now().UnixMilli()) - if err := s.repo.Save(ctx, entry); err != nil { - s.logger.Warn("failed to cache google app", zap.String("appId", id), zap.Error(err)) - } -} diff --git a/internal/api/google/request/google_app_request.go b/internal/api/google/request/google_app_request.go deleted file mode 100644 index 6bcb32e..0000000 --- a/internal/api/google/request/google_app_request.go +++ /dev/null @@ -1,14 +0,0 @@ -package request - -// GoogleAppRequest mirrors Java GoogleAppRequest record. Country defaults to "vn". -type GoogleAppRequest struct { - AppID string `json:"appId"` - Country string `json:"country"` -} - -func New(appID, country string) GoogleAppRequest { - if country == "" { - country = "vn" - } - return GoogleAppRequest{AppID: appID, Country: country} -} diff --git a/internal/bot/bot.go b/internal/bot/bot.go deleted file mode 100644 index 198f635..0000000 --- a/internal/bot/bot.go +++ /dev/null @@ -1,127 +0,0 @@ -package bot - -import ( - "fmt" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/miti99/store-scraper-bot-go/internal/api/apple" - "github.com/miti99/store-scraper-bot-go/internal/api/google" - "github.com/miti99/store-scraper-bot-go/internal/bot/command" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/repository" - "go.uber.org/zap" -) - -// parseMode mirrors Java StoreScrapeBotTelegramClient: HTML for all messages. -const parseMode = "HTML" - -type Bot struct { - api *tgbotapi.BotAPI - cfg *config.Config - commands map[string]command.Command - logger *zap.Logger -} - -func NewBot( - cfg *config.Config, - adminRepo *repository.AdminRepository, - groupRepo *repository.GroupRepository, - appleScraper *apple.AppleScraper, - googleScraper *google.GoogleScraper, -) (*Bot, error) { - api, err := tgbotapi.NewBotAPI(cfg.TelegramBotToken) - if err != nil { - return nil, fmt.Errorf("failed to create telegram bot: %w", err) - } - api.Debug = cfg.Env == config.Development - cfg.Logger.Info("Authorized on account", zap.String("username", api.Self.UserName)) - - b := &Bot{api: api, cfg: cfg, commands: map[string]command.Command{}, logger: cfg.Logger} - - // Java command identifiers (StoreScrapeBot constructor) — keep these strings - // matching exactly so existing users' muscle memory still works. - b.commands["info"] = command.NewInfoCommand(cfg) - b.commands["addgroup"] = command.NewAddGroupCommand(cfg, adminRepo, groupRepo) - b.commands["delgroup"] = command.NewDeleteGroupCommand(cfg, adminRepo, groupRepo) - b.commands["listgroup"] = command.NewListGroupCommand(cfg, adminRepo) - b.commands["addapple"] = command.NewAddAppleAppCommand(cfg, adminRepo, groupRepo, appleScraper) - b.commands["delapple"] = command.NewDeleteAppleAppCommand(cfg, adminRepo, groupRepo) - b.commands["addgoogle"] = command.NewAddGoogleAppCommand(cfg, adminRepo, groupRepo, googleScraper) - b.commands["delgoogle"] = command.NewDeleteGoogleAppCommand(cfg, adminRepo, groupRepo) - b.commands["listapp"] = command.NewListAppCommand(cfg, adminRepo, groupRepo) - b.commands["checkapp"] = command.NewCheckAppCommand(cfg, adminRepo, groupRepo, appleScraper, googleScraper) - b.commands["checkappscore"] = command.NewCheckAppScoresCommand(cfg, adminRepo, groupRepo, appleScraper, googleScraper) - b.commands["rawappleapp"] = command.NewRawAppleAppCommand(cfg, appleScraper) - b.commands["rawgoogleapp"] = command.NewRawGoogleAppCommand(cfg, googleScraper) - - return b, nil -} - -func (b *Bot) Start() { - u := tgbotapi.NewUpdate(0) - u.Timeout = 60 - updates := b.api.GetUpdatesChan(u) - for update := range updates { - if update.Message == nil || !update.Message.IsCommand() { - continue - } - go b.handleCommand(update.Message) - } -} - -func (b *Bot) handleCommand(message *tgbotapi.Message) { - defer func() { - if r := recover(); r != nil { - b.logger.Error("panic in command", zap.Any("panic", r)) - _ = b.SendMessage(message.Chat.ID, "Internal server error") - } - }() - name := message.Command() - cmd, ok := b.commands[name] - if !ok { - b.logger.Debug("Unknown command", zap.String("command", name)) - return - } - b.logger.Info("Executing command", - zap.String("command", name), - zap.Int64("userId", message.From.ID), - zap.Int64("chatId", message.Chat.ID)) - cmd.Execute(message, b) -} - -// SendMessage sends an HTML-parsed message (Java parity). -func (b *Bot) SendMessage(chatID int64, html string) error { - msg := tgbotapi.NewMessage(chatID, html) - msg.ParseMode = parseMode - msg.DisableWebPagePreview = true - _, err := b.api.Send(msg) - if err != nil { - b.logger.Warn("send message failed", zap.Int64("chatId", chatID), zap.Error(err)) - } - return err -} - -// SendMessageSilent sends an HTML message with notifications muted (weekend behavior). -func (b *Bot) SendMessageSilent(chatID int64, html string) error { - msg := tgbotapi.NewMessage(chatID, html) - msg.ParseMode = parseMode - msg.DisableWebPagePreview = true - msg.DisableNotification = true - _, err := b.api.Send(msg) - if err != nil { - b.logger.Warn("send silent message failed", zap.Int64("chatId", chatID), zap.Error(err)) - } - return err -} - -// SendDocument sends body as a file attachment with the given filename -// (used by /rawappleapp and /rawgoogleapp). -func (b *Bot) SendDocument(chatID int64, filename, body string) error { - file := tgbotapi.FileBytes{Name: filename, Bytes: []byte(body)} - doc := tgbotapi.NewDocument(chatID, file) - _, err := b.api.Send(doc) - if err != nil { - b.logger.Warn("send document failed", zap.Int64("chatId", chatID), zap.Error(err)) - } - return err -} diff --git a/internal/bot/command/add_apple_app.go b/internal/bot/command/add_apple_app.go deleted file mode 100644 index 110011d..0000000 --- a/internal/bot/command/add_apple_app.go +++ /dev/null @@ -1,64 +0,0 @@ -package command - -import ( - "fmt" - "strconv" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/miti99/store-scraper-bot-go/internal/api/apple" - "github.com/miti99/store-scraper-bot-go/internal/api/apple/request" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/repository" -) - -// /addapple [country=vn] — Java AddAppleAppCommand. -type AddAppleAppCommand struct { - cfg *config.Config - adminRepo *repository.AdminRepository - groupRepo *repository.GroupRepository - scraper *apple.AppleScraper -} - -func NewAddAppleAppCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository, scraper *apple.AppleScraper) *AddAppleAppCommand { - return &AddAppleAppCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo, scraper: scraper} -} - -func (c *AddAppleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { - if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) { - return - } - args := splitArgs(msg.CommandArguments()) - if len(args) < 1 || len(args) > 2 { - _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") - return - } - country := "vn" - if len(args) == 2 { - country = args[1] - } - - // Java: try parsing arg[0] as Long (trackId); else treat as bundleId. - var req request.AppleAppRequest - if trackID, err := strconv.ParseInt(args[0], 10, 64); err == nil { - req = request.ByTrackID(trackID, country) - } else { - req = request.ByBundleID(args[0], country) - } - - resp, err := c.scraper.FetchAndCache(req) - if err != nil || resp == nil || resp.AppID == "" { - _ = sender.SendMessage(msg.Chat.ID, "Error when request app info") - return - } - - added, err := c.groupRepo.AddAppleApp(msg.Chat.ID, resp.AppID, country) - if err != nil { - _ = sender.SendMessage(msg.Chat.ID, "Internal server error") - return - } - if !added { - _ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Apple app %s is already added", resp.AppID)) - return - } - _ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Apple app %s, country %s added successfully", resp.AppID, country)) -} diff --git a/internal/bot/command/add_google_app.go b/internal/bot/command/add_google_app.go deleted file mode 100644 index 06ec4f6..0000000 --- a/internal/bot/command/add_google_app.go +++ /dev/null @@ -1,54 +0,0 @@ -package command - -import ( - "fmt" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/miti99/store-scraper-bot-go/internal/api/google" - "github.com/miti99/store-scraper-bot-go/internal/api/google/request" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/repository" -) - -// /addgoogle [country=vn] — Java AddGoogleAppCommand. -type AddGoogleAppCommand struct { - cfg *config.Config - adminRepo *repository.AdminRepository - groupRepo *repository.GroupRepository - scraper *google.GoogleScraper -} - -func NewAddGoogleAppCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository, scraper *google.GoogleScraper) *AddGoogleAppCommand { - return &AddGoogleAppCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo, scraper: scraper} -} - -func (c *AddGoogleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { - if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) { - return - } - args := splitArgs(msg.CommandArguments()) - if len(args) < 1 || len(args) > 2 { - _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") - return - } - appID := args[0] - country := "vn" - if len(args) == 2 { - country = args[1] - } - resp, err := c.scraper.FetchAndCache(request.New(appID, country)) - if err != nil || resp == nil { - _ = sender.SendMessage(msg.Chat.ID, "Error when request app info") - return - } - added, err := c.groupRepo.AddGoogleApp(msg.Chat.ID, appID, country) - if err != nil { - _ = sender.SendMessage(msg.Chat.ID, "Internal server error") - return - } - if !added { - _ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Google app %s is already added", appID)) - return - } - _ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Google app %s, country %s added successfully", appID, country)) -} diff --git a/internal/bot/command/add_group.go b/internal/bot/command/add_group.go deleted file mode 100644 index cb053dd..0000000 --- a/internal/bot/command/add_group.go +++ /dev/null @@ -1,55 +0,0 @@ -package command - -import ( - "context" - "strconv" - "time" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/repository" -) - -// /addgroup [groupId] — Java AddGroupCommand. Admin-only. -type AddGroupCommand struct { - cfg *config.Config - adminRepo *repository.AdminRepository - groupRepo *repository.GroupRepository -} - -func NewAddGroupCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository) *AddGroupCommand { - return &AddGroupCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo} -} - -func (c *AddGroupCommand) Execute(msg *tgbotapi.Message, sender Sender) { - if !requireAdminUser(msg.From.ID, msg.Chat.ID, c.cfg, sender) { - return - } - args := splitArgs(msg.CommandArguments()) - if len(args) > 1 { - _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") - return - } - groupID := msg.Chat.ID - if len(args) == 1 { - parsed, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") - return - } - groupID = parsed - } - added, err := c.adminRepo.AddGroup(groupID) - if err != nil { - _ = sender.SendMessage(msg.Chat.ID, "Internal server error") - return - } - if !added { - _ = sender.SendMessage(msg.Chat.ID, "Group is already added") - return - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = c.groupRepo.Init(ctx, groupID) - _ = sender.SendMessage(msg.Chat.ID, "Group added successfully") -} diff --git a/internal/bot/command/args.go b/internal/bot/command/args.go deleted file mode 100644 index 217ff2e..0000000 --- a/internal/bot/command/args.go +++ /dev/null @@ -1,13 +0,0 @@ -package command - -import "strings" - -// splitArgs mirrors Java BotCommand argument parsing: split on whitespace, -// drop empty tokens. -func splitArgs(s string) []string { - if s == "" { - return nil - } - parts := strings.Fields(s) - return parts -} diff --git a/internal/bot/command/check_app.go b/internal/bot/command/check_app.go deleted file mode 100644 index 33913b6..0000000 --- a/internal/bot/command/check_app.go +++ /dev/null @@ -1,120 +0,0 @@ -package command - -import ( - "context" - "fmt" - "strings" - "time" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/miti99/store-scraper-bot-go/internal/api/apple" - "github.com/miti99/store-scraper-bot-go/internal/api/google" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/model" - "github.com/miti99/store-scraper-bot-go/internal/repository" - "github.com/miti99/store-scraper-bot-go/internal/util" -) - -// /checkapp — Java CheckAppCommand. Reports update status per app, per store. -type CheckAppCommand struct { - cfg *config.Config - adminRepo *repository.AdminRepository - groupRepo *repository.GroupRepository - appleScraper *apple.AppleScraper - googleScraper *google.GoogleScraper -} - -func NewCheckAppCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository, a *apple.AppleScraper, g *google.GoogleScraper) *CheckAppCommand { - return &CheckAppCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo, appleScraper: a, googleScraper: g} -} - -func (c *CheckAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { - if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) { - return - } - if len(splitArgs(msg.CommandArguments())) != 0 { - _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - defer cancel() - group, err := c.groupRepo.Get(ctx, msg.Chat.ID) - if err != nil { - _ = sender.SendMessage(msg.Chat.ID, "Internal server error") - return - } - now := time.Now() - threshold := c.cfg.NumDaysWarningNotUpdated - - headers := []string{"AppId", "Updated", "Days", "OK"} - appleRows := c.appleRows(group.AppleApps, now, threshold) - googleRows := c.googleRows(group.GoogleApps, now, threshold) - - var sb strings.Builder - sb.WriteString("Apple Apps\n") - if len(appleRows) == 0 { - sb.WriteString("(none)\n") - } else { - sb.WriteString(fmt.Sprintf("
%s
\n", util.BuildTable(headers, appleRows))) - } - sb.WriteString("\nGoogle Apps\n") - if len(googleRows) == 0 { - sb.WriteString("(none)\n") - } else { - sb.WriteString(fmt.Sprintf("
%s
\n", util.BuildTable(headers, googleRows))) - } - _ = sender.SendMessage(msg.Chat.ID, sb.String()) -} - -func (c *CheckAppCommand) appleRows(apps []model.AppInfo, now time.Time, threshold int) [][]string { - rows := make([][]string, 0, len(apps)) - for _, a := range apps { - resp, err := c.appleScraper.GetApp(a.AppID, a.Country) - if err != nil || resp == nil { - rows = append(rows, []string{a.AppID, "?", "?", okMark(false)}) - continue - } - updated, days, ok := evalAppleUpdated(resp.Updated, now, threshold) - rows = append(rows, []string{a.AppID, updated, fmt.Sprintf("%d", days), okMark(ok)}) - } - return rows -} - -func (c *CheckAppCommand) googleRows(apps []model.AppInfo, now time.Time, threshold int) [][]string { - rows := make([][]string, 0, len(apps)) - for _, a := range apps { - resp, err := c.googleScraper.GetApp(a.AppID, a.Country) - if err != nil || resp == nil { - rows = append(rows, []string{a.AppID, "?", "?", okMark(false)}) - continue - } - updated, days, ok := evalGoogleUpdated(resp.Updated, now, threshold) - rows = append(rows, []string{a.AppID, updated, fmt.Sprintf("%d", days), okMark(ok)}) - } - return rows -} - -// evalAppleUpdated parses Apple's ISO 8601 timestamp and returns (yyyy-MM-dd, -// days since update, OK). -func evalAppleUpdated(updated string, now time.Time, threshold int) (string, int, bool) { - t, err := time.Parse(time.RFC3339, updated) - if err != nil { - return updated, 0, false - } - days := int(now.Sub(t).Hours() / 24) - return t.Format("2006-01-02"), days, days <= threshold -} - -func evalGoogleUpdated(millis int64, now time.Time, threshold int) (string, int, bool) { - t := time.UnixMilli(millis) - days := int(now.Sub(t).Hours() / 24) - return t.Format("2006-01-02"), days, days <= threshold -} - -func okMark(ok bool) string { - if ok { - return "✅" - } - return "❌" -} diff --git a/internal/bot/command/check_app_scores.go b/internal/bot/command/check_app_scores.go deleted file mode 100644 index 56d68b3..0000000 --- a/internal/bot/command/check_app_scores.go +++ /dev/null @@ -1,100 +0,0 @@ -package command - -import ( - "context" - "fmt" - "math" - "strings" - "time" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/miti99/store-scraper-bot-go/internal/api/apple" - "github.com/miti99/store-scraper-bot-go/internal/api/google" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/model" - "github.com/miti99/store-scraper-bot-go/internal/repository" - "github.com/miti99/store-scraper-bot-go/internal/util" -) - -// /checkappscore — Java CheckAppScoreCommand. Reports score + ratings count. -// Score is rounded to 1 decimal (Java Precision.round(score, 1) parity). -type CheckAppScoresCommand struct { - cfg *config.Config - adminRepo *repository.AdminRepository - groupRepo *repository.GroupRepository - appleScraper *apple.AppleScraper - googleScraper *google.GoogleScraper -} - -func NewCheckAppScoresCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository, a *apple.AppleScraper, g *google.GoogleScraper) *CheckAppScoresCommand { - return &CheckAppScoresCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo, appleScraper: a, googleScraper: g} -} - -func (c *CheckAppScoresCommand) Execute(msg *tgbotapi.Message, sender Sender) { - if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) { - return - } - if len(splitArgs(msg.CommandArguments())) != 0 { - _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - defer cancel() - group, err := c.groupRepo.Get(ctx, msg.Chat.ID) - if err != nil { - _ = sender.SendMessage(msg.Chat.ID, "Internal server error") - return - } - - headers := []string{"AppId", "Score", "Ratings"} - appleRows := c.appleScoreRows(group.AppleApps) - googleRows := c.googleScoreRows(group.GoogleApps) - - var sb strings.Builder - sb.WriteString("Apple Apps\n") - if len(appleRows) == 0 { - sb.WriteString("(none)\n") - } else { - sb.WriteString(fmt.Sprintf("
%s
\n", util.BuildTable(headers, appleRows))) - } - sb.WriteString("\nGoogle Apps\n") - if len(googleRows) == 0 { - sb.WriteString("(none)\n") - } else { - sb.WriteString(fmt.Sprintf("
%s
\n", util.BuildTable(headers, googleRows))) - } - _ = sender.SendMessage(msg.Chat.ID, sb.String()) -} - -func (c *CheckAppScoresCommand) appleScoreRows(apps []model.AppInfo) [][]string { - rows := make([][]string, 0, len(apps)) - for _, a := range apps { - resp, err := c.appleScraper.GetApp(a.AppID, a.Country) - if err != nil || resp == nil { - rows = append(rows, []string{a.AppID, "?", "?"}) - continue - } - rows = append(rows, []string{a.AppID, formatScore(resp.Score), fmt.Sprintf("%d", resp.Ratings)}) - } - return rows -} - -func (c *CheckAppScoresCommand) googleScoreRows(apps []model.AppInfo) [][]string { - rows := make([][]string, 0, len(apps)) - for _, a := range apps { - resp, err := c.googleScraper.GetApp(a.AppID, a.Country) - if err != nil || resp == nil { - rows = append(rows, []string{a.AppID, "?", "?"}) - continue - } - rows = append(rows, []string{a.AppID, formatScore(resp.Score), fmt.Sprintf("%d", resp.Ratings)}) - } - return rows -} - -// formatScore rounds to 1 decimal place (Java Precision.round(score, 1)). -func formatScore(score float64) string { - rounded := math.Round(score*10) / 10 - return fmt.Sprintf("%.1f", rounded) -} diff --git a/internal/bot/command/command.go b/internal/bot/command/command.go deleted file mode 100644 index c07bb8b..0000000 --- a/internal/bot/command/command.go +++ /dev/null @@ -1,39 +0,0 @@ -package command - -import ( - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/repository" -) - -// Sender is what bot.Bot exposes to commands. HTML parse mode (Java parity). -type Sender interface { - SendMessage(chatID int64, html string) error - SendMessageSilent(chatID int64, html string) error - SendDocument(chatID int64, filename, body string) error -} - -// Command is the unit registered on the bot dispatcher. -type Command interface { - Execute(msg *tgbotapi.Message, sender Sender) -} - -// authorizeGroup verifies the chat is in the admin's authorized group list. -// Mirrors Java's per-command "Group is not allowed to use bot" gate. -func authorizeGroup(chatID int64, adminRepo *repository.AdminRepository, sender Sender) bool { - ok, err := adminRepo.HasGroup(chatID) - if err != nil || !ok { - _ = sender.SendMessage(chatID, "Group is not allowed to use bot") - return false - } - return true -} - -// requireAdminUser checks the user is in Environment.ADMIN_IDS. -func requireAdminUser(userID, chatID int64, cfg *config.Config, sender Sender) bool { - if !cfg.IsAdmin(userID) { - _ = sender.SendMessage(chatID, "You are not authorized to use this command") - return false - } - return true -} diff --git a/internal/bot/command/delete_apple_app.go b/internal/bot/command/delete_apple_app.go deleted file mode 100644 index 903030c..0000000 --- a/internal/bot/command/delete_apple_app.go +++ /dev/null @@ -1,39 +0,0 @@ -package command - -import ( - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/repository" -) - -// /delapple — Java DeleteAppleAppCommand. -type DeleteAppleAppCommand struct { - cfg *config.Config - adminRepo *repository.AdminRepository - groupRepo *repository.GroupRepository -} - -func NewDeleteAppleAppCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository) *DeleteAppleAppCommand { - return &DeleteAppleAppCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo} -} - -func (c *DeleteAppleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { - if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) { - return - } - args := splitArgs(msg.CommandArguments()) - if len(args) != 1 { - _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") - return - } - removed, err := c.groupRepo.RemoveAppleApp(msg.Chat.ID, args[0]) - if err != nil { - _ = sender.SendMessage(msg.Chat.ID, "Internal server error") - return - } - if !removed { - _ = sender.SendMessage(msg.Chat.ID, "Apple app is not added") - return - } - _ = sender.SendMessage(msg.Chat.ID, "Apple app deleted successfully") -} diff --git a/internal/bot/command/delete_google_app.go b/internal/bot/command/delete_google_app.go deleted file mode 100644 index 01a7a7e..0000000 --- a/internal/bot/command/delete_google_app.go +++ /dev/null @@ -1,39 +0,0 @@ -package command - -import ( - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/repository" -) - -// /delgoogle — Java DeleteGoogleAppCommand. -type DeleteGoogleAppCommand struct { - cfg *config.Config - adminRepo *repository.AdminRepository - groupRepo *repository.GroupRepository -} - -func NewDeleteGoogleAppCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository) *DeleteGoogleAppCommand { - return &DeleteGoogleAppCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo} -} - -func (c *DeleteGoogleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { - if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) { - return - } - args := splitArgs(msg.CommandArguments()) - if len(args) != 1 { - _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") - return - } - removed, err := c.groupRepo.RemoveGoogleApp(msg.Chat.ID, args[0]) - if err != nil { - _ = sender.SendMessage(msg.Chat.ID, "Internal server error") - return - } - if !removed { - _ = sender.SendMessage(msg.Chat.ID, "Google app is not added") - return - } - _ = sender.SendMessage(msg.Chat.ID, "Google app deleted successfully") -} diff --git a/internal/bot/command/delete_group.go b/internal/bot/command/delete_group.go deleted file mode 100644 index e6a906f..0000000 --- a/internal/bot/command/delete_group.go +++ /dev/null @@ -1,50 +0,0 @@ -package command - -import ( - "strconv" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/repository" -) - -// /delgroup [groupId] — Java DeleteGroupCommand. Admin-only. -type DeleteGroupCommand struct { - cfg *config.Config - adminRepo *repository.AdminRepository - groupRepo *repository.GroupRepository -} - -func NewDeleteGroupCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository) *DeleteGroupCommand { - return &DeleteGroupCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo} -} - -func (c *DeleteGroupCommand) Execute(msg *tgbotapi.Message, sender Sender) { - if !requireAdminUser(msg.From.ID, msg.Chat.ID, c.cfg, sender) { - return - } - args := splitArgs(msg.CommandArguments()) - if len(args) > 1 { - _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") - return - } - groupID := msg.Chat.ID - if len(args) == 1 { - parsed, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") - return - } - groupID = parsed - } - removed, err := c.adminRepo.RemoveGroup(groupID) - if err != nil { - _ = sender.SendMessage(msg.Chat.ID, "Internal server error") - return - } - if !removed { - _ = sender.SendMessage(msg.Chat.ID, "Group is not added") - return - } - _ = sender.SendMessage(msg.Chat.ID, "Group deleted successfully") -} diff --git a/internal/bot/command/info.go b/internal/bot/command/info.go deleted file mode 100644 index 641d4db..0000000 --- a/internal/bot/command/info.go +++ /dev/null @@ -1,22 +0,0 @@ -package command - -import ( - "fmt" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/miti99/store-scraper-bot-go/internal/config" -) - -// /info — Java InfoCommand. Reports the chat (group) ID. -type InfoCommand struct{ cfg *config.Config } - -func NewInfoCommand(cfg *config.Config) *InfoCommand { return &InfoCommand{cfg: cfg} } - -func (c *InfoCommand) Execute(msg *tgbotapi.Message, sender Sender) { - args := splitArgs(msg.CommandArguments()) - if len(args) != 0 { - _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") - return - } - _ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Id của nhóm là %d\n", msg.Chat.ID)) -} diff --git a/internal/bot/command/list_app.go b/internal/bot/command/list_app.go deleted file mode 100644 index 4dd93df..0000000 --- a/internal/bot/command/list_app.go +++ /dev/null @@ -1,61 +0,0 @@ -package command - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/model" - "github.com/miti99/store-scraper-bot-go/internal/repository" - "github.com/miti99/store-scraper-bot-go/internal/util" -) - -// /listapp — Java ListAppCommand. Two tables (Apple / Google) of tracked apps. -type ListAppCommand struct { - cfg *config.Config - adminRepo *repository.AdminRepository - groupRepo *repository.GroupRepository -} - -func NewListAppCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository) *ListAppCommand { - return &ListAppCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo} -} - -func (c *ListAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { - if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) { - return - } - if len(splitArgs(msg.CommandArguments())) != 0 { - _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") - return - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - group, err := c.groupRepo.Get(ctx, msg.Chat.ID) - if err != nil { - _ = sender.SendMessage(msg.Chat.ID, "Internal server error") - return - } - - var sb strings.Builder - sb.WriteString("Apple Apps\n") - sb.WriteString(formatAppTable(group.AppleApps)) - sb.WriteString("\nGoogle Apps\n") - sb.WriteString(formatAppTable(group.GoogleApps)) - _ = sender.SendMessage(msg.Chat.ID, sb.String()) -} - -func formatAppTable(apps []model.AppInfo) string { - if len(apps) == 0 { - return "(none)\n" - } - rows := make([][]string, 0, len(apps)) - for i, a := range apps { - rows = append(rows, []string{strconv.Itoa(i + 1), a.AppID, a.Country}) - } - return fmt.Sprintf("
%s
\n", util.BuildTable([]string{"#", "AppId", "Country"}, rows)) -} diff --git a/internal/bot/command/list_group.go b/internal/bot/command/list_group.go deleted file mode 100644 index e80fcc8..0000000 --- a/internal/bot/command/list_group.go +++ /dev/null @@ -1,45 +0,0 @@ -package command - -import ( - "fmt" - "strings" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/repository" -) - -// /listgroup — Java ListGroupCommand. Admin-only. Lists authorized groups. -type ListGroupCommand struct { - cfg *config.Config - adminRepo *repository.AdminRepository -} - -func NewListGroupCommand(cfg *config.Config, adminRepo *repository.AdminRepository) *ListGroupCommand { - return &ListGroupCommand{cfg: cfg, adminRepo: adminRepo} -} - -func (c *ListGroupCommand) Execute(msg *tgbotapi.Message, sender Sender) { - if !requireAdminUser(msg.From.ID, msg.Chat.ID, c.cfg, sender) { - return - } - if len(splitArgs(msg.CommandArguments())) != 0 { - _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") - return - } - groups, err := c.adminRepo.GetAllGroups() - if err != nil { - _ = sender.SendMessage(msg.Chat.ID, "Internal server error") - return - } - if len(groups) == 0 { - _ = sender.SendMessage(msg.Chat.ID, "No groups found") - return - } - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Authorized groups (%d):\n", len(groups))) - for i, gid := range groups { - sb.WriteString(fmt.Sprintf("%d. %d\n", i+1, gid)) - } - _ = sender.SendMessage(msg.Chat.ID, sb.String()) -} diff --git a/internal/bot/command/raw_apple_app.go b/internal/bot/command/raw_apple_app.go deleted file mode 100644 index b8b1b66..0000000 --- a/internal/bot/command/raw_apple_app.go +++ /dev/null @@ -1,48 +0,0 @@ -package command - -import ( - "fmt" - "strconv" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/miti99/store-scraper-bot-go/internal/api/apple" - "github.com/miti99/store-scraper-bot-go/internal/api/apple/request" - "github.com/miti99/store-scraper-bot-go/internal/config" -) - -// /rawappleapp [country=vn] — Java RawAppleAppCommand. -// Sends the raw upstream JSON as a Telegram document attachment. -type RawAppleAppCommand struct { - cfg *config.Config - scraper *apple.AppleScraper -} - -func NewRawAppleAppCommand(cfg *config.Config, scraper *apple.AppleScraper) *RawAppleAppCommand { - return &RawAppleAppCommand{cfg: cfg, scraper: scraper} -} - -func (c *RawAppleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { - args := splitArgs(msg.CommandArguments()) - if len(args) < 1 || len(args) > 2 { - _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") - return - } - country := "vn" - if len(args) == 2 { - country = args[1] - } - - var req request.AppleAppRequest - if trackID, err := strconv.ParseInt(args[0], 10, 64); err == nil { - req = request.ByTrackID(trackID, country) - } else { - req = request.ByBundleID(args[0], country) - } - - raw, err := c.scraper.RawApp(req) - if err != nil || raw == "" { - _ = sender.SendMessage(msg.Chat.ID, "Error when request app info") - return - } - _ = sender.SendDocument(msg.Chat.ID, fmt.Sprintf("%s.json", args[0]), raw) -} diff --git a/internal/bot/command/raw_google_app.go b/internal/bot/command/raw_google_app.go deleted file mode 100644 index 66acb8d..0000000 --- a/internal/bot/command/raw_google_app.go +++ /dev/null @@ -1,40 +0,0 @@ -package command - -import ( - "fmt" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/miti99/store-scraper-bot-go/internal/api/google" - "github.com/miti99/store-scraper-bot-go/internal/api/google/request" - "github.com/miti99/store-scraper-bot-go/internal/config" -) - -// /rawgoogleapp [country=vn] — Java RawGoogleAppCommand. -// Sends raw upstream JSON as a Telegram document. -type RawGoogleAppCommand struct { - cfg *config.Config - scraper *google.GoogleScraper -} - -func NewRawGoogleAppCommand(cfg *config.Config, scraper *google.GoogleScraper) *RawGoogleAppCommand { - return &RawGoogleAppCommand{cfg: cfg, scraper: scraper} -} - -func (c *RawGoogleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { - args := splitArgs(msg.CommandArguments()) - if len(args) < 1 || len(args) > 2 { - _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") - return - } - appID := args[0] - country := "vn" - if len(args) == 2 { - country = args[1] - } - raw, err := c.scraper.RawApp(request.New(appID, country)) - if err != nil || raw == "" { - _ = sender.SendMessage(msg.Chat.ID, "Error when request app info") - return - } - _ = sender.SendDocument(msg.Chat.ID, fmt.Sprintf("%s.json", appID), raw) -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index ee3f25d..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,178 +0,0 @@ -package config - -import ( - "fmt" - "os" - "strconv" - "strings" - "time" - - "go.mongodb.org/mongo-driver/mongo/options" - "go.uber.org/zap" -) - -type Environment string - -const ( - Development Environment = "DEVELOPMENT" - Production Environment = "PRODUCTION" -) - -const DefaultDatabaseName = "store-scraper-bot" - -type Config struct { - // Telegram - TelegramBotToken string - TelegramBotUsername string - - // MongoDB - MongoURI string - MongoDatabase string - MongoTimeout time.Duration - - // Application - Env Environment - AdminIDs []int64 - CreatorID int64 - SourceCommit string - - // Constants - AppCacheSeconds int - NumDaysWarningNotUpdated int - ScheduleCheckAppTime string - VietnamLocation *time.Location - - // Logger - Logger *zap.Logger -} - -var GlobalConfig *Config - -func Load() (*Config, error) { - cfg := &Config{} - - cfg.TelegramBotToken = getEnv("TELEGRAM_BOT_TOKEN", "") - if cfg.TelegramBotToken == "" { - return nil, fmt.Errorf("TELEGRAM_BOT_TOKEN is required") - } - cfg.TelegramBotUsername = getEnv("TELEGRAM_BOT_USERNAME", "") - if cfg.TelegramBotUsername == "" { - return nil, fmt.Errorf("TELEGRAM_BOT_USERNAME is required") - } - - // Java parity: prefer MONGODB_CONNECTION_STRING. Fall back to MONGO_URI. - cfg.MongoURI = getEnv("MONGODB_CONNECTION_STRING", getEnv("MONGO_URI", "mongodb://localhost:27017")) - cfg.MongoDatabase = getEnv("MONGO_DATABASE", "") - if cfg.MongoDatabase == "" { - cfg.MongoDatabase = databaseFromURI(cfg.MongoURI) - } - cfg.MongoTimeout = time.Duration(getEnvInt("MONGO_TIMEOUT_SECONDS", 10)) * time.Second - - envStr := getEnv("ENV", "DEVELOPMENT") - if envStr == "PRODUCTION" { - cfg.Env = Production - } else { - cfg.Env = Development - } - - adminIDsStr := getEnv("ADMIN_IDS", "") - if adminIDsStr == "" { - return nil, fmt.Errorf("ADMIN_IDS is required") - } - cfg.AdminIDs = parseAdminIDs(adminIDsStr) - if len(cfg.AdminIDs) == 0 { - return nil, fmt.Errorf("at least one admin ID is required") - } - cfg.CreatorID = cfg.AdminIDs[0] - - cfg.SourceCommit = getEnv("SOURCE_COMMIT", "unknown") - - cfg.AppCacheSeconds = getEnvInt("APP_CACHE_SECONDS", 600) - cfg.NumDaysWarningNotUpdated = getEnvInt("NUM_DAYS_WARNING_NOT_UPDATED", 30) - cfg.ScheduleCheckAppTime = getEnv("SCHEDULE_CHECK_APP_TIME", "0 7 * * *") - - loc, err := time.LoadLocation("Asia/Ho_Chi_Minh") - if err != nil { - return nil, fmt.Errorf("failed to load Vietnam timezone: %w", err) - } - cfg.VietnamLocation = loc - - var logger *zap.Logger - if cfg.Env == Production { - logger, err = zap.NewProduction() - } else { - logger, err = zap.NewDevelopment() - } - if err != nil { - return nil, fmt.Errorf("failed to initialize logger: %w", err) - } - cfg.Logger = logger - - GlobalConfig = cfg - return cfg, nil -} - -// databaseFromURI extracts the database name from a Mongo connection string, -// falling back to DefaultDatabaseName (Java behavior). -func databaseFromURI(uri string) string { - opts := options.Client().ApplyURI(uri) - if opts != nil && opts.Auth != nil && opts.Auth.AuthSource != "" { - // AuthSource is not the data DB; ignore it. - _ = opts - } - // Manual parse: scheme://...host[:port]/? - rest := uri - if idx := strings.Index(rest, "://"); idx >= 0 { - rest = rest[idx+3:] - } - slash := strings.Index(rest, "/") - if slash < 0 { - return DefaultDatabaseName - } - tail := rest[slash+1:] - if q := strings.Index(tail, "?"); q >= 0 { - tail = tail[:q] - } - tail = strings.TrimSpace(tail) - if tail == "" { - return DefaultDatabaseName - } - return tail -} - -func getEnv(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value - } - return defaultValue -} - -func getEnvInt(key string, defaultValue int) int { - if value := os.Getenv(key); value != "" { - if intVal, err := strconv.Atoi(value); err == nil { - return intVal - } - } - return defaultValue -} - -func parseAdminIDs(adminIDsStr string) []int64 { - parts := strings.Split(adminIDsStr, ",") - adminIDs := make([]int64, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if id, err := strconv.ParseInt(part, 10, 64); err == nil { - adminIDs = append(adminIDs, id) - } - } - return adminIDs -} - -func (c *Config) IsAdmin(userID int64) bool { - for _, adminID := range c.AdminIDs { - if adminID == userID { - return true - } - } - return false -} diff --git a/internal/model/abstract_model.go b/internal/model/abstract_model.go deleted file mode 100644 index 65d4bc9..0000000 --- a/internal/model/abstract_model.go +++ /dev/null @@ -1,8 +0,0 @@ -package model - -// AbstractModel mirrors Java AbstractModel: every persisted entity has _id (string) -// and a `class` discriminator equal to the simple type name. -type AbstractModel struct { - ID string `bson:"_id" json:"_id"` - Class string `bson:"class" json:"class"` -} diff --git a/internal/model/admin.go b/internal/model/admin.go deleted file mode 100644 index 1252a86..0000000 --- a/internal/model/admin.go +++ /dev/null @@ -1,43 +0,0 @@ -package model - -// AdminID is the singleton document _id used by Java AdminRepository. -const AdminID = "admin" - -type Admin struct { - AbstractModel `bson:",inline"` - Groups []int64 `bson:"groups" json:"groups"` -} - -func NewAdmin() *Admin { - return &Admin{ - AbstractModel: AbstractModel{ID: AdminID, Class: "Admin"}, - Groups: []int64{}, - } -} - -func (a *Admin) AddGroup(groupID int64) bool { - if a.HasGroup(groupID) { - return false - } - a.Groups = append(a.Groups, groupID) - return true -} - -func (a *Admin) RemoveGroup(groupID int64) bool { - for i, g := range a.Groups { - if g == groupID { - a.Groups = append(a.Groups[:i], a.Groups[i+1:]...) - return true - } - } - return false -} - -func (a *Admin) HasGroup(groupID int64) bool { - for _, g := range a.Groups { - if g == groupID { - return true - } - } - return false -} diff --git a/internal/model/apple_app.go b/internal/model/apple_app.go deleted file mode 100644 index 46b6412..0000000 --- a/internal/model/apple_app.go +++ /dev/null @@ -1,59 +0,0 @@ -package model - -// AppleAppResponse mirrors Java AppleAppResponse record (api/apple/response). -type AppleAppResponse struct { - ID int64 `bson:"id" json:"id"` - AppID string `bson:"appId" json:"appId"` - Title string `bson:"title" json:"title"` - URL string `bson:"url" json:"url"` - Description string `bson:"description" json:"description"` - Icon string `bson:"icon" json:"icon"` - Genres []string `bson:"genres" json:"genres"` - GenreIDs []string `bson:"genreIds" json:"genreIds"` - PrimaryGenre string `bson:"primaryGenre" json:"primaryGenre"` - PrimaryGenreID int `bson:"primaryGenreId" json:"primaryGenreId"` - ContentRating string `bson:"contentRating" json:"contentRating"` - Languages []string `bson:"languages" json:"languages"` - Size string `bson:"size" json:"size"` - RequiredOsVersion string `bson:"requiredOsVersion" json:"requiredOsVersion"` - Released string `bson:"released" json:"released"` - Updated string `bson:"updated" json:"updated"` // ISO 8601 - ReleaseNotes string `bson:"releaseNotes" json:"releaseNotes"` - Version string `bson:"version" json:"version"` - Price float64 `bson:"price" json:"price"` - Currency string `bson:"currency" json:"currency"` - Free bool `bson:"free" json:"free"` - DeveloperID int64 `bson:"developerId" json:"developerId"` - Developer string `bson:"developer" json:"developer"` - DeveloperURL string `bson:"developerUrl" json:"developerUrl"` - DeveloperWebsite string `bson:"developerWebsite" json:"developerWebsite"` - Score float64 `bson:"score" json:"score"` - Reviews int `bson:"reviews" json:"reviews"` - CurrentVersionScore float64 `bson:"currentVersionScore" json:"currentVersionScore"` - CurrentVersionReviews int `bson:"currentVersionReviews" json:"currentVersionReviews"` - Screenshots []string `bson:"screenshots" json:"screenshots"` - IpadScreenshots []string `bson:"ipadScreenshots" json:"ipadScreenshots"` - AppletvScreenshots []string `bson:"appletvScreenshots" json:"appletvScreenshots"` - SupportedDevices []string `bson:"supportedDevices" json:"supportedDevices"` - Ratings int64 `bson:"ratings" json:"ratings"` - Histogram map[string]int64 `bson:"histogram" json:"histogram"` -} - -type AppleApp struct { - AbstractModel `bson:",inline"` - App AppleAppResponse `bson:"app" json:"app"` - Millis int64 `bson:"millis" json:"millis"` // cache timestamp (ms since epoch) -} - -func NewAppleApp(appID string, response AppleAppResponse, millis int64) *AppleApp { - return &AppleApp{ - AbstractModel: AbstractModel{ID: appID, Class: "AppleApp"}, - App: response, - Millis: millis, - } -} - -// IsExpired reports whether the cache entry is older than cacheMillis. -func (a *AppleApp) IsExpired(nowMillis, cacheMillis int64) bool { - return nowMillis-a.Millis > cacheMillis -} diff --git a/internal/model/google_app.go b/internal/model/google_app.go deleted file mode 100644 index dc02ec5..0000000 --- a/internal/model/google_app.go +++ /dev/null @@ -1,91 +0,0 @@ -package model - -// Category mirrors Java GoogleAppResponse.Category nested record. -type Category struct { - Name string `bson:"name" json:"name"` - ID string `bson:"id" json:"id"` -} - -// Feature mirrors Java GoogleAppResponse.Feature nested record. -type Feature struct { - Title string `bson:"title" json:"title"` - Description string `bson:"description" json:"description"` -} - -// GoogleAppResponse mirrors Java GoogleAppResponse record (api/google/response). -type GoogleAppResponse struct { - Title string `bson:"title" json:"title"` - Description string `bson:"description" json:"description"` - DescriptionHTML string `bson:"descriptionHTML" json:"descriptionHTML"` - Summary string `bson:"summary" json:"summary"` - Installs string `bson:"installs" json:"installs"` - MinInstalls int64 `bson:"minInstalls" json:"minInstalls"` - MaxInstalls int64 `bson:"maxInstalls" json:"maxInstalls"` - Score float64 `bson:"score" json:"score"` - ScoreText string `bson:"scoreText" json:"scoreText"` - Ratings int64 `bson:"ratings" json:"ratings"` - Reviews int64 `bson:"reviews" json:"reviews"` - Histogram map[string]int64 `bson:"histogram" json:"histogram"` - Price float64 `bson:"price" json:"price"` - Free bool `bson:"free" json:"free"` - Currency string `bson:"currency" json:"currency"` - PriceText string `bson:"priceText" json:"priceText"` - OffersIAP bool `bson:"offersIAP" json:"offersIAP"` - IAPRange string `bson:"IAPRange" json:"IAPRange"` - AndroidVersion string `bson:"androidVersion" json:"androidVersion"` - AndroidVersionText string `bson:"androidVersionText" json:"androidVersionText"` - AndroidMaxVersion string `bson:"androidMaxVersion" json:"androidMaxVersion"` - Developer string `bson:"developer" json:"developer"` - DeveloperID string `bson:"developerId" json:"developerId"` - DeveloperEmail string `bson:"developerEmail" json:"developerEmail"` - DeveloperWebsite string `bson:"developerWebsite" json:"developerWebsite"` - DeveloperAddress string `bson:"developerAddress" json:"developerAddress"` - DeveloperLegalName string `bson:"developerLegalName" json:"developerLegalName"` - DeveloperLegalEmail string `bson:"developerLegalEmail" json:"developerLegalEmail"` - DeveloperLegalAddress string `bson:"developerLegalAddress" json:"developerLegalAddress"` - DeveloperLegalPhoneNumber string `bson:"developerLegalPhoneNumber" json:"developerLegalPhoneNumber"` - PrivacyPolicy string `bson:"privacyPolicy" json:"privacyPolicy"` - DeveloperInternalID string `bson:"developerInternalID" json:"developerInternalID"` - Genre string `bson:"genre" json:"genre"` - GenreID string `bson:"genreId" json:"genreId"` - Categories []Category `bson:"categories" json:"categories"` - Icon string `bson:"icon" json:"icon"` - HeaderImage string `bson:"headerImage" json:"headerImage"` - Screenshots []string `bson:"screenshots" json:"screenshots"` - Video string `bson:"video" json:"video"` - VideoImage string `bson:"videoImage" json:"videoImage"` - PreviewVideo string `bson:"previewVideo" json:"previewVideo"` - ContentRating string `bson:"contentRating" json:"contentRating"` - ContentRatingDescription string `bson:"contentRatingDescription" json:"contentRatingDescription"` - AdSupported bool `bson:"adSupported" json:"adSupported"` - Released string `bson:"released" json:"released"` - Updated int64 `bson:"updated" json:"updated"` // ms since epoch - Version string `bson:"version" json:"version"` - RecentChanges string `bson:"recentChanges" json:"recentChanges"` - Comments []string `bson:"comments" json:"comments"` - Preregister bool `bson:"preregister" json:"preregister"` - EarlyAccessEnabled bool `bson:"earlyAccessEnabled" json:"earlyAccessEnabled"` - IsAvailableInPlayPass bool `bson:"isAvailableInPlayPass" json:"isAvailableInPlayPass"` - EditorsChoice bool `bson:"editorsChoice" json:"editorsChoice"` - Features []Feature `bson:"features" json:"features"` - AppID string `bson:"appId" json:"appId"` - URL string `bson:"url" json:"url"` -} - -type GoogleApp struct { - AbstractModel `bson:",inline"` - App GoogleAppResponse `bson:"app" json:"app"` - Millis int64 `bson:"millis" json:"millis"` -} - -func NewGoogleApp(appID string, response GoogleAppResponse, millis int64) *GoogleApp { - return &GoogleApp{ - AbstractModel: AbstractModel{ID: appID, Class: "GoogleApp"}, - App: response, - Millis: millis, - } -} - -func (g *GoogleApp) IsExpired(nowMillis, cacheMillis int64) bool { - return nowMillis-g.Millis > cacheMillis -} diff --git a/internal/model/group.go b/internal/model/group.go deleted file mode 100644 index 5073d93..0000000 --- a/internal/model/group.go +++ /dev/null @@ -1,80 +0,0 @@ -package model - -import "strconv" - -// AppInfo mirrors Java AppleAppInfo / GoogleAppInfo records. -type AppInfo struct { - AppID string `bson:"appId" json:"appId"` - Country string `bson:"country" json:"country"` -} - -type Group struct { - AbstractModel `bson:",inline"` - AppleApps []AppInfo `bson:"appleApps" json:"appleApps"` - GoogleApps []AppInfo `bson:"googleApps" json:"googleApps"` -} - -// GroupIDToKey converts a Telegram chat ID to the string _id used by Java. -func GroupIDToKey(groupID int64) string { - return strconv.FormatInt(groupID, 10) -} - -// GroupKeyToID parses a stored _id back to int64. -func GroupKeyToID(key string) (int64, error) { - return strconv.ParseInt(key, 10, 64) -} - -func NewGroup(groupID int64) *Group { - return &Group{ - AbstractModel: AbstractModel{ID: GroupIDToKey(groupID), Class: "Group"}, - AppleApps: []AppInfo{}, - GoogleApps: []AppInfo{}, - } -} - -// GroupID returns the int64 chat ID parsed from the stored string _id. -// Returns 0 if parsing fails (matches Java behaviour where _id always parses). -func (g *Group) GroupID() int64 { - id, _ := GroupKeyToID(g.ID) - return id -} - -func (g *Group) AddAppleApp(appID, country string) bool { - for _, app := range g.AppleApps { - if app.AppID == appID { - return false - } - } - g.AppleApps = append(g.AppleApps, AppInfo{AppID: appID, Country: country}) - return true -} - -func (g *Group) RemoveAppleApp(appID string) bool { - for i, app := range g.AppleApps { - if app.AppID == appID { - g.AppleApps = append(g.AppleApps[:i], g.AppleApps[i+1:]...) - return true - } - } - return false -} - -func (g *Group) AddGoogleApp(appID, country string) bool { - for _, app := range g.GoogleApps { - if app.AppID == appID { - return false - } - } - g.GoogleApps = append(g.GoogleApps, AppInfo{AppID: appID, Country: country}) - return true -} - -func (g *Group) RemoveGoogleApp(appID string) bool { - for i, app := range g.GoogleApps { - if app.AppID == appID { - g.GoogleApps = append(g.GoogleApps[:i], g.GoogleApps[i+1:]...) - return true - } - } - return false -} diff --git a/internal/model/non_updated_app.go b/internal/model/non_updated_app.go deleted file mode 100644 index 12e4ca8..0000000 --- a/internal/model/non_updated_app.go +++ /dev/null @@ -1,14 +0,0 @@ -package model - -// NonUpdatedApp is a transient (not persisted) struct used by the daily check -// job to report apps not updated in N days. -type NonUpdatedApp struct { - AppID string - Title string - Days int - Updated string - Score float64 - Reviews int64 - Ratings int64 - IsApple bool -} diff --git a/internal/repository/admin_repository.go b/internal/repository/admin_repository.go deleted file mode 100644 index cde918f..0000000 --- a/internal/repository/admin_repository.go +++ /dev/null @@ -1,108 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "time" - - "github.com/miti99/store-scraper-bot-go/internal/model" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" -) - -// AdminRepository persists the singleton Admin document. -// Java equivalent stores it in the "common" collection at _id="admin". -type AdminRepository struct { - collection *mongo.Collection -} - -func NewAdminRepository() *AdminRepository { - return &AdminRepository{collection: GetCollection("common")} -} - -// Init creates the singleton document if it does not yet exist. -func (r *AdminRepository) Init() error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - count, err := r.collection.CountDocuments(ctx, bson.M{"_id": model.AdminID}) - if err != nil { - return fmt.Errorf("failed to count admin: %w", err) - } - if count > 0 { - return nil - } - return r.Save(ctx, model.NewAdmin()) -} - -func (r *AdminRepository) Get(ctx context.Context) (*model.Admin, error) { - admin := &model.Admin{} - err := r.collection.FindOne(ctx, bson.M{"_id": model.AdminID}).Decode(admin) - if err != nil { - if err == mongo.ErrNoDocuments { - return model.NewAdmin(), nil - } - return nil, fmt.Errorf("failed to get admin: %w", err) - } - return admin, nil -} - -func (r *AdminRepository) Save(ctx context.Context, admin *model.Admin) error { - opts := options.Replace().SetUpsert(true) - _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": model.AdminID}, admin, opts) - if err != nil { - return fmt.Errorf("failed to save admin: %w", err) - } - return nil -} - -func (r *AdminRepository) AddGroup(groupID int64) (added bool, err error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - admin, err := r.Get(ctx) - if err != nil { - return false, err - } - if !admin.AddGroup(groupID) { - return false, nil - } - return true, r.Save(ctx, admin) -} - -func (r *AdminRepository) RemoveGroup(groupID int64) (removed bool, err error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - admin, err := r.Get(ctx) - if err != nil { - return false, err - } - if !admin.RemoveGroup(groupID) { - return false, nil - } - return true, r.Save(ctx, admin) -} - -func (r *AdminRepository) HasGroup(groupID int64) (bool, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - admin, err := r.Get(ctx) - if err != nil { - return false, err - } - return admin.HasGroup(groupID), nil -} - -func (r *AdminRepository) GetAllGroups() ([]int64, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - admin, err := r.Get(ctx) - if err != nil { - return nil, err - } - return admin.Groups, nil -} diff --git a/internal/repository/apple_app_repository.go b/internal/repository/apple_app_repository.go deleted file mode 100644 index 2c0eb82..0000000 --- a/internal/repository/apple_app_repository.go +++ /dev/null @@ -1,61 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "time" - - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/model" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" -) - -// AppleAppRepository caches Apple app responses in the "apple_app" collection. -// Java schema stores _id=appId, app=AppleAppResponse, millis=cache timestamp. -type AppleAppRepository struct { - collection *mongo.Collection -} - -func NewAppleAppRepository() *AppleAppRepository { - return &AppleAppRepository{collection: GetCollection("apple_app")} -} - -func (r *AppleAppRepository) Get(ctx context.Context, appID string) (*model.AppleApp, error) { - app := &model.AppleApp{} - err := r.collection.FindOne(ctx, bson.M{"_id": appID}).Decode(app) - if err != nil { - if err == mongo.ErrNoDocuments { - return nil, nil - } - return nil, fmt.Errorf("failed to get apple app: %w", err) - } - return app, nil -} - -func (r *AppleAppRepository) Save(ctx context.Context, app *model.AppleApp) error { - opts := options.Replace().SetUpsert(true) - _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": app.ID}, app, opts) - if err != nil { - return fmt.Errorf("failed to save apple app: %w", err) - } - return nil -} - -// GetCached returns a cached entry if it exists and has not expired (per -// AppCacheSeconds). Returns (nil, nil) on cache miss. -func (r *AppleAppRepository) GetCached(appID string) (*model.AppleApp, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - app, err := r.Get(ctx, appID) - if err != nil || app == nil { - return nil, err - } - cacheMillis := int64(config.GlobalConfig.AppCacheSeconds) * 1000 - if app.IsExpired(time.Now().UnixMilli(), cacheMillis) { - return nil, nil - } - return app, nil -} diff --git a/internal/repository/google_app_repository.go b/internal/repository/google_app_repository.go deleted file mode 100644 index fcfc5cf..0000000 --- a/internal/repository/google_app_repository.go +++ /dev/null @@ -1,58 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "time" - - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/model" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" -) - -// GoogleAppRepository caches Google Play responses in the "google_app" collection. -type GoogleAppRepository struct { - collection *mongo.Collection -} - -func NewGoogleAppRepository() *GoogleAppRepository { - return &GoogleAppRepository{collection: GetCollection("google_app")} -} - -func (r *GoogleAppRepository) Get(ctx context.Context, appID string) (*model.GoogleApp, error) { - app := &model.GoogleApp{} - err := r.collection.FindOne(ctx, bson.M{"_id": appID}).Decode(app) - if err != nil { - if err == mongo.ErrNoDocuments { - return nil, nil - } - return nil, fmt.Errorf("failed to get google app: %w", err) - } - return app, nil -} - -func (r *GoogleAppRepository) Save(ctx context.Context, app *model.GoogleApp) error { - opts := options.Replace().SetUpsert(true) - _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": app.ID}, app, opts) - if err != nil { - return fmt.Errorf("failed to save google app: %w", err) - } - return nil -} - -func (r *GoogleAppRepository) GetCached(appID string) (*model.GoogleApp, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - app, err := r.Get(ctx, appID) - if err != nil || app == nil { - return nil, err - } - cacheMillis := int64(config.GlobalConfig.AppCacheSeconds) * 1000 - if app.IsExpired(time.Now().UnixMilli(), cacheMillis) { - return nil, nil - } - return app, nil -} diff --git a/internal/repository/group_repository.go b/internal/repository/group_repository.go deleted file mode 100644 index 317397f..0000000 --- a/internal/repository/group_repository.go +++ /dev/null @@ -1,131 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "time" - - "github.com/miti99/store-scraper-bot-go/internal/model" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" -) - -// GroupRepository persists Group documents in the "group" collection. -// Java schema stores _id as the string form of the Telegram chat ID. -type GroupRepository struct { - collection *mongo.Collection -} - -func NewGroupRepository() *GroupRepository { - return &GroupRepository{collection: GetCollection("group")} -} - -// Init creates an empty Group if not present. -func (r *GroupRepository) Init(ctx context.Context, groupID int64) error { - exists, err := r.Exists(ctx, groupID) - if err != nil { - return err - } - if exists { - return nil - } - return r.Save(ctx, model.NewGroup(groupID)) -} - -func (r *GroupRepository) Exists(ctx context.Context, groupID int64) (bool, error) { - count, err := r.collection.CountDocuments(ctx, bson.M{"_id": model.GroupIDToKey(groupID)}) - if err != nil { - return false, fmt.Errorf("failed to count group: %w", err) - } - return count > 0, nil -} - -func (r *GroupRepository) Get(ctx context.Context, groupID int64) (*model.Group, error) { - group := &model.Group{} - err := r.collection.FindOne(ctx, bson.M{"_id": model.GroupIDToKey(groupID)}).Decode(group) - if err != nil { - if err == mongo.ErrNoDocuments { - return model.NewGroup(groupID), nil - } - return nil, fmt.Errorf("failed to get group: %w", err) - } - return group, nil -} - -func (r *GroupRepository) Save(ctx context.Context, group *model.Group) error { - opts := options.Replace().SetUpsert(true) - _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": group.ID}, group, opts) - if err != nil { - return fmt.Errorf("failed to save group: %w", err) - } - return nil -} - -func (r *GroupRepository) Delete(ctx context.Context, groupID int64) error { - _, err := r.collection.DeleteOne(ctx, bson.M{"_id": model.GroupIDToKey(groupID)}) - if err != nil { - return fmt.Errorf("failed to delete group: %w", err) - } - return nil -} - -func (r *GroupRepository) shortCtx() (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), 5*time.Second) -} - -func (r *GroupRepository) AddAppleApp(groupID int64, appID, country string) (added bool, err error) { - ctx, cancel := r.shortCtx() - defer cancel() - - group, err := r.Get(ctx, groupID) - if err != nil { - return false, err - } - if !group.AddAppleApp(appID, country) { - return false, nil - } - return true, r.Save(ctx, group) -} - -func (r *GroupRepository) RemoveAppleApp(groupID int64, appID string) (removed bool, err error) { - ctx, cancel := r.shortCtx() - defer cancel() - - group, err := r.Get(ctx, groupID) - if err != nil { - return false, err - } - if !group.RemoveAppleApp(appID) { - return false, nil - } - return true, r.Save(ctx, group) -} - -func (r *GroupRepository) AddGoogleApp(groupID int64, appID, country string) (added bool, err error) { - ctx, cancel := r.shortCtx() - defer cancel() - - group, err := r.Get(ctx, groupID) - if err != nil { - return false, err - } - if !group.AddGoogleApp(appID, country) { - return false, nil - } - return true, r.Save(ctx, group) -} - -func (r *GroupRepository) RemoveGoogleApp(groupID int64, appID string) (removed bool, err error) { - ctx, cancel := r.shortCtx() - defer cancel() - - group, err := r.Get(ctx, groupID) - if err != nil { - return false, err - } - if !group.RemoveGoogleApp(appID) { - return false, nil - } - return true, r.Save(ctx, group) -} diff --git a/internal/repository/mongodb.go b/internal/repository/mongodb.go deleted file mode 100644 index a154d03..0000000 --- a/internal/repository/mongodb.go +++ /dev/null @@ -1,59 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "time" - - "github.com/miti99/store-scraper-bot-go/internal/config" - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" - "go.uber.org/zap" -) - -var ( - client *mongo.Client - database *mongo.Database -) - -func InitMongoDB(cfg *config.Config) error { - ctx, cancel := context.WithTimeout(context.Background(), cfg.MongoTimeout) - defer cancel() - - clientOptions := options.Client().ApplyURI(cfg.MongoURI) - - var err error - client, err = mongo.Connect(ctx, clientOptions) - if err != nil { - return fmt.Errorf("failed to connect to MongoDB: %w", err) - } - - // Ping to verify connection - if err := client.Ping(ctx, nil); err != nil { - return fmt.Errorf("failed to ping MongoDB: %w", err) - } - - database = client.Database(cfg.MongoDatabase) - cfg.Logger.Info("Connected to MongoDB", - zap.String("database", cfg.MongoDatabase), - zap.String("uri", cfg.MongoURI)) - - return nil -} - -func Close() error { - if client != nil { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return client.Disconnect(ctx) - } - return nil -} - -func GetDatabase() *mongo.Database { - return database -} - -func GetCollection(name string) *mongo.Collection { - return database.Collection(name) -} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go deleted file mode 100644 index 031d8b2..0000000 --- a/internal/scheduler/scheduler.go +++ /dev/null @@ -1,189 +0,0 @@ -package scheduler - -import ( - "context" - "fmt" - "time" - - "github.com/miti99/store-scraper-bot-go/internal/api/apple" - "github.com/miti99/store-scraper-bot-go/internal/api/google" - "github.com/miti99/store-scraper-bot-go/internal/bot" - "github.com/miti99/store-scraper-bot-go/internal/config" - "github.com/miti99/store-scraper-bot-go/internal/model" - "github.com/miti99/store-scraper-bot-go/internal/repository" - "github.com/miti99/store-scraper-bot-go/internal/util" - "github.com/robfig/cron/v3" - "go.uber.org/zap" -) - -type Scheduler struct { - cron *cron.Cron - cfg *config.Config - bot *bot.Bot - adminRepo *repository.AdminRepository - groupRepo *repository.GroupRepository - appleScraper *apple.AppleScraper - googleScraper *google.GoogleScraper - logger *zap.Logger -} - -func NewScheduler( - cfg *config.Config, - bot *bot.Bot, - adminRepo *repository.AdminRepository, - groupRepo *repository.GroupRepository, - appleScraper *apple.AppleScraper, - googleScraper *google.GoogleScraper, -) *Scheduler { - c := cron.New(cron.WithLocation(cfg.VietnamLocation)) - return &Scheduler{ - cron: c, - cfg: cfg, - bot: bot, - adminRepo: adminRepo, - groupRepo: groupRepo, - appleScraper: appleScraper, - googleScraper: googleScraper, - logger: cfg.Logger, - } -} - -func (s *Scheduler) Start() error { - if _, err := s.cron.AddFunc(s.cfg.ScheduleCheckAppTime, s.runDailyCheck); err != nil { - return fmt.Errorf("schedule daily check: %w", err) - } - s.logger.Info("Scheduler started", - zap.String("schedule", s.cfg.ScheduleCheckAppTime), - zap.String("timezone", s.cfg.VietnamLocation.String())) - s.cron.Start() - return nil -} - -func (s *Scheduler) Stop() { - s.cron.Stop() - s.logger.Info("Scheduler stopped") -} - -func (s *Scheduler) runDailyCheck() { - now := time.Now().In(s.cfg.VietnamLocation) - silent := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday - s.logger.Info("Running daily check job", zap.Bool("silent", silent)) - - groups, err := s.adminRepo.GetAllGroups() - if err != nil { - s.logger.Error("Failed to get groups", zap.Error(err)) - return - } - for _, gid := range groups { - s.checkGroup(gid, silent, now) - } - s.logger.Info("Daily check job completed", zap.Int("groupsChecked", len(groups))) -} - -func (s *Scheduler) checkGroup(groupID int64, silent bool, now time.Time) { - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - - group, err := s.groupRepo.Get(ctx, groupID) - if err != nil { - s.logger.Error("Failed to get group", zap.Int64("groupId", groupID), zap.Error(err)) - return - } - if len(group.AppleApps) == 0 && len(group.GoogleApps) == 0 { - s.logger.Info("Group has no apps, skipping", zap.Int64("groupId", groupID)) - return - } - - threshold := s.cfg.NumDaysWarningNotUpdated - stale := make([]model.NonUpdatedApp, 0) - - for _, info := range group.AppleApps { - app, err := s.appleScraper.GetApp(info.AppID, info.Country) - if err != nil || app == nil { - s.logger.Error("Apple fetch failed", zap.String("appId", info.AppID), zap.Error(err)) - continue - } - updatedTime, err := time.Parse(time.RFC3339, app.Updated) - if err != nil { - continue - } - days := int(now.Sub(updatedTime).Hours() / 24) - if days > threshold { - stale = append(stale, model.NonUpdatedApp{ - AppID: info.AppID, - Title: app.Title, - Days: days, - Updated: updatedTime.Format("2006-01-02"), - Score: app.Score, - Reviews: int64(app.Reviews), - Ratings: app.Ratings, - IsApple: true, - }) - } - } - - for _, info := range group.GoogleApps { - app, err := s.googleScraper.GetApp(info.AppID, info.Country) - if err != nil || app == nil { - s.logger.Error("Google fetch failed", zap.String("appId", info.AppID), zap.Error(err)) - continue - } - updatedTime := time.UnixMilli(app.Updated) - days := int(now.Sub(updatedTime).Hours() / 24) - if days > threshold { - stale = append(stale, model.NonUpdatedApp{ - AppID: info.AppID, - Title: app.Title, - Days: days, - Updated: updatedTime.Format("2006-01-02"), - Score: app.Score, - Reviews: app.Reviews, - Ratings: app.Ratings, - IsApple: false, - }) - } - } - - if len(stale) == 0 { - s.logger.Info("All apps up-to-date", zap.Int64("groupId", groupID)) - return - } - message := s.buildReport(groupID, stale, now) - var sendErr error - if silent { - sendErr = s.bot.SendMessageSilent(groupID, message) - } else { - sendErr = s.bot.SendMessage(groupID, message) - } - if sendErr != nil { - s.logger.Error("Send daily report failed", zap.Int64("groupId", groupID), zap.Error(sendErr)) - } -} - -func (s *Scheduler) buildReport(groupID int64, apps []model.NonUpdatedApp, now time.Time) string { - headers := []string{"App", "Store", "Days", "Updated", "Score", "Reviews", "Ratings"} - rows := make([][]string, 0, len(apps)) - for _, a := range apps { - store := "Google" - if a.IsApple { - store = "Apple" - } - rows = append(rows, []string{ - util.TruncateString(a.Title, 30), - store, - fmt.Sprintf("%d", a.Days), - a.Updated, - fmt.Sprintf("%.1f", a.Score), - fmt.Sprintf("%d", a.Reviews), - util.FormatNumber(a.Ratings), - }) - } - return fmt.Sprintf( - "Daily App Check Report\nDate: %s\nGroup: %d\nApps not updated in >%d days: %d\n\n
%s
", - now.Format("2006-01-02 15:04"), - groupID, - s.cfg.NumDaysWarningNotUpdated, - len(apps), - util.BuildTable(headers, rows), - ) -} diff --git a/internal/util/table.go b/internal/util/table.go deleted file mode 100644 index ffb557c..0000000 --- a/internal/util/table.go +++ /dev/null @@ -1,95 +0,0 @@ -package util - -import ( - "fmt" - "strings" -) - -// BuildTable mirrors Java bot/table/Table.java: -// - left-aligned columns padded to max(header, cell) width -// - "│" column separator -// - row separator inserted every 5 rows using "─" cells joined by "─┼─" -// -// Output is intended to be wrapped in
 for Telegram HTML rendering.
-func BuildTable(headers []string, rows [][]string) string {
-	widths := computeWidths(headers, rows)
-	var sb strings.Builder
-
-	writeRow(&sb, headers, widths)
-	sb.WriteString("\n")
-	writeSeparator(&sb, widths)
-
-	for i, row := range rows {
-		sb.WriteString("\n")
-		if i > 0 && i%5 == 0 {
-			writeSeparator(&sb, widths)
-			sb.WriteString("\n")
-		}
-		writeRow(&sb, row, widths)
-	}
-	return sb.String()
-}
-
-func computeWidths(headers []string, rows [][]string) []int {
-	widths := make([]int, len(headers))
-	for i, h := range headers {
-		widths[i] = len(h)
-	}
-	for _, row := range rows {
-		for i, cell := range row {
-			if i < len(widths) && len(cell) > widths[i] {
-				widths[i] = len(cell)
-			}
-		}
-	}
-	return widths
-}
-
-func writeRow(sb *strings.Builder, cells []string, widths []int) {
-	for i, w := range widths {
-		var cell string
-		if i < len(cells) {
-			cell = cells[i]
-		}
-		sb.WriteString(padRight(cell, w))
-		if i < len(widths)-1 {
-			sb.WriteString(" │ ")
-		}
-	}
-}
-
-func writeSeparator(sb *strings.Builder, widths []int) {
-	for i, w := range widths {
-		sb.WriteString(strings.Repeat("─", w))
-		if i < len(widths)-1 {
-			sb.WriteString("─┼─")
-		}
-	}
-}
-
-func padRight(s string, length int) string {
-	if len(s) >= length {
-		return s
-	}
-	return s + strings.Repeat(" ", length-len(s))
-}
-
-func TruncateString(s string, maxLen int) string {
-	if len(s) <= maxLen {
-		return s
-	}
-	if maxLen <= 3 {
-		return s[:maxLen]
-	}
-	return s[:maxLen-3] + "..."
-}
-
-func FormatNumber(n int64) string {
-	if n >= 1_000_000 {
-		return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
-	}
-	if n >= 1_000 {
-		return fmt.Sprintf("%.1fK", float64(n)/1_000)
-	}
-	return fmt.Sprintf("%d", n)
-}