diff --git a/commands/osrs.go b/commands/osrs.go new file mode 100644 index 0000000..551d2a2 --- /dev/null +++ b/commands/osrs.go @@ -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 " +} + +// 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 `\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{}) +} diff --git a/main.go b/main.go index 047d09a..5c5a2be 100644 --- a/main.go +++ b/main.go @@ -58,6 +58,15 @@ func handleCallbackQuery(update tgbotapi.Update, bot *tgbotapi.BotAPI) { 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) }