package discord import ( "bytes" "fmt" "github.com/certusone/wormhole/node/pkg/vaa" "github.com/diamondburned/arikawa/v3/api" "github.com/diamondburned/arikawa/v3/discord" "go.uber.org/zap" "strings" "sync" ) type DiscordNotifier struct { c *api.Client chans []discord.Channel logger *zap.Logger groupToIDMu sync.RWMutex groupToID map[string]string } // NewDiscordNotifier returns and initializes a new Discord notifier. // // During initialization, a list of all guilds and channels is fetched. // Newly added guilds and channels won't be detected at runtime. func NewDiscordNotifier(botToken string, channelName string, logger *zap.Logger) (*DiscordNotifier, error) { c := api.NewClient("Bot " + botToken) chans := make([]discord.Channel, 0) guilds, err := c.Guilds(0) if err != nil { return nil, fmt.Errorf("failed to retrieve guilds: %w", err) } for _, guild := range guilds { gcn, err := c.Channels(guild.ID) if err != nil { return nil, fmt.Errorf("failed to retrieve channels for %s: %w", guild.ID, err) } for _, cn := range gcn { if cn.Name == channelName { chans = append(chans, cn) } } } logger.Info("notification channels", zap.Any("channels", chans)) return &DiscordNotifier{ c: c, chans: chans, logger: logger, groupToID: make(map[string]string), }, nil } func wrapCode(in string) string { return fmt.Sprintf("`%s`", in) } func (d *DiscordNotifier) LookupGroupID(groupName string) (string, error) { d.groupToIDMu.RLock() if id, ok := d.groupToID[groupName]; ok { d.groupToIDMu.RUnlock() return id, nil } d.groupToIDMu.RUnlock() guilds, err := d.c.Guilds(0) if err != nil { return "", fmt.Errorf("failed to retrieve guilds: %w", err) } for _, guild := range guilds { gcn, err := d.c.Roles(guild.ID) if err != nil { return "", fmt.Errorf("failed to retrieve roles for %s: %w", guild.ID, err) } for _, cn := range gcn { if cn.Name == groupName { m := cn.ID.String() d.groupToIDMu.Lock() d.groupToID[groupName] = m d.groupToIDMu.Unlock() return m, nil } } } return "", fmt.Errorf("failed to find group %s", groupName) } func (d *DiscordNotifier) MissingSignaturesOnTransaction(v *vaa.VAA, hasSigs, wantSigs int, quorum bool, missing []string) error { if len(missing) == 0 { panic("no missing nodes specified") } var quorumText string if quorum { quorumText = fmt.Sprintf("✔️ yes (%d/%d)", hasSigs, wantSigs) } else { quorumText = fmt.Sprintf("🚨️ **NO** (%d/%d)", hasSigs, wantSigs) } var messageText string if !quorum { messageText = "**NO QUORUM** - Wormhole likely failed to achieve consensus on this message @here" } missingText := &bytes.Buffer{} for _, m := range missing { groupID, err := d.LookupGroupID(m) if err != nil { d.logger.Error("failed to lookup group id", zap.Error(err), zap.String("name", m)) groupID = m } else { groupID = fmt.Sprintf("<@&%s>", groupID) } if _, err := fmt.Fprintf(missingText, "- %s\n", groupID); err != nil { panic(err) } } for _, cn := range d.chans { if _, err := d.c.SendMessage(cn.ID, messageText, discord.Embed{ Title: "Message with missing signatures", Fields: []discord.EmbedField{ {Name: "Message ID", Value: wrapCode(v.MessageID()), Inline: true}, {Name: "Digest", Value: wrapCode(v.HexDigest()), Inline: true}, {Name: "Quorum", Value: quorumText, Inline: true}, {Name: "Source Chain", Value: strings.Title(v.EmitterChain.String()), Inline: false}, {Name: "Missing Guardians", Value: missingText.String(), Inline: false}, }, }, ); err != nil { return err } } return nil }