observer-bot/bot.go

544 lines
17 KiB
Go

package main
import (
"context"
"flag"
"fmt"
"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"
log2 "gopkg.in/inconshreveable/log15.v2"
"gopkg.in/sorcix/irc.v2"
"io"
"log"
"net/http"
"net/url"
"nhooyr.io/websocket"
"os"
"slices"
"strings"
"sync"
"time"
)
type foundBlocks []*index.FoundBlock
func (s foundBlocks) Get(mainId types.Hash) *index.FoundBlock {
if i := slices.IndexFunc(s, func(block *index.FoundBlock) bool {
return block.MainBlock.Id == mainId
}); i != -1 {
return s[i]
}
return nil
}
func (s foundBlocks) GetPrevious(b *index.FoundBlock) *index.FoundBlock {
var prev *index.FoundBlock
for _, block := range s {
if prev == nil {
if block.MainBlock.Height < b.MainBlock.Height {
prev = block
}
} else if block.MainBlock.Height < b.MainBlock.Height && block.MainBlock.Height > prev.MainBlock.Height {
prev = block
}
}
return prev
}
const FormatColorGreen = "\x0303"
const FormatColorRed = "\x0304"
const FormatColorOrange = "\x0307"
const FormatColorYellow = "\x0308"
const FormatColorLightGreen = "\x0309"
const FormatBold = "\x02"
const FormatItalic = "\x1D"
const FormatUnderline = "\x1F"
const FormatReset = "\x0F"
func main() {
ircHost := flag.String("irc-host", "irc.libera.chat", "")
ircPort := flag.Uint("irc-port", 6697, "")
ircSsl := flag.Bool("irc-ssl", true, "")
botNickName := flag.String("bot-nick", "", "")
botUserName := flag.String("bot-user", "", "")
pleromaHost := flag.String("pleroma-host", "", "")
botPassword := os.Getenv("BOT_PASSWORD")
pleromaCookie := os.Getenv("PLEROMA_COOKIE")
dbPath := flag.String("db", "", "Path of database")
botChannels := flag.String("channels", "", "A list of #CHANNEL,NAME,API_ENDPOINT separated by; for example: #p2pool-main,Main,https://p2pool.observer;#p2pool-mini,Mini,https://mini.p2pool.observer")
flag.Parse()
db, err := NewDB(*dbPath)
if err != nil {
log.Panic(err)
}
doPleromaPost := func(e *channelEntry, message string) {
if pleromaCookie != "" {
uri, _ := url.Parse(*pleromaHost + "/api/v1/statuses")
r := &http.Request{
Method: "POST",
URL: uri,
Header: http.Header{
"content-type": {"application/x-www-form-urlencoded"},
},
}
formValues := make(url.Values)
formValues.Set("status", message)
formValues.Set("visibility", "public")
formValues.Set("content_type", "text/plain")
r.Body = io.NopCloser(strings.NewReader(formValues.Encode()))
r.AddCookie(&http.Cookie{
Name: "__Host-pleroma_key",
Value: pleromaCookie,
})
if response, err := http.DefaultClient.Do(r); err != nil {
log.Print(err)
} else {
defer response.Body.Close()
log.Printf("[Pleroma] Posted status %s", message)
}
}
}
var channelEntries []*channelEntry
for _, e := range strings.Split(*botChannels, ";") {
e = strings.TrimSpace(e)
if e == "" {
continue
}
split := strings.Split(e, ",")
if len(split) != 3 {
log.Panicf("invalid entry %s", e)
continue
}
log.Printf("Creating entry for %s", split[2])
entry := &channelEntry{
ApiEndpoint: split[2],
Channel: split[0],
Name: split[1],
Window: make(map[types.Hash]*index.SideBlock),
}
//websocket needs to come after
entry.getConsensus()
entry.getPreviousBlocks()
entry.getPreviousWindow()
entry.openWebSocket()
channelEntries = append(channelEntries, entry)
}
var onlineUsersLock sync.RWMutex
var onlineUsers []string
var beforeJoiningChannels sync.Once
if bot, err := hbot.NewBot(fmt.Sprintf("%s:%d", *ircHost, *ircPort), *botNickName, func(bot *hbot.Bot) {
bot.ThrottleDelay = time.Millisecond * 100
bot.Nick = *botNickName
bot.Realname = *botUserName
bot.SSL = *ircSsl
bot.Password = botPassword
bot.PingTimeout = time.Hour * 24 * 365 //one year, fix issue with hbot timeouting
if bot.Password != "" {
bot.SASL = true
}
//need to empty old list
bot.Channels = bot.Channels[:0]
for _, e := range channelEntries {
bot.Channels = append(bot.Channels, e.Channel)
}
//unique channels
slices.Sort(bot.Channels)
bot.Channels = slices.Compact(bot.Channels)
// Trigger commands before joining channels
bot.AddTrigger(hbot.Trigger{
Condition: func(bot *hbot.Bot, m *hbot.Message) bool {
return m.Command == irc.RPL_WELCOME || m.Command == irc.RPL_ENDOFMOTD // 001 or 372
},
Action: func(bot *hbot.Bot, m *hbot.Message) bool {
beforeJoiningChannels.Do(func() {
})
return false
},
})
// Track channel joins to have users nicks as connected
bot.AddTrigger(hbot.Trigger{
Condition: func(bot *hbot.Bot, message *hbot.Message) bool {
return message.Command == irc.RPL_NAMREPLY || message.Command == irc.RPL_ENDOFNAMES
},
Action: func(bot *hbot.Bot, message *hbot.Message) bool {
if message.Command == irc.RPL_NAMREPLY {
if len(message.Params) < 4 {
return false
}
channelName := message.Params[2]
if slices.ContainsFunc(channelEntries, func(entry *channelEntry) bool {
return entry.Channel == channelName
}) {
onlineUsersLock.Lock()
defer onlineUsersLock.Unlock()
for _, nick := range strings.Split(strings.ToLower(message.Params[3]), " ") {
if !slices.Contains(onlineUsers, nick) {
onlineUsers = append(onlineUsers, strings.TrimLeft(nick, "@~%+"))
}
}
}
}
return false
},
})
//Tracks nick changes
bot.AddTrigger(hbot.Trigger{
Condition: func(bot *hbot.Bot, message *hbot.Message) bool {
return message.Command == irc.NICK
},
Action: func(bot *hbot.Bot, message *hbot.Message) bool {
if message.Command == irc.NICK {
if len(message.Params) != 1 {
return false
}
from := strings.ToLower(message.From)
to := strings.ToLower(message.To)
onlineUsersLock.Lock()
defer onlineUsersLock.Unlock()
if i := slices.Index(onlineUsers, from); i != -1 {
onlineUsers[i] = to
} else {
log.Printf("Could not find old user %s -> %s", from, to)
}
}
return false
},
})
//Tracks JOIN/LEAVE
bot.AddTrigger(hbot.Trigger{
Condition: func(bot *hbot.Bot, message *hbot.Message) bool {
return message.Command == irc.JOIN || message.Command == irc.QUIT || message.Command == irc.PART
},
Action: func(bot *hbot.Bot, message *hbot.Message) bool {
if message.Command == irc.JOIN {
if len(message.Params) != 1 {
return false
}
nick := strings.ToLower(message.From)
channelName := message.To
onlineUsersLock.Lock()
defer onlineUsersLock.Unlock()
var channel *channelEntry
for _, c := range channelEntries {
if c.Channel == channelName {
channel = c
break
}
}
if channel == nil {
//not our channels
return false
}
if i := slices.Index(onlineUsers, nick); i == -1 {
onlineUsers = append(onlineUsers, nick)
}
} else if message.Command == irc.QUIT {
nick := strings.ToLower(message.From)
onlineUsersLock.Lock()
defer onlineUsersLock.Unlock()
if i := slices.Index(onlineUsers, nick); i != -1 {
onlineUsers = slices.Delete(onlineUsers, i, i+1)
} else {
log.Printf("Could not find user who quit %s", nick)
}
}
return false
},
})
}); err != nil {
log.Panic(err)
} else {
logHandler := log2.LvlFilterHandler(log2.LvlDebug, log2.StdoutHandler)
bot.Logger.SetHandler(logHandler)
// see about irc.ERR_NICKNAMEINUSE or irc.ERR_NICKCOLLISION to recover nick
payoutFound := func(e *channelEntry, b *index.FoundBlock, o *index.MainCoinbaseOutput, sub *Subscription) {
bot.Msg(sub.Nick, fmt.Sprintf(
"%sPAYOUT%s on %s: %s%s%s XMR%s to %s%s%s :: Main height %s%d%s, Side height %d :: %s :: Verify payout %s",
FormatColorLightGreen+FormatBold, FormatReset, e.Name,
FormatColorOrange, FormatBold, utils.XMRUnits(o.Value), FormatReset,
FormatItalic, cmdutils.Shorten(string(o.MinerAddress.ToBase58()), 10), FormatReset,
FormatColorRed, b.MainBlock.Height, FormatReset,
b.SideHeight,
GetShareLink(e.ApiEndpoint, b.SideHeight, b.MainBlock.Id),
GetPayoutLink(e.ApiEndpoint, b.SideHeight, o.Index, e.Consensus),
))
}
shareFound := func(e *channelEntry, tip *index.SideBlock, sub *Subscription) {
shareCount := 0
uncleCount := 0
var yourWeight types.Difficulty
var totalWeight types.Difficulty
e.IterateWindow(func(b *index.SideBlock, weight types.Difficulty) {
totalWeight = totalWeight.Add(weight)
if tip.Miner == b.Miner {
if b.IsUncle() {
uncleCount++
} else {
shareCount++
}
yourWeight = yourWeight.Add(weight)
}
})
shareRatio := float64(yourWeight.Lo) / float64(totalWeight.Lo)
if shareRatio > notificationPoolShare { //disable spammy notifications
return
}
if tip.UncleOf != types.ZeroHash {
bot.Msg(sub.Nick, fmt.Sprintf(
"%sUNCLE SHARE FOUND%s on %s: Side height %s%d%s, Parent Side height %s%d%s :: %s :: Accounted for %d%% of value :: Template Id %s%s%s :: Window shares %d (+%d uncles) ~%.03f%% :: Miner Address %s%s",
FormatColorLightGreen+FormatBold, FormatReset, e.Name,
FormatColorRed, tip.SideHeight, FormatReset,
FormatColorRed, tip.EffectiveHeight, FormatReset,
GetShareLink(e.ApiEndpoint, tip.SideHeight, tip.MainId),
100-e.Consensus.UnclePenalty,
FormatItalic, cmdutils.Shorten(tip.TemplateId.String(), 8), FormatReset,
shareCount, uncleCount, shareRatio*100,
FormatItalic, cmdutils.Shorten(string(tip.MinerAddress.ToBase58()), 10),
))
} else {
uncleText := ""
if len(tip.Uncles) > 0 {
uncleText = fmt.Sprintf(":: Includes %d uncle(s) for extra %d%% of their value ", len(tip.Uncles), e.Consensus.UnclePenalty)
}
bot.Msg(sub.Nick, fmt.Sprintf(
"%sSHARE FOUND%s on %s: Side height %s%d%s :: %s %s:: Template Id %s%s%s :: Window shares %d (+%d uncles) ~%.03f%% :: Miner Address %s%s",
FormatColorLightGreen+FormatBold, FormatReset, e.Name,
FormatColorRed, tip.SideHeight, FormatReset,
GetShareLink(e.ApiEndpoint, tip.SideHeight, tip.MainId),
uncleText,
FormatItalic, cmdutils.Shorten(tip.TemplateId.String(), 8), FormatReset,
shareCount, uncleCount, shareRatio*100,
FormatItalic, cmdutils.Shorten(string(tip.MinerAddress.ToBase58()), 10),
))
}
}
blockFound := func(e *channelEntry, b *index.FoundBlock, previous *index.FoundBlock) {
effort := float64(0)
if previous != nil {
effort = float64(b.CumulativeDifficulty.Sub(previous.CumulativeDifficulty).Mul64(100).Lo) / float64(b.MainBlock.Difficulty)
}
bot.Msg(e.Channel, fmt.Sprintf(
"%sBLOCK FOUND%s on %s: Main height %s%d%s, Side height %d :: %s :: Effort %s%.02f%%%s :: %s%d miners paid%s, total %s%s%s XMR%s :: Id %s%s",
FormatColorLightGreen+FormatBold, FormatReset, e.Name,
FormatColorRed, b.MainBlock.Height, FormatReset,
b.SideHeight,
GetShareLink(e.ApiEndpoint, b.SideHeight, b.MainBlock.Id),
EffortColor(effort), effort, FormatReset,
FormatColorOrange, b.WindowOutputs, FormatReset,
FormatColorOrange, FormatBold, utils.XMRUnits(b.MainBlock.Reward), FormatReset,
FormatItalic, cmdutils.Shorten(b.MainBlock.Id.String(), 8),
))
doPleromaPost(e, fmt.Sprintf(
"BLOCK FOUND on %s, Main height %d, Side height %d :: %s :: Effort %.02f%% :: %d miners paid, total %s XMR :: Id %s",
e.Name,
b.MainBlock.Height,
b.SideHeight,
GetShareLink(e.ApiEndpoint, b.SideHeight, b.MainBlock.Id),
effort,
b.WindowOutputs,
utils.XMRUnits(b.MainBlock.Reward),
b.MainBlock.Id,
))
}
blockOrphaned := func(e *channelEntry, b *index.SideBlock) {
bot.Msg(e.Channel, fmt.Sprintf(
"%sBLOCK ORPHANED%s on %s: Main height %s%d%s, Side height %d :: Side Template Id %s%s%s :: Id %s%s",
FormatColorLightGreen+FormatBold, FormatReset, e.Name,
FormatColorRed, b.MainHeight, FormatReset,
b.SideHeight,
FormatItalic, b.TemplateId, FormatReset,
FormatItalic, b.MainId,
))
doPleromaPost(e, fmt.Sprintf(
"BLOCK ORPHANED on %s, Main height %d, Side height %d :: Side Template Id %s :: Id %s",
e.Name,
b.MainHeight,
b.SideHeight,
b.TemplateId,
b.MainId,
))
}
//Private message
bot.AddTrigger(hbot.Trigger{
Condition: func(bot *hbot.Bot, message *hbot.Message) bool {
trimMessage := strings.TrimSpace(message.Content)
if len(trimMessage) <= 0 || trimMessage[0] != '.' {
return false
}
if message.To == bot.Nick {
return true
}
for _, e := range channelEntries {
if message.To == e.Channel {
return true
}
}
return false
},
Action: func(bot *hbot.Bot, message *hbot.Message) bool {
replyTo := message.To
if replyTo == bot.Nick || replyTo[0] != '#' {
replyTo = message.Name
}
for _, c := range commands {
if matches := c.Match.FindStringSubmatch(message.Content); len(matches) > 0 {
if c.Handle(db, channelEntries, bot, message, replyTo, matches...) {
return true
}
}
}
bot.Msg(replyTo, "Command not recognized")
return true
},
})
_ = shareFound
for _, e := range channelEntries {
if e.ApiEndpoint == "" {
continue
}
go func(e *channelEntry) {
for range time.NewTicker(time.Second * 30).C {
ws := e.Ws.Load()
if ws != nil {
func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*15)
defer cancel()
if err := ws.Ping(ctx); err != nil {
log.Printf("[WS] WebSocket for %s disconnected on ping: %s", e.ApiEndpoint, err)
ws.Close(websocket.StatusGoingAway, "error on ping "+err.Error())
e.Ws.Store(nil)
}
}()
}
}
}(e)
go func(e *channelEntry) {
for {
ws := e.Ws.Load()
if ws == nil {
log.Printf("[WS] WebSocket for %s is disconnected, retrying", e.ApiEndpoint)
for ; e.Ws.Load() == nil; e.openWebSocket() {
time.Sleep(time.Second * 30)
log.Printf("[WS] WebSocket for %s is disconnected, retrying again", e.ApiEndpoint)
}
ws = e.Ws.Load()
}
if event, err := e.readEvent(); event == nil || err != nil {
log.Printf("[WS] WebSocket for %s disconnected: %s", e.ApiEndpoint, err)
ws.Close(websocket.StatusGoingAway, "error on reading event")
e.Ws.Store(nil)
continue
} else {
func() {
e.ChainLock.Lock()
defer e.ChainLock.Unlock()
switch event.Type {
case cmdutils.JSONEventSideBlock:
log.Printf("[SideBlock] On %s, id %s, height %d", e.Name, event.SideBlock.TemplateId, event.SideBlock.SideHeight)
b := event.SideBlock
e.Window[b.TemplateId] = b
if !b.IsUncle() {
e.Tip = b
}
onlineUsersLock.RLock()
defer onlineUsersLock.RUnlock()
for _, sub := range db.GetByAddress(b.MinerAddress) {
if slices.Contains(onlineUsers, strings.ToLower(sub.Nick)) {
//do not send notification if user is not online
shareFound(e, b, sub)
} else {
log.Printf("Could not send notification to %s - %s: not online", sub.Nick, sub.Address.ToBase58())
}
}
e.pruneBlocks()
case cmdutils.JSONEventFoundBlock:
log.Printf("[FoundBlock] On %s, id %s, height %d", e.Name, event.FoundBlock.MainBlock.Id, event.FoundBlock.MainBlock.Height)
e.PreviousBlocks = append(e.PreviousBlocks, event.FoundBlock)
blockFound(e, event.FoundBlock, e.PreviousBlocks.GetPrevious(event.FoundBlock))
onlineUsersLock.RLock()
defer onlineUsersLock.RUnlock()
for _, o := range event.MainCoinbaseOutputs {
for _, sub := range db.GetByAddress(o.MinerAddress) {
if slices.Contains(onlineUsers, strings.ToLower(sub.Nick)) {
//do not send notification if user is not online
payoutFound(e, event.FoundBlock, &o, sub)
} else {
log.Printf("Could not send notification to %s - %s: not online", sub.Nick, sub.Address.ToBase58())
}
}
}
if len(e.PreviousBlocks) > numberOfBlockHistoryToKeep {
//delete oldest block
e.PreviousBlocks = slices.Delete(e.PreviousBlocks, 0, 1)
}
case cmdutils.JSONEventOrphanedBlock:
log.Printf("[OrphanedBlock] On %s, id %s, height %d, main id %s, main height %d", e.Name, event.SideBlock.TemplateId, event.SideBlock.SideHeight, event.SideBlock.MainId, event.SideBlock.MainHeight)
if i := slices.IndexFunc(e.PreviousBlocks, func(block *index.FoundBlock) bool {
return event.SideBlock.MainId == block.MainBlock.Id
}); i != -1 {
//only notify if we reported it previously
slices.Delete(e.PreviousBlocks, i, i+1)
blockOrphaned(e, event.SideBlock)
}
default:
log.Printf("unknown event %s", event.Type)
ws.Close(websocket.StatusGoingAway, "unknown event")
e.Ws.Store(nil)
break
}
}()
}
}
}(e)
}
bot.Run()
}
}