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 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)
def handle_message(self, message: Dict[str, Any], **kwargs: Any) -> Union[Response, Iterable[Response]]: if not self.client.user_is_privileged(message['sender_id']): return Response.admin_err(message) handlers: List[logging.Handler] = logging.getLogger().handlers if not handlers or len(handlers) > 1: return Response.build_message(message, 'Cannot determine the logfile.') if not isinstance(handlers[0], logging.FileHandler): return Response.build_message(message, 'No logfile in use.') # Upload the logfile. (see https://zulip.com/api/upload-file) with open(handlers[0].baseFilename, 'rb') as lf: result: Dict[str, Any] = self.client.call_endpoint('user_uploads', method='POST', files=[lf]) if result['result'] != 'success': return Response.build_message(message, 'Could not upload the logfile.') return Response.build_message(message, '[logfile]({})'.format(result['uri']))
def _move_topic( self, message: Dict[str, Any], dest: str, count: int ) -> Union[Response, Iterable[Response]]: if count < 1: return Response.build_message(message, 'Error: Message count must be >= 1.') # Get the stream id ... stream_id: int = message['stream_id'] # ... and the topic of the current message. topic: str = message['subject'] # Get the message that is count "hops" before the given message # in this topic. request: Dict[str, Any] = { 'anchor': 'newest', 'num_before': count + 1, 'num_after': 0, 'narrow': [ {'operator': 'stream', 'operand': stream_id}, {'operator': 'topic', 'operand': topic}, {'operator': 'near', 'operand': str(message['id'])} ] } result = self.client.get_messages(request) if result['result'] != 'success': return Response.error(message) if len(result['messages']) < 2: return Response.build_message(message, 'No message to move.') first_message: Dict[str, Any] = result['messages'][0] # Move message (and all following in the same topic) to the new topic. request = { 'message_id': first_message['id'], 'topic': dest, 'stream_id': stream_id, 'send_notification_to_old_thread': False, 'send_notification_to_new_thread': False, 'propagate_mode': 'change_later' } result = self.client.update_message(request) if result['result'] != 'success': return Response.error(message) # Remove requesting message. self.client.delete_message(message['id']) # Get current stream name. stream_name: Optional[str] = self.client.get_stream_name(stream_id) from_loc: str = (stream_name if stream_name is not None else "unknown") + ">" + topic to_loc: str = (stream_name if stream_name is not None else "unknown") + ">" + dest return Response.build_message( first_message, self.msg_template_topic.format(first_message['sender_full_name'], from_loc, to_loc), msg_type = 'private' )
def handle_message(self, message: Dict[str, Any], **kwargs: Any) -> Union[Response, Iterable[Response]]: if not self.client.user_is_privileged(message['sender_id']): return Response.admin_err(message) stream_regexes: Optional[List[Any]] = split( message['command'], converter=[validate_and_return_regex]) if stream_regexes is None or None in stream_regexes: return Response.build_message( message, 'Found invalid regular expressions.') response: List[str] = [] for stream_regex in stream_regexes: streams: List[str] = self.client.get_streams_from_regex( stream_regex) removed: int = 0 for stream in streams: result: Dict[str, Any] = self.client.get_stream_id(stream) if result['result'] != 'success': continue stream_id: int = result['stream_id'] # Check if stream is empty. result = self.client.get_messages({ 'anchor': 'oldest', 'num_before': 0, 'num_after': 1, 'narrow': [{ 'operator': 'stream', 'operand': stream_id }] }) if result['result'] != 'success' or result['messages']: continue # Archive the stream: https://zulip.com/help/archive-a-stream result = self.client.delete_stream(stream_id) if result['result'] == 'success': removed += 1 response.append('"%s" - found %d matching streams, removed %d' % (stream_regex, len(streams), removed)) return Response.build_message(message, '\n'.join(response))
def subscribe_users( self, message: Dict[str, Any], dest_stream: str, users: List[Union[str, Tuple[str, Optional[int]]]] ) -> Union[Response, Iterable[Response]]: # First, get all the ids of the users whose ids we do not already know. user_ids: Optional[ List[int]] = self.client.get_user_ids_from_display_names( map( lambda o: o[0] if isinstance(o, tuple) else o, filter( lambda o: isinstance(o, str) or (isinstance(o, tuple) and o[1] is None), users))) if user_ids is None: return Response.build_message( message, 'error: could not get the user ids.') user_ids.extend( map( lambda t: cast(int, t[1]), filter( lambda o: isinstance(o, tuple) and isinstance(o[1], int), users))) if not self.client.subscribe_users(user_ids, dest_stream): return Response.error(message) return Response.ok(message)
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)
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 handle_message( self, message: Dict[str, Any], **kwargs: Any ) -> Union[Response, Iterable[Response]]: if not self.client.user_is_privileged(message['sender_id']): return Response.admin_err(message) failed: List[str] = [] stream_tuples: Optional[List[Any]] = split( message['command'], converter = [lambda t: split( t, sep = ',', exact_split = 2, discard_empty = False )] ) if stream_tuples is None or None in stream_tuples: return Response.error(message) for stream, desc in stream_tuples: if not stream: failed.append('one empty stream name') continue result: Dict[str, Any] = self.client.add_subscriptions( streams = [{'name': stream, 'description': desc}] ) if result['result'] != 'success': failed.append(f'stream: {stream}, description: {desc}') if not failed: return Response.ok(message) response: str = 'Failed to create the following streams:\n' + '\n'.join(failed) return Response.build_message(message, response, msg_type = 'private')
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 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)
def _move_stream( self, message: Dict[str, Any], dest: str ) -> Union[Response, Iterable[Response]]: # Get destination stream name. dest_stream: Optional[str] = Regex.get_stream_name(dest) if dest_stream is None: return Response.command_not_found(message) # Get destination stream id. result: Dict[str, Any] = self.client.get_stream_id(dest_stream) if result['result'] != 'success': return Response.error(message) dest_stream_id: int = result['stream_id'] # Get the stream id ... src_stream_id: int = message['stream_id'] # ... and the topic of the current message. topic: str = message['subject'] # Get message which started the topic. request: Dict[str, Any] = { 'anchor': 'oldest', 'num_before': 0, 'num_after': 1, 'narrow': [ { 'operator': 'stream', 'operand': src_stream_id }, { 'operator': 'topic', 'operand': topic } ] } result = self.client.get_messages(request) if result['result'] != 'success': return Response.error(message) first_message: Dict[str, Any] = result['messages'][0] # Move message (and all following in the same topic) = move topic. request = { 'message_id': first_message['id'], 'topic': topic, 'stream_id': dest_stream_id, 'send_notification_to_old_thread': False, 'propagate_mode': 'change_all' } result = self.client.update_message(request) if result['result'] != 'success': return Response.error(message) # Remove requesting message. self.client.delete_message(message['id']) return Response.build_message( first_message, self.msg_template_stream.format(first_message['sender_full_name'], topic, dest_stream), msg_type = 'private' )
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_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)
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 _help_overview( self, message: Dict[str, Any]) -> Union[Response, Iterable[Response]]: # Get the command names. help_message: str = '\n'.join( map(lambda tuple: '- ' + tuple[0], self.help_info)) return Response.build_message(message, self._help_overview_template.format( message['sender_full_name'], help_message), msg_type='private', to=message['sender_email'])
def subscribe_user_emails( self, message: Dict[str, Any], dest_stream: str, user_emails: List[str]) -> Union[Response, Iterable[Response]]: user_ids: Optional[List[int]] = self.client.get_user_ids_from_emails( user_emails) if user_ids is None: return Response.build_message( message, 'error: could not get the user ids.') if not self.client.subscribe_users( user_ids, dest_stream, allow_private_streams=True): return Response.error(message) 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] )
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 handle_message(self, message: Dict[str, Any], **kwargs: Any) -> Union[Response, Iterable[Response]]: if not self.client.user_is_privileged(message['sender_id']): return Response.admin_err(message) # Get the dirname of this file (which is located in the git repo). git_dir: Path = Path(__file__).parent.absolute() try: os.chdir(git_dir) except Exception as e: logging.exception(e) return Response.build_message( message, f'Cannot access the directory of my git repo {git_dir}. Please contact the admin.' ) # Execute command and capture stdout and stderr into one stream (stdout). try: result: sp.CompletedProcess[Any] = sp.run( self._git_pull_cmd, stdout=sp.PIPE, stderr=sp.STDOUT, text=True, timeout=self._timeout, ) except sp.TimeoutExpired: return Response.build_message( message, f'{self._git_pull_cmd} failed: timeout ({self._timeout} seconds) expired' ) return Response.build_message( message, f'Return code: {result.returncode}\nOutput:\n```text\n{result.stdout}\n```' )
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 _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 _help_command(self, message: Dict[str, Any], command: str) -> Union[Response, Iterable[Response]]: info_tuple: Optional[Tuple[str, str, str]] = None for ituple in self.help_info: if ituple[0] == command: info_tuple = ituple break if info_tuple is None: return Response.command_not_found(message) help_message: str = '\n'.join(info_tuple[1:]) return Response.build_message(message, help_message, msg_type='private', to=message['sender_email'])
def handle_message( self, message: Dict[str, Any], **kwargs: Any ) -> Union[Response, Iterable[Response]]: # Get search string and quote it. search: str = urllib.parse.quote(message['command'], safe = '') # Fix strange behavior of Zulip which does not accept literal periods. search = search.replace('.', '%2E') # Get host url (removing trailing 'api/'). base_url: str = self.client.base_url[:-4] # Build the full url. url: str = base_url + self.path + search # Remove requesting message. self.client.delete_message(message['id']) return Response.build_message( message, self.msg_template.format(url) )
def subscribe_streams( self, message: Dict[str, Any], dest_stream: str, streams: List[str]) -> Union[Response, Iterable[Response]]: if not self.client.user_is_privileged(message['sender_id']): return Response.admin_err(message) failed: List[str] = [] for stream in streams: if not self.client.subscribe_all_from_stream_to_stream( stream, dest_stream, None): failed.append(stream) if not failed: return Response.ok(message) return Response.build_message( message, 'Failed to subscribe the following streams:\n' + '\n'.join(failed))
def handle_message( self, message: Dict[str, Any], **kwargs: Any ) -> Union[Response, Iterable[Response]]: if not self.client.user_is_privileged(message['sender_id']): return Response.admin_err(message) failed: List[str] = [] stream_tuples: Optional[List[Any]] = split( message['command'], converter = [lambda t: split(t, sep = ',', exact_split = 2)] ) if stream_tuples is None or None in stream_tuples: return Response.error(message) for old, new in stream_tuples: # Used for error messages. line: str = f'{old} -> {new}' try: old_id: int = self.client.get_stream_id(old)['stream_id'] except Exception as e: logging.exception(e) failed.append(line) continue result: Dict[str, Any] = self.client.update_stream( {'stream_id': old_id, 'new_name': '"{}"'.format(new)} ) if result['result'] != 'success': failed.append(line) if not failed: return Response.ok(message) response: str = 'Failed to perform the following renamings:\n' + '\n'.join(failed) return Response.build_message(message, response, msg_type = 'private')
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) if 'stream_id' not in message: return Response.build_message(message, 'Error: Not a stream topic.') result = self.command_parser.parse('move ' + message['command']) if result is None: return Response.command_not_found(message) _, opts, args = result dest: str = ' '.join(args.dest) if opts.m is not None: return self._move_topic(message, dest, opts.m) return self._move_stream(message, dest)
def handle_message(self, message: Dict[str, Any], **kwargs: Any) -> Union[Response, Iterable[Response]]: return Response.build_message(message, 'https://github.com/ro-i/tumcsbot')