metrics #1

Merged
bdnugget merged 2 commits from metrics into master 2025-05-28 23:02:29 +00:00
4 changed files with 453 additions and 0 deletions

View File

@ -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 <message> 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 <message>")
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{})
}

View File

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

148
metrics/metrics.go Normal file
View File

@ -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 &copy
}
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())
}
}
}

196
metrics/metrics.json Normal file
View File

@ -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
}
}
}