Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core: better bot-mode handling #2448

Merged
merged 8 commits into from
May 28, 2023
21 changes: 16 additions & 5 deletions docs/source/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::

Expand All @@ -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
--------------

Expand Down
12 changes: 9 additions & 3 deletions sopel/config/core_section.py
Original file line number Diff line number Diff line change
Expand Up @@ -949,13 +949,19 @@ def homedir(self):

"""

modes = ValidatedAttribute('modes', default='B')
modes = ValidatedAttribute('modes')
"""User modes to be set on connection.

:default: ``B``

dgw marked this conversation as resolved.
Show resolved Hide resolved
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/')
Expand Down
31 changes: 26 additions & 5 deletions sopel/coretasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

if TYPE_CHECKING:
from sopel.bot import Sopel, SopelWrapper
from sopel.tools import Identifier
from sopel.trigger import Trigger


Expand Down Expand Up @@ -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:
Expand All @@ -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 = {
Expand Down Expand Up @@ -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')
Expand Down
60 changes: 44 additions & 16 deletions sopel/tools/target.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand All @@ -33,34 +33,62 @@ 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.

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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]] = {}
Expand Down
74 changes: 74 additions & 0 deletions test/test_coretasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions test/tools/test_tools_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down