544 lines
17 KiB
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()
|
|
}
|
|
}
|