observer-bot/commands.go

365 lines
13 KiB
Go

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