diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index b3779ac9b9..07b8732974 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -103,15 +103,15 @@ To have Sopel set additional user modes upon connection, use the :attr:`~CoreSection.modes` setting:: [core] - modes = BpR + modes = pR In this example, upon connection to the IRC server, Sopel will send this:: - MODE Sopel +BpR + MODE Sopel +pR -Which means: this is a Bot (B), don't show channels it is in (p), and only -registered users (R) can send it messages. The list of supported modes depends -on the IRC server the bot connects to. +Which means: don't show channels this user is in (p), and only registered +users (R) can send it messages. The list of supported modes depends on the IRC +server the bot connects to. .. important:: @@ -124,6 +124,17 @@ on the IRC server the bot connects to. .. __: https://libera.chat/guides/usermodes .. __: https://www.unrealircd.org/docs/User_modes +.. note:: + + As of version 8.0, Sopel automatically informs networks that it is a bot if + `the BOT feature flag`__ is advertised. + + On nonstandard networks that *have* a "bot" mode character but *do not* + advertise it in ISUPPORT, the ``modes`` setting can be used to disclose + that your Sopel instance is a bot. + + .. __: https://ircv3.net/specs/extensions/bot-mode#the-bot-isupport-token + Owner & Admins -------------- diff --git a/sopel/config/core_section.py b/sopel/config/core_section.py index 80d63a6fbe..bdb220cf2f 100644 --- a/sopel/config/core_section.py +++ b/sopel/config/core_section.py @@ -949,13 +949,19 @@ def homedir(self): """ - modes = ValidatedAttribute('modes', default='B') + modes = ValidatedAttribute('modes') """User modes to be set on connection. - :default: ``B`` - Include only the mode letters; this value is automatically prefixed with ``+`` before Sopel sends the MODE command to IRC. + + .. versionchanged:: 8.0.0 + + Now empty by default. Previous default was ``B``, which has been + dropped in favor of the formal `bot mode specification`__. + + .. __: https://ircv3.net/specs/extensions/bot-mode + """ name = ValidatedAttribute('name', default='Sopel: https://sopel.chat/') diff --git a/sopel/coretasks.py b/sopel/coretasks.py index 8805f8a305..b1b1ed7e52 100644 --- a/sopel/coretasks.py +++ b/sopel/coretasks.py @@ -38,6 +38,7 @@ if TYPE_CHECKING: from sopel.bot import Sopel, SopelWrapper + from sopel.tools import Identifier from sopel.trigger import Trigger @@ -1489,12 +1490,25 @@ def recv_whox(bot, trigger): "While populating `bot.accounts` a WHO response was malformed.") return _, _, channel, user, host, nick, status, account, realname = trigger.args + botmode = bot.isupport.get('BOT') away = 'G' in status + is_bot = (botmode in status) if botmode else None modes = ''.join([c for c in status if c in '~&@%+!']) - _record_who(bot, channel, user, host, nick, realname, account, away, modes) - - -def _record_who(bot, channel, user, host, nick, realname=None, account=None, away=None, modes=None): + _record_who(bot, channel, user, host, nick, realname, account, away, is_bot, modes) + + +def _record_who( + bot: Sopel, + channel: Identifier, + user: str, + host: str, + nick: str, + realname: Optional[str] = None, + account: Optional[str] = None, + away: Optional[bool] = None, + is_bot: Optional[bool] = None, + modes: Optional[str] = None, +): nick = bot.make_identifier(nick) channel = bot.make_identifier(channel) if nick not in bot.users: @@ -1515,6 +1529,8 @@ def _record_who(bot, channel, user, host, nick, realname=None, account=None, awa usr.account = account if away is not None: usr.away = away + if is_bot is not None: + usr.is_bot = is_bot priv = 0 if modes: mapping = { @@ -1543,10 +1559,15 @@ def _record_who(bot, channel, user, host, nick, realname=None, account=None, awa def recv_who(bot, trigger): """Track ``WHO`` responses when ``WHOX`` is not enabled.""" channel, user, host, _, nick, status = trigger.args[1:7] + botmode = bot.isupport.get('BOT') realname = trigger.args[-1].partition(' ')[-1] away = 'G' in status + is_bot = (botmode in status) if botmode else None modes = ''.join([c for c in status if c in '~&@%+!']) - _record_who(bot, channel, user, host, nick, realname, away=away, modes=modes) + _record_who( + bot, channel, user, host, nick, realname, + away=away, is_bot=is_bot, modes=modes, + ) @plugin.event('AWAY') diff --git a/sopel/tools/target.py b/sopel/tools/target.py index 15ef8bf0bf..473ce512e6 100644 --- a/sopel/tools/target.py +++ b/sopel/tools/target.py @@ -23,7 +23,7 @@ class User: :param str host: the user's hostname ("host.name" in `user@host.name`) """ __slots__ = ( - 'nick', 'user', 'host', 'realname', 'channels', 'account', 'away', + 'nick', 'user', 'host', 'realname', 'channels', 'account', 'away', 'is_bot', ) def __init__( @@ -33,16 +33,25 @@ def __init__( host: Optional[str], ) -> None: assert isinstance(nick, identifiers.Identifier) - self.nick = nick + self.nick: identifiers.Identifier = nick """The user's nickname.""" - self.user = user - """The user's local username.""" - self.host = host - """The user's hostname.""" - self.realname = None + self.user: Optional[str] = user + """The user's local username. + + Will be ``None`` if Sopel has not yet received complete user + information from the IRC server. + """ + self.host: Optional[str] = host + """The user's hostname. + + Will be ``None`` if Sopel has not yet received complete user + information from the IRC server. + """ + self.realname: Optional[str] = None """The user's realname. - Will be ``None`` if not received from the server yet. + Will be ``None`` if Sopel has not yet received complete user + information from the IRC server. """ self.channels: Dict[identifiers.Identifier, 'Channel'] = {} """The channels the user is in. @@ -50,17 +59,36 @@ def __init__( This maps channel name :class:`~sopel.tools.identifiers.Identifier`\\s to :class:`Channel` objects. """ - self.account = None + self.account: Optional[str] = None """The IRC services account of the user. This relies on IRCv3 account tracking being enabled. + + Will be ``None`` if the user is not logged into an account (including + when account tracking is not supported by the IRC server.) """ - self.away = None - """Whether the user is marked as away.""" + self.away: Optional[bool] = None + """Whether the user is marked as away. - hostmask = property(lambda self: '{}!{}@{}'.format(self.nick, self.user, - self.host)) - """The user's full hostmask.""" + Will be ``None`` if the user's current away state hasn't been + established yet (via WHO or other means such as ``away-notify``). + """ + self.is_bot: Optional[bool] = None + """Whether the user is flagged as a bot. + + Will be ``None`` if the user hasn't yet been WHOed, or if the IRC + server does not support a 'bot' user mode. + """ + + @property + def hostmask(self) -> str: + """The user's full hostmask (``nick!user@host``).""" + # TODO: this won't work as expected if `user`/`host` is still `None` + return '{}!{}@{}'.format( + self.nick, + self.user, + self.host, + ) def __eq__(self, other: Any) -> bool: if not isinstance(other, User): @@ -99,7 +127,7 @@ def __init__( identifier_factory: IdentifierFactory = identifiers.Identifier, ) -> None: assert isinstance(name, identifiers.Identifier) - self.name = name + self.name: identifiers.Identifier = name """The name of the channel.""" self.make_identifier: IdentifierFactory = identifier_factory @@ -133,7 +161,7 @@ def __init__( bitwise integer values. This can be compared to appropriate constants from :mod:`sopel.privileges`. """ - self.topic = '' + self.topic: str = '' """The topic of the channel.""" self.modes: Dict[str, Union[Set, str, bool]] = {} diff --git a/test/test_coretasks.py b/test/test_coretasks.py index 7d92a49d40..3a1a1f38aa 100644 --- a/test/test_coretasks.py +++ b/test/test_coretasks.py @@ -571,3 +571,77 @@ def test_handle_rpl_namreply_with_malformed_uhnames(mockbot, caplog): assert len(caplog.messages) == 1 assert 'RPL_NAMREPLY item without a hostmask' in caplog.messages[0] + + +def test_handle_who_reply(mockbot): + """Make sure Sopel correctly updates user info from WHO replies""" + # verify we start with no users/channels + assert len(mockbot.users) == 0 + assert 'Internets' not in mockbot.users + assert len(mockbot.channels) == 0 + assert '#channel' not in mockbot.channels + + # add one user, who is Here + mockbot.on_message( + ':some.irc.network 352 Sopel #channel ' + 'internets services.irc.network * Internets Hr* ' + ':0 Network Services Bot') + mockbot.on_message( + ':some.irc.network 315 Sopel #channel ' + ':End of /WHO list.') + + assert len(mockbot.users) == 1 + assert 'Internets' in mockbot.users + assert mockbot.users['Internets'].nick == 'Internets' + assert mockbot.users['Internets'].user == 'internets' + assert mockbot.users['Internets'].host == 'services.irc.network' + assert mockbot.users['Internets'].realname == 'Network Services Bot' + assert mockbot.users['Internets'].away is False + assert mockbot.users['Internets'].is_bot is None + + assert '#channel' in mockbot.channels + assert mockbot.channels['#channel'] + assert len(mockbot.channels['#channel'].users) == 1 + assert 'Internets' in mockbot.channels['#channel'].users + assert ( + mockbot.users['Internets'] is mockbot.channels['#channel'].users['Internets'] + ) + + # on next WHO, user has been marked as Gone + mockbot.on_message( + ':some.irc.network 352 Sopel #channel ' + 'internets services.irc.network * Internets Gr* ' + ':0 Network Services Bot') + mockbot.on_message( + ':some.irc.network 315 Sopel #channel ' + ':End of /WHO list.') + + assert mockbot.users['Internets'].away is True + assert mockbot.users['Internets'].is_bot is None + + +def test_handle_who_reply_botmode(mockbot): + """Make sure Sopel correctly tracks users' bot status from WHO replies""" + mockbot.on_message( + ':irc.example.com 005 Sopel ' + 'BOT=B ' + ':are supported by this server') + + # non-bot user + mockbot.on_message( + ':some.irc.network 352 Sopel #channel ' + 'human somewhere.in.the.world * E_R_Bradshaw H* ' + ':0 E. R. Bradshaw') + + assert mockbot.users['E_R_Bradshaw'].is_bot is False + + # bot user + mockbot.on_message( + ':some.irc.network 352 Sopel #channel ' + 'internets services.irc.network * Internets HBr* ' + ':0 Network Services Bot') + mockbot.on_message( + ':some.irc.network 315 Sopel #channel ' + ':End of /WHO list.') + + assert mockbot.users['Internets'].is_bot is True diff --git a/test/tools/test_tools_target.py b/test/tools/test_tools_target.py index 1347f4e972..3374f7d5b4 100644 --- a/test/tools/test_tools_target.py +++ b/test/tools/test_tools_target.py @@ -5,6 +5,22 @@ from sopel.tools import Identifier, target +def test_user(): + nick = Identifier('River') + username = 'tamr' + host = 'good.ship.serenity' + user = target.User(nick, username, host) + + assert user.nick == nick + assert user.user == username + assert user.host == host + assert user.realname is None + assert user.channels == {} + assert user.account is None + assert user.away is None + assert user.is_bot is None + + def test_channel(): name = Identifier('#chan') channel = target.Channel(name)