Exemplo n.º 1
0
 def __init__(self, plugin_context: PluginContext) -> None:
     super().__init__(plugin_context)
     self._db = DB()
     self._db.checkout_table(
         'PublicStreams', '(StreamName text primary key, Subscribed integer not null)'
     )
     # Ensure that we are subscribed to all existing streams.
     for stream_name, subscribed in self._db.execute(self._select_sql):
         if subscribed == 1:
             continue
         self._handle_stream(stream_name, False)
Exemplo n.º 2
0
 def __init__(self, *args: Any, **kwargs: Any) -> None:
     """Override the constructor of the parent class."""
     super().__init__(*args, **kwargs)
     self.id: int = self.get_profile()['user_id']
     self.ping: str = '@**{}**'.format(self.get_profile()['full_name'])
     self.ping_len: int = len(self.ping)
     self.register_params: Dict[str, Any] = {}
     self._db = DB()
     self._db.checkout_table(
         'PublicStreams',
         '(StreamName text primary key, Subscribed integer not null)')
Exemplo n.º 3
0
 def __init__(self, plugin_context: PluginContext) -> None:
     super().__init__(plugin_context)
     self._db = DB()
     self._db.checkout_table('Conf',
                             '(Key text primary key, Value text not null)')
     self.command_parser: CommandParser = CommandParser()
     self.command_parser.add_subcommand('list')
     self.command_parser.add_subcommand('set',
                                        args={
                                            'key': str,
                                            'value': str
                                        })
     self.command_parser.add_subcommand('remove', args={'key': str})
Exemplo n.º 4
0
 def __init__(self, plugin_context: PluginContext) -> None:
     super().__init__(plugin_context)
     # Get own database connection.
     self._db = DB()
     # Check for database table.
     self._db.checkout_table(
         table = 'Messages', schema = '(MsgId text primary key, MsgText text not null)'
     )
     self.command_parser: CommandParser = CommandParser()
     self.command_parser.add_subcommand('add', args={'id': str, 'text': str})
     self.command_parser.add_subcommand('send', args={'id': str})
     self.command_parser.add_subcommand('remove', args={'id': str})
     self.command_parser.add_subcommand('list')
Exemplo n.º 5
0
 def __init__(self, plugin_context: PluginContext) -> None:
     super().__init__(plugin_context)
     # Get own database connection.
     self._db = DB()
     # Check for database table.
     self._db.checkout_table(
         table = 'Alerts', schema = '(Phrase text primary key, Emoji text not null)'
     )
     self.command_parser: CommandParser = CommandParser()
     self.command_parser.add_subcommand('add', args={
         'alert_phrase': str, 'emoji': Regex.get_emoji_name
     })
     self.command_parser.add_subcommand('remove', args={'alert_phrase': str})
     self.command_parser.add_subcommand('list')
Exemplo n.º 6
0
    def __init__(self, plugin_context: PluginContext) -> None:
        super().__init__(plugin_context)
        # Get own database connection.
        self._db = DB()
        # Check for database tables.
        self._db.checkout_table(
            table = 'Groups',
            schema = '(Id text primary key, Emoji text not null unique, Streams text not null)'
        )
        self._db.checkout_table(
            table = 'GroupUsers',
            schema = ('(UserId integer not null, GroupId text, '
                      'foreign key(GroupId) references Groups(ID) on delete cascade, '
                      'primary key(UserId, GroupID))')
        )
        self._db.checkout_table(
            table = 'GroupClaims',
            schema = ('(MessageId integer not null, GroupId text, '
                      'foreign key(GroupId) references Groups(ID) on delete cascade, '
                      'primary key(MessageId, GroupId))')
        )
        self._db.checkout_table('GroupClaimsAll', '(MessageId integer primary key)')

        # Init command parsing.
        self.command_parser = CommandParser()
        self.command_parser.add_subcommand('subscribe', args={'group_id': str})
        self.command_parser.add_subcommand('unsubscribe', args={'group_id': str})
        self.command_parser.add_subcommand('add', args={'group_id': str, 'emoji': Regex.get_emoji_name})
        self.command_parser.add_subcommand('remove', args={'group_id': str})
        self.command_parser.add_subcommand(
            'add_streams', args={'group_id': str, 'streams': str}, greedy = True
        )
        self.command_parser.add_subcommand(
            'remove_streams', args={'group_id': str, 'streams': str}, greedy = True
        )
        self.command_parser.add_subcommand('list')
        self.command_parser.add_subcommand(
            'claim', args={'group_id': str, 'text': str}, greedy = True, optional = True
        )
        self.command_parser.add_subcommand('unclaim', args={'group_id': str, 'message_id': int})
        self.command_parser.add_subcommand('announce')
        self.command_parser.add_subcommand('unannounce', args={'message_id': int})

        # Init some usefule constants.
        self._get_emoji: Pattern[str] = re.compile(r'\s*:?([^:]+):?\s*')
        # (removing trailing 'api/' from host url).
        self.message_link: str = '[{0}](' + self.client.base_url[:-4] + '#narrow/id/{0})'
Exemplo n.º 7
0
    def _get_bindings(self) -> List[Tuple[Pattern[str], str]]:
        """Compile the regexes and bind them to their emojis."""

        # Get a database connection.
        self._db = DB()

        bindings: List[Tuple[Pattern[str], str]] = []

        # Verify every regex and only use the valid ones.
        for regex, emoji in self._db.execute(self._select_sql):
            try:
                pattern: Pattern[str] = re.compile(regex)
            except re.error:
                continue
            bindings.append((pattern, emoji))

        return bindings
Exemplo n.º 8
0
class AlertWordDaemon(SubBotPlugin):
    plugin_name = 'alert_word_daemon'
    events = ['message']
    _select_sql: str = 'select Phrase, Emoji from Alerts'

    def __init__(self, plugin_context: PluginContext) -> None:
        super().__init__(plugin_context)
        # Get pattern and the alert_phrase - emoji bindings.
        self._bindings: List[Tuple[Pattern[str], str]] = self._get_bindings()
        # Replace markdown links by their textual representation.
        self._markdown_links: Pattern[str] = re.compile(
            r'\[([^\]]*)\]\([^\)]+\)')

    def _get_bindings(self) -> List[Tuple[Pattern[str], str]]:
        """Compile the regexes and bind them to their emojis."""

        # Get a database connection.
        self._db = DB()

        bindings: List[Tuple[Pattern[str], str]] = []

        # Verify every regex and only use the valid ones.
        for regex, emoji in self._db.execute(self._select_sql):
            try:
                pattern: Pattern[str] = re.compile(regex)
            except re.error:
                continue
            bindings.append((pattern, emoji))

        return bindings

    def handle_event(self, event: Dict[str, Any],
                     **kwargs: Any) -> Union[Response, Iterable[Response]]:
        if not self._bindings:
            return Response.none()

        # Get message content.
        # Replace markdown links by their textual representation.
        # Convert to lowercase.
        content: str = self._markdown_links\
            .sub(r'\1', event['message']['content'])\
            .lower()

        return [
            Response.build_reaction(message=event['message'], emoji=emoji)
            for pattern, emoji in self._bindings
            if randint(1, 6) == 3 and pattern.search(content) is not None
        ]

    def is_responsible(self, event: Dict[str, Any]) -> bool:
        # Do not react on own messages or on private messages where we
        # are not the only recipient.
        return (event['type'] == 'message'
                and event['message']['sender_id'] != self.client.id
                and (event['message']['type'] == 'stream'
                     or self.client.is_only_pm_recipient(event['message'])))
