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:
tiennm99 committed 2026-04-25 20:26:25 +07:00
1 parent cfc8d67641
commit e695d9b223
34 files changed
+1132 -1149

No files matched your search

+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)
}