package main import ( "errors" "fmt" "git.gammaspectra.live/P2Pool/consensus/v3/monero" "git.gammaspectra.live/P2Pool/consensus/v3/monero/address" "git.gammaspectra.live/P2Pool/consensus/v3/p2pool/sidechain" "git.gammaspectra.live/P2Pool/consensus/v3/types" "git.gammaspectra.live/P2Pool/consensus/v3/utils" "git.gammaspectra.live/P2Pool/observer-cmd-utils/index" cmdutils "git.gammaspectra.live/P2Pool/observer-cmd-utils/utils" hbot "github.com/whyrusleeping/hellabot" "regexp" "slices" "strings" "time" ) type command struct { Help string Description string Match *regexp.Regexp Handle func(db *DB, entries []*channelEntry, bot *hbot.Bot, message *hbot.Message, replyTo string, matches ...string) bool } var guestUserRegex = regexp.MustCompile("^Guest[0-9]+_*$") func isNickAllowed(nick string) error { if guestUserRegex.MatchString(nick) { return errors.New("guest user is not allowed") } return nil } func filterEntriesForChannel(bot *hbot.Bot, message *hbot.Message, entries []*channelEntry, exactMatches ...string) (result []*channelEntry) { var actualEntry *channelEntry var exactMatchesResult []*channelEntry for _, e := range entries { if e.Channel == message.To { actualEntry = e } if e.ApiEndpoint != "" && e.Name != "" && slices.ContainsFunc(exactMatches, func(s string) bool { return s == strings.ToLower(e.Name) }) { exactMatchesResult = append(exactMatchesResult, e) } if e.ApiEndpoint != "" && (message.To == bot.Nick || e.Channel == message.To) { result = append(result, e) } } if len(exactMatchesResult) > 0 { return exactMatchesResult } if len(result) == 0 && actualEntry != nil && actualEntry.ApiEndpoint == "" { for _, e := range entries { if e.ApiEndpoint != "" { result = append(result, e) } } } return result } var commands = []command{ { Help: ".last [pool name]", Description: "Displays the last time each pool found a Monero block, and other metrics.", Match: regexp.MustCompile("(?i)^\\.(last|block|lastblock|pool)[ \t]*([a-z ]+)?[ \t]*"), Handle: func(db *DB, entries []*channelEntry, bot *hbot.Bot, message *hbot.Message, replyTo string, matches ...string) bool { for _, e := range filterEntriesForChannel(bot, message, entries, strings.TrimSpace(strings.ToLower(matches[2]))) { func(e *channelEntry) { e.ChainLock.RLock() defer e.ChainLock.RUnlock() var lastFound, previous *index.FoundBlock if len(e.PreviousBlocks) > 0 { lastFound = e.PreviousBlocks[len(e.PreviousBlocks)-1] previous = e.PreviousBlocks.GetPrevious(lastFound) } poolInfo := getPoolInfo(e.ApiEndpoint) effort := float64(0) if previous != nil { effort = float64(lastFound.CumulativeDifficulty.Sub(previous.CumulativeDifficulty).Mul64(100).Lo) / float64(lastFound.MainBlock.Difficulty) } currentEffort := toFloat64(poolInfo.SideChain.Effort.Current) bot.Msg(replyTo, fmt.Sprintf( "Pool %s, last block found at height %s%d%s %s, %s UTC :: %s :: Effort %s%.02f%%%s :: %s%d miner outputs%s paid for %s%s%s XMR%s :: Current Effort %s%.02f%%%s :: Pool height %d :: Pool Hashrate %sH/s :: Global hashrate %sH/s", e.Name, FormatColorRed, lastFound.MainBlock.Height, FormatReset, TimeElapsed(lastFound.MainBlock.Timestamp), time.Unix(int64(lastFound.MainBlock.Timestamp), 0).UTC().Format(time.DateTime), GetShareLink(e.ApiEndpoint, lastFound.SideHeight, lastFound.MainBlock.Id), EffortColor(effort), effort, FormatReset, FormatColorOrange, lastFound.WindowOutputs, FormatReset, FormatColorOrange, FormatBold, utils.XMRUnits(lastFound.MainBlock.Reward), FormatReset, EffortColor(currentEffort), currentEffort, FormatReset, e.Tip.SideHeight, utils.SiUnits(float64(types.DifficultyFrom64(e.Tip.Difficulty).Div64(e.Consensus.TargetBlockTime).Lo), 2), utils.SiUnits(float64(poolInfo.MainChain.Difficulty.Div64(monero.BlockTime).Lo), 2), )) }(e) } return true }, }, { Help: ".status [pool name]", Description: "Displays your pool and share status across all subscriptions to your nick", Match: regexp.MustCompile("(?i)^\\.(status|shares)[ \t]*([a-z ]+)?[ \t]*"), Handle: func(db *DB, entries []*channelEntry, bot *hbot.Bot, message *hbot.Message, replyTo string, matches ...string) bool { subs := db.GetByNick(message.Name) if len(subs) == 0 { bot.Msg(replyTo, "No known subscriptions to your nick.") return true } type result struct { Name string Endpoint string ShareCount int UncleCount int YourWeight types.Difficulty TotalWeight types.Difficulty Tip *index.SideBlock Address *address.Address MinerId uint64 SharesPosition *cmdutils.PositionChart UnclesPosition *cmdutils.PositionChart Consensus *sidechain.Consensus } var hasResults bool var results []*result for _, e := range filterEntriesForChannel(bot, message, entries, strings.TrimSpace(strings.ToLower(matches[2]))) { func(e *channelEntry) { e.ChainLock.RLock() defer e.ChainLock.RUnlock() var tr []*result for _, sub := range subs { var r result r.Name = e.Name r.Tip = e.Tip r.Endpoint = e.ApiEndpoint r.Address = sub.Address r.Consensus = e.Consensus r.SharesPosition = cmdutils.NewPositionChart(30, uint64(e.Tip.WindowDepth)) r.UnclesPosition = cmdutils.NewPositionChart(30, uint64(e.Tip.WindowDepth)) tr = append(tr, &r) } e.IterateWindow(func(b *index.SideBlock, weight types.Difficulty) { for _, r := range tr { r.TotalWeight = r.TotalWeight.Add(weight) if b.MinerAddress.Compare(r.Address) == 0 { r.MinerId = b.Miner r.YourWeight = r.YourWeight.Add(weight) if b.IsUncle() { r.UnclesPosition.Add(int(e.Tip.SideHeight-b.SideHeight), 1) r.UncleCount++ } else { r.SharesPosition.Add(int(e.Tip.SideHeight-b.SideHeight), 1) r.ShareCount++ } } } }) for _, r := range tr { if r.ShareCount > 0 || r.UncleCount > 0 { results = append(results, r) hasResults = true } } }(e) } if !hasResults { bot.Msg(replyTo, "You do not currently have any shares within the PPLNS window across the tracked pools.") } else { for _, r := range results { ratio := float64(r.YourWeight.Lo) / float64(r.TotalWeight.Lo) bot.Msg(replyTo, fmt.Sprintf( "Pool %s, your shares %d (+%d uncles) ~%.03f%% %sH/s :: Miner Address %s%s%s :: Miner Statistics %s/m/%s :: Shares/Uncles position %s %s", r.Name, r.ShareCount, r.UncleCount, ratio*100, utils.SiUnits(ratio*float64(types.DifficultyFrom64(r.Tip.Difficulty).Div64(r.Consensus.TargetBlockTime).Lo), 2), FormatItalic, cmdutils.Shorten(string(r.Address.ToBase58()), 10), FormatReset, r.Endpoint, cmdutils.EncodeBinaryNumber(r.MinerId), r.SharesPosition.String(), r.UnclesPosition.String(), )) } } return true }, }, { Help: ".payout [pool name]", Description: "Displays your last payout details across all pools", Match: regexp.MustCompile("(?i)^\\.(payouts?|payments?|last-payments?)[ \t]*([a-z ]+)?[ \t]*"), Handle: func(db *DB, entries []*channelEntry, bot *hbot.Bot, message *hbot.Message, replyTo string, matches ...string) bool { subs := db.GetByNick(message.Name) if len(subs) == 0 { bot.Msg(replyTo, "No known subscriptions to your nick.") return true } type result struct { Name string Endpoint string Payouts []*index.Payout Address *address.Address Consensus *sidechain.Consensus } var results []*result for _, e := range filterEntriesForChannel(bot, message, entries, strings.TrimSpace(strings.ToLower(matches[2]))) { func(e *channelEntry) { for _, sub := range subs { payouts := getSliceFromAPI[*index.Payout](e.ApiEndpoint, fmt.Sprintf("/api/payouts/%s", sub.Address.ToBase58())) if len(payouts) > 0 { results = append(results, &result{ Name: e.Name, Endpoint: e.ApiEndpoint, Payouts: payouts, Address: sub.Address, Consensus: e.Consensus, }) } } }(e) } if len(results) == 0 { bot.Msg(replyTo, "You do not currently have any previous payouts across the tracked pools.") } else { for _, r := range results { bot.Msg(replyTo, fmt.Sprintf( "Pool %s, last payout for address %s%s%s was %s%s%s XMR%s on block %s%d%s %s, %s UTC :: %s :: Verify payout %s", r.Name, FormatItalic, cmdutils.Shorten(string(r.Address.ToBase58()), 10), FormatReset, FormatColorOrange, FormatBold, utils.XMRUnits(r.Payouts[0].Reward), FormatReset, FormatColorRed, r.Payouts[0].MainHeight, FormatReset, TimeElapsed(r.Payouts[0].Timestamp), time.Unix(int64(r.Payouts[0].Timestamp), 0).UTC().Format(time.DateTime), GetShareLink(r.Endpoint, r.Payouts[0].SideHeight, r.Payouts[0].MainId), GetPayoutLink(r.Endpoint, r.Payouts[0].SideHeight, r.Payouts[0].Index, r.Consensus), )) } } return true }, }, { Help: ".subscribe MONERO_ADDRESS", Description: "Subscribes your nick, while online, to shares and payouts specified by MONERO_ADDRESS. You can have multiple subscriptions.", Match: regexp.MustCompile("(?i)^\\.(sub|subscribe)[ \t]+(4[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+)[ \t]*"), Handle: func(db *DB, entries []*channelEntry, bot *hbot.Bot, message *hbot.Message, replyTo string, matches ...string) bool { if err := isNickAllowed(message.Name); err != nil { bot.Msg(replyTo, fmt.Sprintf("Cannot subscribe: %s", err)) return true } addr := address.FromBase58(matches[2]) if addr == nil { bot.Msg(replyTo, "Cannot subscribe: Invalid Monero address") return true } if err := db.Store(&Subscription{ Address: addr, Nick: message.Name, }); err == nil { bot.Msg(replyTo, fmt.Sprintf("Subscribed your nick to shares found by %s%s%s while you are online. You can private message this bot for any commands instead of using public channels.", FormatItalic, cmdutils.Shorten(string(addr.ToBase58()), 10), FormatReset)) for _, e := range entries { if e.ApiEndpoint == "" { continue } func() { e.ChainLock.RLock() defer e.ChainLock.RUnlock() var yourWeight types.Difficulty var totalWeight types.Difficulty e.IterateWindow(func(b *index.SideBlock, weight types.Difficulty) { totalWeight = totalWeight.Add(weight) if b.MinerAddress.Compare(addr) == 0 { yourWeight = yourWeight.Add(weight) } }) shareRatio := float64(yourWeight.Lo) / float64(totalWeight.Lo) if shareRatio > notificationPoolShare { //warn about spammy notifications bot.Msg(replyTo, fmt.Sprintf("You have more than %.01f%% of the %s pool total share weight (%s) with %.03f%%. Share notifications will not be sent above this threshold. Consider using the /api/events interface directly.", notificationPoolShare*100, e.Name, e.ApiEndpoint, shareRatio*100)) } }() } return true } else { bot.Msg(replyTo, fmt.Sprintf("Cannot subscribe: %s", err)) return true } }, }, { Help: ".unsubscribe MONERO_ADDRESS", Description: "Unsubscribes your nick from shares and payouts specified by MONERO_ADDRESS.", Match: regexp.MustCompile("(?i)^\\.(unsub|unsubscribe)[ \t]+(4[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+)[ \t]*"), Handle: func(db *DB, entries []*channelEntry, bot *hbot.Bot, message *hbot.Message, replyTo string, matches ...string) bool { addr := address.FromBase58(matches[2]) if addr == nil { bot.Msg(replyTo, "Cannot unsubscribe: Invalid Monero address") return true } sub := &Subscription{ Address: addr, Nick: message.Name, } if err := db.Unsubscribe(sub); err != nil { bot.Msg(replyTo, fmt.Sprintf("Cannot unsubscribe: %s", err)) return true } else { bot.Msg(replyTo, fmt.Sprintf("Unsubscribed your nick from shares found by %s%s%s", FormatItalic, cmdutils.Shorten(string(addr.ToBase58()), 10), FormatReset)) return true } }, }, { Help: ".unsubscribe", Description: "Unsubscribes from all subscriptions tied to your nick", //fallback unsubscribe for everything Match: regexp.MustCompile("(?i)^[.!]?(unsub|unsubscribe)[ \t]*"), Handle: func(db *DB, entries []*channelEntry, bot *hbot.Bot, message *hbot.Message, replyTo string, matches ...string) bool { removed := 0 for _, sub := range db.GetByNick(message.Name) { db.Unsubscribe(sub) removed++ } bot.Msg(replyTo, fmt.Sprintf("Unsubscribed your nick from %d subscriptions.", removed)) return true }, }, }