Exemplo n.º 9
0
class Source(CommandPlugin):
    plugin_name = 'sql'
    syntax = cleandoc("""
        sql <sql_script>
          or sql list
        """)
    description = cleandoc("""
        Access the internal database of the bot read-only.
        The `list` command is a shortcut to list all tables.
        [administrator/moderator rights needed]
        """)
    _list_sql: str = 'select * from sqlite_master where type = "table"'

    def __init__(self, plugin_context: PluginContext) -> None:
        super().__init__(plugin_context)
        # Get own read-only (!!!) database connection.
        self._db = DB(read_only=True)

    def handle_message(self, message: Dict[str, Any],
                       **kwargs: Any) -> Union[Response, Iterable[Response]]:
        result_sql: List[Tuple[Any, ...]]

        if not self.client.user_is_privileged(message['sender_id']):
            return Response.admin_err(message)

        try:
            if message['command'] == 'list':
                result_sql = self._db.execute(self._list_sql)
            else:
                result_sql = self._db.execute(message['command'])
        except Exception as e:
            return Response.build_message(message, str(e))

        result: str = '```text\n' + '\n'.join(map(str, result_sql)) + '\n```'

        return Response.build_message(message, result)
Exemplo n.º 10
0
class AutoSubscriber(Plugin):
    plugin_name = 'autosubscriber'
    events = ['stream']
    _insert_sql: str = 'insert or ignore into PublicStreams values (?, 0)'
    _select_sql: str = 'select StreamName, Subscribed from PublicStreams'
    _subscribe_sql: str = 'update PublicStreams set Subscribed = 1 where StreamName = ?'
    _remove_sql: str = 'delete from PublicStreams where StreamName = ?'

    def __init__(self, plugin_context: PluginContext) -> None:
        super().__init__(plugin_context)
        self._db = DB()
        self._db.checkout_table(
            'PublicStreams', '(StreamName text primary key, Subscribed integer not null)'
        )
        # Ensure that we are subscribed to all existing streams.
        for stream_name, subscribed in self._db.execute(self._select_sql):
            if subscribed == 1:
                continue
            self._handle_stream(stream_name, False)

    def is_responsible(self, event: Dict[str, Any]) -> bool:
        return (super().is_responsible(event)
                and (
                    event['op'] == 'create'
                    or event['op'] == 'update'
                    or event['op'] == 'delete'
                ))

    def handle_event(
        self,
        event: Dict[str, Any],
        **kwargs: Any
    ) -> Union[Response, Iterable[Response]]:
        if event['op'] == 'create':
            for stream in event['streams']:
                self._handle_stream(stream['name'], stream['invite_only'])
        elif event['op'] == 'delete':
            for stream in event['streams']:
                self._remove_stream_from_table(stream['name'])
        elif event['op'] == 'update':
            if event['property'] == 'invite_only':
                self._handle_stream(event['name'], event['value'])
            elif (event['property'] == 'name' and not
                  self.client.private_stream_exists(event['name'])):
                # Remove the previous stream name from the database.
                self._remove_stream_from_table(event['name'])
                # Add the new stream name.
                self._handle_stream(event['value'], False)

        return Response.none()

    def _handle_stream(self, stream_name: str, private: bool) -> None:
        """Do the actual subscribing.

        Additionally, keep the list of public streams in the database
        up-to-date.
        """
        if private:
            self._remove_stream_from_table(stream_name)
            return

        try:
            self._db.execute(self._insert_sql, stream_name, commit = True)
        except Exception as e:
            logging.exception(e)

        if self.client.subscribe_users([self.client.id], stream_name):
            try:
                self._db.execute(self._subscribe_sql, stream_name, commit = True)
            except Exception as e:
                logging.exception(e)
        else:
            logging.warning('could not subscribe to %s', stream_name)

    def _remove_stream_from_table(self, stream_name: str) -> None:
        """Remove the given stream name from the PublicStreams table."""
        try:
            self._db.execute(self._remove_sql, stream_name, commit = True)
        except Exception as e:
            logging.exception(e)
