Clear and rewrite

Retire the Go port; history continues from the JavaScript implementation (store-scraper-bot).
This commit is contained in:
2026-08-04 23:00:01 +07:00
parent 824279e1fe
commit ca1a409610
45 changed files with 0 additions and 2983 deletions
-43
View File
@@ -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.*
-18
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
-201
View File
@@ -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.
-50
View File
@@ -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
```
-76
View File
@@ -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...")
}
-56
View File
@@ -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:
-39
View File
@@ -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:
-24
View File
@@ -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
)
-66
View File
@@ -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=
-111
View File
@@ -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}
}
-114
View File
@@ -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}
}
-127
View File
@@ -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
}
-64
View File
@@ -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))
}
-54
View File
@@ -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))
}
-55
View File
@@ -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")
}
-13
View File
@@ -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
}
-120
View File
@@ -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 "❌"
}
-100
View File
@@ -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)
}
-39
View File
@@ -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
}
-39
View File
@@ -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")
}
-39
View File
@@ -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")
}
-50
View File
@@ -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")
}
-22
View File
@@ -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))
}
-61
View File
@@ -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))
}
-45
View File
@@ -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())
}
-48
View File
@@ -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)
}
-40
View File
@@ -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)
}
-178
View File
@@ -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
}
-8
View File
@@ -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"`
}
-43
View File
@@ -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
}
-59
View File
@@ -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
}
-91
View File
@@ -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
}
-80
View File
@@ -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
}
-14
View File
@@ -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
}
-108
View File
@@ -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
}
-131
View File
@@ -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)
}
-59
View File
@@ -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)
}
-189
View File
@@ -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 &gt;%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),
)
}
-95
View File
@@ -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)
}