Compare commits

...

17 Commits

Author SHA1 Message Date
e7a8cbc2a1 More commands 2025-06-25 15:53:42 +02:00
1eaef9f22f osrs command 2025-06-25 14:17:36 +02:00
335912ea8a Medchem command improvements 2025-06-25 12:00:29 +02:00
a0f0918504 Medchem command 2025-06-25 11:43:55 +02:00
279a94c754 Make debug slightly cooler 2025-05-29 01:53:46 +02:00
38a318f694 Merge pull request 'stacked boxplot' (#2) from metrics into master
Reviewed-on: #2
2025-05-28 23:42:47 +00:00
a78c8df5a0 Merge branch 'master' into metrics 2025-05-28 23:42:38 +00:00
aa58aa6f2a stacked boxplot 2025-05-29 01:42:01 +02:00
fc29af19b3 gitignore metrics json 2025-05-29 01:05:00 +02:00
71566d9a3a Merge pull request 'metrics' (#1) from metrics into master
Reviewed-on: #1
2025-05-28 23:02:29 +00:00
49a142b076 Sort stats 2025-05-29 01:00:44 +02:00
af1080fb6a Add metrics 2025-05-29 00:52:44 +02:00
3ab37d88f4 Add shutdown and panic notifications and admin only commands 2025-05-28 15:42:30 +02:00
5df5dde506 Mol improvements 2025-05-28 13:15:23 +02:00
bd15d70d3f Add mol command and pubchem searching 2025-05-28 13:09:06 +02:00
cbb4a27334 Add gayname command 2025-05-28 13:00:15 +02:00
f83333d9d3 Bandname command added 2025-05-28 12:55:18 +02:00
19 changed files with 2454 additions and 4 deletions

3
.gitignore vendored
View File

@ -1,9 +1,10 @@
old_json/
json/
old_commands/
metrics/metrics.json
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# If you prefer the white list template instead of the nigger list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins

207
commands/admin.go Normal file
View File

@ -0,0 +1,207 @@
package commands
import (
"bytes"
"fmt"
"image/color"
"image/png"
"nignoggobot/metrics"
"sort"
"strings"
"gonum.org/v1/plot"
"gonum.org/v1/plot/plotter"
"gonum.org/v1/plot/vg"
"gonum.org/v1/plot/vg/draw"
"gonum.org/v1/plot/vg/vgimg"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
var AdminIDs = []int64{126131628}
func IsAdmin(userID int64) bool {
for _, id := range AdminIDs {
if id == userID {
return true
}
}
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
Last30 [30]int
}
var statsList []cmdStat
for name, stat := range stats.Commands {
sum := 0
for _, v := range stat.Last30 {
sum += v
}
if sum > 0 {
statsList = append(statsList, cmdStat{Name: name, AllTime: stat.AllTime, Last30Sum: sum, Last30: stat.Last30})
}
}
// Sort by last 30 days usage for the graph
sort.Slice(statsList, func(i, j int) bool {
return statsList[i].Last30Sum > statsList[j].Last30Sum
})
// Prepare data for stacked bar chart
nCmds := len(statsList)
nDays := 30
if nCmds > 0 {
colors := []color.Color{
color.RGBA{0x1f, 0x77, 0xb4, 0xff}, // blue
color.RGBA{0xff, 0x7f, 0x0e, 0xff}, // orange
color.RGBA{0x2c, 0xa0, 0x2c, 0xff}, // green
color.RGBA{0xd6, 0x27, 0x28, 0xff}, // red
color.RGBA{0x94, 0x67, 0xbd, 0xff}, // purple
color.RGBA{0x8c, 0x56, 0x4b, 0xff}, // brown
color.RGBA{0xe3, 0x77, 0xc2, 0xff}, // pink
color.RGBA{0x7f, 0x7f, 0x7f, 0xff}, // gray
color.RGBA{0xbc, 0xbd, 0x22, 0xff}, // yellow-green
color.RGBA{0x17, 0xbe, 0xcf, 0xff}, // cyan
}
for len(colors) < nCmds {
colors = append(colors, color.RGBA{uint8(50 * len(colors)), uint8(100 * len(colors)), uint8(150 * len(colors)), 0xff})
}
p := plot.New()
p.Title.Text = "Command Usage (Last 30 Days)"
p.Y.Label.Text = "Count"
p.X.Label.Text = "Days Ago"
p.Legend.Top = true
p.Legend.Left = false
p.Legend.XOffs = 0
p.Legend.YOffs = 0
// Prepare the stacked values
stacks := make([][]float64, nCmds)
for i := range stacks {
stacks[i] = make([]float64, nDays)
}
for cmdIdx, stat := range statsList {
for day := 0; day < nDays; day++ {
stacks[cmdIdx][day] = float64(stat.Last30[day]) // 0=today, 29=oldest
}
}
barCharts := make([]*plotter.BarChart, nCmds)
barWidth := vg.Points(10)
for cmdIdx := 0; cmdIdx < nCmds; cmdIdx++ {
values := make(plotter.Values, nDays)
for day := 0; day < nDays; day++ {
values[day] = stacks[cmdIdx][day]
}
bar, err := plotter.NewBarChart(values, barWidth)
if err != nil {
continue
}
bar.Color = colors[cmdIdx]
bar.Offset = 0
if cmdIdx > 0 {
bar.StackOn(barCharts[cmdIdx-1])
}
p.Add(bar)
p.Legend.Add(statsList[cmdIdx].Name, bar)
barCharts[cmdIdx] = bar
}
labels := make([]string, nDays)
for i := 0; i < nDays; i++ {
labels[i] = fmt.Sprintf("%d", i) // 0=Today, 29=Oldest
}
p.NominalX(labels...)
img := vgimg.New(800, 400)
dc := draw.New(img)
p.Draw(dc)
buf := new(bytes.Buffer)
png.Encode(buf, img.Image())
photo := tgbotapi.FileBytes{Name: "stats.png", Bytes: buf.Bytes()}
photoMsg := tgbotapi.NewPhoto(update.Message.Chat.ID, photo)
photoMsg.Caption = "Command usage for the last 30 days (0 = today)"
bot.Send(photoMsg)
}
// Text stats as before
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{})
}

87
commands/bandname.go Normal file
View File

@ -0,0 +1,87 @@
package commands
import (
"math/rand"
"strings"
"sync"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type BandnameCommand struct{}
var (
bandnameOnce sync.Once
randomArticle = []string{"the", ""}
randomAdj = []string{
"massive", "gargantuan", "scorched", "spiteful", "hateful", "deadly", "unwise", "ignorant",
"disgusting", "fearless", "honest", "fleeting", "bloody", "foolish", "unmoving", "black", "sick", "diseased",
"undead", "terrible", "unkind", "burning", "disgraced", "conflicted", "malicious", "unclean", "dirty", "unwashed",
"muddy", "sickly", "fearless", "moon-lit", "bony", "skeletal", "burning", "flaming", "savage", "wild", "gold",
"silver", "overgrown", "wild", "untamed", "ethereal", "ghostly", "haunted", "buried", "nuclear", "radioactive",
"mutated", "accidental", "selfish", "self-serving", "hypocritical", "wicked", "dastardly", "dreadful", "obscene",
"filthy", "vulgar", "vile", "worthless", "despicable", "cruel", "inhuman", "bloodthirsty", "vicious", "sadistic",
"diabolical", "abominable", "bitter", "", "", "", "", "", "", "", "", "", "",
}
randomNounPlural = []string{
"excuses", "clocks", "machines", "needles", "cultists", "slayers", "tyrants", "sound",
"vision", "sight", "taste", "smell", "trumpets", "wanderers", "sirens", "kings", "queens", "knights", "priests",
"liars", "ogres", "devils", "angels", "demons", "screams", "cries", "pigs", "fiends", "locusts", "worms", "ravens",
"vultures", "theives", "warnings", "bodies", "bones", "fingers", "hands", "mouths", "ears", "tongues", "eyes",
"magic", "men", "women", "promises", "confessions", "kingdoms", "kisses", "fists", "suns", "moons", "stars", "weight",
"altars", "tombstones", "monuments", "rain", "artifacts", "wizards", "warlocks", "barbarians", "druids", "warriors",
"monks", "diamonds", "hoard", "embers", "ashes", "swamps", "mountains", "plains", "rivers", "islands", "forests",
"mines", "dungeons", "tombs", "hills", "sons", "daughters", "spires", "pyramids", "crypts", "catacombs", "shrines",
"pits", "whores", "bandits", "stains", "delusion", "mistakes", "weapons", "chains", "pillars", "beasts", "creatures",
}
randomNounSingular = []string{
"reason", "famine", "disease", "death", "war", "hatred", "sadness", "fear", "rage",
"terror", "awe", "disgust", "pain", "suffering", "misery", "avarice", "malice", "lust", "filth", "dirt", "skin",
"control", "pride", "decay", "flesh", "bone", "stone", "metal", "iron", "steel", "burden", "memory", "sorrow", "fire",
"hell", "silence", "loathing", "contempt", "revulsion", "horror", "abomination", "devastation", "dismay", "dread",
"panic", "frenzy", "hysteria", "grief", "remorse", "woe", "anguish", "misery", "desolation", "misfortune", "mourning",
"despair", "lamentation", "faith", "eternity", "gloom", "melancholy", "regret", "distress",
}
)
func (b BandnameCommand) Name() string {
return "bandname"
}
func (b BandnameCommand) Help() string {
return "Generate a random metal band name"
}
func (b BandnameCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) {
// Generate band name: [article] [adj] [plural noun] of [singular noun]
parts := []string{
randChoice(randomArticle),
randChoice(randomAdj),
randChoice(randomNounPlural),
"of",
randChoice(randomNounSingular),
}
// Remove empty strings and extra spaces
name := strings.Join(filterNonEmpty(parts), " ")
msg := tgbotapi.NewMessage(update.Message.Chat.ID, name)
msg.ParseMode = "Markdown"
bot.Send(msg)
}
func randChoice(arr []string) string {
return arr[rand.Intn(len(arr))]
}
func filterNonEmpty(arr []string) []string {
var out []string
for _, s := range arr {
if strings.TrimSpace(s) != "" {
out = append(out, s)
}
}
return out
}
func init() {
Register(BandnameCommand{})
}

56
commands/debug.go Normal file
View File

@ -0,0 +1,56 @@
package commands
import (
"fmt"
"runtime"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
var startTime = time.Now()
type DebugCommand struct{}
func (d DebugCommand) Name() string {
return "debug"
}
func (d DebugCommand) Help() string {
return "Show debug info"
}
func (d DebugCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) {
user := update.Message.From
isAdmin := user != nil && IsAdmin(user.ID)
var info string
if isAdmin {
var m runtime.MemStats
runtime.ReadMemStats(&m)
uptime := time.Since(startTime)
info = fmt.Sprintf(
"[ADMIN DEBUG]\nUserID: %d\nChatID: %d\nText: %s\nGo version: %s\nUptime: %s\nGoroutines: %d\nMemory: %.2f MB\nOS/Arch: %s/%s\n",
user.ID,
update.Message.Chat.ID,
update.Message.Text,
runtime.Version(),
uptime.Truncate(time.Second),
runtime.NumGoroutine(),
float64(m.Alloc)/1024/1024,
runtime.GOOS,
runtime.GOARCH,
)
} else {
info = fmt.Sprintf("UserID: %d\nChatID: %d\nText: %s", user.ID, update.Message.Chat.ID, update.Message.Text)
}
msg := tgbotapi.NewMessage(update.Message.Chat.ID, info)
bot.Send(msg)
if isAdmin && update.Message.CommandArguments() == "panic" {
panic("test panic")
}
}
func init() {
Register(DebugCommand{})
}

76
commands/fortune.go Normal file
View File

@ -0,0 +1,76 @@
package commands
import (
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"os"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type FortuneCommand struct{}
func (f FortuneCommand) Name() string {
return "fortune"
}
func (f FortuneCommand) Help() string {
return "Get your fortune. Usage: /fortune"
}
func (f FortuneCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) {
fortune, err := getRandomFortune()
if err != nil {
log.Printf("Error getting fortune: %v", err)
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "❌ Error loading fortune database. Please try again later.")
bot.Send(msg)
return
}
msg := tgbotapi.NewMessage(update.Message.Chat.ID, fortune)
bot.Send(msg)
}
func getRandomFortune() (string, error) {
// Try current json directory first, then fallback to old_json
file, err := os.Open("json/fortune.json")
if err != nil {
// Try old_json directory
file, err = os.Open("old_json/fortune.json")
if err != nil {
return "", fmt.Errorf("failed to open fortune.json: %v", err)
}
}
defer file.Close()
// Read the file content
data, err := io.ReadAll(file)
if err != nil {
return "", fmt.Errorf("failed to read fortune.json: %v", err)
}
// Parse JSON array
var fortunes []string
if err := json.Unmarshal(data, &fortunes); err != nil {
return "", fmt.Errorf("failed to parse fortune.json: %v", err)
}
if len(fortunes) == 0 {
return "", fmt.Errorf("fortune database is empty")
}
// Seed random number generator
rand.Seed(time.Now().UnixNano())
// Pick a random fortune
randomIndex := rand.Intn(len(fortunes))
return fortunes[randomIndex], nil
}
func init() {
Register(FortuneCommand{})
}

58
commands/gayname.go Normal file
View File

@ -0,0 +1,58 @@
package commands
import (
"encoding/json"
"math/rand"
"os"
"sync"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type GaynameCommand struct{}
var (
gaynameData struct {
First []string `json:"first"`
Last []string `json:"last"`
}
gaynameOnce sync.Once
)
func loadGaynameData() {
file, err := os.Open("json/gayname.json")
if err != nil {
gaynameData.First = []string{"Failed"}
gaynameData.Last = []string{"to load"}
return
}
defer file.Close()
json.NewDecoder(file).Decode(&gaynameData)
}
func (g GaynameCommand) Name() string {
return "gayname"
}
func (g GaynameCommand) Help() string {
return "Your gay name"
}
func (g GaynameCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) {
gaynameOnce.Do(loadGaynameData)
user := update.Message.From
var result string
if user != nil && user.LanguageCode == "pl-PL" {
result = user.FirstName + " aka FILTHY PIECE OF RETARD POLAK MIDGETSHIT"
} else {
first := gaynameData.First[rand.Intn(len(gaynameData.First))]
last := gaynameData.Last[rand.Intn(len(gaynameData.Last))]
result = user.FirstName + " aka " + first + " " + last
}
msg := tgbotapi.NewMessage(update.Message.Chat.ID, result)
bot.Send(msg)
}
func init() {
Register(GaynameCommand{})
}

View File

@ -2,6 +2,7 @@ package commands
import (
"strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)

View File

@ -2,6 +2,7 @@ package commands
import (
"log"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
@ -16,7 +17,7 @@ func (i InfoCommand) Help() string {
}
func (i InfoCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) {
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "This bot does XYZ.")
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "This bot does dumb stuff and chemistry. Now version 2.0, rewritten in Go.\n\nSauce: https://gitea.boner.be/nignogbot/nignoggobot\n\nComplaints and support: @fatboners")
_, err := bot.Send(msg)
if err != nil {
log.Println("Failed to send info message:", err)

66
commands/kekget.go Normal file
View File

@ -0,0 +1,66 @@
package commands
import (
"fmt"
"math/rand"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type KekgetCommand struct{}
func (k KekgetCommand) Name() string {
return "kekget"
}
func (k KekgetCommand) Help() string {
return "Try to get a KEK or KKK, or even a multiKEK. Usage: /kekget"
}
func (k KekgetCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) {
result := generateKekget()
msg := tgbotapi.NewMessage(update.Message.Chat.ID, result)
bot.Send(msg)
}
func generateKekget() string {
rand.Seed(time.Now().UnixNano())
// Generate random length between 1 and 20
length := rand.Intn(20) + 1
var text strings.Builder
for i := 0; i < length; i++ {
if rand.Float64() > 0.5 {
text.WriteString("K")
} else {
text.WriteString("E")
}
}
result := text.String()
// Check for special patterns
switch result {
case "KEK":
return fmt.Sprintf("%s\nYOU WIN TOPKEK!!!", result)
case "KKK":
return fmt.Sprintf("%s\nYOU WIN TOPKKK HEIL HITLER!!!", result)
case "KEKKEK":
return fmt.Sprintf("%s\nYOU WIN DOUBLE TOPKEKKEK!!!", result)
case "KEKKEKKEK":
return fmt.Sprintf("%s\nYOU WIN ULTIMATE TRIPLE TOPKEKKEKKEK!!!", result)
case "KEKKEKKEKKEK":
return fmt.Sprintf("%s\nQUADDRUPPLE TOPKEKKEKKEKKEK!!! YOU ARE GAY!!!", result)
case "KEKKEKKEKKEKKEK":
return fmt.Sprintf("%s\nQUINTUPLE TOPKEKKEKKEKKEKKEK!!! UNBELIEVABLE M8!!!", result)
default:
return fmt.Sprintf("%s\nLength: %d", result, len(result))
}
}
func init() {
Register(KekgetCommand{})
}

24
commands/lenny.go Normal file
View File

@ -0,0 +1,24 @@
package commands
import (
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type LennyCommand struct{}
func (l LennyCommand) Name() string {
return "lenny"
}
func (l LennyCommand) Help() string {
return "( ͡° ͜ʖ ͡°) Usage: /lenny"
}
func (l LennyCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) {
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "( ͡° ͜ʖ ͡°)")
bot.Send(msg)
}
func init() {
Register(LennyCommand{})
}

740
commands/medchem.go Normal file
View File

@ -0,0 +1,740 @@
package commands
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type MedchemCommand struct{}
func (m MedchemCommand) Name() string {
return "medchem"
}
func (m MedchemCommand) Help() string {
return "Get comprehensive medicinal chemistry properties for compounds. Usage: /medchem <compound name>"
}
// PubChem JSON structures
type PubChemCompound struct {
ID struct {
ID struct {
CID int `json:"cid"`
} `json:"id"`
} `json:"id"`
Props []struct {
URN struct {
Label string `json:"label"`
Name string `json:"name,omitempty"`
DataType int `json:"datatype"`
Version string `json:"version,omitempty"`
Software string `json:"software,omitempty"`
Source string `json:"source,omitempty"`
Release string `json:"release,omitempty"`
} `json:"urn"`
Value struct {
IVal int `json:"ival,omitempty"`
FVal float64 `json:"fval,omitempty"`
SVal string `json:"sval,omitempty"`
Binary string `json:"binary,omitempty"`
} `json:"value"`
} `json:"props"`
Atoms struct {
AID []int `json:"aid"`
Element []int `json:"element"`
} `json:"atoms,omitempty"`
Bonds struct {
AID1 []int `json:"aid1"`
AID2 []int `json:"aid2"`
Order []int `json:"order"`
} `json:"bonds,omitempty"`
Count struct {
HeavyAtom int `json:"heavy_atom"`
} `json:"count,omitempty"`
}
type PubChemResponse struct {
PCCompounds []PubChemCompound `json:"PC_Compounds"`
}
type PubChemSearchResponse struct {
IdentifierList struct {
CID []int `json:"CID"`
} `json:"IdentifierList"`
}
type PubChemSynonymsResponse struct {
InformationList struct {
Information []struct {
CID int `json:"CID"`
Synonym []string `json:"Synonym"`
} `json:"Information"`
} `json:"InformationList"`
}
type CompoundData struct {
CID int
Name string
CommonNames []string // Top 3 most common names
IUPACName string
MolecularFormula string
MolecularWeight float64
ExactMass float64
XLogP float64
TPSA float64
Complexity float64
HBondDonors int
HBondAcceptors int
RotatableBonds int
InChI string
InChIKey string
CanonicalSMILES string
HeavyAtomCount int
TotalAtomCount int
BondCount int
}
type PropertyCategory int
const (
CategoryBasic PropertyCategory = iota
CategoryADME
CategoryStructure
CategoryIdentifiers
)
func (m MedchemCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) {
query := strings.TrimSpace(update.Message.CommandArguments())
if query == "" {
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "🧪 *Medchem Command*\n\nUsage: `/medchem <compound name>`\n\nExample: `/medchem aspirin`\n\nThis command provides comprehensive medicinal chemistry properties including ADME parameters, structural information, and molecular identifiers.")
msg.ParseMode = "Markdown"
bot.Send(msg)
return
}
// Send "typing" action
typingAction := tgbotapi.NewChatAction(update.Message.Chat.ID, tgbotapi.ChatTyping)
bot.Send(typingAction)
compound, err := fetchCompoundData(query)
if err != nil {
handleError(bot, update.Message.Chat.ID, err, query)
return
}
sendCompoundInfo(bot, update.Message.Chat.ID, compound, CategoryBasic)
}
func fetchCompoundData(query string) (*CompoundData, error) {
// First, search for compound to get CID
cid, err := searchCompoundCID(query)
if err != nil {
return nil, fmt.Errorf("compound not found: %v", err)
}
// Get full compound record
url := fmt.Sprintf("https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/%d/record/JSON", cid)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("network error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("PubChem API error: status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %v", err)
}
var pubchemResp PubChemResponse
if err := json.Unmarshal(body, &pubchemResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %v", err)
}
if len(pubchemResp.PCCompounds) == 0 {
return nil, fmt.Errorf("no compound data found")
}
compound := parsePubChemData(&pubchemResp.PCCompounds[0], query)
// Fetch common names/synonyms
commonNames, err := fetchCommonNames(compound.CID)
if err == nil && len(commonNames) > 0 {
compound.CommonNames = commonNames
// Use most common name for display
compound.Name = fmt.Sprintf("%s (CID %d)", commonNames[0], compound.CID)
} else {
// Fallback to original query
compound.Name = fmt.Sprintf("%s (CID %d)", query, compound.CID)
}
return compound, nil
}
func searchCompoundCID(query string) (int, error) {
url := fmt.Sprintf("https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/%s/cids/JSON", strings.ReplaceAll(query, " ", "%20"))
resp, err := http.Get(url)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return 0, fmt.Errorf("compound '%s' not found", query)
}
if resp.StatusCode != 200 {
return 0, fmt.Errorf("search failed with status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, err
}
var searchResp PubChemSearchResponse
if err := json.Unmarshal(body, &searchResp); err != nil {
return 0, err
}
if len(searchResp.IdentifierList.CID) == 0 {
return 0, fmt.Errorf("no CID found for compound")
}
return searchResp.IdentifierList.CID[0], nil
}
func fetchCommonNames(cid int) ([]string, error) {
url := fmt.Sprintf("https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/%d/synonyms/JSON", cid)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("synonyms not found")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var synonymsResp PubChemSynonymsResponse
if err := json.Unmarshal(body, &synonymsResp); err != nil {
return nil, err
}
if len(synonymsResp.InformationList.Information) == 0 {
return nil, fmt.Errorf("no synonyms found")
}
synonyms := synonymsResp.InformationList.Information[0].Synonym
return filterCommonNames(synonyms), nil
}
func filterCommonNames(synonyms []string) []string {
var commonNames []string
seen := make(map[string]bool)
// Priority filters to find the most "common" names
for _, synonym := range synonyms {
// Skip if already seen or too long/complex
if seen[synonym] || len(synonym) > 40 {
continue
}
// Convert to lowercase for filtering
lower := strings.ToLower(synonym)
// Skip very technical names
if strings.Contains(lower, "iupac") ||
strings.Contains(lower, "cas") ||
strings.Contains(lower, "einecs") ||
strings.Contains(lower, "unii") ||
strings.Contains(lower, "dtxsid") ||
strings.Contains(lower, "pubchem") ||
strings.Contains(lower, "chembl") ||
strings.Contains(lower, "zinc") ||
strings.Contains(lower, "inchi") ||
strings.Contains(lower, "smiles") ||
strings.Contains(lower, "registry") ||
len(synonym) < 3 {
continue
}
// Prefer shorter, simpler names
if len(synonym) <= 30 && !strings.Contains(synonym, "[") && !strings.Contains(synonym, "(") {
commonNames = append([]string{synonym}, commonNames...)
} else {
commonNames = append(commonNames, synonym)
}
seen[synonym] = true
// Limit to top 3
if len(commonNames) >= 3 {
break
}
}
return commonNames
}
func parsePubChemData(compound *PubChemCompound, originalName string) *CompoundData {
data := &CompoundData{
CID: compound.ID.ID.CID,
Name: originalName,
}
// Parse properties from the props array
for _, prop := range compound.Props {
label := prop.URN.Label
name := prop.URN.Name
switch {
case label == "IUPAC Name" && name == "Preferred":
data.IUPACName = prop.Value.SVal
case label == "Molecular Formula":
data.MolecularFormula = prop.Value.SVal
case label == "Molecular Weight":
if weight, err := strconv.ParseFloat(prop.Value.SVal, 64); err == nil {
data.MolecularWeight = weight
}
case label == "Mass" && name == "Exact":
if mass, err := strconv.ParseFloat(prop.Value.SVal, 64); err == nil {
data.ExactMass = mass
}
case label == "Log P" && name == "XLogP3":
data.XLogP = prop.Value.FVal
case label == "Topological" && name == "Polar Surface Area":
data.TPSA = prop.Value.FVal
case label == "Compound Complexity":
data.Complexity = prop.Value.FVal
case label == "Count" && name == "Hydrogen Bond Donor":
data.HBondDonors = prop.Value.IVal
case label == "Count" && name == "Hydrogen Bond Acceptor":
data.HBondAcceptors = prop.Value.IVal
case label == "Count" && name == "Rotatable Bond":
data.RotatableBonds = prop.Value.IVal
case label == "InChI" && name == "Standard":
data.InChI = prop.Value.SVal
case label == "InChIKey" && name == "Standard":
data.InChIKey = prop.Value.SVal
case label == "SMILES" && name == "Canonical":
data.CanonicalSMILES = prop.Value.SVal
}
}
// Get atom and bond counts
if compound.Count.HeavyAtom > 0 {
data.HeavyAtomCount = compound.Count.HeavyAtom
}
if len(compound.Atoms.AID) > 0 {
data.TotalAtomCount = len(compound.Atoms.AID)
}
if len(compound.Bonds.AID1) > 0 {
data.BondCount = len(compound.Bonds.AID1)
}
return data
}
func sendCompoundInfo(bot *tgbotapi.BotAPI, chatID int64, compound *CompoundData, category PropertyCategory) {
imageURL := fmt.Sprintf("https://pubchem.ncbi.nlm.nih.gov/image/imgsrv.fcgi?t=l&cid=%d", compound.CID)
caption := formatCompoundCaption(compound, category)
keyboard := createNavigationKeyboard(compound.CID, category)
photo := tgbotapi.NewPhoto(chatID, tgbotapi.FileURL(imageURL))
photo.Caption = caption
photo.ParseMode = "Markdown"
photo.ReplyMarkup = keyboard
bot.Send(photo)
}
func formatCompoundCaption(compound *CompoundData, category PropertyCategory) string {
switch category {
case CategoryBasic:
return formatBasicInfo(compound)
case CategoryADME:
return formatADMEInfo(compound)
case CategoryStructure:
return formatStructureInfo(compound)
case CategoryIdentifiers:
return formatIdentifiersInfo(compound)
default:
return formatBasicInfo(compound)
}
}
func formatBasicInfo(c *CompoundData) string {
b := &strings.Builder{}
fmt.Fprintf(b, "🧪 *%s*\n", c.Name)
fmt.Fprintf(b, "📋 *Basic Properties*\n\n")
// Show common names first
if len(c.CommonNames) > 0 {
fmt.Fprintf(b, "*Common Names:*\n")
for i, name := range c.CommonNames {
if i >= 3 { // Limit to 3
break
}
fmt.Fprintf(b, "• %s\n", name)
}
fmt.Fprintf(b, "\n")
}
if c.IUPACName != "" {
fmt.Fprintf(b, "*IUPAC Name:* %s\n\n", c.IUPACName)
}
if c.MolecularFormula != "" {
fmt.Fprintf(b, "*Formula:* `%s`\n", c.MolecularFormula)
}
if c.MolecularWeight > 0 {
fmt.Fprintf(b, "*Molecular Weight:* %.2f g/mol\n", c.MolecularWeight)
}
if c.ExactMass > 0 {
fmt.Fprintf(b, "*Exact Mass:* %.6f\n", c.ExactMass)
}
fmt.Fprintf(b, "\n🔗 [PubChem](%s)",
fmt.Sprintf("https://pubchem.ncbi.nlm.nih.gov/compound/%d", c.CID))
return b.String()
}
func formatADMEInfo(c *CompoundData) string {
b := &strings.Builder{}
fmt.Fprintf(b, "🧪 *%s*\n", c.Name)
fmt.Fprintf(b, "💊 *ADME Properties*\n\n")
if c.XLogP != 0 {
fmt.Fprintf(b, "*XLogP:* %.2f\n", c.XLogP)
fmt.Fprintf(b, "├ Lipophilicity indicator\n")
}
if c.TPSA > 0 {
fmt.Fprintf(b, "*TPSA:* %.1f Ų\n", c.TPSA)
fmt.Fprintf(b, "├ Topological Polar Surface Area\n")
}
if c.HBondDonors >= 0 {
fmt.Fprintf(b, "*H-bond Donors:* %d\n", c.HBondDonors)
}
if c.HBondAcceptors >= 0 {
fmt.Fprintf(b, "*H-bond Acceptors:* %d\n", c.HBondAcceptors)
}
if c.RotatableBonds >= 0 {
fmt.Fprintf(b, "*Rotatable Bonds:* %d\n", c.RotatableBonds)
fmt.Fprintf(b, "├ Flexibility indicator\n")
}
// Lipinski's Rule of Five analysis
fmt.Fprintf(b, "\n📊 *Lipinski's Rule of Five:*\n")
violations := 0
if c.MolecularWeight > 500 {
violations++
fmt.Fprintf(b, "❌ MW > 500\n")
} else {
fmt.Fprintf(b, "✅ MW ≤ 500\n")
}
if c.XLogP > 5 {
violations++
fmt.Fprintf(b, "❌ XLogP > 5\n")
} else {
fmt.Fprintf(b, "✅ XLogP ≤ 5\n")
}
if c.HBondDonors > 5 {
violations++
fmt.Fprintf(b, "❌ HBD > 5\n")
} else {
fmt.Fprintf(b, "✅ HBD ≤ 5\n")
}
if c.HBondAcceptors > 10 {
violations++
fmt.Fprintf(b, "❌ HBA > 10\n")
} else {
fmt.Fprintf(b, "✅ HBA ≤ 10\n")
}
if violations == 0 {
fmt.Fprintf(b, "\n🎯 *Drug-like* (0 violations)")
} else {
fmt.Fprintf(b, "\n⚠ *%d violation(s)*", violations)
}
return b.String()
}
func formatStructureInfo(c *CompoundData) string {
b := &strings.Builder{}
fmt.Fprintf(b, "🧪 *%s*\n", c.Name)
fmt.Fprintf(b, "🏗️ *Structural Properties*\n\n")
if c.Complexity > 0 {
fmt.Fprintf(b, "*Complexity:* %.0f\n", c.Complexity)
fmt.Fprintf(b, "├ Structural complexity score\n")
}
if c.HeavyAtomCount > 0 {
fmt.Fprintf(b, "*Heavy Atoms:* %d\n", c.HeavyAtomCount)
}
if c.TotalAtomCount > 0 {
fmt.Fprintf(b, "*Total Atoms:* %d\n", c.TotalAtomCount)
}
if c.BondCount > 0 {
fmt.Fprintf(b, "*Bonds:* %d\n", c.BondCount)
}
if c.RotatableBonds >= 0 {
fmt.Fprintf(b, "*Rotatable Bonds:* %d\n", c.RotatableBonds)
}
// Structural complexity assessment
if c.Complexity > 0 {
fmt.Fprintf(b, "\n📈 *Complexity Assessment:*\n")
if c.Complexity < 100 {
fmt.Fprintf(b, "🟢 Simple structure")
} else if c.Complexity < 300 {
fmt.Fprintf(b, "🟡 Moderate complexity")
} else if c.Complexity < 500 {
fmt.Fprintf(b, "🟠 Complex structure")
} else {
fmt.Fprintf(b, "🔴 Highly complex")
}
}
return b.String()
}
func formatIdentifiersInfo(c *CompoundData) string {
b := &strings.Builder{}
fmt.Fprintf(b, "🧪 *%s*\n", c.Name)
fmt.Fprintf(b, "🏷️ *Chemical Identifiers*\n\n")
fmt.Fprintf(b, "*CID:* `%d`\n", c.CID)
if c.InChIKey != "" {
fmt.Fprintf(b, "*InChIKey:*\n`%s`\n\n", c.InChIKey)
}
if c.CanonicalSMILES != "" {
fmt.Fprintf(b, "*SMILES:*\n`%s`\n\n", c.CanonicalSMILES)
}
if c.InChI != "" {
// Truncate InChI if too long
inchi := c.InChI
if len(inchi) > 200 {
inchi = inchi[:197] + "..."
}
fmt.Fprintf(b, "*InChI:*\n`%s`", inchi)
}
return b.String()
}
func createNavigationKeyboard(cid int, currentCategory PropertyCategory) tgbotapi.InlineKeyboardMarkup {
var buttons [][]tgbotapi.InlineKeyboardButton
// Category buttons
categoryRow := []tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData(getButtonText("📋", currentCategory == CategoryBasic), fmt.Sprintf("medchem:%d:basic", cid)),
tgbotapi.NewInlineKeyboardButtonData(getButtonText("💊", currentCategory == CategoryADME), fmt.Sprintf("medchem:%d:adme", cid)),
}
buttons = append(buttons, categoryRow)
categoryRow2 := []tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData(getButtonText("🏗️", currentCategory == CategoryStructure), fmt.Sprintf("medchem:%d:structure", cid)),
tgbotapi.NewInlineKeyboardButtonData(getButtonText("🏷️", currentCategory == CategoryIdentifiers), fmt.Sprintf("medchem:%d:identifiers", cid)),
}
buttons = append(buttons, categoryRow2)
return tgbotapi.NewInlineKeyboardMarkup(buttons...)
}
func getButtonText(emoji string, isActive bool) string {
if isActive {
return emoji + " ●"
}
return emoji
}
func handleError(bot *tgbotapi.BotAPI, chatID int64, err error, query string) {
var message string
if strings.Contains(err.Error(), "not found") {
// Try to get suggestions
suggestions := getSuggestions(query)
if len(suggestions) > 0 {
message = fmt.Sprintf("❌ Compound '%s' not found.\n\n💡 *Did you mean:*\n%s",
query, strings.Join(suggestions, "\n"))
} else {
message = fmt.Sprintf("❌ Compound '%s' not found.\n\n💡 *Try:*\n• Check spelling\n• Use common name or IUPAC name\n• Try synonyms (e.g., 'aspirin' instead of 'acetylsalicylic acid')", query)
}
} else if strings.Contains(err.Error(), "network") {
message = "🌐 Network error. Please try again later."
} else {
message = "⚠️ An error occurred while fetching compound data. Please try again."
}
msg := tgbotapi.NewMessage(chatID, message)
msg.ParseMode = "Markdown"
bot.Send(msg)
}
func getSuggestions(query string) []string {
url := fmt.Sprintf("https://pubchem.ncbi.nlm.nih.gov/rest/autocomplete/compound/%s/json?limit=3",
strings.ReplaceAll(query, " ", "%20"))
resp, err := http.Get(url)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil
}
var data struct {
Dictionary struct {
Terms []string `json:"terms"`
} `json:"dictionary"`
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil
}
if err := json.Unmarshal(body, &data); err != nil {
return nil
}
var suggestions []string
for _, term := range data.Dictionary.Terms {
suggestions = append(suggestions, "• "+term)
}
return suggestions
}
// HandleCallback handles inline keyboard button presses for medchem
func (m MedchemCommand) HandleCallback(update tgbotapi.Update, bot *tgbotapi.BotAPI, params []string) {
if len(params) < 2 {
log.Printf("Invalid medchem callback params: %v", params)
return
}
cidStr := params[0]
categoryStr := params[1]
cid, err := strconv.Atoi(cidStr)
if err != nil {
log.Printf("Invalid CID in callback: %s", cidStr)
return
}
var category PropertyCategory
switch categoryStr {
case "basic":
category = CategoryBasic
case "adme":
category = CategoryADME
case "structure":
category = CategoryStructure
case "identifiers":
category = CategoryIdentifiers
default:
log.Printf("Invalid category in callback: %s", categoryStr)
return
}
// Get compound data by CID
compound, err := fetchCompoundDataByCID(cid)
if err != nil {
log.Printf("Error fetching compound data for CID %d: %v", cid, err)
// Send error message
callback := tgbotapi.NewCallback(update.CallbackQuery.ID, "Error loading compound data")
bot.Request(callback)
return
}
// Edit the message caption and keyboard
caption := formatCompoundCaption(compound, category)
keyboard := createNavigationKeyboard(compound.CID, category)
editCaption := tgbotapi.NewEditMessageCaption(
update.CallbackQuery.Message.Chat.ID,
update.CallbackQuery.Message.MessageID,
caption,
)
editCaption.ParseMode = "Markdown"
editCaption.ReplyMarkup = &keyboard
if _, err := bot.Send(editCaption); err != nil {
log.Printf("Error editing message caption: %v", err)
}
}
// fetchCompoundDataByCID fetches compound data directly by CID
func fetchCompoundDataByCID(cid int) (*CompoundData, error) {
url := fmt.Sprintf("https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/%d/record/JSON", cid)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("network error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("PubChem API error: status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %v", err)
}
var pubchemResp PubChemResponse
if err := json.Unmarshal(body, &pubchemResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %v", err)
}
if len(pubchemResp.PCCompounds) == 0 {
return nil, fmt.Errorf("no compound data found")
}
compound := parsePubChemData(&pubchemResp.PCCompounds[0], "")
// Fetch common names/synonyms
commonNames, err := fetchCommonNames(cid)
if err == nil && len(commonNames) > 0 {
compound.CommonNames = commonNames
// Use most common name for display
compound.Name = fmt.Sprintf("%s (CID %d)", commonNames[0], cid)
} else if compound.IUPACName != "" {
// Fallback to IUPAC name, truncate if too long
name := compound.IUPACName
if len(name) > 50 {
name = name[:47] + "..."
}
compound.Name = fmt.Sprintf("%s (CID %d)", name, cid)
} else {
compound.Name = fmt.Sprintf("CID %d", cid)
}
return compound, nil
}
func init() {
Register(MedchemCommand{})
}

62
commands/mol.go Normal file
View File

@ -0,0 +1,62 @@
package commands
import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type MolCommand struct{}
func (m MolCommand) Name() string {
return "mol"
}
func (m MolCommand) Help() string {
return "2D molecular structure. Example usage: /mol tetraethylgermane"
}
func (m MolCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) {
args := strings.TrimSpace(update.Message.CommandArguments())
if args == "" {
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Input a molecule name dumbass")
bot.Send(msg)
return
}
cid, err := fetchPubchemCID(args)
if err != nil {
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Structure not found, maybe try the superior /medchem command")
bot.Send(msg)
return
}
imgURL := fmt.Sprintf("https://pubchem.ncbi.nlm.nih.gov/image/imgsrv.fcgi?t=l&cid=%s", cid)
photo := tgbotapi.NewPhoto(update.Message.Chat.ID, tgbotapi.FileURL(imgURL))
photo.Caption = args + "\nIf you want more info, try the /medchem command instead"
bot.Send(photo)
}
func fetchPubchemCID(compound string) (string, error) {
apiURL := "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/" + url.QueryEscape(compound) + "/cids/TXT"
resp, err := http.Get(apiURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
cid := strings.TrimSpace(string(body))
if cid == "" || strings.Contains(cid, "Status:") {
return "", fmt.Errorf("CID not found")
}
return cid, nil
}
func init() {
Register(MolCommand{})
}

549
commands/osrs.go Normal file
View File

@ -0,0 +1,549 @@
package commands
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type OSRSCommand struct{}
func (o OSRSCommand) Name() string {
return "osrs"
}
func (o OSRSCommand) Help() string {
return "Get Old School RuneScape player stats with interactive navigation. Usage: /osrs <username>"
}
// OSRS JSON API structures
type OSRSResponse struct {
Skills []OSRSSkill `json:"skills"`
Activities []OSRSActivity `json:"activities"`
}
type OSRSSkill struct {
ID int `json:"id"`
Name string `json:"name"`
Rank int `json:"rank"`
Level int `json:"level"`
XP int64 `json:"xp"`
}
type OSRSActivity struct {
ID int `json:"id"`
Name string `json:"name"`
Rank int `json:"rank"`
Score int64 `json:"score"`
}
type OSRSStats struct {
Username string
AccountType string
CombatLevel int
TotalLevel int
TotalXP int64
Skills map[string]OSRSSkill
Activities map[string]OSRSActivity
}
type StatsCategory int
const (
CategoryOverview StatsCategory = iota
CategoryCombat
CategorySkilling
CategoryActivities
)
func (o OSRSCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) {
args := strings.TrimSpace(update.Message.CommandArguments())
if args == "" {
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "⚔️ *OSRS Stats Command*\n\nUsage: `/osrs <username>`\n\nExample: `/osrs Zezima`\n\nThis command shows Old School RuneScape player statistics with interactive navigation through different skill categories.")
msg.ParseMode = "Markdown"
bot.Send(msg)
return
}
// Send "typing" action
typingAction := tgbotapi.NewChatAction(update.Message.Chat.ID, tgbotapi.ChatTyping)
bot.Send(typingAction)
stats, err := fetchOSRSStats(args, "normal")
if err != nil {
handleOSRSError(bot, update.Message.Chat.ID, err, args)
return
}
sendOSRSStats(bot, update.Message.Chat.ID, stats, CategoryOverview)
}
func fetchOSRSStats(username, accountType string) (*OSRSStats, error) {
var url string
switch accountType {
case "ironman":
url = fmt.Sprintf("https://secure.runescape.com/m=hiscore_oldschool_ironman/index_lite.json?player=%s", username)
case "hardcore":
url = fmt.Sprintf("https://secure.runescape.com/m=hiscore_oldschool_hardcore_ironman/index_lite.json?player=%s", username)
case "ultimate":
url = fmt.Sprintf("https://secure.runescape.com/m=hiscore_oldschool_ultimate/index_lite.json?player=%s", username)
default: // normal
url = fmt.Sprintf("https://secure.runescape.com/m=hiscore_oldschool/index_lite.json?player=%s", username)
}
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("network error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, fmt.Errorf("player '%s' not found", username)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("OSRS API error: status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %v", err)
}
var osrsResp OSRSResponse
if err := json.Unmarshal(body, &osrsResp); err != nil {
return nil, fmt.Errorf("failed to parse JSON response: %v", err)
}
return parseOSRSStats(&osrsResp, username, accountType)
}
func parseOSRSStats(resp *OSRSResponse, username, accountType string) (*OSRSStats, error) {
stats := &OSRSStats{
Username: username,
AccountType: accountType,
Skills: make(map[string]OSRSSkill),
Activities: make(map[string]OSRSActivity),
}
// Parse skills
for _, skill := range resp.Skills {
stats.Skills[skill.Name] = skill
if skill.Name == "Overall" {
stats.TotalLevel = skill.Level
stats.TotalXP = skill.XP
}
}
// Parse activities
for _, activity := range resp.Activities {
stats.Activities[activity.Name] = activity
}
// Calculate combat level
stats.CombatLevel = calculateCombatLevel(stats.Skills)
return stats, nil
}
func calculateCombatLevel(skills map[string]OSRSSkill) int {
att := float64(getSkillLevel(skills, "Attack"))
def := float64(getSkillLevel(skills, "Defence"))
str := float64(getSkillLevel(skills, "Strength"))
hp := float64(getSkillLevel(skills, "Hitpoints"))
pray := float64(getSkillLevel(skills, "Prayer"))
rang := float64(getSkillLevel(skills, "Ranged"))
mage := float64(getSkillLevel(skills, "Magic"))
base := 0.25 * (def + hp + (pray / 2))
melee := 0.325 * (att + str)
ranged := 0.325 * (rang + (rang / 2))
magic := 0.325 * (mage + (mage / 2))
var max float64
if melee >= ranged && melee >= magic {
max = melee
} else if ranged >= magic {
max = ranged
} else {
max = magic
}
return int(base + max)
}
func getSkillLevel(skills map[string]OSRSSkill, skillName string) int {
if skill, exists := skills[skillName]; exists {
return skill.Level
}
return 1 // Default level
}
func sendOSRSStats(bot *tgbotapi.BotAPI, chatID int64, stats *OSRSStats, category StatsCategory) {
caption := formatOSRSCaption(stats, category)
keyboard := createOSRSKeyboard(stats.Username, stats.AccountType, category)
// Use OSRS logo
imageURL := "https://oldschool.runescape.wiki/images/thumb/b/b2/Old_School_RuneScape_logo.png/300px-Old_School_RuneScape_logo.png"
photo := tgbotapi.NewPhoto(chatID, tgbotapi.FileURL(imageURL))
photo.Caption = caption
photo.ParseMode = "Markdown"
photo.ReplyMarkup = keyboard
bot.Send(photo)
}
func formatOSRSCaption(stats *OSRSStats, category StatsCategory) string {
switch category {
case CategoryOverview:
return formatOverview(stats)
case CategoryCombat:
return formatCombatStats(stats)
case CategorySkilling:
return formatSkillingStats(stats)
case CategoryActivities:
return formatActivities(stats)
default:
return formatOverview(stats)
}
}
func formatOverview(stats *OSRSStats) string {
b := &strings.Builder{}
fmt.Fprintf(b, "⚔️ *%s*", stats.Username)
if stats.AccountType != "normal" {
accountTypeEmoji := map[string]string{
"ironman": "⚫",
"hardcore": "🔴",
"ultimate": "⚪",
}
emoji := accountTypeEmoji[stats.AccountType]
fmt.Fprintf(b, " %s %s", emoji, strings.Title(stats.AccountType))
}
fmt.Fprintf(b, "\n📊 *Overview*\n\n")
fmt.Fprintf(b, "*Combat Level:* %d\n", stats.CombatLevel)
fmt.Fprintf(b, "*Total Level:* %s\n", formatNumber(stats.TotalLevel))
fmt.Fprintf(b, "*Total XP:* %s\n", formatNumber(int(stats.TotalXP)))
// Show overall rank if available
if overallSkill, exists := stats.Skills["Overall"]; exists && overallSkill.Rank > 0 {
fmt.Fprintf(b, "*Overall Rank:* %d\n", overallSkill.Rank)
}
fmt.Fprintf(b, "\n")
// Show top 5 skills by XP
var skillList []OSRSSkill
for name, skill := range stats.Skills {
if name != "Overall" && skill.Level > 1 {
skillList = append(skillList, skill)
}
}
// Sort by XP (bubble sort for simplicity)
for i := 0; i < len(skillList)-1; i++ {
for j := i + 1; j < len(skillList); j++ {
if skillList[j].XP > skillList[i].XP {
skillList[i], skillList[j] = skillList[j], skillList[i]
}
}
}
fmt.Fprintf(b, "*Top Skills:*\n")
maxSkills := 5
if len(skillList) < 5 {
maxSkills = len(skillList)
}
for i := 0; i < maxSkills; i++ {
skill := skillList[i]
fmt.Fprintf(b, "%d. *%s* - Level %d (%s XP)",
i+1, skill.Name, skill.Level, formatNumber(int(skill.XP)))
if skill.Rank > 0 {
fmt.Fprintf(b, " - Rank: %d", skill.Rank)
}
fmt.Fprintf(b, "\n")
}
fmt.Fprintf(b, "\n🔗 [OSRS Hiscores](https://secure.runescape.com/m=hiscore_oldschool/hiscorepersonal?user1=%s)", stats.Username)
return b.String()
}
func formatCombatStats(stats *OSRSStats) string {
b := &strings.Builder{}
fmt.Fprintf(b, "⚔️ *%s*", stats.Username)
if stats.AccountType != "normal" {
accountTypeEmoji := map[string]string{
"ironman": "⚫",
"hardcore": "🔴",
"ultimate": "⚪",
}
emoji := accountTypeEmoji[stats.AccountType]
fmt.Fprintf(b, " %s %s", emoji, strings.Title(stats.AccountType))
}
fmt.Fprintf(b, "\n⚔ *Combat Stats*\n\n")
fmt.Fprintf(b, "*Combat Level:* %d\n\n", stats.CombatLevel)
combatSkillNames := []string{"Attack", "Strength", "Defence", "Hitpoints", "Ranged", "Prayer", "Magic"}
// Collect combat skills that exist
var combatSkills []OSRSSkill
for _, skillName := range combatSkillNames {
if skill, exists := stats.Skills[skillName]; exists {
combatSkills = append(combatSkills, skill)
}
}
// Sort by XP descending
for i := 0; i < len(combatSkills)-1; i++ {
for j := i + 1; j < len(combatSkills); j++ {
if combatSkills[j].XP > combatSkills[i].XP {
combatSkills[i], combatSkills[j] = combatSkills[j], combatSkills[i]
}
}
}
// Display combat stats sorted by XP
for _, skill := range combatSkills {
fmt.Fprintf(b, "*%s:* Level %d\n", skill.Name, skill.Level)
fmt.Fprintf(b, "└ XP: %s", formatNumber(int(skill.XP)))
if skill.Rank > 0 {
fmt.Fprintf(b, " (Rank: %d)", skill.Rank)
}
fmt.Fprintf(b, "\n")
}
return b.String()
}
func formatSkillingStats(stats *OSRSStats) string {
b := &strings.Builder{}
fmt.Fprintf(b, "⚔️ *%s*", stats.Username)
if stats.AccountType != "normal" {
accountTypeEmoji := map[string]string{
"ironman": "⚫",
"hardcore": "🔴",
"ultimate": "⚪",
}
emoji := accountTypeEmoji[stats.AccountType]
fmt.Fprintf(b, " %s %s", emoji, strings.Title(stats.AccountType))
}
fmt.Fprintf(b, "\n🔨 *Skilling Stats*\n\n")
skillingSkillNames := []string{
"Woodcutting", "Fishing", "Mining", "Cooking", "Firemaking",
"Crafting", "Smithing", "Fletching", "Herblore", "Agility",
"Thieving", "Slayer", "Farming", "Runecraft", "Hunter", "Construction",
}
// Collect skilling skills that exist and have level > 1
var skillingSkills []OSRSSkill
for _, skillName := range skillingSkillNames {
if skill, exists := stats.Skills[skillName]; exists && skill.Level > 1 {
skillingSkills = append(skillingSkills, skill)
}
}
// Sort by XP descending
for i := 0; i < len(skillingSkills)-1; i++ {
for j := i + 1; j < len(skillingSkills); j++ {
if skillingSkills[j].XP > skillingSkills[i].XP {
skillingSkills[i], skillingSkills[j] = skillingSkills[j], skillingSkills[i]
}
}
}
// Display in the same format as combat stats
for _, skill := range skillingSkills {
fmt.Fprintf(b, "*%s:* Level %d\n", skill.Name, skill.Level)
fmt.Fprintf(b, "└ XP: %s", formatNumber(int(skill.XP)))
if skill.Rank > 0 {
fmt.Fprintf(b, " (Rank: %d)", skill.Rank)
}
fmt.Fprintf(b, "\n")
}
return b.String()
}
func formatActivities(stats *OSRSStats) string {
b := &strings.Builder{}
fmt.Fprintf(b, "⚔️ *%s*", stats.Username)
if stats.AccountType != "normal" {
accountTypeEmoji := map[string]string{
"ironman": "⚫",
"hardcore": "🔴",
"ultimate": "⚪",
}
emoji := accountTypeEmoji[stats.AccountType]
fmt.Fprintf(b, " %s %s", emoji, strings.Title(stats.AccountType))
}
fmt.Fprintf(b, "\n🏆 *Activities & Bosses*\n\n")
// Collect all activities with scores > 0
var activeActivities []OSRSActivity
for _, activity := range stats.Activities {
if activity.Score > 0 {
activeActivities = append(activeActivities, activity)
}
}
// Sort by score descending (highest to lowest)
for i := 0; i < len(activeActivities)-1; i++ {
for j := i + 1; j < len(activeActivities); j++ {
if activeActivities[j].Score > activeActivities[i].Score {
activeActivities[i], activeActivities[j] = activeActivities[j], activeActivities[i]
}
}
}
// Display all activities with non-zero scores
if len(activeActivities) > 0 {
for _, activity := range activeActivities {
fmt.Fprintf(b, "*%s:* %s", activity.Name, formatNumber(int(activity.Score)))
if activity.Rank > 0 {
fmt.Fprintf(b, " (Rank: %d)", activity.Rank)
}
fmt.Fprintf(b, "\n")
}
} else {
fmt.Fprintf(b, "No activities found.\nStart bossing to see stats here! 💪")
}
return b.String()
}
func formatNumber(n int) string {
if n >= 1000000 {
return fmt.Sprintf("%.2fM", float64(n)/1000000)
} else if n >= 1000 {
return fmt.Sprintf("%.1fK", float64(n)/1000)
}
return fmt.Sprintf("%d", n)
}
func createOSRSKeyboard(username, accountType string, currentCategory StatsCategory) tgbotapi.InlineKeyboardMarkup {
var buttons [][]tgbotapi.InlineKeyboardButton
// Category buttons
categoryRow1 := []tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData(getOSRSButtonText("📊", currentCategory == CategoryOverview), fmt.Sprintf("osrs:%s:%s:overview", username, accountType)),
tgbotapi.NewInlineKeyboardButtonData(getOSRSButtonText("⚔️", currentCategory == CategoryCombat), fmt.Sprintf("osrs:%s:%s:combat", username, accountType)),
}
buttons = append(buttons, categoryRow1)
categoryRow2 := []tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData(getOSRSButtonText("🔨", currentCategory == CategorySkilling), fmt.Sprintf("osrs:%s:%s:skilling", username, accountType)),
tgbotapi.NewInlineKeyboardButtonData(getOSRSButtonText("🏆", currentCategory == CategoryActivities), fmt.Sprintf("osrs:%s:%s:activities", username, accountType)),
}
buttons = append(buttons, categoryRow2)
// Account type buttons
if accountType == "normal" {
accountRow := []tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("⚫ Ironman", fmt.Sprintf("osrs:%s:ironman:overview", username)),
tgbotapi.NewInlineKeyboardButtonData("🔴 Hardcore", fmt.Sprintf("osrs:%s:hardcore:overview", username)),
}
buttons = append(buttons, accountRow)
ultimateRow := []tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("⚪ Ultimate", fmt.Sprintf("osrs:%s:ultimate:overview", username)),
}
buttons = append(buttons, ultimateRow)
} else {
// Return to normal account button
normalRow := []tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("🔙 Normal Account", fmt.Sprintf("osrs:%s:normal:overview", username)),
}
buttons = append(buttons, normalRow)
}
return tgbotapi.NewInlineKeyboardMarkup(buttons...)
}
func getOSRSButtonText(emoji string, isActive bool) string {
if isActive {
return emoji + " ●"
}
return emoji
}
func handleOSRSError(bot *tgbotapi.BotAPI, chatID int64, err error, username string) {
var message string
if strings.Contains(err.Error(), "not found") {
message = fmt.Sprintf("❌ Player '%s' not found.\n\n💡 *Tips:*\n• Check spelling\n• Username is case-sensitive\n• Player must have logged in recently\n• Try different account type (ironman, etc.)", username)
} else if strings.Contains(err.Error(), "network") {
message = "🌐 Network error. OSRS servers might be down. Try again later."
} else {
message = "⚠️ An error occurred while fetching player data. Please try again."
}
msg := tgbotapi.NewMessage(chatID, message)
msg.ParseMode = "Markdown"
bot.Send(msg)
}
// HandleCallback handles inline keyboard button presses for OSRS
func (o OSRSCommand) HandleCallback(update tgbotapi.Update, bot *tgbotapi.BotAPI, params []string) {
if len(params) < 3 {
log.Printf("Invalid OSRS callback params: %v", params)
return
}
username := params[0]
accountType := params[1]
categoryStr := params[2]
var category StatsCategory
switch categoryStr {
case "overview":
category = CategoryOverview
case "combat":
category = CategoryCombat
case "skilling":
category = CategorySkilling
case "activities":
category = CategoryActivities
default:
log.Printf("Invalid OSRS category in callback: %s", categoryStr)
return
}
// Fetch stats
stats, err := fetchOSRSStats(username, accountType)
if err != nil {
log.Printf("Error fetching OSRS stats for %s: %v", username, err)
callback := tgbotapi.NewCallback(update.CallbackQuery.ID, "Error loading player data")
bot.Request(callback)
return
}
// Edit the message caption and keyboard
caption := formatOSRSCaption(stats, category)
keyboard := createOSRSKeyboard(stats.Username, stats.AccountType, category)
editCaption := tgbotapi.NewEditMessageCaption(
update.CallbackQuery.Message.Chat.ID,
update.CallbackQuery.Message.MessageID,
caption,
)
editCaption.ParseMode = "Markdown"
editCaption.ReplyMarkup = &keyboard
if _, err := bot.Send(editCaption); err != nil {
log.Printf("Error editing OSRS message caption: %v", err)
}
}
func init() {
Register(OSRSCommand{})
}