Exemplo n.º 11
0
class Group(CommandPlugin):
    plugin_name = 'group'
    events = ['message', 'reaction', 'stream']
    syntax = cleandoc(
        """
        group (un)subscribe <group_id>
          or group add <group_id> <emoji>
          or group remove <group_id>
          or group add_streams <group_id> <stream_pattern>...
          or group remove_streams <group_id> <stream_pattern>...
          or group list
          or group claim <group_id>
          or group unclaim <group_id> <message_id>
          or group announce
          or group unannounce <message_id>
        """
    )
    description = cleandoc(
        """
        Manage stream groups using identifiers.
        **Note that "streams" here only cover public streams!**

        Subscribe to / unsubscribe from a group using \
        `group (un)subscribe`.

        Create/remove a stream group with `group add`/`group remove` by \
        specifing an identifier and an emoji. Note that removing a stream \
        group has no other consequences than removing the associations \
        in the bot!
        Use `group add_streams` to add a newline-separated list of \
        regexes representing the streams which should be considered as \
        part of this stream group. Use `group remove_streams` to do the \
        opposite. Note that you have to quote the regexes!
        With `group list`, you get a list of all group ids with their \
        associated stream patterns.
        Use `group claim` to make a message "special" for a given \
        group. If a user reacts on a "special" message with the emoji \
        that is assigned to the group the message is special for, the \
        user gets subscribed to all streams belonging to this group. \
        A message with the `group claim` command in the first line may \
        also contain arbitrary other text.
        Finally, `group announce` triggers a message from the bot \
        which will be "special" for all groups and in which the bot \
        will maintain a list of all groups.

        [administrator/moderator rights needed except for (un)subscribe]
        """
    )
    _announcement_msg: str = cleandoc(
        """
        Hi! :smile:

        I have the pleasure to announce some stream groups here.
        You may subscribe to a stream group in order to be automatically \
        subscribed to all streams belonging to that group. Also, you \
        will be kept updated when new streams are added to the group.
        Just react to this message with the emoji of the stream group \
        you like to subscribe to. Remove your emoji to unsubscribe \
        from this group. (Note that this will not unsubscribe you from \
        the streams of this group.)

        stream group | emoji
        ------------ | -----
        {}
        *to be continued*

        In case the emojis do not work for you, you may write me a PM:
        - `group subscribe <group_id>`
        - `group unsubscribe <group_id>`

        Have a nice day! :sunglasses:
        """
    )
    _announcement_msg_table_row_fmt: str = '%s | :%s:'
    _announcement_msg_table_row_regex: str = r'\n*%s \| :[^:]+:\s*\n*'
    _claim_all_sql: str = 'insert into GroupClaimsAll values (?)'
    _claim_group_sql: str = 'insert into GroupClaims values (?,?)'
    _get_all_emojis_sql: str = 'select Emoji from Groups'
    _get_claims_for_all_sql: str = 'select MessageId from GroupClaimsAll'
    _get_claims_for_group: str = 'select MessageId from GroupClaims where GroupId = ?'
    _get_emoji_from_group_sql: str = 'select Emoji from Groups where Id = ?'
    _get_group_from_emoji_sql: str = 'select Id from Groups where Emoji = ?'
    _get_group_subscribers_sql: str = 'select UserId from GroupUsers where GroupId = ?'
    _get_streams_sql: str = 'select Streams from Groups where Id = ? collate nocase'
    _insert_sql: str = 'insert into Groups values (?,?,?)'
    _is_group_claimed_by_msg_sql: str = (
        'select * from GroupClaims where GroupId = ? and MessageId = ?'
    )
    _is_message_announcement_sql: str = 'select * from GroupClaimsAll where MessageId = ?'
    _list_sql: str = 'select * from Groups'
    _remove_sql: str = 'delete from Groups where Id = ? collate nocase'
    _subscribe_user_sql: str = 'insert into GroupUsers values (?,?)'
    _update_streams_sql: str = 'update Groups set Streams = ? where Id = ? collate nocase'
    _unclaim_msg_from_group_sql: str = (
        'delete from GroupClaims where MessageId = ? and GroupId = ?'
    )
    _unclaim_msg_for_all_sql: str = 'delete from GroupClaimsAll where MessageId = ?'
    _unsubscribe_user_sql: str = 'delete from GroupUsers where UserId = ? and GroupId = ?'

    def __init__(self, plugin_context: PluginContext) -> None:
        super().__init__(plugin_context)
        # Get own database connection.
        self._db = DB()
        # Check for database tables.
        self._db.checkout_table(
            table = 'Groups',
            schema = '(Id text primary key, Emoji text not null unique, Streams text not null)'
        )
        self._db.checkout_table(
            table = 'GroupUsers',
            schema = ('(UserId integer not null, GroupId text, '
                      'foreign key(GroupId) references Groups(ID) on delete cascade, '
                      'primary key(UserId, GroupID))')
        )
        self._db.checkout_table(
            table = 'GroupClaims',
            schema = ('(MessageId integer not null, GroupId text, '
                      'foreign key(GroupId) references Groups(ID) on delete cascade, '
                      'primary key(MessageId, GroupId))')
        )
        self._db.checkout_table('GroupClaimsAll', '(MessageId integer primary key)')

        # Init command parsing.
        self.command_parser = CommandParser()
        self.command_parser.add_subcommand('subscribe', args={'group_id': str})
        self.command_parser.add_subcommand('unsubscribe', args={'group_id': str})
        self.command_parser.add_subcommand('add', args={'group_id': str, 'emoji': Regex.get_emoji_name})
        self.command_parser.add_subcommand('remove', args={'group_id': str})
        self.command_parser.add_subcommand(
            'add_streams', args={'group_id': str, 'streams': str}, greedy = True
        )
        self.command_parser.add_subcommand(
            'remove_streams', args={'group_id': str, 'streams': str}, greedy = True
        )
        self.command_parser.add_subcommand('list')
        self.command_parser.add_subcommand(
            'claim', args={'group_id': str, 'text': str}, greedy = True, optional = True
        )
        self.command_parser.add_subcommand('unclaim', args={'group_id': str, 'message_id': int})
        self.command_parser.add_subcommand('announce')
        self.command_parser.add_subcommand('unannounce', args={'message_id': int})

        # Init some usefule constants.
        self._get_emoji: Pattern[str] = re.compile(r'\s*:?([^:]+):?\s*')
        # (removing trailing 'api/' from host url).
        self.message_link: str = '[{0}](' + self.client.base_url[:-4] + '#narrow/id/{0})'

    def handle_event(
        self,
        event: Dict[str, Any],
        **kwargs: Any
    ) -> Union[Response, Iterable[Response]]:
        if event['type'] == 'reaction':
            return self.handle_reaction_event(event)
        if event['type'] == 'stream':
            return self.handle_stream_event(event)
        return self.handle_message(event['message'])

    def handle_message(
        self,
        message: Dict[str, Any],
        **kwargs: Any
    ) -> Union[Response, Iterable[Response]]:
        result: Optional[Tuple[str, CommandParser.Opts, CommandParser.Args]]

        # Get command and parameters.
        result = self.command_parser.parse(message['command'])
        if result is None:
            return Response.command_not_found(message)
        command, _, args = result

        if command == 'subscribe':
            return self._subscribe(message['sender_id'], args.group_id, message)
        if command == 'unsubscribe':
            return self._unsubscribe(message['sender_id'], args.group_id, message)

        if not self.client.user_is_privileged(message['sender_id']):
            return Response.admin_err(message)

        if command == 'list':
            return self._list(message)
        if command == 'announce':
            if message['type'] != 'stream':
                return Response.build_message(message, 'Claim only stream messages.')
            return self._announce(message)
        if command == 'unannounce':
            return self._unannounce(message, args.message_id)
        if command == 'claim':
            if message['type'] != 'stream':
                return Response.build_message(message, 'Claim only stream messages.')
            return self._claim(message, args.group_id)
        if command == 'unclaim':
            return self._unclaim(message, args.group_id, args.message_id)
        if command == 'add':
            return self._add(message, args.group_id, args.emoji)
        if command == 'remove':
            return self._remove(message, args.group_id)
        if command in ['add_streams', 'remove_streams']:
            return self._change_streams(message, args.group_id, command, args.streams)

        return Response.command_not_found(message)

    def handle_reaction_event(
        self,
        event: Dict[str, Any],
    ) -> Union[Response, Iterable[Response]]:
        group_id: Optional[str] = self._get_group_id_from_emoji_event(
            event['message_id'], event['emoji_name']
        )

        if group_id is None:
            return Response.none()
        if event['op'] == 'add':
            return self._subscribe(event['user_id'], group_id)
        if event['op'] == 'remove':
            return self._unsubscribe(event['user_id'], group_id)

        return Response.none()

    def handle_stream_event(
        self,
        event: Dict[str, Any],
    ) -> Union[Response, Iterable[Response]]:
        for stream in event['streams']:
            # Get all the groups this stream belongs to.
            group_ids: List[str] = self._get_group_ids_from_stream(stream['name'])
            # Get all user ids to subscribe to this new stream ...
            user_ids: List[int] = self._get_group_subscribers(group_ids)
            # ... and subscribe them.
            self.client.subscribe_users(user_ids, stream['name'])

        return Response.none()

    def is_responsible(
        self,
        event: Dict[str, Any]
    ) -> bool:
        return (
            super().is_responsible(event)
            or (event['type'] == 'reaction'
                and event['op'] in ['add', 'remove']
                and event['user_id'] != self.client.id)
            or (event['type'] == 'stream' and event['op'] == 'create')
        )

    def _add(
        self,
        message: Dict[str, Any],
        group_id: str,
        emoji: str
    ) -> Union[Response, Iterable[Response]]:
        """Command `group add <id> <emoji>`."""
        if '\n' in group_id:
            return Response.build_message(message, 'The group id must not contain newlines.')

        try:
            self._db.execute(
                self._insert_sql, group_id, emoji, '', commit = True
            )
        except IntegrityError as e:
            return Response.build_message(message, str(e))

        # Update the announcement messages.
        if not self._announcements_add_group(group_id):
            return Response.build_message(
                message, 'Group added, but announcement failed for some messages.'
            )

        return Response.ok(message)

    def _announce(
        self,
        message: Dict[str, Any]
    ) -> Union[Response, Iterable[Response]]:
        table: str = '\n'.join(
            self._announcement_msg_table_row_fmt % (group_id, emoji)
            for group_id, emoji, _ in self._db.execute(self._list_sql)
        )

        # Remove the requesting message.
        self.client.delete_message(message['id'])

        # Send own message.
        result: Dict[str, Any] = self.client.send_response(
            Response.build_message(message, self._announcement_msg.format(table))
        )
        if result['result'] != 'success':
            return Response.none()

        # Insert the id of the bot's message into the database.
        try:
            self._db.execute(self._claim_all_sql, result['id'], commit = True)
        except Exception as e:
            return Response.build_message(message, str(e))

        # Get all the currently existant emojis.
        result_sql: List[Tuple[Any, ...]] = self._db.execute(self._get_all_emojis_sql)
        if not result_sql:
            return Response.none()

        # React with all those emojis on this message.
        for emoji in map(lambda t: cast(str, t[0]), result_sql):
            self.client.send_response(Response.build_reaction_from_id(result['id'], emoji))

        return Response.none()

    def _announcements_add_group(
        self,
        group_id: str
    ) -> bool:
        """Add the given group to all announcement messages."""
        emoji: Optional[str] = self._get_emoji_from_group(group_id)
        if not emoji:
            return False
        to_insert: str = self._announcement_msg_table_row_fmt % (group_id, emoji)

        pattern: Pattern[str] = re.compile(r'\n*\*to be continued\*\n*')

        return self._do_for_all_announcement_messages([
            lambda msg: msg.update(content = pattern.sub(
                '\n' + to_insert + '\n*to be continued*\n\n', msg['content']
            )),
            lambda msg: self.client.send_response(
                Response.build_reaction(msg, cast(str, emoji))
            )
        ])

    def _announcements_remove_group(
        self,
        group_id: str
    ) -> bool:
        """Remove the given group from all announcement messages."""
        emoji: Optional[str] = self._get_emoji_from_group(group_id)
        if not emoji:
            return False

        pattern: Pattern[str] = re.compile(
            self._announcement_msg_table_row_regex % re.escape(group_id)
        )

        return self._do_for_all_announcement_messages([
            lambda msg: msg.update(content = pattern.sub('\n', msg['content'])),
            lambda msg: self.client.remove_reaction(
                {'message_id': msg['id'], 'emoji_name': cast(str, emoji)}
            )
        ])

    def _change_streams(
        self,
        message: Dict[str, Any],
        group_id: str,
        command: str,
        change_stream_regs: List[str]
    ) -> Union[Response, Iterable[Response]]:
        """Command `group (add_streams|remove_streams) <id> <stream>...`."""
        # Validate the regexes.
        for reg in change_stream_regs:
            try:
                re.compile(reg)
            except re.error as e:
                return Response.build_message(message, 'invalid regex: %s\n%s', reg, str(e))

        result_sql: List[Tuple[Any, ...]] = self._db.execute(
            self._get_streams_sql, group_id, commit = True
        )
        if not result_sql:
            return Response.build_message(message, f'Group {group_id} does not exist.')

        # Current stream patterns.
        stream_list: List[str] = result_sql[0][0].split('\n')
        # The string containing the new list of stream patterns (newline separated).
        # The patterns have to be non-empty.
        new_streams: str = '\n'.join(filter(
            bool,
            set(stream_list + change_stream_regs) if command == 'add_streams' else
            [s for s in stream_list if s not in change_stream_regs]
        ))

        try:
            self._db.execute(self._update_streams_sql, new_streams, group_id, commit = True)
        except Exception as e:
            logging.exception(e)
            return Response.build_message(message, str(e))

        # Subscribe the group subscribers to the new streams.
        self._subscribe_users_to_stream_regexes(
            self._get_group_subscribers([group_id]), change_stream_regs
        )

        return Response.ok(message)

    def _claim(
        self,
        message: Dict[str, Any],
        group_id: Optional[str],
    ) -> Union[Response, Iterable[Response]]:
        """Command `group claim [id]`."""
        if group_id:
            self._db.execute(self._claim_group_sql, message['id'], group_id, commit = True)
        else:
            self._db.execute(self._claim_all_sql, message['id'], commit = True)

        return Response.ok(message)

    def _do_for_all_announcement_messages(
        self,
        funcs: List[Callable[[Dict[str, Any]], Any]]
    ) -> bool:
        """Apply functions to all announcement messages.

        The return values of the functions will be ignored. The message
        dict may be modified inplace.
        """
        success: bool = True

        for (msg_id,) in self._db.execute(self._get_claims_for_all_sql):
            request: Dict[str, Any] = {
                'anchor': msg_id, 'num_before': 0, 'num_after': 1,
            }
            result: Dict[str, Any] = self.client.get_messages(request)
            if result['result'] != 'success' or not result['messages']:
                logging.warning('could not get message %s', str(request))
                success = False
                continue
            msg: Dict[str, Any] = result['messages'][0]
            for func in funcs:
                func(msg)
            result = self.client.update_message({'message_id': msg_id, 'content': msg['content']})
            if result['result'] != 'success':
                logging.warning('could not edit message %d: %s', msg_id, str(result))
                success = False

        return success

    def _get_emoji_from_group(self, group_id: str) -> Optional[str]:
        """Get the emoji for a given group id."""
        result_sql: List[Tuple[Any, ...]] = self._db.execute(
            self._get_emoji_from_group_sql, group_id
        )
        if not result_sql:
            logging.debug('no emoji found for group %s', group_id)
            return None
        return cast(str, result_sql[0][0])

    def _get_group_id_from_emoji_event(
        self,
        message_id: int,
        emoji: str
    ) -> Optional[str]:
        result_sql: List[Tuple[Any, ...]]

        result_sql = self._db.execute(self._get_group_from_emoji_sql, emoji)
        if not result_sql:
            return None
        group_id: str = cast(str, result_sql[0][0])

        # Check whether the message is claimed by this group.
        result_sql = self._db.execute(self._is_group_claimed_by_msg_sql, group_id, message_id)
        if not result_sql:
            result_sql = self._db.execute(self._is_message_announcement_sql, message_id)

        return group_id if result_sql else None


    def _get_group_ids_from_stream(self, stream_name: str) -> List[str]:
        """Get the ids of the groups the given stream name belongs to."""
        result: List[str] = []

        for group_id, _, stream_regs_str in self._db.execute(self._list_sql):
            stream_regs: List[str] = stream_regs_str.split('\n')
            for stream_reg in stream_regs:
                if not stream_name_match(stream_reg, stream_name):
                    continue
                result.append(group_id)
                break

        return result

    def _get_group_subscribers(self, group_ids: List[str]) -> List[int]:
        """Get the user_ids of all subscribers of the given groups.

        Return no duplicate user_ids.
        """
        result: Set[int] = set()

        for group_id in group_ids:
            result = result.union(set(
                user_id for (user_id,) in
                self._db.execute(self._get_group_subscribers_sql, group_id)
            ))

        return list(result)

    def _list(
        self,
        message: Dict[str, Any]
    ) -> Union[Response, Iterable[Response]]:
        """Command `group list`."""
        response: str = 'Group Id | Emoji | Streams | ClaimedBy\n---- | ---- | ---- | ----'

        for (group_id, emoji, streams) in self._db.execute(self._list_sql):
            streams_concat: str = ', '.join(
                '"{}"'.format(s) for s in streams.split('\n')
            )
            claims: str = ', '.join([
                self.message_link.format(msg_id)
                for (msg_id,) in self._db.execute(self._get_claims_for_group, group_id)
            ])
            response += '\n{0} | {1} :{1}: | `{2}` | {3}'.format(
                group_id, emoji, streams_concat, claims
            )

        response += '\n\nMessages claimed for all groups: ' + ', '.join(
            self.message_link.format(msg_id)
            for msg_id, in self._db.execute(self._get_claims_for_all_sql)
        )

        return Response.build_message(message, response)

    def _remove(
        self,
        message: Dict[str, Any],
        group_id: str,
    ) -> Union[Response, Iterable[Response]]:
        msg_success: bool = self._announcements_remove_group(group_id)

        self._db.execute(self._remove_sql, group_id, commit = True)

        if msg_success:
            return Response.ok(message)

        return Response.build_message(
            message, 'Group removed, but removal failed for some announcement messages.'
        )

    def _subscribe(
        self,
        user_id: int,
        group_id: str,
        message: Optional[Dict[str, Any]] = None
    ) -> Union[Response, Iterable[Response]]:
        """Subscribe a user to a group."""
        msg: str

        try:
            self._db.execute(self._subscribe_user_sql, user_id, group_id, commit = True)
        except IntegrityError as e:
            logging.exception(e)
            # User already subscribed.
            msg = f'I think you are already subscribed to group {group_id}.'
            if message:
                return Response.build_message(message, msg)
            return Response.build_message(
                message = None, content = msg, msg_type = 'private', to = [user_id]
            )

        stream_regs: List[str] = []
        for (stream_regs_str,) in self._db.execute(self._get_streams_sql, group_id):
            if not stream_regs_str:
                continue
            stream_regs.extend(stream_regs_str.split('\n'))

        no_success: List[str] = self._subscribe_users_to_stream_regexes([user_id], stream_regs)

        if not no_success:
            if message is not None:
                return Response.ok(message)
            return Response.build_message(
                message = None, content = f'Subscribed to group {group_id}.',
                msg_type = 'private', to = [user_id]
            )

        msg = 'Failed to subscribe you to the following streams: %s.' % str(no_success)

        if message is not None:
            return Response.build_message(message, msg)
        # Write a private message to the user.
        return Response.build_message(
            message = None, content = msg, msg_type = 'private', to = [user_id]
        )

    def _subscribe_users_to_stream_regexes(
        self,
        user_ids: List[int],
        stream_regs: List[str]
    ) -> List[str]:
        """Subscribe the given group to all streams matching the regexes.

        Return a list of streams to which the users could not be
        subscribed.
        """
        no_success: List[str] = []

        for stream_reg in stream_regs:
            for stream in self.client.get_streams_from_regex(stream_reg):
                if not self.client.subscribe_users(user_ids, stream):
                    no_success.append(stream)

        return no_success

    def _unannounce(
        self,
        message: Dict[str, Any],
        message_id: str
    ) -> Union[Response, Iterable[Response]]:
        self._db.execute(self._unclaim_msg_for_all_sql, message_id, commit = True)
        return Response.ok(message)

    def _unclaim(
        self,
        message: Dict[str, Any],
        group_id: str,
        message_id: str
    ) -> Union[Response, Iterable[Response]]:
        try:
            msg_id: int = int(message_id)
        except ValueError:
            return Response.build_message(message, f'{message_id} is not an integer.')
        self._db.execute(
            self._unclaim_msg_from_group_sql, msg_id, group_id, commit = True
        )
        return Response.ok(message)

    def _unsubscribe(
        self,
        user_id: int,
        group_id: str,
        message: Optional[Dict[str, Any]] = None
    ) -> Union[Response, Iterable[Response]]:
        """Unsubscribe a user from a group."""
        self._db.execute(self._unsubscribe_user_sql, user_id, group_id, commit = True)
        if message is not None:
            return Response.ok(message)
        return Response.build_message(
            message = None, content = f'Unsubscribed from group {group_id}.',
            msg_type = 'private', to = [user_id]
        )
