Files
store-scraper-bot/internal/scheduler/scheduler.go
T
tiennm99 e695d9b223 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.
2026-04-25 20:26:25 +07:00

190 lines
5.3 KiB
Go

package scheduler
import (
"context"
"fmt"
"time"
"github.com/miti99/store-scraper-bot-go/internal/api/apple"
"github.com/miti99/store-scraper-bot-go/internal/api/google"
"github.com/miti99/store-scraper-bot-go/internal/bot"
"github.com/miti99/store-scraper-bot-go/internal/config"
"github.com/miti99/store-scraper-bot-go/internal/model"
"github.com/miti99/store-scraper-bot-go/internal/repository"
"github.com/miti99/store-scraper-bot-go/internal/util"
"github.com/robfig/cron/v3"
"go.uber.org/zap"
)
type Scheduler struct {
cron *cron.Cron
cfg *config.Config
bot *bot.Bot
adminRepo *repository.AdminRepository
groupRepo *repository.GroupRepository
appleScraper *apple.AppleScraper
googleScraper *google.GoogleScraper
logger *zap.Logger
}
func NewScheduler(
cfg *config.Config,
bot *bot.Bot,
adminRepo *repository.AdminRepository,
groupRepo *repository.GroupRepository,
appleScraper *apple.AppleScraper,
googleScraper *google.GoogleScraper,
) *Scheduler {
c := cron.New(cron.WithLocation(cfg.VietnamLocation))
return &Scheduler{
cron: c,
cfg: cfg,
bot: bot,
adminRepo: adminRepo,
groupRepo: groupRepo,
appleScraper: appleScraper,
googleScraper: googleScraper,
logger: cfg.Logger,
}
}
func (s *Scheduler) Start() error {
if _, err := s.cron.AddFunc(s.cfg.ScheduleCheckAppTime, s.runDailyCheck); err != nil {
return fmt.Errorf("schedule daily check: %w", err)
}
s.logger.Info("Scheduler started",
zap.String("schedule", s.cfg.ScheduleCheckAppTime),
zap.String("timezone", s.cfg.VietnamLocation.String()))
s.cron.Start()
return nil
}
func (s *Scheduler) Stop() {
s.cron.Stop()
s.logger.Info("Scheduler stopped")
}
func (s *Scheduler) runDailyCheck() {
now := time.Now().In(s.cfg.VietnamLocation)
silent := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday
s.logger.Info("Running daily check job", zap.Bool("silent", silent))
groups, err := s.adminRepo.GetAllGroups()
if err != nil {
s.logger.Error("Failed to get groups", zap.Error(err))
return
}
for _, gid := range groups {
s.checkGroup(gid, silent, now)
}
s.logger.Info("Daily check job completed", zap.Int("groupsChecked", len(groups)))
}
func (s *Scheduler) checkGroup(groupID int64, silent bool, now time.Time) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
group, err := s.groupRepo.Get(ctx, groupID)
if err != nil {
s.logger.Error("Failed to get group", zap.Int64("groupId", groupID), zap.Error(err))
return
}
if len(group.AppleApps) == 0 && len(group.GoogleApps) == 0 {
s.logger.Info("Group has no apps, skipping", zap.Int64("groupId", groupID))
return
}
threshold := s.cfg.NumDaysWarningNotUpdated
stale := make([]model.NonUpdatedApp, 0)
for _, info := range group.AppleApps {
app, err := s.appleScraper.GetApp(info.AppID, info.Country)
if err != nil || app == nil {
s.logger.Error("Apple fetch failed", zap.String("appId", info.AppID), zap.Error(err))
continue
}
updatedTime, err := time.Parse(time.RFC3339, app.Updated)
if err != nil {
continue
}
days := int(now.Sub(updatedTime).Hours() / 24)
if days > threshold {
stale = append(stale, model.NonUpdatedApp{
AppID: info.AppID,
Title: app.Title,
Days: days,
Updated: updatedTime.Format("2006-01-02"),
Score: app.Score,
Reviews: int64(app.Reviews),
Ratings: app.Ratings,
IsApple: true,
})
}
}
for _, info := range group.GoogleApps {
app, err := s.googleScraper.GetApp(info.AppID, info.Country)
if err != nil || app == nil {
s.logger.Error("Google fetch failed", zap.String("appId", info.AppID), zap.Error(err))
continue
}
updatedTime := time.UnixMilli(app.Updated)
days := int(now.Sub(updatedTime).Hours() / 24)
if days > threshold {
stale = append(stale, model.NonUpdatedApp{
AppID: info.AppID,
Title: app.Title,
Days: days,
Updated: updatedTime.Format("2006-01-02"),
Score: app.Score,
Reviews: app.Reviews,
Ratings: app.Ratings,
IsApple: false,
})
}
}
if len(stale) == 0 {
s.logger.Info("All apps up-to-date", zap.Int64("groupId", groupID))
return
}
message := s.buildReport(groupID, stale, now)
var sendErr error
if silent {
sendErr = s.bot.SendMessageSilent(groupID, message)
} else {
sendErr = s.bot.SendMessage(groupID, message)
}
if sendErr != nil {
s.logger.Error("Send daily report failed", zap.Int64("groupId", groupID), zap.Error(sendErr))
}
}
func (s *Scheduler) buildReport(groupID int64, apps []model.NonUpdatedApp, now time.Time) string {
headers := []string{"App", "Store", "Days", "Updated", "Score", "Reviews", "Ratings"}
rows := make([][]string, 0, len(apps))
for _, a := range apps {
store := "Google"
if a.IsApple {
store = "Apple"
}
rows = append(rows, []string{
util.TruncateString(a.Title, 30),
store,
fmt.Sprintf("%d", a.Days),
a.Updated,
fmt.Sprintf("%.1f", a.Score),
fmt.Sprintf("%d", a.Reviews),
util.FormatNumber(a.Ratings),
})
}
return fmt.Sprintf(
"<b>Daily App Check Report</b>\nDate: %s\nGroup: <code>%d</code>\nApps not updated in &gt;%d days: <b>%d</b>\n\n<pre>%s</pre>",
now.Format("2006-01-02 15:04"),
groupID,
s.cfg.NumDaysWarningNotUpdated,
len(apps),
util.BuildTable(headers, rows),
)
}