home · contact · privacy
Add server setting channel membership prefixes.
authorChristian Heller <c.heller@plomlompom.de>
Sat, 22 Nov 2025 02:32:11 +0000 (03:32 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Sat, 22 Nov 2025 02:32:11 +0000 (03:32 +0100)
src/ircplom/client.py
src/ircplom/msg_parse_expectations.py
src/tests/channels.test

index 514e4103777145e70ac9d1e39496ac90dc8143aa..873b1f1839343210fd1d4d52829e44fe29989c51 100644 (file)
@@ -36,6 +36,12 @@ def _tuple_key_val_from_eq_str(eq_str: str) -> tuple[str, str]:
     return toks[0], ('' if len(toks) == 1 else toks[1])
 
 
+def _operation_chars_from_modeset_str(modeset_str: str) -> tuple[str, str]:
+    operation, chars = modeset_str[:1], modeset_str[1:]
+    assert chars and operation in '+-'
+    return operation, chars
+
+
 class SendFail(Exception):
     'When Client.send fails.'
 
@@ -186,19 +192,29 @@ class _Channel(Channel):
         self.purge_users = purge_users
         super().__init__(**kwargs)
 
+    def _id_from_nick(self, nick: str, create_if_none: bool) -> str:
+        return self._userid_for_nickuserhost(NickUserHost(nick),
+                                             create_if_none=create_if_none)
+
+    def _add_membership_prefix(self, user_id: str, prefix: str) -> None:
+        if prefix in self.prefixes.keys():
+            self.prefixes[prefix] += (user_id,)
+        else:
+            self.prefixes[prefix] = (user_id,)
+
+    def _remove_membership_prefix(self, user_id: str, prefix: str) -> None:
+        self.prefixes[prefix] = tuple(id_ for id_ in self.prefixes[prefix]
+                                      if id_ != user_id)
+
     def add_from_namreply(self, items: tuple[str, ...]) -> None:
         'Add to .user_ids items assumed as nicknames with membership prefixes.'
-        prefixes = self._get_membership_prefixes()
+        prefixes = self._get_membership_prefixes().values()
         for item in items:
             prefix = ''.join([pfx for pfx in prefixes if item.startswith(pfx)])
-            n_u_h = NickUserHost(item.lstrip(prefix))
-            user_id = self._userid_for_nickuserhost(n_u_h, create_if_none=True)
+            user_id = self._id_from_nick(item.lstrip(prefix), True)
             self.user_ids.completable_add(user_id, on_complete=False)
             if prefix:
-                if prefix in self.prefixes.keys():
-                    self.prefixes[prefix] += (user_id,)
-                else:
-                    self.prefixes[prefix] = (user_id,)
+                self._add_membership_prefix(user_id, prefix)
 
     def add_user(self, user: '_User') -> None:
         'To .user_ids add user.nickname, keep .user_ids declared complete.'
@@ -209,13 +225,26 @@ class _Channel(Channel):
     def remove_user(self, user: '_User', msg: str) -> None:
         'From .user_ids remove .nickname, keep .user_ids declared complete.'
         for prefix in self.prefixes.keys():
-            self.prefixes[prefix] = tuple(id_ for id_ in self.prefixes[prefix]
-                                          if id_ != user.id_)
+            self._remove_membership_prefix(user.id_, prefix)
         self.exits[user.id_] = msg
         self.user_ids.completable_remove(user.id_, on_complete=True)
         del self.exits[user.id_]
         self.purge_users()
 
+    def mode_on_nick(self, nick: str, modeset: str) -> None:
+        'Set channel mode towards user of nick.'
+        user_id = self._id_from_nick(nick, False)
+        operation, prefix_char = _operation_chars_from_modeset_str(modeset)
+        prefixes = self._get_membership_prefixes()
+        if prefix_char not in prefixes.keys():
+            raise ImplementationFail(
+                    f'for nickname setting channel mode: {modeset}')
+        prefix = prefixes[prefix_char]
+        if operation == '+':
+            self._add_membership_prefix(user_id, prefix)
+        else:
+            self._remove_membership_prefix(user_id, prefix)
+
 
 class _ChatMessage(ChatMessage):
 
@@ -322,8 +351,7 @@ class _User(_SetNickuserhostMixin, User):
 
     @modes.setter
     def modes(self, modeset: str) -> None:
-        operation, chars = modeset[:1], modeset[1:]
-        assert chars and operation in '+-'
+        operation, chars = _operation_chars_from_modeset_str(modeset)
         for char in chars:
             if operation == '+':
                 self._modes.add(char)
@@ -556,18 +584,21 @@ class _ClientDb(Clearable, UpdatingAttrsMixin, SharedClientDbFields):
             return False
         if nick[0] in (ILLEGAL_NICK_FIRSTCHARS
                        + self.isupport['CHANTYPES']
-                       + self._get_membership_prefixes()):
+                       + ''.join(self._get_membership_prefixes().values())):
             return False
         for c in [c for c in nick if c in ILLEGAL_NICK_CHARS]:
             return False
         return True
 
-    def _get_membership_prefixes(self) -> str:
-        'Registered possible membership nickname prefixes.'
+    def _get_membership_prefixes(self) -> dict[str, str]:
+        'Registered membership nickname prefix possibilities.'
         toks = self.isupport['PREFIX'].split(')', maxsplit=1)
         assert len(toks) == 2
         assert toks[0][0] == '('
-        return toks[1]
+        toks[0] = toks[0][1:]
+        assert len(toks[0]) == len(toks[1])
+        prefixes = {toks[0][idx]: toks[1][idx] for idx in range(len(toks[0]))}
+        return prefixes
 
 
 class _CapsManager(Clearable):
