feat: port logic from Java original to align with current Java behavior

- Switch Telegram parse mode to HTML to match Java client.
- Rename command identifiers to Java's: delgroup/delapple/delgoogle/
  checkappscore/rawappleapp/rawgoogleapp.
- Move admin singleton to "common" collection at _id="admin".
- Store group _id as string-of-int64 to match Java AbstractModel schema.
- Add `class` discriminator field on persisted models.
- Expand AppleAppResponse (42 fields) and GoogleAppResponse (63 fields,
  with nested Category/Feature) to match Java records.
- Add AppleAppRequest (track id or bundle id) / GoogleAppRequest types.
- Implement Raw* commands as Telegram document attachments instead of
  truncated text.
- Group-authorization gate (admin.HasGroup(chatId)) on non-admin commands.
- Cache TTL via millis timestamp; round score to 1 decimal; weekend silent
  send in scheduler.
- Match Java table renderer: " │ " separators, "─┼─" rows every 5 lines.
- Prefer MONGODB_CONNECTION_STRING env (Java parity); auto-extract DB
  name from URI with fallback to "store-scraper-bot".

Note: This is an AI-assisted port (not human-verified). Behavior parity
with the Java implementation has not been tested end-to-end; treat as a
starting point.
This commit is contained in:
2026-04-25 20:26:25 +07:00
parent cfc8d67641
commit e695d9b223
34 changed files with 1132 additions and 1149 deletions
+5
View File
@@ -39,6 +39,11 @@ func main() {
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)
+75 -79
View File
@@ -5,111 +5,107 @@ import (
"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"
)
const appleAPIURL = "https://store-scraper.vercel.app/apple/app"
type AppleAppRequest struct {
ID *int64 `json:"id,omitempty"`
AppID *string `json:"appId,omitempty"`
Country string `json:"country"`
Ratings bool `json:"ratings"`
}
// BaseURL mirrors Java AppStoreScraper (api/apple/AppStoreScraper.java).
const BaseURL = "https://store-scraper.vercel.app/apple"
type AppleScraper struct {
httpClient *http.Client
appRepo *repository.AppleAppRepository
logger *zap.Logger
repo *repository.AppleAppRepository
cfg *config.Config
client *http.Client
logger *zap.Logger
}
func NewAppleScraper(appRepo *repository.AppleAppRepository, cfg *config.Config) *AppleScraper {
func NewAppleScraper(repo *repository.AppleAppRepository, cfg *config.Config) *AppleScraper {
return &AppleScraper{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
appRepo: appRepo,
logger: cfg.Logger,
repo: repo,
cfg: cfg,
client: &http.Client{Timeout: 30 * time.Second},
logger: cfg.Logger,
}
}
func (s *AppleScraper) GetApp(appID, country string) (*model.AppleAppResponse, error) {
// Check cache first
cachedApp, err := s.appRepo.GetCached(appID)
// 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 {
s.logger.Error("Failed to get cached apple app", zap.Error(err), zap.String("appId", appID))
return "", fmt.Errorf("marshal apple request: %w", err)
}
if cachedApp != nil {
s.logger.Debug("Returning cached apple app", zap.String("appId", appID))
return &cachedApp.App, nil
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")
// Fetch from API
s.logger.Info("Fetching apple app from API", zap.String("appId", appID), zap.String("country", country))
response, err := s.fetchFromAPI(appID, country)
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
}
// Save to cache
// 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()
appleApp := model.NewAppleApp(appID, *response)
if err := s.appRepo.Save(ctx, appleApp); err != nil {
s.logger.Error("Failed to save apple app to cache", zap.Error(err), zap.String("appId", appID))
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))
}
return response, nil
}
func (s *AppleScraper) fetchFromAPI(appID, country string) (*model.AppleAppResponse, error) {
request := AppleAppRequest{
AppID: &appID,
Country: country,
Ratings: true,
}
requestBody, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", appleAPIURL, bytes.NewBuffer(requestBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode)
}
var response model.AppleAppResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &response, nil
}
func (s *AppleScraper) GetAppUpdated(appID, country string) (string, error) {
app, err := s.GetApp(appID, country)
if err != nil {
return "", err
}
return app.Updated, nil
}
@@ -0,0 +1,18 @@
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}
}
+78 -76
View File
@@ -5,108 +5,110 @@ import (
"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"
)
const googleAPIURL = "https://store-scraper.vercel.app/google/app"
type GoogleAppRequest struct {
AppID string `json:"appId"`
Country string `json:"country"`
}
// BaseURL mirrors Java GooglePlayScraper (api/google/GooglePlayScraper.java).
const BaseURL = "https://store-scraper.vercel.app/google"
type GoogleScraper struct {
httpClient *http.Client
appRepo *repository.GoogleAppRepository
logger *zap.Logger
repo *repository.GoogleAppRepository
cfg *config.Config
client *http.Client
logger *zap.Logger
}
func NewGoogleScraper(appRepo *repository.GoogleAppRepository, cfg *config.Config) *GoogleScraper {
func NewGoogleScraper(repo *repository.GoogleAppRepository, cfg *config.Config) *GoogleScraper {
return &GoogleScraper{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
appRepo: appRepo,
logger: cfg.Logger,
repo: repo,
cfg: cfg,
client: &http.Client{Timeout: 30 * time.Second},
logger: cfg.Logger,
}
}
func (s *GoogleScraper) GetApp(appID, country string) (*model.GoogleAppResponse, error) {
// Check cache first
cachedApp, err := s.appRepo.GetCached(appID)
func (s *GoogleScraper) RawApp(req request.GoogleAppRequest) (string, error) {
body, err := json.Marshal(req)
if err != nil {
s.logger.Error("Failed to get cached google app", zap.Error(err), zap.String("appId", appID))
return "", fmt.Errorf("marshal google request: %w", err)
}
if cachedApp != nil {
s.logger.Debug("Returning cached google app", zap.String("appId", appID))
return &cachedApp.App, nil
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")
// Fetch from API
s.logger.Info("Fetching google app from API", zap.String("appId", appID), zap.String("country", country))
response, err := s.fetchFromAPI(appID, country)
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
}
// Save to cache
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()
googleApp := model.NewGoogleApp(appID, *response)
if err := s.appRepo.Save(ctx, googleApp); err != nil {
s.logger.Error("Failed to save google app to cache", zap.Error(err), zap.String("appId", appID))
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))
}
return response, nil
}
func (s *GoogleScraper) fetchFromAPI(appID, country string) (*model.GoogleAppResponse, error) {
request := GoogleAppRequest{
AppID: appID,
Country: country,
}
requestBody, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", googleAPIURL, bytes.NewBuffer(requestBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode)
}
var response model.GoogleAppResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &response, nil
}
func (s *GoogleScraper) GetLastUpdate(appID, country string) (int64, error) {
app, err := s.GetApp(appID, country)
if err != nil {
return 0, err
}
return app.Updated, nil
}
@@ -0,0 +1,14 @@
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}
}
+65 -74
View File
@@ -12,15 +12,14 @@ import (
"go.uber.org/zap"
)
// parseMode mirrors Java StoreScrapeBotTelegramClient: HTML for all messages.
const parseMode = "HTML"
type Bot struct {
api *tgbotapi.BotAPI
cfg *config.Config
adminRepo *repository.AdminRepository
groupRepo *repository.GroupRepository
appleScraper *apple.AppleScraper
googleScraper *google.GoogleScraper
commands map[string]command.Command
logger *zap.Logger
api *tgbotapi.BotAPI
cfg *config.Config
commands map[string]command.Command
logger *zap.Logger
}
func NewBot(
@@ -30,107 +29,99 @@ func NewBot(
appleScraper *apple.AppleScraper,
googleScraper *google.GoogleScraper,
) (*Bot, error) {
bot, err := tgbotapi.NewBotAPI(cfg.TelegramBotToken)
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))
bot.Debug = cfg.Env == config.Development
b := &Bot{api: api, cfg: cfg, commands: map[string]command.Command{}, logger: cfg.Logger}
cfg.Logger.Info("Authorized on account", zap.String("username", bot.Self.UserName))
// 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)
b := &Bot{
api: bot,
cfg: cfg,
adminRepo: adminRepo,
groupRepo: groupRepo,
appleScraper: appleScraper,
googleScraper: googleScraper,
commands: make(map[string]command.Command),
logger: cfg.Logger,
}
b.registerCommands()
return b, nil
}
func (b *Bot) registerCommands() {
b.commands["addgroup"] = command.NewAddGroupCommand(b.cfg, b.adminRepo, b.groupRepo)
b.commands["deletegroup"] = command.NewDeleteGroupCommand(b.cfg, b.adminRepo, b.groupRepo)
b.commands["listgroup"] = command.NewListGroupCommand(b.cfg, b.adminRepo)
b.commands["addapple"] = command.NewAddAppleAppCommand(b.cfg, b.adminRepo, b.groupRepo, b.appleScraper)
b.commands["deleteapple"] = command.NewDeleteAppleAppCommand(b.cfg, b.adminRepo, b.groupRepo)
b.commands["addgoogle"] = command.NewAddGoogleAppCommand(b.cfg, b.adminRepo, b.groupRepo, b.googleScraper)
b.commands["deletegoogle"] = command.NewDeleteGoogleAppCommand(b.cfg, b.adminRepo, b.groupRepo)
b.commands["listapp"] = command.NewListAppCommand(b.cfg, b.adminRepo, b.groupRepo)
b.commands["checkapp"] = command.NewCheckAppCommand(b.cfg, b.adminRepo, b.groupRepo, b.appleScraper, b.googleScraper)
b.commands["checkappscores"] = command.NewCheckAppScoresCommand(b.cfg, b.adminRepo, b.groupRepo, b.appleScraper, b.googleScraper)
b.commands["rawapple"] = command.NewRawAppleAppCommand(b.cfg, b.appleScraper)
b.commands["rawgoogle"] = command.NewRawGoogleAppCommand(b.cfg, b.googleScraper)
b.commands["info"] = command.NewInfoCommand(b.cfg)
}
func (b *Bot) Start() {
u := tgbotapi.NewUpdate(0)
u.Timeout = 60
updates := b.api.GetUpdatesChan(u)
for update := range updates {
if update.Message == nil {
if update.Message == nil || !update.Message.IsCommand() {
continue
}
if !update.Message.IsCommand() {
continue
}
go b.handleCommand(update.Message)
}
}
func (b *Bot) handleCommand(message *tgbotapi.Message) {
commandName := message.Command()
cmd, exists := b.commands[commandName]
if !exists {
b.logger.Debug("Unknown command", zap.String("command", commandName))
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", commandName),
zap.String("command", name),
zap.Int64("userId", message.From.ID),
zap.Int64("chatId", message.Chat.ID))
response := cmd.Execute(message)
if response != "" {
msg := tgbotapi.NewMessage(message.Chat.ID, response)
msg.ParseMode = "Markdown"
msg.DisableWebPagePreview = true
if _, err := b.api.Send(msg); err != nil {
b.logger.Error("Failed to send message", zap.Error(err))
}
}
cmd.Execute(message, b)
}
func (b *Bot) SendMessage(chatID int64, text string) error {
msg := tgbotapi.NewMessage(chatID, text)
msg.ParseMode = "Markdown"
// 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
msg.DisableNotification = false
_, err := b.api.Send(msg)
if err != nil {
b.logger.Warn("send message failed", zap.Int64("chatId", chatID), zap.Error(err))
}
return err
}
func (b *Bot) SendMessageSilent(chatID int64, text string) error {
msg := tgbotapi.NewMessage(chatID, text)
msg.ParseMode = "Markdown"
// 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
}
+38 -44
View File
@@ -2,69 +2,63 @@ package command
import (
"fmt"
"strings"
"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 {
BaseCommand
adminRepo *repository.AdminRepository
groupRepo *repository.GroupRepository
appleScraper *apple.AppleScraper
cfg *config.Config
adminRepo *repository.AdminRepository
groupRepo *repository.GroupRepository
scraper *apple.AppleScraper
}
func NewAddAppleAppCommand(
cfg *config.Config,
adminRepo *repository.AdminRepository,
groupRepo *repository.GroupRepository,
appleScraper *apple.AppleScraper,
) *AddAppleAppCommand {
return &AddAppleAppCommand{
BaseCommand: BaseCommand{cfg: cfg},
adminRepo: adminRepo,
groupRepo: groupRepo,
appleScraper: 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(message *tgbotapi.Message) string {
if !c.requireAdmin(message) {
return "You are not authorized to use this command."
func (c *AddAppleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) {
if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) {
return
}
groupID := message.Chat.ID
hasGroup, err := c.adminRepo.HasGroup(groupID)
if err != nil {
return fmt.Sprintf("Failed to check group: %v", err)
args := splitArgs(msg.CommandArguments())
if len(args) < 1 || len(args) > 2 {
_ = sender.SendMessage(msg.Chat.ID, "Invalid arguments")
return
}
if !hasGroup {
return "This group is not registered. Please use /addgroup first."
}
args := strings.Fields(message.CommandArguments())
if len(args) == 0 {
return "Usage: /addapple <appId> [country]\nExample: /addapple com.example.app vn"
}
appID := args[0]
country := "vn"
if len(args) > 1 {
if len(args) == 2 {
country = args[1]
}
// Verify app exists
app, err := c.appleScraper.GetApp(appID, country)
// 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 {
return fmt.Sprintf("Failed to fetch app from store: %v", err)
_ = sender.SendMessage(msg.Chat.ID, "Internal server error")
return
}
if err := c.groupRepo.AddAppleApp(groupID, appID, country); err != nil {
return fmt.Sprintf("Failed to add app: %v", err)
if !added {
_ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Apple app <code>%s</code> is already added", resp.AppID))
return
}
return fmt.Sprintf("Apple app added successfully:\n*%s*\nApp ID: %s\nCountry: %s\nScore: %.1f", app.Title, appID, country, app.Score)
_ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Apple app <code>%s</code>, country <b>%s</b> added successfully", resp.AppID, country))
}
+28 -44
View File
@@ -2,69 +2,53 @@ package command
import (
"fmt"
"strings"
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 {
BaseCommand
adminRepo *repository.AdminRepository
groupRepo *repository.GroupRepository
googleScraper *google.GoogleScraper
cfg *config.Config
adminRepo *repository.AdminRepository
groupRepo *repository.GroupRepository
scraper *google.GoogleScraper
}
func NewAddGoogleAppCommand(
cfg *config.Config,
adminRepo *repository.AdminRepository,
groupRepo *repository.GroupRepository,
googleScraper *google.GoogleScraper,
) *AddGoogleAppCommand {
return &AddGoogleAppCommand{
BaseCommand: BaseCommand{cfg: cfg},
adminRepo: adminRepo,
groupRepo: groupRepo,
googleScraper: 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(message *tgbotapi.Message) string {
if !c.requireAdmin(message) {
return "You are not authorized to use this command."
func (c *AddGoogleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) {
if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) {
return
}
groupID := message.Chat.ID
hasGroup, err := c.adminRepo.HasGroup(groupID)
if err != nil {
return fmt.Sprintf("Failed to check group: %v", err)
args := splitArgs(msg.CommandArguments())
if len(args) < 1 || len(args) > 2 {
_ = sender.SendMessage(msg.Chat.ID, "Invalid arguments")
return
}
if !hasGroup {
return "This group is not registered. Please use /addgroup first."
}
args := strings.Fields(message.CommandArguments())
if len(args) == 0 {
return "Usage: /addgoogle <appId> [country]\nExample: /addgoogle com.example.app vn"
}
appID := args[0]
country := "vn"
if len(args) > 1 {
if len(args) == 2 {
country = args[1]
}
// Verify app exists
app, err := c.googleScraper.GetApp(appID, country)
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 {
return fmt.Sprintf("Failed to fetch app from store: %v", err)
_ = sender.SendMessage(msg.Chat.ID, "Internal server error")
return
}
if err := c.groupRepo.AddGoogleApp(groupID, appID, country); err != nil {
return fmt.Sprintf("Failed to add app: %v", err)
if !added {
_ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Google app <code>%s</code> is already added", appID))
return
}
return fmt.Sprintf("Google app added successfully:\n*%s*\nApp ID: %s\nCountry: %s\nScore: %.1f", app.Title, appID, country, app.Score)
_ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Google app <code>%s</code>, country <b>%s</b> added successfully", appID, country))
}
+35 -16
View File
@@ -1,36 +1,55 @@
package command
import (
"fmt"
"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 {
BaseCommand
cfg *config.Config
adminRepo *repository.AdminRepository
groupRepo *repository.GroupRepository
}
func NewAddGroupCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository) *AddGroupCommand {
return &AddGroupCommand{
BaseCommand: BaseCommand{cfg: cfg},
adminRepo: adminRepo,
groupRepo: groupRepo,
}
return &AddGroupCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo}
}
func (c *AddGroupCommand) Execute(message *tgbotapi.Message) string {
if !c.requireAdmin(message) {
return "You are not authorized to use this command."
func (c *AddGroupCommand) Execute(msg *tgbotapi.Message, sender Sender) {
if !requireAdminUser(msg.From.ID, msg.Chat.ID, c.cfg, sender) {
return
}
groupID := message.Chat.ID
if err := c.adminRepo.AddGroup(groupID); err != nil {
return fmt.Sprintf("Failed to add group: %v", err)
args := splitArgs(msg.CommandArguments())
if len(args) > 1 {
_ = sender.SendMessage(msg.Chat.ID, "Invalid arguments")
return
}
return fmt.Sprintf("Group %d has been added successfully.", groupID)
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
@@ -0,0 +1,13 @@
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
}
+84 -129
View File
@@ -13,153 +13,108 @@ import (
"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"
"go.uber.org/zap"
)
// /checkapp — Java CheckAppCommand. Reports update status per app, per store.
type CheckAppCommand struct {
BaseCommand
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,
appleScraper *apple.AppleScraper,
googleScraper *google.GoogleScraper,
) *CheckAppCommand {
return &CheckAppCommand{
BaseCommand: BaseCommand{cfg: cfg},
adminRepo: adminRepo,
groupRepo: groupRepo,
appleScraper: appleScraper,
googleScraper: 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(message *tgbotapi.Message) string {
if !c.requireAdmin(message) {
return "You are not authorized to use this command."
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
}
groupID := message.Chat.ID
hasGroup, err := c.adminRepo.HasGroup(groupID)
if err != nil {
return fmt.Sprintf("Failed to check group: %v", err)
}
if !hasGroup {
return "This group is not registered."
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
group, err := c.groupRepo.Get(ctx, groupID)
group, err := c.groupRepo.Get(ctx, msg.Chat.ID)
if err != nil {
return fmt.Sprintf("Failed to get group: %v", err)
_ = sender.SendMessage(msg.Chat.ID, "Internal server error")
return
}
now := time.Now()
threshold := c.cfg.NumDaysWarningNotUpdated
if len(group.AppleApps) == 0 && len(group.GoogleApps) == 0 {
return "No apps in this group."
}
nonUpdatedApps := make([]model.NonUpdatedApp, 0)
now := time.Now().In(c.cfg.VietnamLocation)
// Check Apple apps
for _, appInfo := range group.AppleApps {
app, err := c.appleScraper.GetApp(appInfo.AppID, appInfo.Country)
if err != nil {
c.cfg.Logger.Error("Failed to fetch Apple app",
zap.String("appId", appInfo.AppID),
zap.Error(err))
continue
}
updatedTime, err := time.Parse(time.RFC3339, app.Updated)
if err != nil {
c.cfg.Logger.Error("Failed to parse update time",
zap.String("appId", appInfo.AppID),
zap.String("updated", app.Updated),
zap.Error(err))
continue
}
daysSinceUpdate := int(now.Sub(updatedTime).Hours() / 24)
if daysSinceUpdate > c.cfg.NumDaysWarningNotUpdated {
nonUpdatedApps = append(nonUpdatedApps, model.NonUpdatedApp{
AppID: appInfo.AppID,
Title: app.Title,
Days: daysSinceUpdate,
Updated: app.Updated[:10], // Just the date part
Score: app.Score,
Reviews: app.Reviews,
Ratings: app.Ratings,
IsApple: true,
})
}
}
// Check Google apps
for _, appInfo := range group.GoogleApps {
app, err := c.googleScraper.GetApp(appInfo.AppID, appInfo.Country)
if err != nil {
c.cfg.Logger.Error("Failed to fetch Google app",
zap.String("appId", appInfo.AppID),
zap.Error(err))
continue
}
updatedTime := time.UnixMilli(app.Updated)
daysSinceUpdate := int(now.Sub(updatedTime).Hours() / 24)
if daysSinceUpdate > c.cfg.NumDaysWarningNotUpdated {
nonUpdatedApps = append(nonUpdatedApps, model.NonUpdatedApp{
AppID: appInfo.AppID,
Title: app.Title,
Days: daysSinceUpdate,
Updated: updatedTime.Format("2006-01-02"),
Score: app.Score,
Reviews: app.Reviews,
Ratings: app.Ratings,
IsApple: false,
})
}
}
if len(nonUpdatedApps) == 0 {
return fmt.Sprintf("All apps are up to date (checked within %d days).", c.cfg.NumDaysWarningNotUpdated)
}
// Build table
var rows [][]string
for _, app := range nonUpdatedApps {
store := "Google"
if app.IsApple {
store = "Apple"
}
rows = append(rows, []string{
util.TruncateString(app.Title, 30),
store,
fmt.Sprintf("%d", app.Days),
app.Updated,
fmt.Sprintf("%.1f", app.Score),
fmt.Sprintf("%v", app.Reviews),
util.FormatNumber(app.Ratings),
})
}
headers := []string{"App", "Store", "Days", "Updated", "Score", "Reviews", "Ratings"}
table := util.BuildTable(headers, rows)
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(fmt.Sprintf("*Non-Updated Apps Report*\nGroup: %d\nApps not updated in >%d days: *%d*\n\n",
groupID, c.cfg.NumDaysWarningNotUpdated, len(nonUpdatedApps)))
sb.WriteString(table)
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())
}
return 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 "❌"
}
+65 -88
View File
@@ -3,6 +3,7 @@ package command
import (
"context"
"fmt"
"math"
"strings"
"time"
@@ -10,114 +11,90 @@ import (
"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 {
BaseCommand
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,
appleScraper *apple.AppleScraper,
googleScraper *google.GoogleScraper,
) *CheckAppScoresCommand {
return &CheckAppScoresCommand{
BaseCommand: BaseCommand{cfg: cfg},
adminRepo: adminRepo,
groupRepo: groupRepo,
appleScraper: appleScraper,
googleScraper: 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(message *tgbotapi.Message) string {
if !c.requireAdmin(message) {
return "You are not authorized to use this command."
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
}
groupID := message.Chat.ID
hasGroup, err := c.adminRepo.HasGroup(groupID)
if err != nil {
return fmt.Sprintf("Failed to check group: %v", err)
}
if !hasGroup {
return "This group is not registered."
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
group, err := c.groupRepo.Get(ctx, groupID)
group, err := c.groupRepo.Get(ctx, msg.Chat.ID)
if err != nil {
return fmt.Sprintf("Failed to get group: %v", err)
_ = sender.SendMessage(msg.Chat.ID, "Internal server error")
return
}
if len(group.AppleApps) == 0 && len(group.GoogleApps) == 0 {
return "No apps in this group."
}
var rows [][]string
// Check Apple apps
for _, appInfo := range group.AppleApps {
app, err := c.appleScraper.GetApp(appInfo.AppID, appInfo.Country)
if err != nil {
rows = append(rows, []string{
util.TruncateString(appInfo.AppID, 30),
"Apple",
"Error",
"0",
"0",
})
continue
}
rows = append(rows, []string{
util.TruncateString(app.Title, 30),
"Apple",
fmt.Sprintf("%.1f", app.Score),
fmt.Sprintf("%d", app.Reviews),
util.FormatNumber(app.Ratings),
})
}
// Check Google apps
for _, appInfo := range group.GoogleApps {
app, err := c.googleScraper.GetApp(appInfo.AppID, appInfo.Country)
if err != nil {
rows = append(rows, []string{
util.TruncateString(appInfo.AppID, 30),
"Google",
"Error",
"0",
"0",
})
continue
}
rows = append(rows, []string{
util.TruncateString(app.Title, 30),
"Google",
fmt.Sprintf("%.1f", app.Score),
fmt.Sprintf("%d", app.Reviews),
util.FormatNumber(app.Ratings),
})
}
headers := []string{"App", "Store", "Score", "Reviews", "Ratings"}
table := util.BuildTable(headers, rows)
headers := []string{"AppId", "Score", "Ratings"}
appleRows := c.appleScoreRows(group.AppleApps)
googleRows := c.googleScoreRows(group.GoogleApps)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("*App Scores Report*\nGroup: %d\n\n", groupID))
sb.WriteString(table)
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())
}
return 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)
}
+23 -9
View File
@@ -3,22 +3,36 @@ 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(message *tgbotapi.Message) string
Execute(msg *tgbotapi.Message, sender Sender)
}
type BaseCommand struct {
cfg *config.Config
// 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
}
func (c *BaseCommand) isAdmin(userID int64) bool {
return c.cfg.IsAdmin(userID)
}
func (c *BaseCommand) requireAdmin(message *tgbotapi.Message) bool {
if !c.isAdmin(message.From.ID) {
// 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
+18 -31
View File
@@ -1,52 +1,39 @@
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"
)
// /delapple <appId> — Java DeleteAppleAppCommand.
type DeleteAppleAppCommand struct {
BaseCommand
cfg *config.Config
adminRepo *repository.AdminRepository
groupRepo *repository.GroupRepository
}
func NewDeleteAppleAppCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository) *DeleteAppleAppCommand {
return &DeleteAppleAppCommand{
BaseCommand: BaseCommand{cfg: cfg},
adminRepo: adminRepo,
groupRepo: groupRepo,
}
return &DeleteAppleAppCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo}
}
func (c *DeleteAppleAppCommand) Execute(message *tgbotapi.Message) string {
if !c.requireAdmin(message) {
return "You are not authorized to use this command."
func (c *DeleteAppleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) {
if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) {
return
}
groupID := message.Chat.ID
hasGroup, err := c.adminRepo.HasGroup(groupID)
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 {
return fmt.Sprintf("Failed to check group: %v", err)
_ = sender.SendMessage(msg.Chat.ID, "Internal server error")
return
}
if !hasGroup {
return "This group is not registered."
if !removed {
_ = sender.SendMessage(msg.Chat.ID, "Apple app is not added")
return
}
args := strings.Fields(message.CommandArguments())
if len(args) == 0 {
return "Usage: /deleteapple <appId>\nExample: /deleteapple com.example.app"
}
appID := args[0]
if err := c.groupRepo.RemoveAppleApp(groupID, appID); err != nil {
return fmt.Sprintf("Failed to remove app: %v", err)
}
return fmt.Sprintf("Apple app %s has been removed successfully.", appID)
_ = sender.SendMessage(msg.Chat.ID, "Apple app deleted successfully")
}
+18 -31
View File
@@ -1,52 +1,39 @@
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"
)
// /delgoogle <appId> — Java DeleteGoogleAppCommand.
type DeleteGoogleAppCommand struct {
BaseCommand
cfg *config.Config
adminRepo *repository.AdminRepository
groupRepo *repository.GroupRepository
}
func NewDeleteGoogleAppCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository) *DeleteGoogleAppCommand {
return &DeleteGoogleAppCommand{
BaseCommand: BaseCommand{cfg: cfg},
adminRepo: adminRepo,
groupRepo: groupRepo,
}
return &DeleteGoogleAppCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo}
}
func (c *DeleteGoogleAppCommand) Execute(message *tgbotapi.Message) string {
if !c.requireAdmin(message) {
return "You are not authorized to use this command."
func (c *DeleteGoogleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) {
if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) {
return
}
groupID := message.Chat.ID
hasGroup, err := c.adminRepo.HasGroup(groupID)
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 {
return fmt.Sprintf("Failed to check group: %v", err)
_ = sender.SendMessage(msg.Chat.ID, "Internal server error")
return
}
if !hasGroup {
return "This group is not registered."
if !removed {
_ = sender.SendMessage(msg.Chat.ID, "Google app is not added")
return
}
args := strings.Fields(message.CommandArguments())
if len(args) == 0 {
return "Usage: /deletegoogle <appId>\nExample: /deletegoogle com.example.app"
}
appID := args[0]
if err := c.groupRepo.RemoveGoogleApp(groupID, appID); err != nil {
return fmt.Sprintf("Failed to remove app: %v", err)
}
return fmt.Sprintf("Google app %s has been removed successfully.", appID)
_ = sender.SendMessage(msg.Chat.ID, "Google app deleted successfully")
}
+29 -25
View File
@@ -1,46 +1,50 @@
package command
import (
"context"
"fmt"
"time"
"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 {
BaseCommand
cfg *config.Config
adminRepo *repository.AdminRepository
groupRepo *repository.GroupRepository
}
func NewDeleteGroupCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository) *DeleteGroupCommand {
return &DeleteGroupCommand{
BaseCommand: BaseCommand{cfg: cfg},
adminRepo: adminRepo,
groupRepo: groupRepo,
}
return &DeleteGroupCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo}
}
func (c *DeleteGroupCommand) Execute(message *tgbotapi.Message) string {
if !c.requireAdmin(message) {
return "You are not authorized to use this command."
func (c *DeleteGroupCommand) Execute(msg *tgbotapi.Message, sender Sender) {
if !requireAdminUser(msg.From.ID, msg.Chat.ID, c.cfg, sender) {
return
}
groupID := message.Chat.ID
if err := c.adminRepo.RemoveGroup(groupID); err != nil {
return fmt.Sprintf("Failed to remove group: %v", err)
args := splitArgs(msg.CommandArguments())
if len(args) > 1 {
_ = sender.SendMessage(msg.Chat.ID, "Invalid arguments")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := c.groupRepo.Delete(ctx, groupID); err != nil {
return fmt.Sprintf("Group removed from admin but failed to delete group data: %v", err)
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
}
return fmt.Sprintf("Group %d has been deleted successfully.", groupID)
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")
}
+10 -37
View File
@@ -2,48 +2,21 @@ package command
import (
"fmt"
"runtime"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/miti99/store-scraper-bot-go/internal/config"
)
type InfoCommand struct {
BaseCommand
}
// /info — Java InfoCommand. Reports the chat (group) ID.
type InfoCommand struct{ cfg *config.Config }
func NewInfoCommand(cfg *config.Config) *InfoCommand {
return &InfoCommand{
BaseCommand: BaseCommand{cfg: cfg},
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
}
}
func (c *InfoCommand) Execute(message *tgbotapi.Message) string {
return fmt.Sprintf(`*Store Scraper Bot - Go Edition*
*Version:* 1.0.0
*Environment:* %s
*Source Commit:* %s
*Go Version:* %s
*Bot Username:* @%s
*Commands:*
/addgroup - Add current group to monitoring
/deletegroup - Remove current group
/listgroup - List all monitored groups
/addapple <appId> [country] - Add Apple app
/deleteapple <appId> - Remove Apple app
/addgoogle <appId> [country] - Add Google app
/deletegoogle <appId> - Remove Google app
/listapp - List apps in current group
/checkapp - Check for non-updated apps
/checkappscores - Check app scores
/rawapple <appId> [country] - Get raw Apple data
/rawgoogle <appId> [country] - Get raw Google data
/info - Show this info`,
c.cfg.Env,
c.cfg.SourceCommit,
runtime.Version(),
c.cfg.TelegramBotUsername,
)
_ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Id của nhóm là <code>%d</code>\n", msg.Chat.ID))
}
+28 -40
View File
@@ -3,71 +3,59 @@ 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 {
BaseCommand
cfg *config.Config
adminRepo *repository.AdminRepository
groupRepo *repository.GroupRepository
}
func NewListAppCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository) *ListAppCommand {
return &ListAppCommand{
BaseCommand: BaseCommand{cfg: cfg},
adminRepo: adminRepo,
groupRepo: groupRepo,
}
return &ListAppCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo}
}
func (c *ListAppCommand) Execute(message *tgbotapi.Message) string {
if !c.requireAdmin(message) {
return "You are not authorized to use this command."
func (c *ListAppCommand) Execute(msg *tgbotapi.Message, sender Sender) {
if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) {
return
}
groupID := message.Chat.ID
hasGroup, err := c.adminRepo.HasGroup(groupID)
if err != nil {
return fmt.Sprintf("Failed to check group: %v", err)
if len(splitArgs(msg.CommandArguments())) != 0 {
_ = sender.SendMessage(msg.Chat.ID, "Invalid arguments")
return
}
if !hasGroup {
return "This group is not registered."
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
group, err := c.groupRepo.Get(ctx, groupID)
group, err := c.groupRepo.Get(ctx, msg.Chat.ID)
if err != nil {
return fmt.Sprintf("Failed to get group: %v", err)
_ = sender.SendMessage(msg.Chat.ID, "Internal server error")
return
}
var sb strings.Builder
sb.WriteString("*Apps in this group:*\n\n")
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())
}
if len(group.AppleApps) > 0 {
sb.WriteString("*Apple Apps:*\n")
for i, app := range group.AppleApps {
sb.WriteString(fmt.Sprintf("%d. %s (%s)\n", i+1, app.AppID, app.Country))
}
sb.WriteString("\n")
func formatAppTable(apps []model.AppInfo) string {
if len(apps) == 0 {
return "<i>(none)</i>\n"
}
if len(group.GoogleApps) > 0 {
sb.WriteString("*Google Apps:*\n")
for i, app := range group.GoogleApps {
sb.WriteString(fmt.Sprintf("%d. %s (%s)\n", i+1, app.AppID, app.Country))
}
}
if len(group.AppleApps) == 0 && len(group.GoogleApps) == 0 {
return "No apps in this group."
rows := make([][]string, 0, len(apps))
for i, a := range apps {
rows = append(rows, []string{strconv.Itoa(i + 1), a.AppID, a.Country})
}
return sb.String()
return fmt.Sprintf("<pre>%s</pre>\n", util.BuildTable([]string{"#", "AppId", "Country"}, rows))
}
+18 -18
View File
@@ -9,37 +9,37 @@ import (
"github.com/miti99/store-scraper-bot-go/internal/repository"
)
// /listgroup — Java ListGroupCommand. Admin-only. Lists authorized groups.
type ListGroupCommand struct {
BaseCommand
cfg *config.Config
adminRepo *repository.AdminRepository
}
func NewListGroupCommand(cfg *config.Config, adminRepo *repository.AdminRepository) *ListGroupCommand {
return &ListGroupCommand{
BaseCommand: BaseCommand{cfg: cfg},
adminRepo: adminRepo,
}
return &ListGroupCommand{cfg: cfg, adminRepo: adminRepo}
}
func (c *ListGroupCommand) Execute(message *tgbotapi.Message) string {
if !c.requireAdmin(message) {
return "You are not authorized to use this command."
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 {
return fmt.Sprintf("Failed to get groups: %v", err)
_ = sender.SendMessage(msg.Chat.ID, "Internal server error")
return
}
if len(groups) == 0 {
return "No groups found."
_ = sender.SendMessage(msg.Chat.ID, "No groups found")
return
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("*Total groups: %d*\n\n", len(groups)))
for i, groupID := range groups {
sb.WriteString(fmt.Sprintf("%d. %d\n", i+1, groupID))
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))
}
return sb.String()
_ = sender.SendMessage(msg.Chat.ID, sb.String())
}
+24 -34
View File
@@ -1,58 +1,48 @@
package command
import (
"encoding/json"
"fmt"
"strings"
"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 {
BaseCommand
appleScraper *apple.AppleScraper
cfg *config.Config
scraper *apple.AppleScraper
}
func NewRawAppleAppCommand(cfg *config.Config, appleScraper *apple.AppleScraper) *RawAppleAppCommand {
return &RawAppleAppCommand{
BaseCommand: BaseCommand{cfg: cfg},
appleScraper: appleScraper,
}
func NewRawAppleAppCommand(cfg *config.Config, scraper *apple.AppleScraper) *RawAppleAppCommand {
return &RawAppleAppCommand{cfg: cfg, scraper: scraper}
}
func (c *RawAppleAppCommand) Execute(message *tgbotapi.Message) string {
if !c.requireAdmin(message) {
return "You are not authorized to use this command."
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
}
args := strings.Fields(message.CommandArguments())
if len(args) == 0 {
return "Usage: /rawapple <appId> [country]\nExample: /rawapple com.example.app vn"
}
appID := args[0]
country := "vn"
if len(args) > 1 {
if len(args) == 2 {
country = args[1]
}
app, err := c.appleScraper.GetApp(appID, country)
if err != nil {
return fmt.Sprintf("Failed to fetch app: %v", err)
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)
}
jsonData, err := json.MarshalIndent(app, "", " ")
if err != nil {
return fmt.Sprintf("Failed to marshal JSON: %v", err)
raw, err := c.scraper.RawApp(req)
if err != nil || raw == "" {
_ = sender.SendMessage(msg.Chat.ID, "Error when request app info")
return
}
// Telegram has a message size limit, so we might need to truncate
jsonStr := string(jsonData)
if len(jsonStr) > 4000 {
jsonStr = jsonStr[:4000] + "\n...(truncated)"
}
return fmt.Sprintf("```json\n%s\n```", jsonStr)
_ = sender.SendDocument(msg.Chat.ID, fmt.Sprintf("%s.json", args[0]), raw)
}
+18 -36
View File
@@ -1,58 +1,40 @@
package command
import (
"encoding/json"
"fmt"
"strings"
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 {
BaseCommand
googleScraper *google.GoogleScraper
cfg *config.Config
scraper *google.GoogleScraper
}
func NewRawGoogleAppCommand(cfg *config.Config, googleScraper *google.GoogleScraper) *RawGoogleAppCommand {
return &RawGoogleAppCommand{
BaseCommand: BaseCommand{cfg: cfg},
googleScraper: googleScraper,
}
func NewRawGoogleAppCommand(cfg *config.Config, scraper *google.GoogleScraper) *RawGoogleAppCommand {
return &RawGoogleAppCommand{cfg: cfg, scraper: scraper}
}
func (c *RawGoogleAppCommand) Execute(message *tgbotapi.Message) string {
if !c.requireAdmin(message) {
return "You are not authorized to use this command."
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
}
args := strings.Fields(message.CommandArguments())
if len(args) == 0 {
return "Usage: /rawgoogle <appId> [country]\nExample: /rawgoogle com.example.app vn"
}
appID := args[0]
country := "vn"
if len(args) > 1 {
if len(args) == 2 {
country = args[1]
}
app, err := c.googleScraper.GetApp(appID, country)
if err != nil {
return fmt.Sprintf("Failed to fetch app: %v", err)
raw, err := c.scraper.RawApp(request.New(appID, country))
if err != nil || raw == "" {
_ = sender.SendMessage(msg.Chat.ID, "Error when request app info")
return
}
jsonData, err := json.MarshalIndent(app, "", " ")
if err != nil {
return fmt.Sprintf("Failed to marshal JSON: %v", err)
}
// Telegram has a message size limit, so we might need to truncate
jsonStr := string(jsonData)
if len(jsonStr) > 4000 {
jsonStr = jsonStr[:4000] + "\n...(truncated)"
}
return fmt.Sprintf("```json\n%s\n```", jsonStr)
_ = sender.SendDocument(msg.Chat.ID, fmt.Sprintf("%s.json", appID), raw)
}
+45 -20
View File
@@ -7,6 +7,7 @@ import (
"strings"
"time"
"go.mongodb.org/mongo-driver/mongo/options"
"go.uber.org/zap"
)
@@ -17,15 +18,17 @@ const (
Production Environment = "PRODUCTION"
)
const DefaultDatabaseName = "store-scraper-bot"
type Config struct {
// Telegram
TelegramBotToken string
TelegramBotUsername string
// MongoDB
MongoURI string
MongoDatabase string
MongoTimeout time.Duration
MongoURI string
MongoDatabase string
MongoTimeout time.Duration
// Application
Env Environment
@@ -34,10 +37,10 @@ type Config struct {
SourceCommit string
// Constants
AppCacheSeconds int
NumDaysWarningNotUpdated int
ScheduleCheckAppTime string
VietnamLocation *time.Location
AppCacheSeconds int
NumDaysWarningNotUpdated int
ScheduleCheckAppTime string
VietnamLocation *time.Location
// Logger
Logger *zap.Logger
@@ -48,7 +51,6 @@ var GlobalConfig *Config
func Load() (*Config, error) {
cfg := &Config{}
// Telegram
cfg.TelegramBotToken = getEnv("TELEGRAM_BOT_TOKEN", "")
if cfg.TelegramBotToken == "" {
return nil, fmt.Errorf("TELEGRAM_BOT_TOKEN is required")
@@ -58,12 +60,14 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("TELEGRAM_BOT_USERNAME is required")
}
// MongoDB
cfg.MongoURI = getEnv("MONGO_URI", "mongodb://localhost:27017")
cfg.MongoDatabase = getEnv("MONGO_DATABASE", "store_scraper_bot")
// 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
// Application
envStr := getEnv("ENV", "DEVELOPMENT")
if envStr == "PRODUCTION" {
cfg.Env = Production
@@ -83,19 +87,16 @@ func Load() (*Config, error) {
cfg.SourceCommit = getEnv("SOURCE_COMMIT", "unknown")
// Constants
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 * * *") // Cron format: 7:00 AM daily
cfg.ScheduleCheckAppTime = getEnv("SCHEDULE_CHECK_APP_TIME", "0 7 * * *")
// Vietnam timezone
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
// Initialize logger
var logger *zap.Logger
if cfg.Env == Production {
logger, err = zap.NewProduction()
@@ -111,6 +112,34 @@ func Load() (*Config, error) {
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
@@ -147,7 +176,3 @@ func (c *Config) IsAdmin(userID int64) bool {
}
return false
}
func (c *Config) GetScopeName() string {
return strings.ToLower(string(c.Env))
}
+8
View File
@@ -0,0 +1,8 @@
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"`
}
+9 -8
View File
@@ -1,22 +1,23 @@
package model
// AdminID is the singleton document _id used by Java AdminRepository.
const AdminID = "admin"
type Admin struct {
Key string `bson:"_id,omitempty" json:"key"`
Groups []int64 `bson:"groups" json:"groups"`
AbstractModel `bson:",inline"`
Groups []int64 `bson:"groups" json:"groups"`
}
func NewAdmin() *Admin {
return &Admin{
Key: "admin",
Groups: make([]int64, 0),
AbstractModel: AbstractModel{ID: AdminID, Class: "Admin"},
Groups: []int64{},
}
}
func (a *Admin) AddGroup(groupID int64) bool {
for _, g := range a.Groups {
if g == groupID {
return false // Already exists
}
if a.HasGroup(groupID) {
return false
}
a.Groups = append(a.Groups, groupID)
return true
+48 -38
View File
@@ -1,49 +1,59 @@
package model
import "time"
// 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 {
Key string `bson:"_id" json:"key"`
App AppleAppResponse `bson:"app" json:"app"`
UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"`
AbstractModel `bson:",inline"`
App AppleAppResponse `bson:"app" json:"app"`
Millis int64 `bson:"millis" json:"millis"` // cache timestamp (ms since epoch)
}
type AppleAppResponse struct {
ID int64 `json:"id"`
AppID string `json:"appId"`
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
Icon string `json:"icon"`
Genres []string `json:"genres"`
PrimaryGenre string `json:"primaryGenre"`
ContentRating string `json:"contentRating"`
Size string `json:"size"`
RequiredOsVersion string `json:"requiredOsVersion"`
Released string `json:"released"`
Updated string `json:"updated"` // ISO 8601 timestamp
Version string `json:"version"`
Price float64 `json:"price"`
Currency string `json:"currency"`
Free bool `json:"free"`
DeveloperID int64 `json:"developerId"`
Developer string `json:"developer"`
DeveloperURL string `json:"developerUrl"`
Score float64 `json:"score"`
Reviews int `json:"reviews"`
Ratings int64 `json:"ratings"`
Screenshots []string `json:"screenshots"`
Histogram map[string]int64 `json:"histogram"`
}
func NewAppleApp(appID string, response AppleAppResponse) *AppleApp {
func NewAppleApp(appID string, response AppleAppResponse, millis int64) *AppleApp {
return &AppleApp{
Key: appID,
App: response,
UpdatedAt: time.Now(),
AbstractModel: AbstractModel{ID: appID, Class: "AppleApp"},
App: response,
Millis: millis,
}
}
func (a *AppleApp) IsExpired(cacheSeconds int) bool {
return time.Since(a.UpdatedAt).Seconds() > float64(cacheSeconds)
// IsExpired reports whether the cache entry is older than cacheMillis.
func (a *AppleApp) IsExpired(nowMillis, cacheMillis int64) bool {
return nowMillis-a.Millis > cacheMillis
}
+80 -37
View File
@@ -1,48 +1,91 @@
package model
import "time"
// 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 {
Key string `bson:"_id" json:"key"`
App GoogleAppResponse `bson:"app" json:"app"`
UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"`
AbstractModel `bson:",inline"`
App GoogleAppResponse `bson:"app" json:"app"`
Millis int64 `bson:"millis" json:"millis"`
}
type GoogleAppResponse struct {
Title string `json:"title"`
Description string `json:"description"`
Installs string `json:"installs"`
MinInstalls int64 `json:"minInstalls"`
MaxInstalls int64 `json:"maxInstalls"`
Score float64 `json:"score"`
ScoreText string `json:"scoreText"`
Ratings int64 `json:"ratings"`
Reviews int64 `json:"reviews"`
Histogram map[string]int64 `json:"histogram"`
Price float64 `json:"price"`
Free bool `json:"free"`
Currency string `json:"currency"`
Developer string `json:"developer"`
Genre string `json:"genre"`
Icon string `json:"icon"`
HeaderImage string `json:"headerImage"`
Screenshots []string `json:"screenshots"`
ContentRating string `json:"contentRating"`
AdSupported bool `json:"adSupported"`
Updated int64 `json:"updated"` // Milliseconds since epoch
Version string `json:"version"`
AppID string `json:"appId"`
URL string `json:"url"`
}
func NewGoogleApp(appID string, response GoogleAppResponse) *GoogleApp {
func NewGoogleApp(appID string, response GoogleAppResponse, millis int64) *GoogleApp {
return &GoogleApp{
Key: appID,
App: response,
UpdatedAt: time.Now(),
AbstractModel: AbstractModel{ID: appID, Class: "GoogleApp"},
App: response,
Millis: millis,
}
}
func (g *GoogleApp) IsExpired(cacheSeconds int) bool {
return time.Since(g.UpdatedAt).Seconds() > float64(cacheSeconds)
func (g *GoogleApp) IsExpired(nowMillis, cacheMillis int64) bool {
return nowMillis-g.Millis > cacheMillis
}
+30 -10
View File
@@ -1,28 +1,48 @@
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 {
Key int64 `bson:"_id" json:"key"`
AppleApps []AppInfo `bson:"appleApps" json:"appleApps"`
GoogleApps []AppInfo `bson:"googleApps" json:"googleApps"`
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{
Key: groupID,
AppleApps: make([]AppInfo, 0),
GoogleApps: make([]AppInfo, 0),
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 && app.Country == country {
return false // Already exists
if app.AppID == appID {
return false
}
}
g.AppleApps = append(g.AppleApps, AppInfo{AppID: appID, Country: country})
@@ -41,8 +61,8 @@ func (g *Group) RemoveAppleApp(appID string) bool {
func (g *Group) AddGoogleApp(appID, country string) bool {
for _, app := range g.GoogleApps {
if app.AppID == appID && app.Country == country {
return false // Already exists
if app.AppID == appID {
return false
}
}
g.GoogleApps = append(g.GoogleApps, AppInfo{AppID: appID, Country: country})
+3 -1
View File
@@ -1,12 +1,14 @@
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 interface{} // Can be int or string
Reviews int64
Ratings int64
IsApple bool
}
+27 -19
View File
@@ -11,22 +11,36 @@ import (
"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("admin"),
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": "admin"}).Decode(admin)
err := r.collection.FindOne(ctx, bson.M{"_id": model.AdminID}).Decode(admin)
if err != nil {
if err == mongo.ErrNoDocuments {
// Return new admin if not found
return model.NewAdmin(), nil
}
return nil, fmt.Errorf("failed to get admin: %w", err)
@@ -36,43 +50,39 @@ func (r *AdminRepository) Get(ctx context.Context) (*model.Admin, error) {
func (r *AdminRepository) Save(ctx context.Context, admin *model.Admin) error {
opts := options.Replace().SetUpsert(true)
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": "admin"}, admin, opts)
_, 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) error {
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 err
return false, err
}
if !admin.AddGroup(groupID) {
return fmt.Errorf("group already exists")
return false, nil
}
return r.Save(ctx, admin)
return true, r.Save(ctx, admin)
}
func (r *AdminRepository) RemoveGroup(groupID int64) error {
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 err
return false, err
}
if !admin.RemoveGroup(groupID) {
return fmt.Errorf("group not found")
return false, nil
}
return r.Save(ctx, admin)
return true, r.Save(ctx, admin)
}
func (r *AdminRepository) HasGroup(groupID int64) (bool, error) {
@@ -83,7 +93,6 @@ func (r *AdminRepository) HasGroup(groupID int64) (bool, error) {
if err != nil {
return false, err
}
return admin.HasGroup(groupID), nil
}
@@ -95,6 +104,5 @@ func (r *AdminRepository) GetAllGroups() ([]int64, error) {
if err != nil {
return nil, err
}
return admin.Groups, nil
}
+12 -12
View File
@@ -12,14 +12,14 @@ import (
"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"),
}
return &AppleAppRepository{collection: GetCollection("apple_app")}
}
func (r *AppleAppRepository) Get(ctx context.Context, appID string) (*model.AppleApp, error) {
@@ -27,7 +27,7 @@ func (r *AppleAppRepository) Get(ctx context.Context, appID string) (*model.Appl
err := r.collection.FindOne(ctx, bson.M{"_id": appID}).Decode(app)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil // Not found
return nil, nil
}
return nil, fmt.Errorf("failed to get apple app: %w", err)
}
@@ -35,27 +35,27 @@ func (r *AppleAppRepository) Get(ctx context.Context, appID string) (*model.Appl
}
func (r *AppleAppRepository) Save(ctx context.Context, app *model.AppleApp) error {
app.UpdatedAt = time.Now()
opts := options.Replace().SetUpsert(true)
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": app.Key}, app, opts)
_, 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 {
if err != nil || app == nil {
return nil, err
}
if app != nil && !app.IsExpired(config.GlobalConfig.AppCacheSeconds) {
return app, nil
cacheMillis := int64(config.GlobalConfig.AppCacheSeconds) * 1000
if app.IsExpired(time.Now().UnixMilli(), cacheMillis) {
return nil, nil
}
return nil, nil // Cache expired or not found
return app, nil
}
+9 -12
View File
@@ -12,14 +12,13 @@ import (
"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"),
}
return &GoogleAppRepository{collection: GetCollection("google_app")}
}
func (r *GoogleAppRepository) Get(ctx context.Context, appID string) (*model.GoogleApp, error) {
@@ -27,7 +26,7 @@ func (r *GoogleAppRepository) Get(ctx context.Context, appID string) (*model.Goo
err := r.collection.FindOne(ctx, bson.M{"_id": appID}).Decode(app)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil // Not found
return nil, nil
}
return nil, fmt.Errorf("failed to get google app: %w", err)
}
@@ -35,9 +34,8 @@ func (r *GoogleAppRepository) Get(ctx context.Context, appID string) (*model.Goo
}
func (r *GoogleAppRepository) Save(ctx context.Context, app *model.GoogleApp) error {
app.UpdatedAt = time.Now()
opts := options.Replace().SetUpsert(true)
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": app.Key}, app, opts)
_, 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)
}
@@ -49,13 +47,12 @@ func (r *GoogleAppRepository) GetCached(appID string) (*model.GoogleApp, error)
defer cancel()
app, err := r.Get(ctx, appID)
if err != nil {
if err != nil || app == nil {
return nil, err
}
if app != nil && !app.IsExpired(config.GlobalConfig.AppCacheSeconds) {
return app, nil
cacheMillis := int64(config.GlobalConfig.AppCacheSeconds) * 1000
if app.IsExpired(time.Now().UnixMilli(), cacheMillis) {
return nil, nil
}
return nil, nil // Cache expired or not found
return app, nil
}
+49 -34
View File
@@ -11,22 +11,41 @@ import (
"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"),
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": groupID}).Decode(group)
err := r.collection.FindOne(ctx, bson.M{"_id": model.GroupIDToKey(groupID)}).Decode(group)
if err != nil {
if err == mongo.ErrNoDocuments {
// Return new group if not found
return model.NewGroup(groupID), nil
}
return nil, fmt.Errorf("failed to get group: %w", err)
@@ -36,7 +55,7 @@ func (r *GroupRepository) Get(ctx context.Context, groupID int64) (*model.Group,
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.Key}, group, opts)
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": group.ID}, group, opts)
if err != nil {
return fmt.Errorf("failed to save group: %w", err)
}
@@ -44,73 +63,69 @@ func (r *GroupRepository) Save(ctx context.Context, group *model.Group) error {
}
func (r *GroupRepository) Delete(ctx context.Context, groupID int64) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": groupID})
_, 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) AddAppleApp(groupID int64, appID, country string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
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 err
return false, err
}
if !group.AddAppleApp(appID, country) {
return fmt.Errorf("apple app already exists in group")
return false, nil
}
return r.Save(ctx, group)
return true, r.Save(ctx, group)
}
func (r *GroupRepository) RemoveAppleApp(groupID int64, appID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
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 err
return false, err
}
if !group.RemoveAppleApp(appID) {
return fmt.Errorf("apple app not found in group")
return false, nil
}
return r.Save(ctx, group)
return true, r.Save(ctx, group)
}
func (r *GroupRepository) AddGoogleApp(groupID int64, appID, country string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
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 err
return false, err
}
if !group.AddGoogleApp(appID, country) {
return fmt.Errorf("google app already exists in group")
return false, nil
}
return r.Save(ctx, group)
return true, r.Save(ctx, group)
}
func (r *GroupRepository) RemoveGoogleApp(groupID int64, appID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
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 err
return false, err
}
if !group.RemoveGoogleApp(appID) {
return fmt.Errorf("google app not found in group")
return false, nil
}
return r.Save(ctx, group)
return true, r.Save(ctx, group)
}
+55 -98
View File
@@ -35,9 +35,7 @@ func NewScheduler(
appleScraper *apple.AppleScraper,
googleScraper *google.GoogleScraper,
) *Scheduler {
// Create cron with Vietnam timezone
c := cron.New(cron.WithLocation(cfg.VietnamLocation))
return &Scheduler{
cron: c,
cfg: cfg,
@@ -51,16 +49,12 @@ func NewScheduler(
}
func (s *Scheduler) Start() error {
// Schedule daily check at configured time (default: 7:00 AM Vietnam time)
_, err := s.cron.AddFunc(s.cfg.ScheduleCheckAppTime, s.runDailyCheck)
if err != nil {
return fmt.Errorf("failed to schedule daily check: %w", err)
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
}
@@ -71,27 +65,22 @@ func (s *Scheduler) Stop() {
}
func (s *Scheduler) runDailyCheck() {
s.logger.Info("Running daily check job")
now := time.Now().In(s.cfg.VietnamLocation)
// Check if today is weekend (Saturday or Sunday)
isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday
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 for daily check", zap.Error(err))
s.logger.Error("Failed to get groups", zap.Error(err))
return
}
for _, groupID := range groups {
s.checkGroup(groupID, isWeekend)
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, isWeekend bool) {
func (s *Scheduler) checkGroup(groupID int64, silent bool, now time.Time) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
@@ -100,70 +89,52 @@ func (s *Scheduler) checkGroup(groupID int64, isWeekend bool) {
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
}
nonUpdatedApps := make([]model.NonUpdatedApp, 0)
now := time.Now().In(s.cfg.VietnamLocation)
threshold := s.cfg.NumDaysWarningNotUpdated
stale := make([]model.NonUpdatedApp, 0)
// Check Apple apps
for _, appInfo := range group.AppleApps {
app, err := s.appleScraper.GetApp(appInfo.AppID, appInfo.Country)
if err != nil {
s.logger.Error("Failed to fetch Apple app",
zap.Int64("groupId", groupID),
zap.String("appId", appInfo.AppID),
zap.Error(err))
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 {
s.logger.Error("Failed to parse update time",
zap.Int64("groupId", groupID),
zap.String("appId", appInfo.AppID),
zap.String("updated", app.Updated),
zap.Error(err))
continue
}
daysSinceUpdate := int(now.Sub(updatedTime).Hours() / 24)
if daysSinceUpdate > s.cfg.NumDaysWarningNotUpdated {
nonUpdatedApps = append(nonUpdatedApps, model.NonUpdatedApp{
AppID: appInfo.AppID,
days := int(now.Sub(updatedTime).Hours() / 24)
if days > threshold {
stale = append(stale, model.NonUpdatedApp{
AppID: info.AppID,
Title: app.Title,
Days: daysSinceUpdate,
Updated: app.Updated[:10],
Days: days,
Updated: updatedTime.Format("2006-01-02"),
Score: app.Score,
Reviews: app.Reviews,
Reviews: int64(app.Reviews),
Ratings: app.Ratings,
IsApple: true,
})
}
}
// Check Google apps
for _, appInfo := range group.GoogleApps {
app, err := s.googleScraper.GetApp(appInfo.AppID, appInfo.Country)
if err != nil {
s.logger.Error("Failed to fetch Google app",
zap.Int64("groupId", groupID),
zap.String("appId", appInfo.AppID),
zap.Error(err))
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)
daysSinceUpdate := int(now.Sub(updatedTime).Hours() / 24)
if daysSinceUpdate > s.cfg.NumDaysWarningNotUpdated {
nonUpdatedApps = append(nonUpdatedApps, model.NonUpdatedApp{
AppID: appInfo.AppID,
days := int(now.Sub(updatedTime).Hours() / 24)
if days > threshold {
stale = append(stale, model.NonUpdatedApp{
AppID: info.AppID,
Title: app.Title,
Days: daysSinceUpdate,
Days: days,
Updated: updatedTime.Format("2006-01-02"),
Score: app.Score,
Reviews: app.Reviews,
@@ -173,60 +144,46 @@ func (s *Scheduler) checkGroup(groupID int64, isWeekend bool) {
}
}
// Send report
if len(nonUpdatedApps) == 0 {
s.logger.Info("No non-updated apps found for group", zap.Int64("groupId", groupID))
if len(stale) == 0 {
s.logger.Info("All apps up-to-date", zap.Int64("groupId", groupID))
return
}
message := s.buildReport(groupID, nonUpdatedApps)
var err2 error
if isWeekend {
err2 = s.bot.SendMessageSilent(groupID, message)
message := s.buildReport(groupID, stale, now)
var sendErr error
if silent {
sendErr = s.bot.SendMessageSilent(groupID, message)
} else {
err2 = s.bot.SendMessage(groupID, message)
sendErr = s.bot.SendMessage(groupID, message)
}
if err2 != nil {
s.logger.Error("Failed to send daily check report",
zap.Int64("groupId", groupID),
zap.Error(err2))
} else {
s.logger.Info("Daily check report sent",
zap.Int64("groupId", groupID),
zap.Int("nonUpdatedApps", len(nonUpdatedApps)),
zap.Bool("silent", isWeekend))
if sendErr != nil {
s.logger.Error("Send daily report failed", zap.Int64("groupId", groupID), zap.Error(sendErr))
}
}
func (s *Scheduler) buildReport(groupID int64, nonUpdatedApps []model.NonUpdatedApp) string {
var rows [][]string
for _, app := range nonUpdatedApps {
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 app.IsApple {
if a.IsApple {
store = "Apple"
}
rows = append(rows, []string{
util.TruncateString(app.Title, 30),
util.TruncateString(a.Title, 30),
store,
fmt.Sprintf("%d", app.Days),
app.Updated,
fmt.Sprintf("%.1f", app.Score),
fmt.Sprintf("%v", app.Reviews),
util.FormatNumber(app.Ratings),
fmt.Sprintf("%d", a.Days),
a.Updated,
fmt.Sprintf("%.1f", a.Score),
fmt.Sprintf("%d", a.Reviews),
util.FormatNumber(a.Ratings),
})
}
headers := []string{"App", "Store", "Days", "Updated", "Score", "Reviews", "Ratings"}
table := util.BuildTable(headers, rows)
now := time.Now().In(s.cfg.VietnamLocation)
return fmt.Sprintf("*Daily App Check Report*\nDate: %s\nGroup: %d\nApps not updated in >%d days: *%d*\n\n%s",
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(nonUpdatedApps),
table)
len(apps),
util.BuildTable(headers, rows),
)
}
+53 -49
View File
@@ -5,66 +5,66 @@ import (
"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 {
if len(rows) == 0 {
return ""
}
// Calculate column widths
columnWidths := make([]int, len(headers))
for i, header := range headers {
columnWidths[i] = len(header)
}
for _, row := range rows {
for i, cell := range row {
if i < len(columnWidths) && len(cell) > columnWidths[i] {
columnWidths[i] = len(cell)
}
}
}
// Build table
widths := computeWidths(headers, rows)
var sb strings.Builder
// Top border
sb.WriteString("```\n")
// Header
for i, header := range headers {
sb.WriteString(padRight(header, columnWidths[i]))
if i < len(headers)-1 {
sb.WriteString(" | ")
}
}
writeRow(&sb, headers, widths)
sb.WriteString("\n")
writeSeparator(&sb, widths)
// Separator
for i, width := range columnWidths {
sb.WriteString(strings.Repeat("-", width))
if i < len(columnWidths)-1 {
sb.WriteString("-+-")
for i, row := range rows {
sb.WriteString("\n")
if i > 0 && i%5 == 0 {
writeSeparator(&sb, widths)
sb.WriteString("\n")
}
writeRow(&sb, row, widths)
}
sb.WriteString("\n")
return sb.String()
}
// Rows
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(columnWidths) {
sb.WriteString(padRight(cell, columnWidths[i]))
if i < len(row)-1 {
sb.WriteString(" | ")
}
if i < len(widths) && len(cell) > widths[i] {
widths[i] = len(cell)
}
}
sb.WriteString("\n")
}
return widths
}
// Bottom border
sb.WriteString("```")
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(" │ ")
}
}
}
return sb.String()
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 {
@@ -78,14 +78,18 @@ 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 >= 1000000 {
return fmt.Sprintf("%.1fM", float64(n)/1000000)
} else if n >= 1000 {
return fmt.Sprintf("%.1fK", float64(n)/1000)
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)
}