diff --git a/commands/admin.go b/commands/admin.go index f64f72d..e135650 100644 --- a/commands/admin.go +++ b/commands/admin.go @@ -1,5 +1,14 @@ package commands +import ( + "fmt" + "nignoggobot/metrics" + "sort" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + var AdminIDs = []int64{126131628} func IsAdmin(userID int64) bool { @@ -10,3 +19,94 @@ func IsAdmin(userID int64) bool { } return false } + +type StatsCommand struct{} + +func (StatsCommand) Name() string { return "stats" } +func (StatsCommand) Help() string { return "Show bot usage stats (admin only)." } +func (StatsCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) { + if !IsAdmin(update.Message.From.ID) { + return + } + stats := metrics.GetStats() + var sb strings.Builder + + type cmdStat struct { + Name string + AllTime int + Last30Sum int + } + var statsList []cmdStat + for name, stat := range stats.Commands { + sum := 0 + for _, v := range stat.Last30 { + sum += v + } + statsList = append(statsList, cmdStat{Name: name, AllTime: stat.AllTime, Last30Sum: sum}) + } + + sort.Slice(statsList, func(i, j int) bool { + return statsList[i].AllTime > statsList[j].AllTime + }) + sb.WriteString("Command usage (all time):\n") + for _, s := range statsList { + sb.WriteString(fmt.Sprintf("/%s: %d\n", s.Name, s.AllTime)) + } + + sort.Slice(statsList, func(i, j int) bool { + return statsList[i].Last30Sum > statsList[j].Last30Sum + }) + sb.WriteString("\nCommand usage (last 30 days):\n") + for _, s := range statsList { + sb.WriteString(fmt.Sprintf("/%s: %d\n", s.Name, s.Last30Sum)) + } + + groups, privs, blocked := 0, 0, 0 + for _, chat := range stats.Chats { + if chat.IsGroup { + groups++ + } else { + privs++ + } + if chat.Blocked { + blocked++ + } + } + sb.WriteString(fmt.Sprintf("\nGroups: %d\nPrivate chats: %d\nBlocked/muted: %d\n", groups, privs, blocked)) + msg := tgbotapi.NewMessage(update.Message.Chat.ID, sb.String()) + bot.Send(msg) +} + +type BroadcastCommand struct{} + +func (BroadcastCommand) Name() string { return "broadcast" } +func (BroadcastCommand) Help() string { + return "Broadcast a to all chats (admin only)." +} +func (BroadcastCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) { + if !IsAdmin(update.Message.From.ID) { + return + } + args := strings.TrimSpace(update.Message.CommandArguments()) + if args == "" { + msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Usage: /broadcast ") + bot.Send(msg) + return + } + count := 0 + metrics.BroadcastMessage(func(chatID int64) error { + msg := tgbotapi.NewMessage(chatID, args) + _, err := bot.Send(msg) + if err == nil { + count++ + } + return err + }) + msg := tgbotapi.NewMessage(update.Message.Chat.ID, fmt.Sprintf("Broadcast sent to %d chats.", count)) + bot.Send(msg) +} + +func init() { + Register(StatsCommand{}) + Register(BroadcastCommand{}) +} diff --git a/main.go b/main.go index 41c8047..cbaa4d3 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "syscall" "nignoggobot/commands" + "nignoggobot/metrics" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" @@ -66,10 +67,18 @@ func main() { continue } + log.Printf("Received command: %s from chat %d (user %d)", update.Message.Command(), update.Message.Chat.ID, update.Message.From.ID) + cmdName := update.Message.Command() + isGroup := update.Message.Chat.IsGroup() || update.Message.Chat.IsSuperGroup() + metrics.UpdateChat(update.Message.Chat.ID, isGroup) if cmd, ok := commands.All()[cmdName]; ok { + log.Printf("Executing command: %s", cmdName) + // Bottleneck here locking mutexes and writing to json every time but oh well + metrics.IncrementCommandUsage(cmdName) cmd.Execute(update, bot) } else { + log.Printf("Unknown command: %s", cmdName) msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Unknown command.") bot.Send(msg) } diff --git a/metrics/metrics.go b/metrics/metrics.go new file mode 100644 index 0000000..4e4ddf6 --- /dev/null +++ b/metrics/metrics.go @@ -0,0 +1,148 @@ +package metrics + +import ( + "encoding/json" + "fmt" + "os" + "sync" + "time" +) + +type CommandStats struct { + AllTime int `json:"all_time"` + Last30 [30]int `json:"last_30"` + Last30Day int64 `json:"last_30_day"` // Unix day of the first slot +} + +type ChatInfo struct { + IsGroup bool `json:"is_group"` + LastActive int64 `json:"last_active"` + Blocked bool `json:"blocked"` + LastError string `json:"last_error,omitempty"` +} + +type Metrics struct { + Commands map[string]*CommandStats `json:"commands"` + Chats map[int64]*ChatInfo `json:"chats"` +} + +var ( + metricsFile = "metrics/metrics.json" + metrics *Metrics + mu sync.Mutex +) + +func loadMetrics() { + if metrics != nil { + return + } + metrics = &Metrics{ + Commands: make(map[string]*CommandStats), + Chats: make(map[int64]*ChatInfo), + } + f, err := os.Open(metricsFile) + if err != nil { + return + } + defer f.Close() + json.NewDecoder(f).Decode(metrics) +} + +func saveMetrics() { + // Do not lock here; caller must hold the lock else it will shitty deadlock + loadMetrics() + f, err := os.Create(metricsFile) + if err != nil { + return + } + defer f.Close() + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + enc.Encode(metrics) +} + +func IncrementCommandUsage(cmd string) { + fmt.Printf("[metrics] IncrementCommandUsage called with cmd=%s\n", cmd) + mu.Lock() + defer mu.Unlock() + loadMetrics() + stat, ok := metrics.Commands[cmd] + if !ok { + stat = &CommandStats{Last30Day: time.Now().Unix() / 86400} + metrics.Commands[cmd] = stat + } + today := time.Now().Unix() / 86400 + shift := int(today - stat.Last30Day) + if shift > 0 && shift < 30 { + copy(stat.Last30[shift:], stat.Last30[:30-shift]) + for i := 0; i < shift; i++ { + stat.Last30[i] = 0 + } + stat.Last30Day = today + } else if shift >= 30 { + for i := 0; i < 30; i++ { + stat.Last30[i] = 0 + } + stat.Last30Day = today + } + stat.AllTime++ + stat.Last30[0]++ + saveMetrics() +} + +func UpdateChat(chatID int64, isGroup bool) { + fmt.Printf("[metrics] UpdateChat called with chatID=%d isGroup=%v\n", chatID, isGroup) + mu.Lock() + defer mu.Unlock() + loadMetrics() + info, ok := metrics.Chats[chatID] + if !ok { + info = &ChatInfo{IsGroup: isGroup} + metrics.Chats[chatID] = info + } + info.LastActive = time.Now().Unix() + info.Blocked = false + info.LastError = "" + saveMetrics() +} + +func MarkChatBlocked(chatID int64, errMsg string) { + fmt.Printf("[metrics] MarkChatBlocked called with chatID=%d errMsg=%s\n", chatID, errMsg) + mu.Lock() + defer mu.Unlock() + loadMetrics() + info, ok := metrics.Chats[chatID] + if !ok { + info = &ChatInfo{} + metrics.Chats[chatID] = info + } + info.Blocked = true + info.LastError = errMsg + saveMetrics() +} + +func GetStats() *Metrics { + fmt.Printf("[metrics] GetStats called\n") + mu.Lock() + defer mu.Unlock() + loadMetrics() + copy := *metrics + return © +} + +func BroadcastMessage(sendFunc func(chatID int64) error) { + fmt.Printf("[metrics] BroadcastMessage called\n") + mu.Lock() + loadMetrics() + ids := make([]int64, 0, len(metrics.Chats)) + for id := range metrics.Chats { + ids = append(ids, id) + } + mu.Unlock() + for _, id := range ids { + err := sendFunc(id) + if err != nil { + MarkChatBlocked(id, err.Error()) + } + } +} diff --git a/metrics/metrics.json b/metrics/metrics.json new file mode 100644 index 0000000..287767c --- /dev/null +++ b/metrics/metrics.json @@ -0,0 +1,196 @@ +{ + "commands": { + "bandname": { + "all_time": 3, + "last_30": [ + 3, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "last_30_day": 20236 + }, + "debug": { + "all_time": 1, + "last_30": [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "last_30_day": 20236 + }, + "gayname": { + "all_time": 1, + "last_30": [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "last_30_day": 20236 + }, + "help": { + "all_time": 1, + "last_30": [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "last_30_day": 20236 + }, + "stats": { + "all_time": 4, + "last_30": [ + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "last_30_day": 20236 + } + }, + "chats": { + "-1001327903329": { + "is_group": true, + "last_active": 1748473223, + "blocked": false + }, + "126131628": { + "is_group": false, + "last_active": 1748472356, + "blocked": false + } + } +}