81
commands/troll.go Normal file
View File

@ -0,0 +1,81 @@
package commands
import (
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"os"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type TrollCommand struct{}
func (t TrollCommand) Name() string {
return "troll"
}
func (t TrollCommand) Help() string {
return "Get a random troll message from the database. Usage: /troll"
}
func (t TrollCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) {
// Send "typing" action
typingAction := tgbotapi.NewChatAction(update.Message.Chat.ID, tgbotapi.ChatTyping)
bot.Send(typingAction)
trollMessage, err := getRandomTrollMessage()
if err != nil {
log.Printf("Error getting troll message: %v", err)
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "❌ Error loading troll database. Please try again later.")
bot.Send(msg)
return
}
// Limit message length for Telegram (max 4096 characters)
if len(trollMessage) > 4000 {
trollMessage = trollMessage[:3997] + "..."
}
msg := tgbotapi.NewMessage(update.Message.Chat.ID, trollMessage)
bot.Send(msg)
}
func getRandomTrollMessage() (string, error) {
// Open the troll database file
file, err := os.Open("json/trolldb.json")
if err != nil {
return "", fmt.Errorf("failed to open trolldb.json: %v", err)
}
defer file.Close()
// Read the file content
data, err := io.ReadAll(file)
if err != nil {
return "", fmt.Errorf("failed to read trolldb.json: %v", err)
}
// Parse JSON array
var trollMessages []string
if err := json.Unmarshal(data, &trollMessages); err != nil {
return "", fmt.Errorf("failed to parse trolldb.json: %v", err)
}
if len(trollMessages) == 0 {
return "", fmt.Errorf("troll database is empty")
}
// Seed random number generator
rand.Seed(time.Now().UnixNano())
// Pick a random message
randomIndex := rand.Intn(len(trollMessages))
return trollMessages[randomIndex], nil
}
func init() {
Register(TrollCommand{})
}