Exemplo n.º 12
0
class Conf(CommandPlugin):
    plugin_name = 'conf'
    syntax = cleandoc("""
        conf set <key> <value>
          or conf remove <key>
          or conf list
        """)
    description = cleandoc("""
        Set/get/remove bot configuration variables.
        [administrator/moderator rights needed]
        """)
    _list_sql: str = 'select * from Conf'
    _remove_sql: str = 'delete from Conf where Key = ?'
    _update_sql: str = 'replace into Conf values (?,?)'

    def __init__(self, plugin_context: PluginContext) -> None:
        super().__init__(plugin_context)
        self._db = DB()
        self._db.checkout_table('Conf',
                                '(Key text primary key, Value text not null)')
        self.command_parser: CommandParser = CommandParser()
        self.command_parser.add_subcommand('list')
        self.command_parser.add_subcommand('set',
                                           args={
                                               'key': str,
                                               'value': str
                                           })
        self.command_parser.add_subcommand('remove', args={'key': str})

    def handle_message(self, message: Dict[str, Any],
                       **kwargs: Any) -> Union[Response, Iterable[Response]]:
        result: Optional[Tuple[str, CommandParser.Opts, CommandParser.Args]]

        if not self.client.user_is_privileged(message['sender_id']):
            return Response.admin_err(message)

        result = self.command_parser.parse(message['command'])
        if result is None:
            return Response.command_not_found(message)
        command, _, args = result

        if command == 'list':
            response: str = 'Key | Value\n ---- | ----'
            for key, value in self._db.execute(self._list_sql):
                response += f'\n{key} | {value}'
            return Response.build_message(message, response)

        if command == 'remove':
            self._db.execute(self._remove_sql, args.key, commit=True)
            return Response.ok(message)

        if command == 'set':
            try:
                self._db.execute(self._update_sql,
                                 args.key,
                                 args.value,
                                 commit=True)
            except Exception as e:
                logging.exception(e)
                return Response.build_message(message, 'Failed: %s' % str(e))
            return Response.ok(message)

        return Response.command_not_found(message)
