diff --git a/internal/database/irc.go b/internal/database/irc.go index 7515407..a5329c3 100644 --- a/internal/database/irc.go +++ b/internal/database/irc.go @@ -30,7 +30,7 @@ func NewIrcRepo(log logger.Logger, db *DB) domain.IrcRepo { func (r *IrcRepo) GetNetworkByID(ctx context.Context, id int64) (*domain.IrcNetwork, error) { queryBuilder := r.db.squirrel. - Select("id", "enabled", "name", "server", "port", "tls", "pass", "nick", "auth_mechanism", "auth_account", "auth_password", "invite_command", "bouncer_addr", "use_bouncer"). + Select("id", "enabled", "name", "server", "port", "tls", "pass", "nick", "auth_mechanism", "auth_account", "auth_password", "invite_command", "bouncer_addr", "use_bouncer", "bot_mode"). From("irc_network"). Where(sq.Eq{"id": id}) @@ -47,7 +47,7 @@ func (r *IrcRepo) GetNetworkByID(ctx context.Context, id int64) (*domain.IrcNetw var tls sql.NullBool row := r.db.handler.QueryRowContext(ctx, query, args...) - if err := row.Scan(&n.ID, &n.Enabled, &n.Name, &n.Server, &n.Port, &tls, &pass, &nick, &n.Auth.Mechanism, &account, &password, &inviteCmd, &bouncerAddr, &n.UseBouncer); err != nil { + if err := row.Scan(&n.ID, &n.Enabled, &n.Name, &n.Server, &n.Port, &tls, &pass, &nick, &n.Auth.Mechanism, &account, &password, &inviteCmd, &bouncerAddr, &n.UseBouncer, &n.BotMode); err != nil { return nil, errors.Wrap(err, "error scanning row") } @@ -107,7 +107,7 @@ func (r *IrcRepo) DeleteNetwork(ctx context.Context, id int64) error { func (r *IrcRepo) FindActiveNetworks(ctx context.Context) ([]domain.IrcNetwork, error) { queryBuilder := r.db.squirrel. - Select("id", "enabled", "name", "server", "port", "tls", "pass", "nick", "auth_mechanism", "auth_account", "auth_password", "invite_command", "bouncer_addr", "use_bouncer"). + Select("id", "enabled", "name", "server", "port", "tls", "pass", "nick", "auth_mechanism", "auth_account", "auth_password", "invite_command", "bouncer_addr", "use_bouncer", "bot_mode"). From("irc_network"). Where(sq.Eq{"enabled": true}) @@ -131,7 +131,7 @@ func (r *IrcRepo) FindActiveNetworks(ctx context.Context) ([]domain.IrcNetwork, var account, password sql.NullString var tls sql.NullBool - if err := rows.Scan(&net.ID, &net.Enabled, &net.Name, &net.Server, &net.Port, &tls, &pass, &nick, &net.Auth.Mechanism, &account, &password, &inviteCmd, &bouncerAddr, &net.UseBouncer); err != nil { + if err := rows.Scan(&net.ID, &net.Enabled, &net.Name, &net.Server, &net.Port, &tls, &pass, &nick, &net.Auth.Mechanism, &account, &password, &inviteCmd, &bouncerAddr, &net.UseBouncer, &net.BotMode); err != nil { return nil, errors.Wrap(err, "error scanning row") } @@ -155,7 +155,7 @@ func (r *IrcRepo) FindActiveNetworks(ctx context.Context) ([]domain.IrcNetwork, func (r *IrcRepo) ListNetworks(ctx context.Context) ([]domain.IrcNetwork, error) { queryBuilder := r.db.squirrel. - Select("id", "enabled", "name", "server", "port", "tls", "pass", "nick", "auth_mechanism", "auth_account", "auth_password", "invite_command", "bouncer_addr", "use_bouncer"). + Select("id", "enabled", "name", "server", "port", "tls", "pass", "nick", "auth_mechanism", "auth_account", "auth_password", "invite_command", "bouncer_addr", "use_bouncer", "bot_mode"). From("irc_network"). OrderBy("name ASC") @@ -179,7 +179,7 @@ func (r *IrcRepo) ListNetworks(ctx context.Context) ([]domain.IrcNetwork, error) var account, password sql.NullString var tls sql.NullBool - if err := rows.Scan(&net.ID, &net.Enabled, &net.Name, &net.Server, &net.Port, &tls, &pass, &nick, &net.Auth.Mechanism, &account, &password, &inviteCmd, &bouncerAddr, &net.UseBouncer); err != nil { + if err := rows.Scan(&net.ID, &net.Enabled, &net.Name, &net.Server, &net.Port, &tls, &pass, &nick, &net.Auth.Mechanism, &account, &password, &inviteCmd, &bouncerAddr, &net.UseBouncer, &net.BotMode); err != nil { return nil, errors.Wrap(err, "error scanning row") } @@ -240,7 +240,7 @@ func (r *IrcRepo) ListChannels(networkID int64) ([]domain.IrcChannel, error) { func (r *IrcRepo) CheckExistingNetwork(ctx context.Context, network *domain.IrcNetwork) (*domain.IrcNetwork, error) { queryBuilder := r.db.squirrel. - Select("id", "enabled", "name", "server", "port", "tls", "pass", "nick", "auth_mechanism", "auth_account", "auth_password", "invite_command", "bouncer_addr", "use_bouncer"). + Select("id", "enabled", "name", "server", "port", "tls", "pass", "nick", "auth_mechanism", "auth_account", "auth_password", "invite_command", "bouncer_addr", "use_bouncer", "bot_mode"). From("irc_network"). Where(sq.Eq{"server": network.Server}). Where(sq.Eq{"port": network.Port}). @@ -260,7 +260,7 @@ func (r *IrcRepo) CheckExistingNetwork(ctx context.Context, network *domain.IrcN var account, password sql.NullString var tls sql.NullBool - if err = row.Scan(&net.ID, &net.Enabled, &net.Name, &net.Server, &net.Port, &tls, &pass, &nick, &net.Auth.Mechanism, &account, &password, &inviteCmd, &bouncerAddr, &net.UseBouncer); err != nil { + if err = row.Scan(&net.ID, &net.Enabled, &net.Name, &net.Server, &net.Port, &tls, &pass, &nick, &net.Auth.Mechanism, &account, &password, &inviteCmd, &bouncerAddr, &net.UseBouncer, &net.BotMode); err != nil { if errors.Is(err, sql.ErrNoRows) { // no result is not an error in our case return nil, nil @@ -308,6 +308,7 @@ func (r *IrcRepo) StoreNetwork(ctx context.Context, network *domain.IrcNetwork) "invite_command", "bouncer_addr", "use_bouncer", + "bot_mode", ). Values( network.Enabled, @@ -323,6 +324,7 @@ func (r *IrcRepo) StoreNetwork(ctx context.Context, network *domain.IrcNetwork) inviteCmd, bouncerAddr, network.UseBouncer, + network.BotMode, ). Suffix("RETURNING id"). RunWith(r.db.handler) @@ -363,6 +365,7 @@ func (r *IrcRepo) UpdateNetwork(ctx context.Context, network *domain.IrcNetwork) Set("invite_command", inviteCmd). Set("bouncer_addr", bouncerAddr). Set("use_bouncer", network.UseBouncer). + Set("bot_mode", network.BotMode). Set("updated_at", time.Now().Format(time.RFC3339)). Where(sq.Eq{"id": network.ID}) diff --git a/internal/database/postgres_migrate.go b/internal/database/postgres_migrate.go index 6f3e898..2076aa4 100644 --- a/internal/database/postgres_migrate.go +++ b/internal/database/postgres_migrate.go @@ -47,6 +47,7 @@ CREATE TABLE irc_network invite_command TEXT, use_bouncer BOOLEAN, bouncer_addr TEXT, + bot_mode BOOLEAN DEFAULT FALSE, connected BOOLEAN, connected_since TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -827,5 +828,8 @@ ALTER TABLE filter_external `, `ALTER TABLE filter_external DROP COLUMN IF EXISTS webhook_retry_max_jitter_seconds; +`, + `ALTER TABLE irc_network + ADD COLUMN bot_mode BOOLEAN DEFAULT FALSE; `, } diff --git a/internal/database/sqlite_migrate.go b/internal/database/sqlite_migrate.go index f8cd3fc..709426a 100644 --- a/internal/database/sqlite_migrate.go +++ b/internal/database/sqlite_migrate.go @@ -47,6 +47,7 @@ CREATE TABLE irc_network invite_command TEXT, use_bouncer BOOLEAN, bouncer_addr TEXT, + bot_mode BOOLEAN DEFAULT FALSE, connected BOOLEAN, connected_since TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -1421,4 +1422,7 @@ ALTER TABLE filter_external_dg_tmp `ALTER TABLE filter_external DROP COLUMN webhook_retry_max_jitter_seconds; `, + `ALTER TABLE irc_network + ADD COLUMN bot_mode BOOLEAN DEFAULT FALSE; + `, } diff --git a/internal/domain/irc.go b/internal/domain/irc.go index 12fde64..b11c2d9 100644 --- a/internal/domain/irc.go +++ b/internal/domain/irc.go @@ -45,6 +45,7 @@ type IrcNetwork struct { InviteCommand string `json:"invite_command"` UseBouncer bool `json:"use_bouncer"` BouncerAddr string `json:"bouncer_addr"` + BotMode bool `json:"bot_mode"` Channels []IrcChannel `json:"channels"` Connected bool `json:"connected"` ConnectedSince *time.Time `json:"connected_since"` @@ -63,6 +64,7 @@ type IrcNetworkWithHealth struct { InviteCommand string `json:"invite_command"` UseBouncer bool `json:"use_bouncer"` BouncerAddr string `json:"bouncer_addr"` + BotMode bool `json:"bot_mode"` CurrentNick string `json:"current_nick"` PreferredNick string `json:"preferred_nick"` Channels []ChannelWithHealth `json:"channels"` diff --git a/internal/irc/handler.go b/internal/irc/handler.go index 52cd705..f43bf4d 100644 --- a/internal/irc/handler.go +++ b/internal/irc/handler.go @@ -85,6 +85,8 @@ type Handler struct { connectionErrors []string failedNickServAttempts int + botModeChar string + authenticated bool saslauthed bool } @@ -201,6 +203,9 @@ func (h *Handler) Run() error { h.client.AddDisconnectCallback(h.onDisconnect) h.client.AddCallback("MODE", h.handleMode) + if h.network.BotMode { + h.client.AddCallback("501", h.handleModeUnknownFlag) + } h.client.AddCallback("INVITE", h.handleInvite) h.client.AddCallback("366", h.handleJoined) h.client.AddCallback("PART", h.handlePart) @@ -383,8 +388,13 @@ func (h *Handler) onConnect(m ircmsg.Message) { time.Sleep(1 * time.Second) - h.authenticate() + if h.network.BotMode && h.botModeSupported() { + // if we set Bot Mode, we'll try to authenticate after the MODE response + h.setBotMode() + return + } + h.authenticate() } // onDisconnect is the disconnect callback @@ -492,6 +502,20 @@ func (h *Handler) handleNickServ(msg ircmsg.Message) { } } +// botModeSupported checks if IRCv3 Bot Mode is supported by the server +// See https://ircv3.net/specs/extensions/bot-mode +func (h *Handler) botModeSupported() bool { + h.botModeChar = h.client.ISupport()["BOT"] + + return h.botModeChar != "" +} + +// setBotMode attempts to set Bot Mode on ourself +// See https://ircv3.net/specs/extensions/bot-mode +func (h *Handler) setBotMode() { + h.client.Send("MODE", h.CurrentNick(), "+"+h.botModeChar) +} + // authenticate sends NickServIdentify if not authenticated func (h *Handler) authenticate() bool { h.m.RLock() @@ -869,7 +893,15 @@ func (h *Handler) handleMode(msg ircmsg.Message) { return } - return + if h.network.BotMode && h.botModeChar != "" && h.isOurCurrentNick(msg.Params[0]) && strings.Contains(msg.Params[1], "+"+h.botModeChar) { + h.authenticate() + } +} + +// listens for ERR_UMODEUNKNOWNFLAG events +func (h *Handler) handleModeUnknownFlag(msg ircmsg.Message) { + // if Bot Mode setting failed, still try to authenticate + h.authenticate() } func (h *Handler) SendMsg(channel, msg string) error { diff --git a/internal/irc/service.go b/internal/irc/service.go index bebf708..866e387 100644 --- a/internal/irc/service.go +++ b/internal/irc/service.go @@ -211,6 +211,10 @@ func (s *service) checkIfNetworkRestartNeeded(network *domain.IrcNetwork) error restartNeeded = true fieldsChanged = append(fieldsChanged, "bouncer addr") } + if handler.BotMode != network.BotMode { + restartNeeded = true + fieldsChanged = append(fieldsChanged, "bot mode") + } if handler.Auth.Mechanism != network.Auth.Mechanism { restartNeeded = true fieldsChanged = append(fieldsChanged, "auth mechanism") @@ -447,6 +451,7 @@ func (s *service) GetNetworksWithHealth(ctx context.Context) ([]domain.IrcNetwor InviteCommand: n.InviteCommand, BouncerAddr: n.BouncerAddr, UseBouncer: n.UseBouncer, + BotMode: n.BotMode, Connected: false, Channels: []domain.ChannelWithHealth{}, ConnectionErrors: []string{}, diff --git a/web/src/forms/settings/IrcForms.tsx b/web/src/forms/settings/IrcForms.tsx index c0f52f0..9e65fc7 100644 --- a/web/src/forms/settings/IrcForms.tsx +++ b/web/src/forms/settings/IrcForms.tsx @@ -268,6 +268,7 @@ interface IrcNetworkUpdateFormValues { invite_command: string; use_bouncer: boolean; bouncer_addr: string; + bot_mode: boolean; channels: Array; } @@ -323,6 +324,7 @@ export function IrcNetworkUpdateForm({ invite_command: network.invite_command, use_bouncer: network.use_bouncer, bouncer_addr: network.bouncer_addr, + bot_mode: network.bot_mode, channels: network.channels }; @@ -384,6 +386,8 @@ export function IrcNetworkUpdateForm({ /> )} + +
Identification diff --git a/web/src/types/Irc.d.ts b/web/src/types/Irc.d.ts index 118de2d..6889fdd 100644 --- a/web/src/types/Irc.d.ts +++ b/web/src/types/Irc.d.ts @@ -16,6 +16,7 @@ interface IrcNetwork { invite_command: string; use_bouncer: boolean; bouncer_addr: string; + bot_mode: boolean; channels: IrcChannel[]; connected: boolean; connected_since: string; @@ -33,6 +34,7 @@ interface IrcNetworkCreate { invite_command: string; use_bouncer?: boolean; bouncer_addr?: string; + bot_mode?: boolean; channels: IrcChannel[]; connected: boolean; } @@ -64,6 +66,7 @@ interface IrcNetworkWithHealth { invite_command: string; use_bouncer: boolean; bouncer_addr: string; + bot_mode: boolean; channels: IrcChannelWithHealth[]; connected: boolean; connected_since: string;