133
commands/xkcd.go Normal file
View File

@ -0,0 +1,133 @@
package commands
import (
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type XkcdCommand struct{}
type XkcdComic struct {
Num int `json:"num"`
Title string `json:"title"`
SafeTitle string `json:"safe_title"`
Img string `json:"img"`
Alt string `json:"alt"`
Transcript string `json:"transcript"`
Link string `json:"link"`
News string `json:"news"`
Year string `json:"year"`
Month string `json:"month"`
Day string `json:"day"`
}
func (x XkcdCommand) Name() string {
return "xkcd"
}
func (x XkcdCommand) Help() string {
return "Get a random XKCD comic. Usage: /xkcd"
}
func (x XkcdCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) {
// Send "typing" action
typingAction := tgbotapi.NewChatAction(update.Message.Chat.ID, tgbotapi.ChatTyping)
bot.Send(typingAction)
comic, err := getRandomXkcdComic()
if err != nil {
log.Printf("Error getting XKCD comic: %v", err)
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "❌ Error fetching XKCD comic. Please try again later.")
bot.Send(msg)
return
}
// Create caption
caption := createXkcdCaption(comic)
// Send photo with caption
photo := tgbotapi.NewPhoto(update.Message.Chat.ID, tgbotapi.FileURL(comic.Img))
photo.Caption = caption
bot.Send(photo)
}
func getRandomXkcdComic() (*XkcdComic, error) {
// First get the latest comic to know the range
resp, err := http.Get("https://xkcd.com/info.0.json")
if err != nil {
return nil, fmt.Errorf("failed to get latest comic info: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("XKCD API returned status: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %v", err)
}
var latest XkcdComic
if err := json.Unmarshal(body, &latest); err != nil {
return nil, fmt.Errorf("failed to parse latest comic: %v", err)
}
// Generate random comic number (avoiding 404 which doesn't exist)
rand.Seed(time.Now().UnixNano())
var randomNum int
for {
randomNum = rand.Intn(latest.Num) + 1
if randomNum != 404 { // Comic 404 doesn't exist
break
}
}
// Get the random comic
url := fmt.Sprintf("https://xkcd.com/%d/info.0.json", randomNum)
resp, err = http.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to get comic %d: %v", randomNum, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("comic %d returned status: %d", randomNum, resp.StatusCode)
}
body, err = io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read comic response: %v", err)
}
var comic XkcdComic
if err := json.Unmarshal(body, &comic); err != nil {
return nil, fmt.Errorf("failed to parse comic: %v", err)
}
return &comic, nil
}
func createXkcdCaption(comic *XkcdComic) string {
url := fmt.Sprintf("https://xkcd.com/%d/", comic.Num)
// Try to include alt text if caption isn't too long
fullCaption := fmt.Sprintf("%s\n\n%s\n\n%s", comic.Title, comic.Alt, url)
if len(fullCaption) <= 1000 { // Keep it reasonable for Telegram
return fullCaption
}
// Fallback to just title and URL if too long
return fmt.Sprintf("%s\n\n%s", comic.Title, url)
}
func init() {
Register(XkcdCommand{})
}