Exemplo n.º 13
0
class Msg(CommandPlugin):
    plugin_name = 'msg'
    syntax = cleandoc(
        """
        msg add <identifier> <text>
          or msg send|remove <identifier>
          or msg list
        """
    )
    description = cleandoc(
        """
        Store a message for later use, send or delete a stored message \
        or list all stored messages. The text must be quoted but may
        contain line breaks.
        The identifiers are handled case insensitively.
        [administrator/moderator rights needed]
        """
    )
    _delete_sql: str = 'delete from Messages where MsgId = ?'
    _list_sql: str = 'select * from Messages'
    _search_sql: str = 'select MsgText from Messages where MsgId = ?'
    _update_sql: str = 'replace into Messages values (?,?)'

    def __init__(self, plugin_context: PluginContext) -> None:
        super().__init__(plugin_context)
        # Get own database connection.
        self._db = DB()
        # Check for database table.
        self._db.checkout_table(
            table = 'Messages', schema = '(MsgId text primary key, MsgText text not null)'
        )
        self.command_parser: CommandParser = CommandParser()
        self.command_parser.add_subcommand('add', args={'id': str, 'text': str})
        self.command_parser.add_subcommand('send', args={'id': str})
        self.command_parser.add_subcommand('remove', args={'id': str})
        self.command_parser.add_subcommand('list')

    def handle_message(
        self,
        message: Dict[str, Any],
        **kwargs: Any
    ) -> Union[Response, Iterable[Response]]:
        result: Optional[Tuple[str, CommandParser.Opts, CommandParser.Args]]
        result_sql: List[Tuple[Any, ...]]

        if not self.client.user_is_privileged(message['sender_id']):
            return Response.admin_err(message)

        # Get command and parameters.
        result = self.command_parser.parse(message['command'])
        if result is None:
            return Response.command_not_found(message)
        command, _, args = result

        if command == 'list':
            response: str = '***List of Identifiers and Messages***\n'
            for (ident, text) in self._db.execute(self._list_sql):
                response += '\n--------\nTitle: **{}**\n{}'.format(ident, text)
            return Response.build_message(message, response)

        # Use lowercase -> no need for case insensitivity.
        ident = args.id.lower()

        if command == 'send':
            result_sql = self._db.execute(self._search_sql, ident)
            if not result_sql:
                return Response.command_not_found(message)
            # Remove requesting message.
            self.client.delete_message(message['id'])
            return Response.build_message(message, result_sql[0][0])

        if command == 'add':
            self._db.execute(self._update_sql, ident, args.text, commit = True)
            return Response.ok(message)

        if command == 'remove':
            self._db.execute(self._delete_sql, ident, commit = True)
            return Response.ok(message)

        return Response.command_not_found(message)