@@ -846,6 +877,9 @@ class Client(ABC, ClientQueueMixin):
                     self.caps.end_negotiation()
         elif ret['_verb'] == 'JOIN' and ret['joiner'] != self.db.users['me']:
             self.db.channels[ret['channel']].add_user(ret['joiner'])
+        elif ret['_verb'] == 'MODE' and 'mode_on_nick' in ret:
+            self.db.channels[ret['channel']].mode_on_nick(
+                    ret['nick'], ret['mode_on_nick'])
         elif ret['_verb'] == 'NICK':
             user_id = self.db.users.id_for_nickuserhost(ret['named'],
                                                         updating=True)
index 89c1d56b19b13a71ee22c3298583f176b1780c91..0feaa0f10828e49510b67aa4b0e13c2a86779826 100644 (file)
@@ -567,6 +567,13 @@ MSG_EXPECTATIONS: list[_MsgParseExpectation] = [
         ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
          (_MsgTok.ANY, 'setattr_db.users.me:modes'))),
 
+    _MsgParseExpectation(
+        'MODE',
+        _MsgTok.SERVER,
+        ((_MsgTok.CHANNEL, ':channel'),
+         (_MsgTok.ANY, ':mode_on_nick'),
+         (_MsgTok.NICKNAME, ':nick'))),
+
     _MsgParseExpectation(
         'TOPIC',
         (_MsgTok.NICK_USER_HOST, 'setattr_db.channels.CHAN.topic:who'),
index 657188f0c87b21cd5399cfa5bc009a96a672633c..605a91b509cba3863e946278d87a0fb4475bc532 100644 (file)
@@ -11,6 +11,7 @@ insert ./lib/disconnect
 # for: disconnect0, disconnect1
 insert ./lib/join-empty
 # for: join-channel-0, join-channel-1, join-empty
+insert ./lib/no-handler
 insert ./lib/part
 # for: exit-channel, part, parts-core, quit
 insert ./lib/privmsg
@@ -29,8 +30,8 @@ log 4 $ baz!~baz@baz.baz set topic: NEWTOPIC
 × reconnect
 > /reconnect
 insert attempting-to-connected : + WIN_IDS :2,3,4
-insert caps-neg-empty : +
-insert 001-setting-nick : +
+insert caps-neg-empty
+insert 001-setting-nick
 log 1 > JOIN :#ch_test0
 insert usermode
 insert servermsglogged : + MSG ::foo!~baz@baz.bar.foo JOIN #ch_test0
@@ -113,7 +114,7 @@ log 3 $ residents: bar, foo
 insert part : + CHANNEL=#ch_test0 CHAN_WIN_ID=3 USERIDS_CLEAR :set to: [1]
 log 1 $ users:1 deleted
 
-# check /join into channel with many other users, with multi-line 353
+# check /join into channel with many other users, with multi-line 353, and membership prefixes
 insert join-channel-0 : + CHANNEL=#ch_test0 RESIDENT_NAMES :foo @baz +oof
 insert user-set-to :1 + USER_ID=2 USERNICK :baz
 log 1 $ channels:#ch_test0:prefixes:@ set to: [2]
@@ -126,6 +127,18 @@ insert user-set-to :1 + USER_ID=5 USERNICK :zab
 insert join-channel-1 : + CHANNEL=#ch_test0 RESIDENT_IDS :[2], [3], [4], [5], [me]
 log 3 $ residents: baz, oof, rab, zab, foo
 
+# check server giving and taking membership prefixes
+insert servermsglogged : + MSG ::foo.bar.baz MODE #ch_test0 +o zab
+log 1 $ channels:#ch_test0:prefixes:@ set to: [2], [5]
+insert servermsglogged : + MSG ::foo.bar.baz MODE #ch_test0 -v rab
+log 1 $ channels:#ch_test0:prefixes:+ set to: [3]
+
+# check server setting unknown modes towards users
+insert servermsglogged : + MSG ::foo.bar.baz MODE #ch_test0 +a zab
+insert no-handler -2: + ALERT_WIN_IDS=2,3,4 ? :for nickname setting channel mode: +a
+insert servermsglogged : + MSG ::foo.bar.baz MODE #ch_test0 -b rab
+insert no-handler -2: + ALERT_WIN_IDS=2,3,4 ? :for nickname setting channel mode: -b
+
 # check /join into channel with topic set
 > /window 4
 insert part-empty : + CHAN_WIN_ID=4 CHANNEL :#ch_test1
@@ -160,16 +173,17 @@ log 3 < (*.?.net) msg_test6 msg_test7
 
 # check part of user visible, and of user NOT visible in other channel
 insert part-other-0-no-msg : + NICK :baz
-log 1 $ channels:#ch_test0:prefixes:@ emptied
+log 1 $ channels:#ch_test0:prefixes:@ set to: [5]
 insert part-other-1-no-msg : + USER_ID=2 NICK=baz REMAINING_IDS :[3], [4], [5], [me]
 insert part-other-0-no-msg : + NICK :oof
-log 1 $ channels:#ch_test0:prefixes:+ set to: [4]
+log 1 $ channels:#ch_test0:prefixes:+ emptied
 insert part-other-1-no-msg : + USER_ID=3 NICK=oof REMAINING_IDS :[4], [5], [me]
 log 1 $ users:3 deleted
 
 # check other-user part with exit message
 insert part-other-0 : + NICK=zab ARGS :#ch_test0 :goodbye
 insert user-set-to 1: + USER_ID=5 USERNAME=~zab USERHOST :zab.zab
+log 1 $ channels:#ch_test0:prefixes:@ emptied
 insert part-other-1 : + USER_ID=5 NICK=zab exitPREFIX=:§ exitMSG=goodbye REMAINING_IDS=[4],§[me] § : 
 log 1 $ users:5 deleted