metrics #1
@ -1,5 +1,14 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"nignoggobot/metrics"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
)
|
||||||
|
|
||||||
var AdminIDs = []int64{126131628}
|
var AdminIDs = []int64{126131628}
|
||||||
|
|
||||||
func IsAdmin(userID int64) bool {
|
func IsAdmin(userID int64) bool {
|
||||||
@ -10,3 +19,94 @@ func IsAdmin(userID int64) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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{})
|
||||||
|
}
|
||||||
|
9
main.go
9
main.go
@ -8,6 +8,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"nignoggobot/commands"
|
"nignoggobot/commands"
|
||||||
|
"nignoggobot/metrics"
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
|
||||||
@ -66,10 +67,18 @@ func main() {
|
|||||||
continue
|
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()
|
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 {
|
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)
|
cmd.Execute(update, bot)
|
||||||
} else {
|
} else {
|
||||||
|
log.Printf("Unknown command: %s", cmdName)
|
||||||
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Unknown command.")
|
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Unknown command.")
|
||||||
bot.Send(msg)
|
bot.Send(msg)
|
||||||
}
|
}
|
||||||
|
148
metrics/metrics.go
Normal file
148
metrics/metrics.go
Normal 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 ©
|
||||||
|
}
|
||||||
|
|
||||||
|
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
196
metrics/metrics.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user