Exemplo n.º 14
0
 def __init__(self, plugin_context: PluginContext) -> None:
     super().__init__(plugin_context)
     # Get own read-only (!!!) database connection.
     self._db = DB(read_only=True)
Exemplo n.º 15
0
class AlertWord(CommandPlugin):
    plugin_name = 'alert_word'
    syntax = cleandoc(
        """
        alert_word add '<alert phrase>' <emoji>
          or alert_word remove '<alert phrase>'
          or alert_word list
        """
    )
    description = cleandoc(
        """
        Add an alert word / phrase together with the emoji the bot \
        should use to react on messages containing the corresponding \
        alert phrase.
        For the new alert phrases to take effect, please restart the \
        bot.
        Note that an alert phrase may be any regular expression.
        Hint: `\\b` represents word boundaries.
        [administrator/moderator rights needed]
        """
    )
    _update_sql: str = 'replace into Alerts values (?,?)'
    _remove_sql: str = 'delete from Alerts where Phrase = ?'
    _list_sql: str = 'select * from Alerts'

    def __init__(self, plugin_context: PluginContext) -> None:
        super().__init__(plugin_context)
        # Get own database connection.
        self._db = DB()
        # Check for database table.
        self._db.checkout_table(
            table = 'Alerts', schema = '(Phrase text primary key, Emoji text not null)'
        )
        self.command_parser: CommandParser = CommandParser()
        self.command_parser.add_subcommand('add', args={
            'alert_phrase': str, 'emoji': Regex.get_emoji_name
        })
        self.command_parser.add_subcommand('remove', args={'alert_phrase': str})
        self.command_parser.add_subcommand('list')

    def handle_message(
        self,
        message: Dict[str, Any],
        **kwargs: Any
    ) -> Union[Response, Iterable[Response]]:
        result: Optional[Tuple[str, CommandParser.Opts, CommandParser.Args]]
        result_sql: List[Tuple[Any, ...]]

        if not self.client.user_is_privileged(message['sender_id']):
            return Response.admin_err(message)

        # Get command and parameters.
        result = self.command_parser.parse(message['command'])
        if result is None:
            return Response.command_not_found(message)
        command, _, args = result

        if command == 'list':
            result_sql = self._db.execute(self._list_sql)
            response: str = 'Alert word or phrase | Emoji\n---- | ----'
            for (phrase, emoji) in result_sql:
                response += '\n`{0}` | {1} :{1}:'.format(phrase, emoji)
            return Response.build_message(message, response)

        # Use lowercase -> no need for case insensitivity.
        alert_phrase: str = args.alert_phrase.lower()

        if command == 'add':
            # Add binding to database or update it.
            self._db.execute(self._update_sql, alert_phrase, args.emoji, commit = True)
        elif command == 'remove':
            self._db.execute(self._remove_sql, alert_phrase, commit = True)

        return Response.ok(message)