15
go.mod
View File

@ -3,3 +3,18 @@ module nignoggobot
go 1.24.3
require github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
require (
codeberg.org/go-fonts/liberation v0.5.0 // indirect
codeberg.org/go-latex/latex v0.1.0 // indirect
codeberg.org/go-pdf/fpdf v0.10.0 // indirect
git.sr.ht/~sbinet/gg v0.6.0 // indirect
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b // indirect
github.com/campoy/embedmd v1.0.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/image v0.25.0 // indirect
golang.org/x/text v0.23.0 // indirect
gonum.org/v1/plot v0.16.0 // indirect
rsc.io/pdf v0.1.1 // indirect
)

51
go.sum
View File

@ -1,2 +1,53 @@
codeberg.org/go-fonts/liberation v0.5.0 h1:SsKoMO1v1OZmzkG2DY+7ZkCL9U+rrWI09niOLfQ5Bo0=
codeberg.org/go-fonts/liberation v0.5.0/go.mod h1:zS/2e1354/mJ4pGzIIaEtm/59VFCFnYC7YV6YdGl5GU=
codeberg.org/go-latex/latex v0.1.0 h1:hoGO86rIbWVyjtlDLzCqZPjNykpWQ9YuTZqAzPcfL3c=
codeberg.org/go-latex/latex v0.1.0/go.mod h1:LA0q/AyWIYrqVd+A9Upkgsb+IqPcmSTKc9Dny04MHMw=
codeberg.org/go-pdf/fpdf v0.10.0 h1:u+w669foDDx5Ds43mpiiayp40Ov6sZalgcPMDBcZRd4=
codeberg.org/go-pdf/fpdf v0.10.0/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU=
git.sr.ht/~sbinet/gg v0.6.0 h1:RIzgkizAk+9r7uPzf/VfbJHBMKUr0F5hRFxTUGMnt38=
git.sr.ht/~sbinet/gg v0.6.0/go.mod h1:uucygbfC9wVPQIfrmwM2et0imr8L7KQWywX0xpFMm94=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY=
github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/plot v0.16.0 h1:dK28Qx/Ky4VmPUN/2zeW0ELyM6ucDnBAj5yun7M9n1g=
gonum.org/v1/plot v0.16.0/go.mod h1:Xz6U1yDMi6Ni6aaXILqmVIb6Vro8E+K7Q/GeeH+Pn0c=
honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

