mirror of
https://github.com/tiennm99/store-scraper-bot.git
synced 2026-08-30 22:24:52 +00:00
Clear and rewrite
Retire the Go port; history continues from the JavaScript implementation (store-scraper-bot).
This commit is contained in:
@@ -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.*
|
||||
@@ -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 * * *
|
||||
-32
@@ -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/
|
||||
-31
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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...")
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <id|appId> [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 <code>%s</code> is already added", resp.AppID))
|
||||
return
|
||||
}
|
||||
_ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Apple app <code>%s</code>, country <b>%s</b> added successfully", resp.AppID, country))
|
||||
}
|
||||
@@ -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 <appId> [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 <code>%s</code> is already added", appID))
|
||||
return
|
||||
}
|
||||
_ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Google app <code>%s</code>, country <b>%s</b> added successfully", appID, country))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("<b>Apple Apps</b>\n")
|
||||
if len(appleRows) == 0 {
|
||||
sb.WriteString("<i>(none)</i>\n")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("<pre>%s</pre>\n", util.BuildTable(headers, appleRows)))
|
||||
}
|
||||
sb.WriteString("\n<b>Google Apps</b>\n")
|
||||
if len(googleRows) == 0 {
|
||||
sb.WriteString("<i>(none)</i>\n")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("<pre>%s</pre>\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 "❌"
|
||||
}
|
||||
@@ -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("<b>Apple Apps</b>\n")
|
||||
if len(appleRows) == 0 {
|
||||
sb.WriteString("<i>(none)</i>\n")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("<pre>%s</pre>\n", util.BuildTable(headers, appleRows)))
|
||||
}
|
||||
sb.WriteString("\n<b>Google Apps</b>\n")
|
||||
if len(googleRows) == 0 {
|
||||
sb.WriteString("<i>(none)</i>\n")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("<pre>%s</pre>\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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <appId> — 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")
|
||||
}
|
||||
@@ -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 <appId> — 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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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à <code>%d</code>\n", msg.Chat.ID))
|
||||
}
|
||||
@@ -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("<b>Apple Apps</b>\n")
|
||||
sb.WriteString(formatAppTable(group.AppleApps))
|
||||
sb.WriteString("\n<b>Google Apps</b>\n")
|
||||
sb.WriteString(formatAppTable(group.GoogleApps))
|
||||
_ = sender.SendMessage(msg.Chat.ID, sb.String())
|
||||
}
|
||||
|
||||
func formatAppTable(apps []model.AppInfo) string {
|
||||
if len(apps) == 0 {
|
||||
return "<i>(none)</i>\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("<pre>%s</pre>\n", util.BuildTable([]string{"#", "AppId", "Country"}, rows))
|
||||
}
|
||||
@@ -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("<b>Authorized groups (%d):</b>\n", len(groups)))
|
||||
for i, gid := range groups {
|
||||
sb.WriteString(fmt.Sprintf("%d. <code>%d</code>\n", i+1, gid))
|
||||
}
|
||||
_ = sender.SendMessage(msg.Chat.ID, sb.String())
|
||||
}
|
||||
@@ -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 <id|appId> [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)
|
||||
}
|
||||
@@ -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 <appId> [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)
|
||||
}
|
||||
@@ -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]/<dbname>?<params>
|
||||
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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(
|
||||
"<b>Daily App Check Report</b>\nDate: %s\nGroup: <code>%d</code>\nApps not updated in >%d days: <b>%d</b>\n\n<pre>%s</pre>",
|
||||
now.Format("2006-01-02 15:04"),
|
||||
groupID,
|
||||
s.cfg.NumDaysWarningNotUpdated,
|
||||
len(apps),
|
||||
util.BuildTable(headers, rows),
|
||||
)
|
||||
}
|
||||
@@ -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 <pre> 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)
|
||||
}
|
||||
Reference in New Issue
Block a user