Exemplo n.º 16
0
class Client(ZulipClient):
    """Wrapper around zulip.Client.

    Additional attributes:
      id         direct access to get_profile()['user_id']
      ping       string used to ping the bot "@**<bot name>**"
      ping_len   len(ping)

    Additional Methods:
    -------------------
    get_public_stream_names   Get the names of all public streams.
    get_streams_from_regex    Get the names of all public streams
                              matching a regex.
    get_stream_name           Get stream name for provided stream id.
    private_stream_exists     Check if there is a private stream with
                              the given name.
    send_response             Send one single response.
    send_responses            Send a list of responses.
    subscribe_all_from_stream_to_stream
                              Try to subscribe all users from one public
                              stream to another.
    subscribe_users           Subscribe a list of user ids to a public
                              stream.
    """
    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Override the constructor of the parent class."""
        super().__init__(*args, **kwargs)
        self.id: int = self.get_profile()['user_id']
        self.ping: str = '@**{}**'.format(self.get_profile()['full_name'])
        self.ping_len: int = len(self.ping)
        self.register_params: Dict[str, Any] = {}
        self._db = DB()
        self._db.checkout_table(
            'PublicStreams',
            '(StreamName text primary key, Subscribed integer not null)')

    def call_endpoint(self,
                      url: Optional[str] = None,
                      method: str = "POST",
                      request: Optional[Dict[str, Any]] = None,
                      longpolling: bool = False,
                      files: Optional[List[IO[Any]]] = None,
                      timeout: Optional[float] = None) -> Dict[str, Any]:
        """Override zulip.Client.call_on_each_event.

        This is the backend for almost all API-user facing methods.
        Automatically resend requests if they failed because of the
        API rate limit.
        """
        result: Dict[str, Any]

        while True:
            result = super().call_endpoint(url, method, request, longpolling,
                                           files, timeout)
            if not (result['result'] == 'error' and 'code' in result
                    and result['code'] == 'RATE_LIMIT_HIT'):
                break
            secs: float = result[
                'retry-after'] if 'retry-after' in result else 1
            logging.warning('hit API rate limit, waiting for %f seconds...',
                            secs)
            time.sleep(secs)

        return result

    def call_on_each_event(self,
                           callback: Callable[[Dict[str, Any]], None],
                           event_types: Optional[List[str]] = None,
                           narrow: Optional[List[List[str]]] = None,
                           **kwargs: Any) -> None:
        """Override zulip.Client.call_on_each_event.

        Add additional parameters to pass to register().
        See https://zulip.com/api/register-queue for the parameters
        the register() method accepts.
        """
        self.register_params = kwargs
        super().call_on_each_event(callback, event_types, narrow)

    def get_messages(self, message_filters: Dict[str, Any]) -> Dict[str, Any]:
        """Override zulip.Client.get_messages.

        Defaults to 'apply_markdown' = False.
        """
        message_filters['apply_markdown'] = False
        return super().get_messages(message_filters)

    def get_public_stream_names(self, use_db: bool = True) -> List[str]:
        """Get the names of all public streams.

        Use the database in conjunction with the plugin "autosubscriber"
        to avoid unnecessary network requests.
        In case of an error, return an empty list.
        """
        def without_db() -> List[str]:
            result: Dict[str, Any] = self.get_streams(include_public=True,
                                                      include_subscribed=False)
            if result['result'] != 'success':
                return []
            return list(map(lambda d: cast(str, d['name']), result['streams']))

        if not use_db:
            return without_db()

        try:
            return list(
                map(lambda t: cast(str, t[0]),
                    self._db.execute('select StreamName from PublicStreams')))
        except Exception as e:
            logging.exception(e)
            return without_db()

    def get_streams_from_regex(self, regex: str) -> List[str]:
        """Get the names of all public streams matching a regex.

        The regex has to match the full stream name.
        Note that Zulip handles stream names case insensitively at the
        moment.

        Return an empty list if the regex is not valid.
        """
        if not regex:
            return []

        try:
            pat: Pattern[str] = re.compile(regex, flags=re.I)
        except re.error:
            return []

        return [
            stream_name for stream_name in self.get_public_stream_names()
            if pat.fullmatch(stream_name)
        ]

    def get_stream_name(self, stream_id: int) -> Optional[str]:
        """Get stream name for provided stream id.

        Return the stream name as string or None if the stream name
        could not be determined.
        """
        result: Dict[str, Any] = self.get_streams(include_all_active=True)
        if result['result'] != 'success':
            return None

        for stream in result['streams']:
            if stream['stream_id'] == stream_id:
                return cast(str, stream['name'])

        return None

    def get_user_ids_from_attribute(
            self,
            attribute: str,
            values: Iterable[Any],
            case_sensitive: bool = True) -> Optional[List[int]]:
        """Get the user ids from a given user attribute.

        Get and return a list of user ids of all users whose profiles
        contain the attribute "attribute" with a value present in
        "values.
        If case_sensitive is set to False, the values will be
        interpreted as strings and compared case insensitively.
        Return None on error.
        """
        result: Dict[str, Any] = self.get_users()
        if result['result'] != 'success':
            return None

        if not case_sensitive:
            values = map(lambda x: str(x).lower(), values)

        value_set: Set[Any] = set(values)

        return [
            user['user_id'] for user in result['members']
            if attribute in user and (
                user[attribute] in value_set if case_sensitive else str(
                    user[attribute]).lower() in value_set)
        ]

    def get_user_ids_from_display_names(
            self, display_names: Iterable[str]) -> Optional[List[int]]:
        """Get the user id from a user display name.

        Since there may be multiple users with the same display name,
        the returned list of user ids may be longer than the given list
        of user display names.
        Return None on error.
        """
        return self.get_user_ids_from_attribute('full_name', display_names)

    def get_user_ids_from_emails(self,
                                 emails: Iterable[str]) -> Optional[List[int]]:
        """Get the user id from a user email address.

        Return None on error.
        """
        return self.get_user_ids_from_attribute('delivery_email',
                                                emails,
                                                case_sensitive=False)

    def get_users(self,
                  request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
        """Override method from parent class."""
        # Try to minimize the network traffic.
        if request is not None:
            request.update(client_gravatar=True,
                           include_custom_profile_fields=False)
        return super().get_users(request)

    def is_only_pm_recipient(self, message: Dict[str, Any]) -> bool:
        """Check whether the bot is the only recipient of the given pm.

        Check whether the message is a private message and the bot is
        the only recipient.
        """
        if not message['type'] == 'private' or message['sender_id'] == self.id:
            return False

        # Note that the list of users who received the pm includes the sender.

        recipients: List[Dict[str, Any]] = message['display_recipient']
        if len(recipients) != 2:
            return False

        return self.id in [recipients[0]['id'], recipients[1]['id']]

    def private_stream_exists(self, stream_name: str) -> bool:
        """Check if there is a private stream with the given name.

        Return true if there is a private stream with the given name.
        Return false if there is no stream with this name or if the
        stream is not private.
        """
        result: Dict[str, Any] = self.get_streams(include_all_active=True)
        if result['result'] != 'success':
            return False  # TODO?

        for stream in result['streams']:
            if stream_names_equal(stream['name'], stream_name):
                return bool(stream['invite_only'])

        return False

    def register(self,
                 event_types: Optional[Iterable[str]] = None,
                 narrow: Optional[List[List[str]]] = None,
                 **kwargs: Any) -> Dict[str, Any]:
        """Override zulip.Client.register.

        Override the parent method in order to enable additional
        parameters for the register() call internally used by
        call_on_each_event.
        """
        logging.debug('event_types: %s, narrow: %s', str(event_types),
                      str(narrow))
        return super().register(event_types, narrow, **self.register_params)

    def send_response(self, response: Response) -> Dict[str, Any]:
        """Send one single response."""
        logging.debug('send_response: %s', str(response))

        if response.message_type == MessageType.MESSAGE:
            return self.send_message(response.response)
        if response.message_type == MessageType.EMOJI:
            return self.add_reaction(response.response)
        return {}

    def send_responses(
        self, responses: Union[Response, Iterable[Union[Response,
                                                        Iterable[Response]]],
                               Union[Response, Iterable[Response]]]
    ) -> None:
        """Send the given responses."""
        if responses is None:
            logging.debug('responses is None, this should never happen')
            return

        if not isinstance(responses, IterableClass):
            self.send_response(responses)
            return

        for response in responses:
            self.send_responses(response)

    def subscribe_all_from_stream_to_stream(
            self,
            from_stream: str,
            to_stream: str,
            description: Optional[str] = None) -> bool:
        """Try to subscribe all users from one public stream to another.

        Arguments:
        ----------
        from_stream   An existant public stream.
        to_stream     The stream to subscribe to.
                      Must be public, if already existant. If it does
                      not already exists, it will be created.
        description   An optional description to be used to
                      create the stream first.

        Return true on success or false otherwise.
        """
        if (self.private_stream_exists(from_stream)
                or self.private_stream_exists(to_stream)):
            return False

        subs: Dict[str, Any] = self.get_subscribers(stream=from_stream)
        if subs['result'] != 'success':
            return False

        return self.subscribe_users(subs['subscribers'], to_stream,
                                    description)

    def subscribe_users(self,
                        user_ids: List[int],
                        stream_name: str,
                        description: Optional[str] = None,
                        allow_private_streams: bool = False) -> bool:
        """Subscribe a list of user ids to a public stream.

        Arguments:
        ----------
        user_ids      The list of user ids to subscribe.
        stream_name   The name of the stream to subscribe to.
        description   An optional description to be used to
                      create the stream first.

        Return true on success or false otherwise.
        """
        chunk_size: int = 100
        success: bool = True

        if not allow_private_streams and self.private_stream_exists(
                stream_name):
            return False

        subscription: Dict[str, str] = {'name': stream_name}
        if description is not None:
            subscription.update(description=description)

        for i in range(0, len(user_ids), chunk_size):
            # (a too large index will be automatically reduced to len())
            user_id_chunk: List[int] = user_ids[i:i + chunk_size]

            while True:
                result: Dict[str, Any] = self.add_subscriptions(
                    streams=[subscription], principals=user_id_chunk)
                if result['result'] == 'success':
                    break
                if result[
                        'code'] == 'UNAUTHORIZED_PRINCIPAL' and 'principal' in result:
                    user_id_chunk.remove(result['principal'])
                    continue
                logging.warning(str(result))
                success = False
                break

        return success

#    def subscribe_user(
#        self,
#        user_id: int,
#        stream_name: str
#    ) -> bool:
#        """Subscribe a user to a public stream.
#
#        The subscription is only executed if the user is not yet
#        subscribed to the stream with the given name.
#        See docs: https://zulip.com/api/get-events#stream-add.
#        Do not subscribe to private streams.
#
#        Return True if the user has already subscribed to the given
#        stream or if they now are subscribed and False otherwise.
#        """
#        result: Dict[str, Any]
#
#        if self.private_stream_exists(stream_name):
#            return False
#
#        result = self.get_stream_id(stream_name)
#        if result['result'] != 'success':
#            return False
#        stream_id: int = result['stream_id']
#
#        # Check whether the user has already subscribed to that stream.
#        result = self.call_endpoint(
#            url = '/users/{}/subscriptions/{}'.format(user_id, stream_id),
#            method = 'GET'
#        )
#        # If the request failed, we try to subscribe anyway.
#        if result['result'] == 'success' and result['is_subscribed']:
#            return True
#        elif result['result'] != 'success':
#            logging.warning('failed subscription status check, stream_id %s', stream_id)
#
#        success: bool = self.subscribe_users([user_id], stream_name)
#        if not success:
#            logging.warning('cannot subscribe %s to stream: %s', user_id, str(result))
#
#        return success

    def user_is_privileged(self, user_id: int) -> bool:
        """Check whether a user is allowed to perform privileged commands.

        Some commands of this bot are only allowed to be performed by
        privileged users. Which user roles are considered to be privileged
        in the context of this bot:
            - prior to Zulip 4.0:
                Organization owner, Organization administrator
            - since Zulip 4.0:
                Organization owner, Organization administrator,
                Organization moderator

        Arguments:
        ----------
            user_id    The user_id to examine.
        """
        result: Dict[str, Any] = self.get_user_by_id(user_id)
        if result['result'] != 'success':
            return False
        user: Dict[str, Any] = result['user']

        if 'role' in user and isinstance(user['role'],
                                         int) and user['role'] in [100, 200]:
            return True
        if 'is_admin' in user and isinstance(user['is_admin'], bool):
            return user['is_admin']

        return False