98
main.go
View File

@ -1,17 +1,96 @@
package main
import (
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"nignoggobot/commands"
"nignoggobot/metrics"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
_ "nignoggobot/commands" // ensure init() is called
)
var (
devChannelID int64 = -1001327903329
AdminIDs = []int64{126131628}
)
func notifyShutdown(reason string) {
token := os.Getenv("TELEGRAM_TOKEN")
bot, err := tgbotapi.NewBotAPI(token)
if err != nil {
log.Println("Failed to create bot for shutdown notification:", err)
return
}
msg := tgbotapi.NewMessage(devChannelID, "Bot shutting down/crashed: "+reason)
bot.Send(msg)
}
func handleCallbackQuery(update tgbotapi.Update, bot *tgbotapi.BotAPI) {
query := update.CallbackQuery
// Answer the callback query to remove the loading state
callback := tgbotapi.NewCallback(query.ID, "")
bot.Request(callback)
// Parse callback data format: "command:param1:param2:..."
parts := strings.Split(query.Data, ":")
if len(parts) < 2 {
log.Printf("Invalid callback data format: %s", query.Data)
return
}
commandName := parts[0]
switch commandName {
case "medchem":
// Get the medchem command and handle callback
if cmd, ok := commands.All()["medchem"]; ok {
if medchemCmd, ok := cmd.(interface {
HandleCallback(tgbotapi.Update, *tgbotapi.BotAPI, []string)
}); ok {
medchemCmd.HandleCallback(update, bot, parts[1:])
}
}
case "osrs":
// Get the OSRS command and handle callback
if cmd, ok := commands.All()["osrs"]; ok {
if osrsCmd, ok := cmd.(interface {
HandleCallback(tgbotapi.Update, *tgbotapi.BotAPI, []string)
}); ok {
osrsCmd.HandleCallback(update, bot, parts[1:])
}
}
default:
log.Printf("Unknown callback command: %s", commandName)
}
}
func main() {
// Signal handling for SIGINT/SIGTERM
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigs
notifyShutdown(fmt.Sprintf("Received signal: %v", sig))
os.Exit(0)
}()
defer func() {
if r := recover(); r != nil {
notifyShutdown("panic: " + fmt.Sprint(r))
panic(r) // re-throw after notifying
} else {
notifyShutdown("normal shutdown")
}
}()
bot, err := tgbotapi.NewBotAPI(os.Getenv("TELEGRAM_TOKEN"))
log.Println(os.Getenv("TELEGRAM_TOKEN"))
if err != nil {
@ -25,16 +104,31 @@ func main() {
updates := bot.GetUpdatesChan(u)
for update := range updates {
// Handle callback queries (inline keyboard button presses)
if update.CallbackQuery != nil {
log.Printf("Received callback query: %s from user %d", update.CallbackQuery.Data, update.CallbackQuery.From.ID)
handleCallbackQuery(update, bot)
continue
}
if update.Message == nil || !update.Message.IsCommand() {
continue
}
log.Printf("Received command: %s with args %s from chat %d (user %d)", update.Message.Command(), update.Message.CommandArguments(), 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 {
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Unknown command.")
bot.Send(msg)
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())
}
}
}