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() } }