class KeywordPromptMiddleware(Middleware): """ """ middleware_id: ModuleID = ModuleID("blueness.KeywordPromptMiddleware") middleware_name: str = "Keyword Prompt Middleware" __version__: str = '0.0.1' keywords = ['收到红包,请在手机上查看'] def __init__(self, instance_id: Optional[InstanceID] = None): super().__init__(instance_id) def process_message(self, message: Message) -> Optional[Message]: if message.type == MsgType.Text and \ message.author.module_id.startswith("blueset.wechat") and \ type(message.chat) == GroupChat and \ self.match_list(message.text): x: int = 0 message.text = "@ME " + message.text message.substitutions = Substitutions({}) message.substitutions[(x, x + 3)] = message.chat return message def match_list(self, text) -> bool: """ 关键字的匹配,主要匹配keywords的列表 """ for i in self.keywords: if text.find(i) != -1: return True return False
def __init__( self, db: 'DatabaseManager', *, channel: Optional[SlaveChannel] = None, middleware: Optional[Middleware] = None, module_name: str = "", channel_emoji: str = "", module_id: ModuleID = ModuleID(""), name: str = "", alias: Optional[str] = None, uid: ChatID = ChatID(""), vendor_specific: Dict[str, Any] = None, description: str = "", notification: ChatNotificationState = ChatNotificationState.ALL, with_self: bool = True): super().__init__(db, channel=channel, middleware=middleware, module_name=module_name, channel_emoji=channel_emoji, module_id=module_id, name=name, alias=alias, uid=uid, vendor_specific=vendor_specific, description=description, notification=notification, with_self=with_self)
def chat_id_str_to_id(s: EFBChannelChatIDStr) -> Tuple[ModuleID, ChatID]: """ Reverse of chat_id_to_str. Returns: channel_id, chat_uid """ chat_ids = s.split(" ", 1) return ModuleID(chat_ids[0]), ChatID(chat_ids[1])
class CaiYunWeatherSlave(SlaveChannel): """ This slave channel crawls weather data from CaiYun API regularly and optionally opens a web server to serve the data. """ channel_name: str = 'Echo channel' channel_emoji: str = '🔁' channel_id: ModuleID = ModuleID('hawthorn.echo') __version__: str = __version__ supported_message_types = {MsgType.Text} def __init__(self, instance_id: InstanceID = None): super().__init__(instance_id) # For test self.chat: PrivateChat = PrivateChat( channel=self, name='Echo Message', uid=ChatID('echo_chat')) self.condition: Optional[Condition] = None def send_message(self, msg: Message) -> Message: if msg.type == MsgType.Text: coordinator.send_message(Message( uid=MessageID(f'echo_{uuid4()}'), type=MsgType.Text, chat=self.chat, author=self.chat.other, deliver_to=coordinator.master, text=msg.text, )) return msg def send_status(self, status: Status): raise EFBOperationNotSupported() def get_chat(self, chat_uid: ChatID) -> Chat: if chat_uid == self.chat.uid: return self.chat raise EFBChannelNotFound() def get_chat_picture(self, chat: Chat) -> BinaryIO: raise EFBOperationNotSupported() def get_chats(self) -> Collection[Chat]: return [self.chat] def get_message_by_id(self, chat: Chat, msg_id: MessageID) -> Optional[Message]: return None def poll(self): pass def stop_polling(self): pass
def __init__(self, profile: str, instance_id: str): coordinator.profile = profile self.profile = profile self.instance_id = instance_id self.channel_id = WeChatChannel.channel_id if instance_id: self.channel_id = ModuleID(self.channel_id + "#" + instance_id) self.config_path = utils.get_config_path(self.channel_id) self.yaml = YAML() if not self.config_path.exists(): self.build_default_config() else: self.data = self.yaml.load(self.config_path.open())
def chat_id_str_to_id(s: EFBChannelChatIDStr) -> Tuple[ModuleID, ChatID, Optional[ChatID]]: """ Reverse of chat_id_to_str. Returns: channel_id, chat_uid, group_id """ ids = s.split(" ", 2) channel_id = ModuleID(ids[0]) chat_uid = ChatID(ids[1]) if len(ids) < 3: group_id = None else: group_id = ChatID(ids[2]) return channel_id, chat_uid, group_id
def __init__(self, profile: str, instance_id: str): print("==== etm_wizard, data mod, init", profile) coordinator.profile = profile self.profile = profile self.instance_id = instance_id self.channel_id = TelegramChannel.channel_id if instance_id: self.channel_id = ModuleID(self.channel_id + "#" + instance_id) self.config_path = utils.get_config_path(self.channel_id) self.yaml = YAML() if not self.config_path.exists(): self.build_default_config() else: self.data = self.yaml.load(self.config_path.open())
class MockMiddleware(EFBMiddleware): """ Attributes: mode: * Default "log": Logging only * "append_text": Append to the text attribute of the message * "interrupt": Interrupt and stop all messages from delivering. * "interrupt_non_text": Interrupt only non-text messages. """ middleware_id: ModuleID = ModuleID("tests.mocks.middleware.MockMiddleware") middleware_name: str = "Mock Middleware" __version__: str = '0.0.1' logger = logging.getLogger(middleware_id) def __init__(self, instance_id: Optional[InstanceID] = None, mode: str = "log"): super().__init__(instance_id=instance_id) self.mode: str = mode def process_message(self, message: EFBMsg) -> Optional[EFBMsg]: self.logger.debug("Processing Message %s", message) if self.mode == "append_text": message.text += " (Processed by " + self.middleware_id + ")" elif self.mode == "interrupt": return None elif self.mode == "interrupt_non_text" and message.type != MsgType.Text: return None return message def process_status(self, status: EFBStatus) -> Optional[EFBStatus]: if self.mode == "interrupt": return None return status @extra(name="Echo", desc="Echo back the input.\n" "Usage:\n" " {function_name} text") def echo(self, args): return args
class TelegramChannel(MasterChannel): """ EFB Channel - Telegram (Master) Based on python-telegram-bot, Telegram Bot API Author: Eana Hufwe <https://github.com/blueset> Configuration file example: .. code-block:: yaml token: "12345678:1a2b3c4d5e6g7h8i9j" admins: - 102938475 - 91827364 flags: join_msg_threshold_secs: 10 multiple_slave_chats: false """ # Meta Info channel_name = "Telegram Master" channel_emoji = "✈" channel_id = ModuleID("blueset.telegram") supported_message_types = { MsgType.Text, MsgType.File, MsgType.Voice, MsgType.Image, MsgType.Link, MsgType.Location, MsgType.Sticker, MsgType.Video, MsgType.Animation, MsgType.Status } __version__ = __version__ # Data _stop_polling = False timeout_count = 0 # Constants config: dict # Translator translator: NullTranslations = translation("efb_telegram_master", resource_filename( 'efb_telegram_master', 'locale'), fallback=True) locale: Optional[str] = None # RPC server rpc_server: SimpleXMLRPCServer = None def __init__(self, instance_id: InstanceID = None): """ Initialization. """ super().__init__(instance_id) # Check PIL support for WebP Image.init() if 'WEBP' not in Image.ID or not WebPImagePlugin.SUPPORTED: raise EFBException( self._( "WebP support of Pillow is required.\n" "Please refer to Pillow Documentation for instructions.\n" "https://pillow.readthedocs.io/")) # Suppress debug logs from dependencies logging.getLogger('requests').setLevel(logging.CRITICAL) logging.getLogger('urllib3').setLevel(logging.CRITICAL) logging.getLogger('telegram.bot').setLevel(logging.CRITICAL) logging.getLogger( 'telegram.vendor.ptb_urllib3.urllib3.connectionpool').setLevel( logging.CRITICAL) # Set up logger self.logger: logging.Logger = logging.getLogger(__name__) # Load configs self.load_config() # Load predefined MIME types mimetypes.init(files=["mimetypes"]) # Initialize managers self.flag: ExperimentalFlagsManager = ExperimentalFlagsManager(self) self.db: DatabaseManager = DatabaseManager(self) self.chat_manager: ChatObjectCacheManager = ChatObjectCacheManager( self) self.chat_dest_cache: ChatDestinationCache = ChatDestinationCache( self.flag("send_to_last_chat")) self.bot_manager: TelegramBotManager = TelegramBotManager(self) self.commands: CommandsManager = CommandsManager(self) self.chat_binding: ChatBindingManager = ChatBindingManager(self) self.slave_messages: SlaveMessageProcessor = SlaveMessageProcessor( self) if not self.flag('auto_locale'): self.translator = translation("efb_telegram_master", resource_filename( 'efb_telegram_master', 'locale'), fallback=True) # Basic message handlers non_edit_filter = Filters.update.message | Filters.update.channel_post self.bot_manager.dispatcher.add_handler( CommandHandler("start", self.start, filters=non_edit_filter)) self.bot_manager.dispatcher.add_handler( CommandHandler("help", self.help, filters=non_edit_filter)) self.bot_manager.dispatcher.add_handler( CommandHandler("info", self.info, filters=non_edit_filter)) self.bot_manager.dispatcher.add_handler( CallbackQueryHandler(self.void_callback_handler, pattern="void")) self.bot_manager.dispatcher.add_handler( CallbackQueryHandler(self.bot_manager.session_expired)) self.bot_manager.dispatcher.add_handler( CommandHandler("react", self.react, filters=non_edit_filter)) # Register master message handlers after commands to prevent commands # commands to be delivered as messages self.master_messages: MasterMessageProcessor = MasterMessageProcessor( self) self.bot_manager.dispatcher.add_error_handler(self.error) self.rpc_utilities = RPCUtilities(self) @property def _(self): return self.translator.gettext @property def ngettext(self): return self.translator.ngettext def load_config(self): """ Load configuration from path specified by the framework. Configuration file is in YAML format. """ config_path = efb_utils.get_config_path(self.channel_id) if not config_path.exists(): raise FileNotFoundError( self._("Config File does not exist. ({path})").format( path=config_path)) with config_path.open() as f: data = YAML().load(f) # Verify configuration if not isinstance(data.get('token', None), str): raise ValueError(self._('Telegram bot token must be a string')) if isinstance(data.get('admins', None), int): data['admins'] = [data['admins']] if isinstance(data.get('admins', None), str) and data['admins'].isdigit(): data['admins'] = [int(data['admins'])] if not isinstance(data.get('admins', None), list) or not data['admins']: raise ValueError( self. _("Admins' user IDs must be a list of one number or more." )) for i in range(len(data['admins'])): if isinstance(data['admins'][i], str) and data['admins'][i].isdigit(): data['admins'][i] = int(data['admins'][i]) if not isinstance(data['admins'][i], int): raise ValueError( self. _('Admin ID is expected to be an int, but {data} is found.' ).format(data=data['admins'][i])) self.config = data.copy() def info(self, update: Update, context: CallbackContext): """ Show info of the current telegram conversation. Triggered by `/info`. """ if update.message.chat.type != telegram.Chat.PRIVATE: # Group message msg = self.info_group(update) elif update.effective_message.forward_from_chat and \ update.effective_message.forward_from_chat.type == 'channel': # Forwarded channel command. msg = self.info_channel(update) else: # Talking to the bot. msg = self.info_general() update.message.reply_text(msg) def info_general(self): """Generate string for information of the current running EFB instance.""" if self.instance_id: if coordinator.profile != "default": msg = self._( "This is EFB Telegram Master Channel {version}, running on profile “{profile}”, " "instance “{instance}”, on EFB {fw_version}.") else: # Default profile msg = self._( "This is EFB Telegram Master Channel {version}, running on default profile, " "instance “{instance}”, on EFB {fw_version}.") else: # Default instance if coordinator.profile != "default": msg = self._( "This is EFB Telegram Master Channel {version}, running on profile “{profile}”, " "default instance, on EFB {fw_version}.") else: # Default profile msg = self._( "This is EFB Telegram Master Channel {version}, running on default profile and instance, " "on EFB {fw_version}.") msg = msg.format(version=self.__version__, fw_version=ehforwarderbot.__version__, profile=coordinator.profile, instance=self.instance_id) msg += "\n" + self.ngettext( "{count} slave channel activated:", "{count} slave channels activated:", len( coordinator.slaves)).format(count=len(coordinator.slaves)) for i in coordinator.slaves: msg += "\n- %s %s (%s, %s)" % (coordinator.slaves[i].channel_emoji, coordinator.slaves[i].channel_name, i, coordinator.slaves[i].__version__) if coordinator.middlewares: msg += self.ngettext("\n\n{count} middleware activated:", "\n\n{count} middlewares activated:", len(coordinator.middlewares)).format( count=len(coordinator.middlewares)) for i in coordinator.middlewares: msg += "\n- %s (%s, %s)" % (i.middleware_name, i.middleware_id, i.__version__) return msg def info_channel(self, update): """Generate string for chat linking info of a channel.""" chat = update.effective_message.forward_from_chat links = self.db.get_chat_assoc( master_uid=etm_utils.chat_id_to_str(self.channel_id, chat.id)) if links: # Linked chat # TRANSLATORS: ‘channel’ here refers to a Telegram channel. msg = self._("The channel {group_name} ({group_id}) is linked to:") \ .format(group_name=chat.title, group_id=chat.id) msg += self.build_link_chats_info_str(links) else: # TRANSLATORS: ‘channel’ here means an EFB channel. msg = self._("The channel {group_name} ({group_id}) is " "not linked to any remote chat. " "To link one, use /link.").format( group_name=chat.title, group_id=chat.id) return msg def info_group(self, update): """Generate string for chat linking info of a group.""" links = self.db.get_chat_assoc(master_uid=etm_utils.chat_id_to_str( self.channel_id, update.message.chat_id)) if links: # Linked chat msg = self._( "The group {group_name} ({group_id}) is linked to:").format( group_name=update.message.chat.title, group_id=update.message.chat_id) msg += self.build_link_chats_info_str(links) else: msg = self._( "The group {group_name} ({group_id}) is not linked to any remote chat. " "To link one, use /link.").format( group_name=update.message.chat.title, group_id=update.message.chat_id) return msg def build_link_chats_info_str(self, links: List[EFBChannelChatIDStr]) -> str: """Build a string indicating all linked chats in argument. Returns: String that starts with a line break. """ msg = "" for i in links: channel_id, chat_id, _ = etm_utils.chat_id_str_to_id(i) chat_object = self.chat_manager.get_chat(channel_id, chat_id) if chat_object: msg += "\n- %s (%s:%s)" % (chat_object.full_name, channel_id, chat_id) else: try: module = coordinator.get_module_by_id(channel_id) if isinstance(module, Channel): channel_name = f"{module.channel_emoji} {module.channel_name}" else: # module is Middleware channel_name = module.middleware_name msg += self._( "\n- {channel_name}: Unknown chat ({channel_id}:{chat_id})" ).format(channel_name=channel_name, channel_id=channel_id, chat_id=chat_id) except NameError: # TRANSLATORS: ‘channel’ here means an EFB channel. msg += self._( "\n- Unknown channel {channel_id}: ({chat_id})" ).format(channel_id=channel_id, chat_id=chat_id) return msg def start(self, update: Update, context: CallbackContext): """ Process bot command `/start`. """ if context.args: # Group binding command if update.effective_message.chat.type != telegram.Chat.PRIVATE or \ (update.effective_message.forward_from_chat and update.effective_message.forward_from_chat.type == telegram.Chat.CHANNEL): self.chat_binding.link_chat(update, context.args) else: self.bot_manager.send_message( update.effective_chat.id, self. _('You cannot link remote chats to here. Please try again.' )) else: txt = self._( "This is EFB Telegram Master Channel.\n\n" "To learn more, please visit https://etm.1a23.studio .") self.bot_manager.send_message(update.effective_chat.id, txt) def react(self, update: Update, context: CallbackContext): """React to a message.""" message: Message = update.effective_message reaction = None args = message.text and message.text.split(' ', 1) if args and len(args) > 1: reaction = args[1] if not message.reply_to_message: message.reply_html( self._("Reply to a message with this command and an emoji " "to send a reaction. " "Ex.: <code>/react 👍</code>.\n" "Send <code>/react -</code> to remove your reaction " "from a message.")) return target: Message = update.message.reply_to_message msg_log = self.db.get_msg_log( master_msg_id=etm_utils.message_id_to_str( chat_id=target.chat_id, message_id=target.message_id)) if msg_log is None: message.reply_text( self. _("The message you replied to is not recorded in ETM database. " "You cannot react to this message.")) return if not reaction: msg_log_obj: ETMMsg = msg_log.build_etm_msg(self.chat_manager) reactors = msg_log_obj.reactions if not reactors: message.reply_html( self._("This message has no reactions yet. " "Reply to a message with this command and " "an emoji to send a reaction. " "Ex.: <code>/react 👍</code>.")) return else: text = "" for key, values in reactors.items(): if not values: continue text += f"{key}:\n" for j in values: text += f" {j.display_name}\n" text = text.strip() message.reply_text(text) return message_id = msg_log.slave_message_id channel_id, chat_uid, _ = etm_utils.chat_id_str_to_id( msg_log.slave_origin_uid) if channel_id not in coordinator.slaves: message.reply_text( self. _("The slave channel involved in this message ({}) is not available. " "You cannot react to this message.").format(channel_id)) return channel = coordinator.slaves[channel_id] if channel.suggested_reactions is None: message.reply_text( self. _("The channel involved in this message ({}) does not accept reactions. " "You cannot react to this message.").format(channel_id)) return try: chat_obj = channel.get_chat(chat_uid) except EFBChatNotFound: message.reply_text( self._("The chat involved in this message ({}) is not found. " "You cannot react to this message.").format(chat_uid)) return if reaction == "-": reaction = None try: coordinator.send_status( ReactToMessage(chat=chat_obj, msg_id=message_id, reaction=reaction)) except EFBOperationNotSupported: message.reply_text( self._("You cannot react anything to this message.")) return except EFBMessageReactionNotPossible: prompt = self._("{} is not accepted as a reaction to this message." ).format(reaction) if channel.suggested_reactions: # TRANSLATORS: {} is a list of names of possible reactions, separated with comma. prompt += "\n" + self._("You may want to try: {}").format( ", ".join(channel.suggested_reactions[:10])) message.reply_text(prompt) return def help(self, update: Update, context: CallbackContext): txt = self._( "EFB Telegram Master Channel\n" "/link\n" " Link a remote chat to an empty Telegram group.\n" " Followed by a regular expression to filter results.\n" "/chat\n" " Generate a chat head to start a conversation.\n" " Followed by a regular expression to filter results.\n" "/extra\n" " List all additional features from slave channels.\n" "/unlink_all\n" " Unlink all remote chats in this chat.\n" "/info\n" " Show information of the current Telegram chat.\n" "/react [emoji]\n" " React to a message with an emoji, or show a list of members reacted.\n" "/update_info\n" " Update info of linked Telegram group.\n" " Only works in singly linked group where the bot is an admin.\n" "/rm\n" " Remove the quoted message from its remote chat.\n" "/help\n" " Print this command list.") self.bot_manager.send_message(update.message.from_user.id, txt) def poll(self): """ Message polling process. """ self.bot_manager.polling() def error(self, update: Update, context: CallbackContext): """ Print error to console, and send error message to first admin. Triggered by python-telegram-bot error callback. """ error = context.error if "make sure that only one bot instance is running" in str(error): msg = self._( 'Conflicted polling detected. If this error persists, ' 'please ensure you are running only one instance of this Telegram bot.' ) self.logger.critical(msg) self.bot_manager.send_message(self.config['admins'][0], msg) return if "Invalid server response" in str(error) and not update: self.logger.error( "Boom! Telegram API is no good. (Invalid server response.)") return # noinspection PyBroadException try: raise error except telegram.error.Unauthorized: self.logger.error( "The bot is not authorised to send update:\n%s\n%s", str(update), str(error)) except telegram.error.BadRequest as e: if e.message == "Message is not modified" and update.callback_query: self.logger.error("Chill bro, don't click that fast.") else: self.logger.exception("Message request is invalid.\n%s\n%s", str(update), str(error)) self.bot_manager.send_message( self.config['admins'][0], self._("Message request is invalid.\n{error}\n" "<code>{update}</code>").format( error=html.escape(str(error)), update=html.escape(str(update))), parse_mode="HTML") except (telegram.error.TimedOut, telegram.error.NetworkError): self.timeout_count += 1 self.logger.error( "Poor internet connection detected.\n" "Number of network error occurred since last startup: %s\n%s\nUpdate: %s", self.timeout_count, str(error), str(update)) if update is not None and isinstance( getattr(update, "message", None), telegram.Message): update.message.reply_text(self._( "This message is not processed due to poor internet environment " "of the server.\n" "<code>{code}</code>").format( code=html.escape(str(error))), quote=True, parse_mode="HTML") timeout_interval = self.flag('network_error_prompt_interval') if timeout_interval > 0 and self.timeout_count % timeout_interval == 0: self.bot_manager.send_message( self.config['admins'][0], self.ngettext( "<b>EFB Telegram Master channel</b>\n" "You may have a poor internet connection on your server. " "Currently {count} network error is detected.\n" "For more details, please refer to the log.", "<b>EFB Telegram Master channel</b>\n" "You may have a poor internet connection on your server. " "Currently {count} network errors are detected.\n" "For more details, please refer to the log.", self.timeout_count).format(count=self.timeout_count), parse_mode="HTML") except telegram.error.ChatMigrated as e: new_id = e.new_chat_id old_id = update.message.chat_id count = 0 for i in self.db.get_chat_assoc( master_uid=etm_utils.chat_id_to_str( self.channel_id, old_id)): self.logger.debug( 'Migrating slave chat %s from Telegram chat %s to %s.', i, old_id, new_id) self.db.remove_chat_assoc(slave_uid=i) self.db.add_chat_assoc(master_uid=etm_utils.chat_id_to_str( self.channel_id, new_id), slave_uid=i) count += 1 self.bot_manager.send_message( new_id, self.ngettext( "Chat migration detected.\n" "All {count} remote chat are now linked to this new group.", "Chat migration detected.\n" "All {count} remote chats are now linked to this new group.", count).format(count=count)) except Exception: try: self.bot_manager.send_message( self.config['admins'][0], self. _("EFB Telegram Master channel encountered error <code>{error}</code> " "caused by update <code>{update}</code>. See log for details." ).format(error=html.escape(str(error)), update=html.escape(str(update))), parse_mode="HTML") except Exception as ex: self.logger.exception( "Failed to send error message through Telegram: %s", ex) finally: self.logger.exception( 'Unhandled telegram bot error!\n' 'Update %s caused error %s. Exception', update, error) def send_message(self, msg: Message) -> Message: return self.slave_messages.send_message(msg) def send_status(self, status: Status): return self.slave_messages.send_status(status) def get_message_by_id(self, chat: Chat, msg_id: MessageID) -> Optional['Message']: origin_uid = etm_utils.chat_id_to_str(chat=chat) msg_log = self.db.get_msg_log(slave_origin_uid=origin_uid, slave_msg_id=msg_id) if msg_log is not None: return msg_log.build_etm_msg(self.chat_manager) else: # Message is not found. return None def void_callback_handler(self, update: Update, context: CallbackContext): self.bot_manager.answer_callback_query( update.callback_query.id, text=self._("This button does nothing."), message_id=update.effective_message.message_id, cache_time=180) def stop_polling(self): self.logger.debug("Gracefully stopping %s (%s).", self.channel_name, self.channel_id) self.rpc_utilities.shutdown() self.bot_manager.graceful_stop() self.master_messages.stop_worker() self.db.stop_worker() self.logger.debug("%s (%s) gracefully stopped.", self.channel_name, self.channel_id) def get_chats(self) -> List[Chat]: raise EFBOperationNotSupported()
class MockSlaveChannel(EFBChannel): channel_name: str = "Mock Slave" channel_emoji: str = "➖" channel_id: ModuleID = ModuleID("tests.mocks.slave.MockSlaveChannel") channel_type: ChannelType = ChannelType.Slave supported_message_types: Set[MsgType] = {MsgType.Text, MsgType.Link} __version__: str = '0.0.1' logger = getLogger(channel_id) polling = threading.Event() __picture_dict = { "alice": "A.png", "bob": "B.png", "carol": "C.png", "dave": "D.png", "wonderland001": "W.png" } def __init__(self, instance_id=None): super().__init__(instance_id) alice = EFBChat(self) alice.chat_name = "Alice" alice.chat_uid = "alice" alice.chat_type = ChatType.User self.alice = alice bob = EFBChat(self) bob.chat_name = "Bob" bob.chat_alias = "Little bobby" bob.chat_uid = "bob" bob.chat_type = ChatType.User self.bob = bob carol = EFBChat(self) carol.chat_name = "Carol" carol.chat_uid = "carol" carol.chat_type = ChatType.User self.carol = carol dave = EFBChat(self) dave.chat_name = "デブ" # Nah, that's a joke dave.chat_uid = "dave" dave.chat_type = ChatType.User self.dave = dave wonderland = EFBChat(self) wonderland.chat_name = "Wonderland" wonderland.chat_uid = "wonderland001" wonderland.chat_type = ChatType.Group wonderland.members = [bob.copy(), carol.copy(), dave.copy()] for i in wonderland.members: i.group = wonderland self.wonderland = wonderland self.chats: List[EFBChat] = [alice, bob, wonderland] def poll(self): self.polling.wait() def send_status(self, status: EFBStatus): self.logger.debug("Received status: %r", status) def send_message(self, msg: EFBMsg) -> EFBMsg: self.logger.debug("Received message: %r", msg) return msg def stop_polling(self): self.polling.set() def get_chat(self, chat_uid: str, member_uid: Optional[str] = None) -> EFBChat: for i in self.chats: if chat_uid == i.chat_uid: if member_uid: if i.chat_type == ChatType.Group: for j in i.members: if j.chat_uid == member_uid: return j raise EFBChatNotFound() else: raise EFBChatNotFound() return i raise EFBChatNotFound() def get_chats(self) -> List[EFBChat]: return self.chats.copy() def get_chat_picture(self, chat: EFBChat): if chat.chat_uid in self.__picture_dict: return open('tests/mocks/' + self.__picture_dict[chat.chat_uid], 'rb') def get_message_by_id(self, chat: EFBChat, msg_id: MessageID) -> Optional['EFBMsg']: pass @extra(name="Echo", desc="Echo back the input.\n" "Usage:\n" " {function_name} text") def echo(self, args): return args @extra(name="Extra function A", desc="Do something A.\nUsage: {function_name}") def function_a(self): return f"Value of function A from {self.channel_id}." @extra(name="Extra function B", desc="Do something B.\nUsage: {function_name}") def function_b(self): return f"Value of function B from {self.channel_name}."
class WeChatChannel(SlaveChannel): """ EFB Channel - WeChat Slave Channel Based on wxpy (itchat), WeChat Web Client Author: Eana Hufwe <https://github.com/blueset> """ channel_name = "WeChat Slave" channel_emoji = "𝙒𝙚𝙘𝙝𝙖𝙩" channel_id = ModuleID('blueset.wechat') __version__ = __version__ supported_message_types = {MsgType.Text, MsgType.Sticker, MsgType.Image, MsgType.File, MsgType.Video, MsgType.Link, MsgType.Voice, MsgType.Animation} logger: logging.Logger = logging.getLogger( "plugins.%s.WeChatChannel" % channel_id) done_reauth: threading.Event = threading.Event() _stop_polling_event: threading.Event = threading.Event() config: Dict[str, Any] = dict() bot: wxpy.Bot #third part wwechat_sender:listenser #listen(bot) # GNU Gettext Translator translator = translation("efb_wechat_slave", resource_filename('efb_wechat_slave', 'locale'), fallback=True) _: Callable = translator.gettext ngettext: Callable = translator.ngettext # Constants MAX_FILE_SIZE: int = 5 * 2 ** 20 SYSTEM_ACCOUNTS: Final = { # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'filehelper': _('filehelper'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'newsapp': _('newsapp'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'fmessage': _('fmessage'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'weibo': _('weibo'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'qqmail': _('qqmail'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'tmessage': _('tmessage'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'qmessage': _('qmessage'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'qqsync': _('qqsync'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'floatbottle': _('floatbottle'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'lbsapp': _('lbsapp'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'shakeapp': _('shakeapp'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'medianote': _('medianote'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'qqfriend': _('qqfriend'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'readerapp': _('readerapp'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'blogapp': _('blogapp'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'facebookapp': _('facebookapp'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'masssendapp': _('masssendapp'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'meishiapp': _('meishiapp'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'feedsapp': _('feedsapp'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'voip': _('voip'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'blogappweixin': _('blogappweixin'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'weixin': _('weixin'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'brandsessionholder': _('brandsessionholder'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'weixinreminder': _('weixinreminder'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'officialaccounts': _('officialaccounts'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'notification_messages': _('notification_messages'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'wxitil': _('wxitil'), # TRANSLATORS: Translate this to the corresponding display name of the WeChat system account. Guessed names are not accepted. 'userexperience_alarm': _('userexperience_alarm'), } MEDIA_MSG_TYPES: Final = {MsgType.Voice, MsgType.Video, MsgType.Animation, MsgType.Image, MsgType.Sticker, MsgType.File} def __init__(self, instance_id: InstanceID = None): """ Initialize the channel Args: coordinator (:obj:`ehforwarderbot.coordinator.EFBCoordinator`): The EFB framework coordinator """ super().__init__(instance_id) self.load_config() PuidMap.SYSTEM_ACCOUNTS = self.SYSTEM_ACCOUNTS self.flag: ExperimentalFlagsManager = ExperimentalFlagsManager(self) self.qr_uuid: Tuple[str, int] = ('', 0) self.master_qr_picture_id: Optional[str] = None self.authenticate('console_qr_code', first_start=True) # Managers self.slave_message: SlaveMessageManager = SlaveMessageManager(self) self.chats: ChatManager = ChatManager(self) self.user_auth_chat = SystemChat(channel=self, name=self._("EWS User Auth"), uid=ChatID("__ews_user_auth__")) def load_config(self): """ Load configuration from path specified by the framework. Configuration file is in YAML format. """ config_path = efb_utils.get_config_path(self.channel_id) if not config_path.exists(): return with config_path.open() as f: d = yaml.full_load(f) if not d: return self.config: Dict[str, Any] = d # # Utilities # def console_qr_code(self, uuid, status, qrcode=None): status = int(status) if self.qr_uuid == (uuid, status): return self.qr_uuid = (uuid, status) if status == 201: qr = self._('Confirm on your phone.') return self.logger.log(99, qr) elif status == 200: qr = self._("Successfully logged in.") return self.logger.log(99, qr) else: # 0: First QR code # 408: Updated QR code qr = self._("EWS: Please scan the QR code with your camera, screenshots will not work. ({0}, {1})") \ .format(uuid, status) + "\n" if status == 408: qr += self._("QR code expired, please scan the new one.") + "\n" qr += "\n" qr_url = "https://login.weixin.qq.com/l/" + uuid qr_obj = QRCode(qr_url) if self.flag("imgcat_qr"): qr_file = io.BytesIO() qr_obj.png(qr_file, scale=10) qr_file.seek(0) qr += ews_utils.imgcat(qr_file, f"{self.channel_id}_QR_{uuid}.png") else: qr += qr_obj.terminal() qr += "\n" + self._("If the QR code was not shown correctly, please visit:\n" "https://login.weixin.qq.com/qrcode/{0}").format(uuid) return self.logger.log(99, qr) def master_qr_code(self, uuid, status, qrcode=None): status = int(status) if self.qr_uuid == (uuid, status): return self.qr_uuid = (uuid, status) msg = Message( uid=f"ews_auth_{uuid}_{status}_{uuid4()}", type=MsgType.Text, chat=self.user_auth_chat, author=self.user_auth_chat.other, deliver_to=coordinator.master, ) if status == 201: msg.type = MsgType.Text msg.text = self._('Confirm on your phone.') self.master_qr_picture_id = None elif status == 200: msg.type = MsgType.Text msg.text = self._("Successfully logged in.") self.master_qr_picture_id = None elif uuid != self.qr_uuid: msg.type = MsgType.Image file = NamedTemporaryFile(suffix=".png") qr_url = "https://login.weixin.qq.com/l/" + uuid QRCode(qr_url).png(file, scale=10) msg.text = self._("QR code expired, please scan the new one.") msg.path = Path(file.name) msg.file = file msg.mime = 'image/png' if self.master_qr_picture_id is not None: msg.edit = True msg.edit_media = True msg.uid = self.master_qr_picture_id else: self.master_qr_picture_id = msg.uid if status in (200, 201) or uuid != self.qr_uuid: coordinator.send_message(msg) def exit_callback(self): # Don't send prompt if there's nowhere to send. if not getattr(coordinator, 'master', None): raise Exception( self._("Web WeChat logged your account out before master channel is ready.")) self.logger.debug('Calling exit callback...') if self._stop_polling_event.is_set(): return msg = Message( chat=self.user_auth_chat, author=self.user_auth_chat.other, deliver_to=coordinator.master, text=self._( "WeChat server has logged you out. Please log in again when you are ready."), uid=f"__reauth__.{uuid4()}", type=MsgType.Text, ) on_log_out = self.flag("on_log_out") on_log_out = on_log_out if on_log_out in ( "command", "idle", "reauth") else "command" if on_log_out == "command": msg.type = MsgType.Text msg.commands = MessageCommands( [MessageCommand(name=self._("Log in again"), callable_name="reauth", kwargs={"command": True})]) elif on_log_out == "reauth": if self.flag("qr_reload") == "console_qr_code": msg.text += "\n" + self._("Please check your log to continue.") self.reauth() coordinator.send_message(msg) def poll(self): self.bot.start() self._stop_polling_event.wait() # while not self.stop_polling: # if not self.bot.alive: # self.done_reauth.wait() # self.done_reauth.clear() self.logger.debug("%s (%s) gracefully stopped.", self.channel_name, self.channel_id) def send_message(self, msg: Message) -> Message: """Send a message to WeChat. Supports text, image, sticker, and file. Args: msg (channel.Message): Message Object to be sent. Returns: This method returns nothing. Raises: EFBChatNotFound: Raised when a chat required is not found. EFBMessageTypeNotSupported: Raised when the message type sent is not supported by the channel. EFBOperationNotSupported: Raised when an message edit request is sent, but not supported by the channel. EFBMessageNotFound: Raised when an existing message indicated is not found. E.g.: The message to be edited, the message referred in the :attr:`msg.target <.Message.target>` attribute. EFBMessageError: Raised when other error occurred while sending or editing the message. """ if msg.chat == self.user_auth_chat: raise EFBChatNotFound chat: wxpy.Chat = self.chats.get_wxpy_chat_by_uid(msg.chat.uid) # List of "SentMessage" response for all messages sent r: List[wxpy.SentMessage] = [] self.logger.info("[%s] Sending message to WeChat:\n" "uid: %s\n" "UserName: %s\n" "NickName: %s\n" "Type: %s\n" "Text: %s", msg.uid, msg.chat.uid, chat.user_name, chat.name, msg.type, msg.text) try: chat.mark_as_read() except wxpy.ResponseError as e: self.logger.exception( "[%s] Error occurred while marking chat as read. (%s)", msg.uid, e) send_text_only = False self.logger.debug('[%s] Is edited: %s', msg.uid, msg.edit) if msg.edit and msg.uid: if self.flag('delete_on_edit'): msg_ids = json.loads(msg.uid) if msg.type in self.MEDIA_MSG_TYPES and not msg.edit_media: # Treat message as text message to prevent resend of media msg_ids = msg_ids[1:] send_text_only = True failed = 0 for i in msg_ids: try: ews_utils.message_id_to_dummy_message(i, self).recall() except wxpy.ResponseError as e: self.logger.error( "[%s] Trying to recall message but failed: %s", msg.uid, e) failed += 1 if failed: raise EFBMessageError( self.ngettext('Failed to recall {failed} out of {total} message, edited message was not sent.', 'Failed to recall {failed} out of {total} messages, edited message was not sent.', len(msg_ids)).format( failed=failed, total=len(msg_ids) )) # Not caching message ID as message recall feedback is not needed in edit mode else: raise EFBOperationNotSupported() if send_text_only or msg.type in [MsgType.Text, MsgType.Link]: if isinstance(msg.target, Message): max_length = self.flag("max_quote_length") qt_txt = msg.target.text or msg.target.type.name if max_length > 0: if len(qt_txt) >= max_length: tgt_text = qt_txt[:max_length] tgt_text += "…" else: tgt_text = qt_txt elif max_length < 0: tgt_text = qt_txt else: tgt_text = "" if isinstance(chat, wxpy.Group) and not isinstance(msg.target.author, SelfChatMember): tgt_alias = "@%s\u2005:" % msg.target.author.display_name else: tgt_alias = "" msg.text = f"「{tgt_alias}{tgt_text}」\n- - - - - - - - - - - - - - -\n{msg.text}" r.append(self._bot_send_msg(chat, msg.text)) self.logger.debug( '[%s] Sent as a text message. %s', msg.uid, msg.text) elif msg.type in (MsgType.Image, MsgType.Sticker, MsgType.Animation): self.logger.info("[%s] Image/GIF/Sticker %s", msg.uid, msg.type) convert_to = None file = msg.file assert file is not None if self.flag('send_stickers_and_gif_as_jpeg'): if msg.type == MsgType.Sticker or msg.mime == "image/gif": convert_to = "image/jpeg" else: if msg.type == MsgType.Sticker: convert_to = "image/gif" if convert_to == "image/gif": with NamedTemporaryFile(suffix=".gif") as f: try: img = Image.open(file) try: alpha = img.split()[3] mask = Image.eval( alpha, lambda a: 255 if a <= 128 else 0) except IndexError: mask = Image.eval(img.split()[0], lambda a: 0) img = img.convert('RGB').convert( 'P', palette=Image.ADAPTIVE, colors=255) img.paste(255, mask) img.save(f, transparency=255) msg.path = Path(f.name) self.logger.debug( '[%s] Image converted from %s to GIF', msg.uid, msg.mime) file.close() if f.seek(0, 2) > self.MAX_FILE_SIZE: raise EFBMessageError( self._("Image size is too large. (IS02)")) f.seek(0) r.append(self._bot_send_image(chat, f.name, f)) finally: if not file.closed: file.close() elif convert_to == "image/jpeg": with NamedTemporaryFile(suffix=".jpg") as f: try: img = Image.open(file).convert('RGBA') out = Image.new("RGBA", img.size, (255, 255, 255, 255)) out.paste(img, img) out.convert('RGB').save(f) msg.path = Path(f.name) self.logger.debug( '[%s] Image converted from %s to JPEG', msg.uid, msg.mime) file.close() if f.seek(0, 2) > self.MAX_FILE_SIZE: raise EFBMessageError( self._("Image size is too large. (IS02)")) f.seek(0) r.append(self._bot_send_image(chat, f.name, f)) finally: if not file.closed: file.close() else: try: if file.seek(0, 2) > self.MAX_FILE_SIZE: raise EFBMessageError( self._("Image size is too large. (IS01)")) file.seek(0) self.logger.debug( "[%s] Sending %s (image) to WeChat.", msg.uid, msg.path) filename = msg.filename or (msg.path and msg.path.name) assert filename r.append(self._bot_send_image(chat, filename, file)) finally: if not file.closed: file.close() if msg.text: r.append(self._bot_send_msg(chat, msg.text)) elif msg.type in (MsgType.File, MsgType.Audio): self.logger.info("[%s] Sending %s to WeChat\nFileName: %s\nPath: %s\nFilename: %s", msg.uid, msg.type, msg.text, msg.path, msg.filename) filename = msg.filename or (msg.path and msg.path.name) assert filename and msg.file r.append(self._bot_send_file(chat, filename, file=msg.file)) if msg.text: self._bot_send_msg(chat, msg.text) if not msg.file.closed: msg.file.close() elif msg.type == MsgType.Video: self.logger.info( "[%s] Sending video to WeChat\nFileName: %s\nPath: %s", msg.uid, msg.text, msg.path) filename = msg.filename or (msg.path and msg.path.name) assert filename and msg.file r.append(self._bot_send_video(chat, filename, file=msg.file)) if msg.text: r.append(self._bot_send_msg(chat, msg.text)) if not msg.file.closed: msg.file.close() else: raise EFBMessageTypeNotSupported() msg.uid = ews_utils.generate_message_uid(r) self.logger.debug( 'WeChat message is assigned with unique ID: %s', msg.uid) return msg def send_status(self, status: Status): if isinstance(status, MessageRemoval): if not isinstance(status.message.author, SelfChatMember): raise EFBOperationNotSupported( self._('You can only recall your own messages.')) if status.message.uid: try: msg_ids = json.loads(status.message.uid) except JSONDecodeError: raise EFBMessageError( self._("ID of the message to recall is invalid.")) else: raise EFBMessageError( self._("ID of the message to recall is not found.")) failed = 0 if any(len(i) == 1 for i in msg_ids): # Message is not sent through EWS raise EFBOperationNotSupported( self._("You may only recall messages sent via EWS.") ) for i in msg_ids: try: ews_utils.message_id_to_dummy_message(i, self).recall() except wxpy.ResponseError: failed += 1 if failed: raise EFBMessageError( self.ngettext( 'Failed to recall {failed} of {total} message.', 'Failed to recall {failed} of {total} messages.', len(msg_ids) ).format(failed=failed, total=len(msg_ids))) else: val = (status.message.uid, len(msg_ids)) for i in msg_ids: self.slave_message.recall_msg_id_conversion[str( i[1])] = val else: raise EFBOperationNotSupported() def get_chat_picture(self, chat: Chat) -> BinaryIO: uid = chat.uid if uid in wxpy.Chat.SYSTEM_ACCOUNTS: wxpy_chat: wxpy.Chat = wxpy.Chat( wxpy.utils.wrap_user_name(uid), self.bot) else: wxpy_chat = wxpy.utils.ensure_one(self.bot.search(puid=uid)) f: BinaryIO = None # type: ignore try: f = tempfile.NamedTemporaryFile(suffix='.jpg') # type: ignore data = wxpy_chat.get_avatar(None) if not data: raise EFBOperationNotSupported() f.write(data) f.seek(0) return f except (TypeError, ResponseError): if f is not None: f.close() raise EFBOperationNotSupported() # Additional features @extra(name=_("Show chat list"), desc=_("Show a list of chats from WeChat.\n" "Usage:\n {function_name} [-r]\n" " -r: Refresh list")) def get_chat_list(self, param: str = "") -> str: refresh = False if param: if param == "-r": refresh = True else: return self._("Unknown parameter: {}.").format(param) l: List[wxpy.Chat] = self.bot.chats(refresh) msg = self._("Chat list:") + "\n" for i in l: alias = ews_utils.wechat_string_unescape(getattr(i, 'remark_name', '') or getattr(i, 'display_name', '')) name = ews_utils.wechat_string_unescape(i.nick_name) display_name = "%s (%s)" % ( alias, name) if alias and alias != name else name chat_type = "?" if isinstance(i, wxpy.MP): # TRANSLATORS: Acronym for MP accounts chat_type = self._('MP') elif isinstance(i, wxpy.Group): # TRANSLATORS: Acronym for groups chat_type = self._('Gr') elif isinstance(i, wxpy.User): # TRANSLATORS: Acronym for users/friends chat_type = self._('Fr') msg += "\n%s: [%s] %s" % (i.puid, chat_type, display_name) return msg @extra(name=_("Set alias"), desc=_("Set an alias (remark name) for friends. Not applicable to " "groups and MPs.\n" "Usage:\n" " {function_name} id [alias]\n" " id: Chat ID, available from \"Show chat list\".\n" " alias: Alias. Leave empty to delete alias.")) def set_alias(self, r_param: str = "") -> str: if r_param: param = r_param.split(maxsplit=1) if len(param) == 1: cid = param[0] alias = "" else: cid, alias = param else: return self.set_alias.desc # type: ignore chat = self.bot.search(cid) if not chat: return self._("Chat {0} is not found.").format(cid) if not isinstance(chat, wxpy.User): return self._("Remark name is only applicable to friends.") chat.set_remark_name(alias) if alias: return self._("\"{0}\" now has remark name \"{1}\".").format(chat.nick_name, alias) else: return self._("Remark name of \"{0}\" has been removed.").format(chat.nick_name) @extra(name=_("Log out"), desc=_("Log out from WeChat and try to log in again.\n" "Usage: {function_name}")) def force_log_out(self, _: str = "") -> str: self.bot.logout() self.exit_callback() return self._("Done.") # region [Command functions] def reauth(self, command=False): msg = self._("Preparing to log in...") qr_reload = self.flag("qr_reload") if command and qr_reload == "console_qr_code": msg += "\n" + self._("Please check your log to continue.") threading.Thread(target=self.authenticate, args=( qr_reload,), name="EWS reauth thread").start() return msg # endregion [Command functions] def authenticate(self, qr_reload, first_start=False): self.master_qr_picture_id = None qr_callback = getattr(self, qr_reload, self.master_qr_code) if getattr(self, 'bot', None): # if a bot exists self.bot.cleanup() with coordinator.mutex: self.bot: wxpy.Bot = wxpy.Bot(cache_path=str(efb_utils.get_data_path(self.channel_id) / "wxpy.pkl"), qr_callback=qr_callback, logout_callback=self.exit_callback, user_agent=self.flag('user_agent'), start_immediately=not first_start) self.bot.enable_puid( efb_utils.get_data_path(self.channel_id) / "wxpy_puid.pkl", self.flag('puid_logs') ) self.done_reauth.set() if hasattr(self, "slave_message"): self.slave_message.bot = self.bot self.slave_message.wechat_msg_register() def add_friend(self, username: str = None, verify_information: str = "") -> str: if not username: return self._("Empty username (UE02).") try: self.bot.add_friend( user=username, verify_content=verify_information) except wxpy.ResponseError as r: return self._("Error occurred while processing (AF01).") + "\n\n{}: {!r}".format(r.err_code, r.err_msg) return self._("Request sent.") def accept_friend(self, username: str = None, verify_information: str = "") -> str: if not username: return self._("Empty username (UE03).") try: self.bot.accept_friend( user=username, verify_content=verify_information) except wxpy.ResponseError as r: return self._("Error occurred while processing (AF02).") + "n\n{}: {!r}".format(r.err_code, r.err_msg) return self._("Request accepted.") def get_chats(self) -> List[Chat]: """ Get all chats available from WeChat """ return self.chats.get_chats() def get_chat(self, chat_uid: str) -> Chat: chat = self.chats.search_chat(uid=chat_uid) if not chat: raise EFBChatNotFound() else: return chat def stop_polling(self): self.bot.cleanup() if not self._stop_polling_event.is_set(): self._stop_polling_event.set() else: self.done_reauth.set() def _bot_send_msg(self, chat: wxpy.Chat, message: str) -> wxpy.SentMessage: try: return chat.send_msg(message) except wxpy.ResponseError as e: e = self.substitute_known_error_reason(e) raise EFBMessageError(self._("Error from Web WeChat while sending message: [{code}] {message}") .format(code=e.err_code, message=e.err_msg)) def _bot_send_file(self, chat: wxpy.Chat, filename: str, file: IO[bytes]) -> wxpy.SentMessage: try: return chat.send_file(filename, file=file) except wxpy.ResponseError as e: e = self.substitute_known_error_reason(e) raise EFBMessageError(self._("Error from Web WeChat while sending file: [{code}] {message}") .format(code=e.err_code, message=e.err_msg)) def _bot_send_image(self, chat: wxpy.Chat, filename: str, file: IO[bytes]) -> wxpy.SentMessage: try: return chat.send_image(filename, file=file) except wxpy.ResponseError as e: e = self.substitute_known_error_reason(e) raise EFBMessageError(self._("Error from Web WeChat while sending image: [{code}] {message}") .format(code=e.err_code, message=e.err_msg)) def _bot_send_video(self, chat: wxpy.Chat, filename: str, file: IO[bytes]) -> wxpy.SentMessage: try: return chat.send_video(filename, file=file) except wxpy.ResponseError as e: e = self.substitute_known_error_reason(e) raise EFBMessageError(self._("Error from Web WeChat while sending video: [{code}] {message}") .format(code=e.err_code, message=e.err_msg)) def substitute_known_error_reason(self, err: wxpy.ResponseError) -> wxpy.ResponseError: if not err.err_msg: issue_url = "https://ews.1a23.studio/issues/55" if err.err_code in (1101, 1102, 1103): err.err_msg = self._("Your Web WeChat session might be expired. " "Please try to log out with the “force_log_out” command, and log in again. " "If you believe that is not the case, please leave a comment at {issue_url} .").format( issue_url=issue_url ) elif err.err_code == 1204: err.err_msg = self._( "You don’t have access to the chat that you are trying to send message to.") elif err.err_code == 1205: err.err_msg = self._("You might have sent your messages too fast. Please try to slow down " "and retry after a while.") else: err.err_msg = self._("This is an unknown error from Web WeChat which we know nothing about why this " "is happening. If you have seen a pattern or if you happen to know the reason " "for this error code, please leave a comment at {issue_url} .").format( issue_url=issue_url ) return err def get_message_by_id(self, chat: Chat, msg_id: MessageID) -> Optional['Message']: raise EFBOperationNotSupported()
class MockSlaveChannel(SlaveChannel): channel_name: str = "Mock Slave" channel_emoji: str = "➖" channel_id: ModuleID = ModuleID("tests.mocks.slave") supported_message_types: Set[MsgType] = { MsgType.Text, MsgType.Image, MsgType.Voice, MsgType.Animation, MsgType.Video, MsgType.File, MsgType.Location, MsgType.Link, MsgType.Sticker, MsgType.Status, MsgType.Unsupported } __version__: str = '0.0.2' logger = getLogger(channel_id) CHAT_ID_FORMAT = "__chat_{hash}__" polling = threading.Event() __picture_dict: Dict[str, str] = {} suggested_reactions: List[ReactionName] = [ ReactionName("R0"), ReactionName("R1"), ReactionName("R2"), ReactionName("R3"), ReactionName("R4") ] # region [Chat data] # fields: name, type, notification, avatar, alias __chat_templates = [ ("A", PrivateChat, ChatNotificationState.NONE, "A.png", "Alice"), ("B", PrivateChat, ChatNotificationState.MENTIONS, "B.png", "Bob"), ("C", PrivateChat, ChatNotificationState.ALL, "C.png", "Carol"), ("D", SystemChat, ChatNotificationState.NONE, "D.png", "Dave"), ("E", SystemChat, ChatNotificationState.MENTIONS, "E.png", "Eve"), ("F", SystemChat, ChatNotificationState.ALL, "F.png", "Frank"), ("G", PrivateChat, ChatNotificationState.NONE, "G.png", None), ("H", PrivateChat, ChatNotificationState.MENTIONS, "H.png", None), ("I", PrivateChat, ChatNotificationState.ALL, "I.png", None), ("J", SystemChat, ChatNotificationState.NONE, "J.png", None), ("K", SystemChat, ChatNotificationState.MENTIONS, "K.png", None), ("L", SystemChat, ChatNotificationState.ALL, "L.png", None), ("Ur", GroupChat, ChatNotificationState.NONE, "U.png", "Uranus"), ("Ve", GroupChat, ChatNotificationState.MENTIONS, "V.png", "Venus"), ("Wo", GroupChat, ChatNotificationState.ALL, "W.png", "Wonderland"), ("Xe", GroupChat, ChatNotificationState.NONE, "X.png", None), ("Yb", GroupChat, ChatNotificationState.MENTIONS, "Y.png", None), ("Zn", GroupChat, ChatNotificationState.ALL, "Z.png", None), ("あ", PrivateChat, ChatNotificationState.NONE, None, "あべ"), ("い", PrivateChat, ChatNotificationState.MENTIONS, None, "いとう"), ("う", PrivateChat, ChatNotificationState.ALL, None, "うえだ"), ("え", SystemChat, ChatNotificationState.NONE, None, "えのもと"), ("お", SystemChat, ChatNotificationState.MENTIONS, None, "おがわ"), ("か", SystemChat, ChatNotificationState.ALL, None, "かとう"), ("き", PrivateChat, ChatNotificationState.NONE, None, None), ("く", PrivateChat, ChatNotificationState.MENTIONS, None, None), ("け", PrivateChat, ChatNotificationState.ALL, None, None), ("こ", SystemChat, ChatNotificationState.NONE, None, None), ("さ", SystemChat, ChatNotificationState.MENTIONS, None, None), ("し", SystemChat, ChatNotificationState.ALL, None, None), ("らん", GroupChat, ChatNotificationState.NONE, None, "ランド"), ("りぞ", GroupChat, ChatNotificationState.MENTIONS, None, "リゾート"), ("るう", GroupChat, ChatNotificationState.ALL, None, "ルートディレクトリ"), ("れつ", GroupChat, ChatNotificationState.NONE, None, None), ("ろく", GroupChat, ChatNotificationState.MENTIONS, None, None), ("われ", GroupChat, ChatNotificationState.ALL, None, None), ] __group_member_templates = [ ("A", ChatNotificationState.NONE, "A.png", "安"), ("B & S", ChatNotificationState.MENTIONS, "B.png", "柏"), ("C", ChatNotificationState.ALL, "C.png", "陈"), ("D", ChatNotificationState.NONE, "D.png", None), ("E", ChatNotificationState.MENTIONS, "E.png", None), ("F", ChatNotificationState.ALL, "F.png", None), ("Ал", ChatNotificationState.NONE, None, "Александра"), ("Бэ", ChatNotificationState.MENTIONS, None, "Борис"), ("Вэ", ChatNotificationState.ALL, None, "Владислав"), ("Э", ChatNotificationState.NONE, None, None), ("Ю", ChatNotificationState.MENTIONS, None, None), ("Я", ChatNotificationState.ALL, None, None), ] # endregion [Chat data] def __init__(self, instance_id=None): super().__init__(instance_id) self.generate_chats() self.chat_with_alias: PrivateChat = self.chats_by_alias[True][0] self.chat_without_alias: PrivateChat = self.chats_by_alias[False][0] self.group: GroupChat = self.chats_by_chat_type['GroupChat'][0] self.messages: "Queue[Message]" = Queue() self.statuses: "Queue[Status]" = Queue() self.messages_sent: Dict[str, Message] = dict() # flags self.message_removal_possible: bool = True self.accept_message_reactions: str = "accept" # chat/member changes self.chat_to_toggle: PrivateChat = self.get_chat( self.CHAT_ID_FORMAT.format(hash=hash("I"))) self.chat_to_edit: PrivateChat = self.get_chat( self.CHAT_ID_FORMAT.format(hash=hash("われ"))) self.member_to_toggle: ChatMember = self.get_chat( self.group.uid).get_member( self.CHAT_ID_FORMAT.format(hash=hash("Ю"))) self.member_to_edit: ChatMember = self.get_chat( self.group.uid).get_member( self.CHAT_ID_FORMAT.format(hash=hash("Я"))) # region [Clear queues] def clear_messages(self): self._clear_queue(self.messages) def clear_statuses(self): self._clear_queue(self.statuses) @staticmethod def _clear_queue(q: Queue): """Safely clear all items in a queue. Written by Niklas R on StackOverflow https://stackoverflow.com/a/31892187/1989455 """ with q.mutex: unfinished = q.unfinished_tasks - len(q.queue) if unfinished <= 0: if unfinished < 0: raise ValueError('task_done() called too many times') q.all_tasks_done.notify_all() q.unfinished_tasks = unfinished q.queue.clear() q.not_full.notify_all() # endregion [Clear queues] # region [Populate chats] def generate_chats(self): """Generate a list of chats per the chat templates, and categorise them accordingly. """ self.chats: List[Chat] = [] self.chats_by_chat_type: Dict[ChatTypeName, List[Chat]] = { 'PrivateChat': [], 'GroupChat': [], 'SystemChat': [], } self.chats_by_notification_state: Dict[ ChatNotificationState, List[Chat]] = { ChatNotificationState.ALL: [], ChatNotificationState.MENTIONS: [], ChatNotificationState.NONE: [], } self.chats_by_profile_picture: Dict[bool, List[Chat]] = { True: [], False: [] } self.chats_by_alias: Dict[bool, List[Chat]] = {True: [], False: []} for name, chat_type, notification, avatar, alias in self.__chat_templates: chat = chat_type(channel=self, name=name, alias=alias, uid=ChatID( self.CHAT_ID_FORMAT.format(hash=hash(name))), notification=notification) self.__picture_dict[chat.uid] = avatar if chat_type == GroupChat: self.fill_group(chat) self.chats_by_chat_type[chat_type.__name__].append(chat) self.chats_by_notification_state[notification].append(chat) self.chats_by_profile_picture[avatar is not None].append(chat) self.chats_by_alias[alias is not None].append(chat) self.chats.append(chat) name = "Unknown Chat" self.unknown_chat: PrivateChat = PrivateChat( channel=self, name=name, alias="不知道", uid=ChatID(self.CHAT_ID_FORMAT.format(hash=hash(name))), notification=ChatNotificationState.ALL) name = "Unknown Chat @ unknown channel" self.unknown_channel: PrivateChat = PrivateChat( module_id="__this_is_not_a_channel__", module_name="Unknown Channel", channel_emoji="‼️", name=name, alias="知らんでぇ", uid=ChatID(self.CHAT_ID_FORMAT.format(hash=hash(name))), notification=ChatNotificationState.ALL) name = "backup_chat" self.backup_chat: PrivateChat = PrivateChat( channel=self, name=name, uid=ChatID(self.CHAT_ID_FORMAT.format(hash=hash(name))), notification=ChatNotificationState.ALL) name = "backup_member" self.backup_member: ChatMember = ChatMember( self.chats_by_chat_type['GroupChat'][0], name=name, uid=ChatID(self.CHAT_ID_FORMAT.format(hash=hash(name)))) def fill_group(self, group: Chat): """Populate members into a group per membership template.""" for name, notification, avatar, alias in self.__group_member_templates: group.add_member( name=name, alias=f"{alias} @ {group.name[::-1]}" if alias is not None else None, uid=ChatID(self.CHAT_ID_FORMAT.format(hash=hash(name))), ) # endregion [Populate chats] # region [Necessities] def poll(self): self.polling.wait() def send_status(self, status: Status): self.logger.debug("Received status: %r", status) if isinstance(status, MessageRemoval): self.message_removal_status(status) elif isinstance(status, ReactToMessage): self.react_to_message_status(status) self.statuses.put(status) def send_message(self, msg: Message) -> Message: self.logger.debug("Received message: %r", msg) self.messages.put(msg) msg.uid = MessageID(str(uuid4())) self.messages_sent[msg.uid] = msg return msg def stop_polling(self): self.polling.set() def get_chat(self, chat_uid: str) -> Chat: for i in self.chats: if chat_uid == i.uid: return i raise EFBChatNotFound() def get_chats(self) -> List[Chat]: return self.chats.copy() def get_chat_picture(self, chat: Chat) -> Optional[BinaryIO]: if self.__picture_dict.get(chat.uid): return open(f'tests/mocks/{self.__picture_dict[chat.uid]}', 'rb') # endregion [Necessities] def get_chats_by_criteria( self, chat_type: Optional[ChatTypeName] = None, notification: Optional[ChatNotificationState] = None, avatar: Optional[bool] = None, alias: Optional[bool] = None) -> List[Chat]: """Find a list of chats that satisfy a criteria. Leave a value unset (None) to pick all possible values of the criteria. """ s = self.chats.copy() if chat_type is not None: s = [i for i in s if i in self.chats_by_chat_type[chat_type]] if notification is not None: s = [ i for i in s if i in self.chats_by_notification_state[notification] ] if avatar is not None: s = [i for i in s if i in self.chats_by_profile_picture[avatar]] if alias is not None: s = [i for i in s if i in self.chats_by_alias[alias]] return s def get_chat_by_criteria( self, chat_type: Optional[ChatTypeName] = None, notification: Optional[ChatNotificationState] = None, avatar: Optional[bool] = None, alias: Optional[bool] = None) -> Chat: """Alias of ``get_chats_by_criteria(*args)[0]``.""" return self.get_chats_by_criteria(chat_type=chat_type, notification=notification, avatar=avatar, alias=alias)[0] @extra(name="Echo", desc="Echo back the input.\n" "Usage:\n" " {function_name} text") def echo(self, args): return args # region [Reactions] def build_reactions(self, group: Chat) -> Reactions: possible_reactions = self.suggested_reactions[:-1] + [None] chats = group.members reactions: Dict[ReactionName, List[Chat]] = {} for i in chats: reaction = random.choice(possible_reactions) if reaction is None: continue elif reaction not in reactions: reactions[reaction] = [i] else: reactions[reaction].append(i) return reactions def send_reactions_update(self, message: Message) -> MessageReactionsUpdate: reactions = self.build_reactions(message.chat) message.reactions = reactions status = MessageReactionsUpdate(chat=message.chat, msg_id=message.uid, reactions=reactions) coordinator.send_status(status) return status # endregion [Reactions] # region [Commands] @staticmethod def build_message_commands() -> MessageCommands: return MessageCommands([ MessageCommand("Ping!", "command_ping"), MessageCommand("Bam", "command_bam"), ]) @staticmethod def command_ping() -> Optional[str]: return "Pong!" @staticmethod def command_bam(): return None # endregion [Commands] @staticmethod def build_substitutions(text: str, chat: Chat) -> Substitutions: size = len(text) a_0, a_1, b_0, b_1 = sorted(random.sample(range(size + 1), k=4)) a = chat.self b = getattr(chat, 'other', random.choice(chat.members)) if random.randrange(2) == 1: # randomly swap a and b a, b = b, a return Substitutions({ (a_0, a_1): a, (b_0, b_1): b, }) def attach_message_properties(self, message: Message, reactions: bool, commands: bool, substitutions: bool) -> Message: reactions_val = self.build_reactions(message.chat) if reactions else {} commands_val = self.build_message_commands() if commands else None substitutions_val = self.build_substitutions( message.text, message.chat) if substitutions else None message.reactions = reactions_val message.commands = commands_val message.substitutions = substitutions_val return message def send_text_message(self, chat: Chat, author: Optional[ChatMember] = None, target: Optional[Message] = None, reactions: bool = False, commands: bool = False, substitution: bool = False, unsupported: bool = False) -> Message: """Send a text message to master channel. Leave author blank to use “self” of the chat. Returns the message sent. """ author = author or chat.self uid = f"__msg_id_{uuid4()}__" msg_type = MsgType.Unsupported if unsupported else MsgType.Text message = Message( chat=chat, author=author, type=msg_type, target=target, uid=uid, text=f"Content of {msg_type.name} message with ID {uid}", deliver_to=coordinator.master) message = self.attach_message_properties(message, reactions, commands, substitution) coordinator.send_message(message) self.messages_sent[uid] = message return message def edit_text_message(self, message: Message, reactions: bool = False, commands: bool = False, substitution: bool = False) -> Message: message.edit = True message.text = f"Edited {message.type.name} message {message.uid} @ {time.time_ns()}" message = self.attach_message_properties(message, reactions, commands, substitution) self.messages_sent[message.uid] = message coordinator.send_message(message) return message def send_link_message(self, chat: Chat, author: Optional[ChatMember] = None, target: Optional[Message] = None, reactions: bool = False, commands: bool = False, substitution: bool = False) -> Message: author = author or chat.self uid = f"__msg_id_{uuid4()}__" message = Message(chat=chat, author=author, type=MsgType.Link, target=target, uid=uid, text=f"Content of link message with ID {uid}", attributes=LinkAttribute( title="EH Forwarder Bot", description="EH Forwarder Bot project site.", url="https://efb.1a23.studio"), deliver_to=coordinator.master) message = self.attach_message_properties(message, reactions, commands, substitution) self.messages_sent[uid] = message coordinator.send_message(message) return message def edit_link_message(self, message: Message, reactions: bool = False, commands: bool = False, substitution: bool = False) -> Message: message.text = f"Content of edited link message with ID {message.uid}" message.edit = True message.attributes = LinkAttribute( title="EH Forwarder Bot (edited)", description="EH Forwarder Bot project site. (edited)", url="https://efb.1a23.studio/#edited") message = self.attach_message_properties(message, reactions, commands, substitution) self.messages_sent[message.uid] = message coordinator.send_message(message) return message def send_location_message(self, chat: Chat, author: Optional[ChatMember] = None, target: Optional[Message] = None, reactions: bool = False, commands: bool = False, substitution: bool = False) -> Message: author = author or chat.self uid = f"__msg_id_{uuid4()}__" message = Message(chat=chat, author=author, type=MsgType.Location, target=target, uid=uid, text=f"Content of location message with ID {uid}", attributes=LocationAttribute( latitude=random.uniform(0.0, 90.0), longitude=random.uniform(0.0, 90.0)), deliver_to=coordinator.master) message = self.attach_message_properties(message, reactions, commands, substitution) self.messages_sent[uid] = message coordinator.send_message(message) return message def edit_location_message(self, message: Message, reactions: bool = False, commands: bool = False, substitution: bool = False) -> Message: message.text = f"Content of edited location message with ID {message.uid}" message.edit = True message.attributes = LocationAttribute( latitude=random.uniform(0.0, 90.0), longitude=random.uniform(0.0, 90.0)) message = self.attach_message_properties(message, reactions, commands, substitution) self.messages_sent[message.uid] = message coordinator.send_message(message) return message def send_file_like_message(self, msg_type: MsgType, file_path: Path, mime: str, chat: Chat, author: Optional[ChatMember] = None, target: Optional[Message] = None, reactions: bool = False, commands: bool = False, substitution: bool = False) -> Message: author = author or chat.self uid = f"__msg_id_{uuid4()}__" message = Message( chat=chat, author=author, type=msg_type, target=target, uid=uid, file=file_path.open('rb'), filename=file_path.name, path=file_path, mime=mime, text=f"Content of {msg_type.name} message with ID {uid}", deliver_to=coordinator.master) message = self.attach_message_properties(message, reactions, commands, substitution) self.messages_sent[uid] = message coordinator.send_message(message) return message def edit_file_like_message_text(self, message: Message, reactions: bool = False, commands: bool = False, substitution: bool = False) -> Message: message.text = f"Content of edited {message.type.name} message with ID {message.uid}" message.edit = True message.edit_media = False message = self.attach_message_properties(message, reactions, commands, substitution) self.messages_sent[message.uid] = message coordinator.send_message(message) return message def edit_file_like_message(self, message: Message, file_path: Path, mime: str, reactions: bool = False, commands: bool = False, substitution: bool = False) -> Message: message.text = f"Content of edited {message.type.name} media with ID {message.uid}" message.edit = True message.edit_media = True message.file = file_path.open('rb') message.filename = file_path.name message.path = file_path message.mime = mime message = self.attach_message_properties(message, reactions, commands, substitution) self.messages_sent[message.uid] = message coordinator.send_message(message) return message def send_status_message(self, status: StatusAttribute, chat: Chat, author: Optional[ChatMember] = None, target: Optional[Message] = None) -> Message: """Send a status message to master channel. Leave author blank to use “self” of the chat. Returns the message sent. """ author = author or chat.self uid = f"__msg_id_{uuid4()}__" message = Message(chat=chat, author=author, type=MsgType.Status, target=target, uid=uid, text="", attributes=status, deliver_to=coordinator.master) coordinator.send_message(message) self.messages_sent[uid] = message return message # region [Message removal] def message_removal_status(self, status: MessageRemoval): if not self.message_removal_possible: raise EFBOperationNotSupported( "Message removal is not possible by flag.") @contextmanager def set_message_removal(self, value: bool): backup = self.message_removal_possible self.message_removal_possible = value try: yield finally: self.message_removal_possible = backup # endregion [Message removal] # region [Message reactions] # noinspection PyUnresolvedReferences def react_to_message_status(self, status: ReactToMessage): if self.accept_message_reactions == "reject_one": raise EFBMessageReactionNotPossible( "Message reaction is rejected by flag.") if self.accept_message_reactions == "reject_all": raise EFBOperationNotSupported( "All message reactions are rejected by flag.") message = self.messages_sent.get(status.msg_id) if message is None: raise EFBOperationNotSupported("Message is not found.") if status.reaction is None: for idx, i in message.reactions.items(): message.reactions[idx] = [ j for j in i if not isinstance(j, SelfChatMember) ] else: if status.reaction not in message.reactions: message.reactions[status.reaction] = [] message.reactions[status.reaction].append(message.chat.self) coordinator.send_status( MessageReactionsUpdate(chat=message.chat, msg_id=message.uid, reactions=message.reactions)) @contextmanager def set_react_to_message(self, value: bool): """ Set reaction response status. Args: value: "reject_one": Reject with EFBMessageReactionNotPossible. "reject_all": Reject with EFBOperationNotSupported. """ backup = self.accept_message_reactions self.accept_message_reactions = value try: yield finally: self.accept_message_reactions = backup # endregion [Message reactions] # region [Chat/Member updates] def send_chat_update_status(self) -> Tuple[Chat, Chat, Chat]: """ Returns: chat added, chat edited, chat removed """ keyword = " (Edited)" if self.backup_chat not in self.chats: to_add, to_remove = self.backup_chat, self.chat_to_toggle self.chat_to_edit.name += keyword else: to_add, to_remove = self.chat_to_toggle, self.backup_chat self.chat_to_edit.name = self.chat_to_edit.name.replace( keyword, '') self.chats.append(to_add) self.chats.remove(to_remove) coordinator.send_status( ChatUpdates( self, new_chats=[to_add.uid], modified_chats=[self.chat_to_edit.uid], removed_chats=[to_remove.uid], )) return to_add, self.chat_to_edit, to_remove # noinspection PyUnresolvedReferences def send_member_update_status( self) -> Tuple[ChatMember, ChatMember, ChatMember]: """ Returns: member added, member edited, member removed """ keyword = " (Edited)" if self.backup_member not in self.group.members: to_add, to_remove = self.backup_member, self.member_to_toggle self.member_to_edit.name += keyword else: to_add, to_remove = self.member_to_toggle, self.backup_member self.member_to_edit.name = self.member_to_edit.name.replace( keyword, '') self.group.members.append(to_add) self.group.members.remove(to_remove) coordinator.send_status( MemberUpdates( self, self.group.uid, new_members=[to_add.uid], modified_members=[self.member_to_edit.uid], removed_members=[to_remove.uid], )) return to_add, self.member_to_edit, to_remove # endregion [Chat/Member updates] def get_message_by_id(self, chat: 'Chat', msg_id: MessageID) -> Optional['Message']: raise NotImplementedError
class FilterMiddleware(Middleware): """ Filter middleware. A demo of advanced user interaction with master channel. """ middleware_id: ModuleID = ModuleID("filter.FilterMiddleware") middleware_name: str = "Filter Middleware" __version__: str = '1.1.1' message_cache: Dict[MessageID, Message] = {} def __init__(self, instance_id: Optional[InstanceID] = None): super().__init__(instance_id) # load config self.yaml = YAML() conf_path = utils.get_config_path(self.middleware_id) if not conf_path.exists(): conf_path.touch() self.config = self.yaml.load(conf_path) self.filters = [self.chat_id_based_filter] # Mapping self.FILTER_MAPPING = { "chat_name_contains": self.chat_name_contains_filter, "chat_name_matches": self.chat_name_matches_filter, "message_contains": self.message_contains_filter, "ews_mp": self.ews_mp_filter } # Chat ID based filter init shelve_path = str( utils.get_data_path(self.middleware_id) / "chat_id_filter.db") self.chat_id_filter_db = shelve.open(shelve_path) atexit.register(self.atexit) # load other filters if isinstance(self.config, Mapping): for i in self.config.keys(): f = self.FILTER_MAPPING.get(i) if f: self.filters.append(f) def atexit(self): self.chat_id_filter_db.close() def process_message(self, message: Message) -> Optional[Message]: # Only collect the message when it's a text message match the # hotword "filter`" if message.type == MsgType.Text and message.text == "filter`" and \ message.deliver_to != coordinator.master: reply = self.make_status_message(message) self.message_cache[reply.uid] = message coordinator.master.send_message(reply) return None # Do not filter messages from master channel if message.deliver_to != coordinator.master: return message # Try to filter all other messages. return self.filter(message) def make_status_message(self, msg_base: Message = None, mid: MessageID = None) -> Message: if mid is not None: msg = self.message_cache[mid] elif msg_base is not None: msg = msg_base else: raise ValueError reply = Message( type=MsgType.Text, chat=msg.chat, author=msg.chat.make_system_member(uid=ChatID("filter_info"), name="Filter middleware", middleware=self), deliver_to=coordinator.master, ) if mid: reply.uid = mid else: reply.uid = str(uuid.uuid4()) status = self.filter_reason(msg) if not status: # Blue circle emoji status = "\U0001F535 This chat is not filtered." else: # Red circle emoji status = "\U0001F534 " + status reply.text = "Filter status for chat {chat_id} from {module_id}:\n" \ "\n" \ "{status}\n".format( module_id=msg.chat.module_id, chat_id=msg.chat.id, status=status ) command = MessageCommand(name="%COMMAND_NAME%", callable_name="toggle_filter_by_chat_id", kwargs={ "mid": reply.uid, "module_id": msg.chat.module_id, "chat_id": msg.chat.id }) if self.is_chat_filtered_by_id(msg.chat): command.name = "Unfilter by chat ID" command.kwargs['value'] = False else: command.name = "Filter by chat ID" command.kwargs['value'] = True reply.commands = MessageCommands([command]) return reply def toggle_filter_by_chat_id(self, mid: str, module_id: str, chat_id: str, value: bool): self.chat_id_filter_db[str((module_id, chat_id))] = value reply = self.make_status_message(mid=mid) reply.edit = True # Timer(0.5, coordinator.master.send_message, args=(reply,)).start() coordinator.master.send_message(reply) return None @staticmethod def get_chat_key(chat: Chat) -> str: return str((chat.module_id, chat.id)) def process_status(self, status: Status) -> Optional[Status]: for i in self.filters: if i(status, False): return None return status def filter_reason(self, message: Message): for i in self.filters: reason = i(message, True) if reason is not False: return reason return False def filter(self, message: Message): for i in self.filters: if i(message, False): return None return message @staticmethod def get_chat_from_entity(entity: Union[Message, Status]) -> Optional[Chat]: if isinstance(entity, Message): return entity.chat elif isinstance(entity, MessageRemoval): return entity.message.chat elif isinstance(entity, ReactToMessage): return entity.chat elif isinstance(entity, MessageReactionsUpdate): return entity.chat else: return None # region [Filters] """ Filters Filter must take only two argument apart from self - ``entity`` (``Union[Message, Status]``) The message entity to filter - ``reason`` (``bool``) Determine whether or not to return the reason to block a message To allow a message to be delivered, return ``False``. Otherwise, return ``True`` or a string to explain the reason of filtering if ``reason`` is ``True``. """ @overload def chat_id_based_filter(self, entity: Union[Message, Status], reason: Literal[True]) -> Union[bool, str]: ... @overload def chat_id_based_filter(self, entity: Union[Message, Status], reason: Literal[False]) -> bool: ... def chat_id_based_filter(self, entity: Union[Message, Status], reason: bool) -> Union[bool, str]: chat = self.get_chat_from_entity(entity) if not chat: return False if self.is_chat_filtered_by_id(chat): if reason: return "Chat is manually filtered." else: return True else: return False def is_chat_filtered_by_id(self, chat: Chat) -> bool: key = str((chat.module_id, chat.id)) if key in self.chat_id_filter_db: return self.chat_id_filter_db[key] return False def chat_name_contains_filter(self, entity, reason): chat = self.get_chat_from_entity(entity) if not chat: return False for i in self.config['chat_name_contains']: if i in chat.display_name: if reason: return "Chat is filtered because its name contains \"{}\".".format( i) else: return True return False def chat_name_matches_filter(self, entity, reason): chat = self.get_chat_from_entity(entity) if not chat: return False for i in self.config['chat_name_matches']: if i == chat.display_name: if reason: return "Chat is filtered because its name matches \"{}\".".format( i) else: return True return False def message_contains_filter(self, entity, reason): if not isinstance(entity, Message): return False for i in self.config['message_contains']: if i in entity.text: if reason: return "Message is filtered because its contains \"{}\".".format( i) else: return True return False def ews_mp_filter(self, entity, reason): chat = self.get_chat_from_entity(entity) if not chat: return False if chat.vendor_specific.get('is_mp'): if reason: return "Chat is filtered as it's a EWS \"WeChat Official Account\" chat." else: return True return False
class MockMasterChannel(EFBChannel): channel_name: str = "Mock Master" channel_emoji: str = "➕" channel_id: ModuleID = ModuleID("tests.mocks.master.MockMasterChannel") channel_type: ChannelType = ChannelType.Master supported_message_types: Set[MsgType] = {MsgType.Text, MsgType.Link} __version__: str = '0.0.1' logger = getLogger(channel_id) polling = threading.Event() def poll(self): self.polling.wait() def send_status(self, status: EFBStatus): self.logger.debug("Received status: %r", status) def send_message(self, msg: EFBMsg) -> EFBMsg: self.logger.debug("Received message: %r", msg) return msg def stop_polling(self): self.polling.set() def send_text_msg(self): slave = next(iter(coordinator.slaves.values())) wonderland = slave.get_chat('wonderland001') msg = EFBMsg() msg.deliver_to = slave msg.chat = wonderland msg.author = EFBChat(self).self() msg.type = MsgType.Text msg.text = "Hello, world." return coordinator.send_message(msg) def send_link_msg(self): slave = next(iter(coordinator.slaves.values())) alice = slave.get_chat('alice') msg = EFBMsg() msg.deliver_to = slave msg.chat = alice msg.author = EFBChat(self).self() msg.type = MsgType.Link msg.text = "Check it out." msg.attributes = EFBMsgLinkAttribute( title="Example", url="https://example.com" ) return coordinator.send_message(msg) def send_location_msg(self): slave = next(iter(coordinator.slaves.values())) alice = slave.get_chat('alice') msg = EFBMsg() msg.deliver_to = slave msg.chat = alice msg.author = EFBChat(self).self() msg.type = MsgType.Location msg.text = "I'm not here." msg.attributes = EFBMsgLocationAttribute(latitude=0.1, longitude=1.0) return coordinator.send_message(msg) def send_message_recall_status(self): slave = next(iter(coordinator.slaves.values())) alice = slave.get_chat('alice') msg = EFBMsg() msg.deliver_to = slave msg.chat = alice msg.author = EFBChat(self).self() msg.uid = "1" status = EFBMessageRemoval(self, slave, msg) return coordinator.send_status(status) def get_message_by_id(self, chat: EFBChat, msg_id: MessageID) -> Optional['EFBMsg']: pass def get_chats(self) -> List['EFBChat']: raise NotImplementedError() def get_chat(self, chat_uid: ChatID, member_uid: Optional[ChatID] = None) -> 'EFBChat': raise NotImplementedError() def get_chat_picture(self, chat: 'EFBChat') -> IO[bytes]: raise NotImplementedError()
class MockSlaveChannel(SlaveChannel): channel_name: str = "Mock Slave" channel_emoji: str = "➖" channel_id: ModuleID = ModuleID("tests.mocks.slave.MockSlaveChannel") supported_message_types: Set[MsgType] = {MsgType.Text, MsgType.Link} __version__: str = '0.0.1' logger = getLogger(channel_id) polling = threading.Event() __picture_dict = { "alice": "A.png", "bob": "B.png", "carol": "C.png", "dave": "D.png", "wonderland001": "W.png" } def __init__(self, instance_id=None): super().__init__(instance_id) self.alice = PrivateChat(channel=self, name="Alice", uid="alice") self.bob = PrivateChat(channel=self, name="Bob", alias="Little bobby", uid="bob") self.wonderland = GroupChat(channel=self, name="Wonderland", uid="wonderland001") self.wonderland.add_member(name="bob", alias="Bob James", uid="bob") self.carol = self.wonderland.add_member(name="Carol", uid="carol") self.dave = self.wonderland.add_member( name="デブ", uid="dave") # Nah, that's a joke self.chats: List[Chat] = [self.alice, self.bob, self.wonderland] def poll(self): self.polling.wait() def send_status(self, status: Status): self.logger.debug("Received status: %r", status) def send_message(self, msg: Message) -> Message: self.logger.debug("Received message: %r", msg) return msg def stop_polling(self): self.polling.set() def get_chat(self, chat_uid: str) -> Chat: for i in self.chats: if chat_uid == i.uid: return i raise EFBChatNotFound() def get_chats(self) -> List[Chat]: return self.chats.copy() def get_chat_picture(self, chat: Chat): if chat.uid in self.__picture_dict: return open('tests/mocks/' + self.__picture_dict[chat.uid], 'rb') def get_message_by_id(self, chat: Chat, msg_id: MessageID) -> Optional['Message']: pass @extra(name="Echo", desc="Echo back the input.\n" "Usage:\n" " {function_name} text") def echo(self, args): return args @extra(name="Extra function A", desc="Do something A.\nUsage: {function_name}") def function_a(self): return f"Value of function A from {self.channel_id}." @extra(name="Extra function B", desc="Do something B.\nUsage: {function_name}") def function_b(self): return f"Value of function B from {self.channel_name}."
class MockMasterChannel(MasterChannel): channel_name: str = "Mock Master" channel_emoji: str = "➕" channel_id: ModuleID = ModuleID("tests.mocks.master.MockMasterChannel") supported_message_types: Set[MsgType] = {MsgType.Text, MsgType.Link} __version__: str = '0.0.1' logger = getLogger(channel_id) polling = threading.Event() def poll(self): self.polling.wait() def send_status(self, status: Status): self.logger.debug("Received status: %r", status) def send_message(self, msg: Message) -> Message: self.logger.debug("Received message: %r", msg) return msg def stop_polling(self): self.polling.set() def send_text_msg(self): slave = next(iter(coordinator.slaves.values())) wonderland = slave.get_chat('wonderland001') msg = Message( deliver_to=slave, chat=wonderland, author=wonderland.self, type=MsgType.Text, text="Hello, world.", ) return coordinator.send_message(msg) def send_link_msg(self): slave = next(iter(coordinator.slaves.values())) alice = slave.get_chat('alice') msg = Message( deliver_to=slave, chat=alice, author=alice.self, type=MsgType.Link, text="Check it out.", attributes=LinkAttribute( title="Example", url="https://example.com" ) ) return coordinator.send_message(msg) def send_location_msg(self): slave = next(iter(coordinator.slaves.values())) alice = slave.get_chat('alice') msg = Message( deliver_to=slave, chat=alice, author=alice.self, type=MsgType.Location, text="I'm not here.", attributes=LocationAttribute(latitude=0.1, longitude=1.0), ) return coordinator.send_message(msg) def send_message_recall_status(self): slave = next(iter(coordinator.slaves.values())) alice = slave.get_chat('alice') msg = Message( deliver_to=slave, chat=alice, author=alice.self, uid="1" ) status = MessageRemoval(self, slave, msg) return coordinator.send_status(status) def get_message_by_id(self, chat: Chat, msg_id: MessageID) -> Optional['Message']: pass
class TelegramChannel(SlaveChannel): channel_name = 'Telegram Slave' channel_emoji = '✈️' channel_id = ModuleID('sharzy.telegram') __version__ = __version__ supported_message_types = { MsgType.Text, MsgType.Image, MsgType.File, MsgType.Sticker, MsgType.Video, MsgType.Audio } logger: logging.Logger = logging.getLogger( f'plugins.{channel_id}.TelegramChannel') config: Dict[str, Any] = dict() def __init__(self, instance_id: InstanceID = None): super().__init__(instance_id) self.load_config() self.loop = asyncio.get_event_loop() data_path = efb_utils.get_data_path(self.channel_id) proxy = (self.config['proxy']['protocol'], self.config['proxy']['host'], self.config['proxy']['port']) self.client: TelegramClient = TelegramClient( f'{data_path}/{instance_id}', self.config['api_id'], self.config['api_hash'], loop=self.loop, proxy=proxy).start() self.get_chat_cache: Dict[str, ChatID] = {} self.task = None self._main_thread_name = threading.current_thread().name def load_config(self): config_path = efb_utils.get_config_path(self.channel_id) if not config_path.exists(): return with config_path.open() as f: d = yaml.full_load(f) if not d: return self.config: Dict[str, Any] = d def make_efb_chat_obj(self, diag) -> Chat: if isinstance(diag.entity, TgUser): return PrivateChat(channel=self, name=diag.name, uid=str(diag.entity.id), other_is_self=diag.entity.is_self) if isinstance(diag.entity, TgChat) or isinstance( diag.entity, TgChannel): return GroupChat(channel=self, name=diag.name, uid=str(diag.entity.id)) async def async_get_chat(self, chat_uid: ChatID) -> Chat: cache = self.get_chat_cache.get(chat_uid, None) if cache: return cache else: chat = None async for diag in self.client.iter_dialogs(): if int(chat_uid) == diag.entity.id: chat = self.make_efb_chat_obj(diag) if chat is None: raise EFBChatNotFound() self.get_chat_cache[chat_uid] = chat return chat async def async_get_chats(self) -> Collection['Chat']: chats = [] async for diag in self.client.iter_dialogs(): chats.append(self.make_efb_chat_obj(diag)) return chats def get_chats(self) -> Collection['Chat']: return self._async_run(self.async_get_chats()) def get_chat(self, chat_uid: ChatID) -> 'Chat': return self._async_run(self.async_get_chat(chat_uid)) def send_message(self, msg: 'EfbMsg') -> 'EfbMsg': if msg.file: print_color( f'{msg.text=}, {msg.uid=}, {msg.file.name}, {msg.file.fileno}') file = msg.file if hasattr(file, 'name'): # for file in file system, take its path as input # otherwise mimetype may be mistakenly guessed by telethon file = file.name self._async_run( self.client.send_message(int(msg.chat.uid), msg.text, file=file)) return msg def poll(self): @self.client.on(events.NewMessage()) async def handleMsg(event: NewMessage): # because telethon swallows exceptions in handlers, # we need to add extra exception handler try: await self.handle_new_telegram_message(event) except Exception as e: self.logger.error(e) raise e self.loop.run_forever() def send_status(self, status: 'Status'): pass def stop_polling(self): self.loop.call_soon_threadsafe(self.loop.stop) def get_message_by_id(self, chat: 'Chat', msg_id: MessageID) -> Optional['Message']: raise EFBOperationNotSupported() def get_chat_picture(self, chat: 'Chat') -> BinaryIO: picture = io.BytesIO() chat_id = int(chat.uid) entity = self._async_run(self.client.get_entity(chat_id)) self._async_run(self.client.download_profile_photo(entity, picture)) return picture def _async_run(self, promise): # Warning: do not use this in async function if not self.loop.is_running(): return self.loop.run_until_complete(promise) else: return asyncio.run_coroutine_threadsafe(promise, self.loop).result() async def handle_new_telegram_message(self, event: NewMessage): msg: TgMsg = event.message chat_id = get_chat_id(msg.peer_id) chat = await self.async_get_chat(chat_id) self.logger.debug(msg) file = None path = None filename = None mime = None msg_type = MsgType.Text tempfile_suffix = '' if getattr(msg, 'media', None): media = msg.media tempfile_suffix = get_extension(media) if isinstance(media, MessageMediaPhoto): msg_type = MsgType.Image if isinstance(media, MessageMediaDocument): document = media.document mime = document.mime_type msg_type = MsgType.File for attr in document.attributes: if isinstance(attr, DocumentAttributeFilename): tempfile_suffix = attr.file_name filename = attr.file_name if isinstance(attr, DocumentAttributeSticker): msg_type = MsgType.Sticker if isinstance(attr, DocumentAttributeVideo): msg_type = MsgType.Video if isinstance(attr, DocumentAttributeAudio): msg_type = MsgType.Audio if msg_type != MsgType.Text: file = tempfile.NamedTemporaryFile(suffix=tempfile_suffix) path = file.name await self.client.download_media(msg, file) msg_peer = await self.client.get_entity( get_chat_id(msg.from_id or msg.peer_id)) self.logger.debug(msg_peer) chat_member = ChatMember( chat=chat, name=format_entity_name(msg_peer), uid=str(chat_id), ) efb_msg = Message( deliver_to=coordinator.master, author=chat_member, chat=chat, text=msg.message, type=msg_type, uid=f'{chat_id}_{msg.id}', file=file, filename=filename, mime=mime, path=path, ) coordinator.send_message(efb_msg)
class FBMessengerChannel(SlaveChannel): channel_name: str = "Facebook Messenger Slave" channel_emoji: str = "⚡️" channel_id: ModuleID = ModuleID("blueset.fbmessenger") __version__: str = __version__ client: EFMSClient config: Dict[str, Any] = {} logger = logging.getLogger(channel_id) suggested_reactions = [ MessageReaction.LOVE.value, MessageReaction.SMILE.value, MessageReaction.WOW.value, MessageReaction.SAD.value, MessageReaction.ANGRY.value, MessageReaction.YES.value, MessageReaction.NO.value ] supported_message_types = MasterMessageManager.supported_message_types # Translator translator = translation("efb_fb_messenger_slave", resource_filename("efb_fb_messenger_slave", 'locale'), fallback=True) _: Callable = translator.gettext ngettext: Callable = translator.ngettext def __init__(self, instance_id: InstanceID = None): super().__init__(instance_id) session_path = efb_utils.get_data_path( self.channel_id) / "session.pickle" try: data = pickle.load(session_path.open('rb')) self.client = EFMSClient(self, None, None, session_cookies=data) except FileNotFoundError: raise EFBException( self._("Session not found, please authorize your account.\n" "To do so, run: efms-auth")) except FBchatUserError as e: message = str(e) + "\n" + \ self._("You may need to re-authorize your account.\n" + "To do so, run: efms-auth") raise EFBException(message) self.load_config() self.chat_manager: EFMSChatManager = EFMSChatManager(self) self.flag: ExperimentalFlagsManager = ExperimentalFlagsManager(self) self.master_message: MasterMessageManager = MasterMessageManager(self) self.extra_functions: ExtraFunctionsManager = ExtraFunctionsManager( self) # Initialize list of chat from server self.get_chats() # Monkey patching Thread.__eq__ = lambda a, b: a.uid == b.uid def load_config(self): """ Load configuration from path specified by the framework. Configuration file is in YAML format. """ config_path = efb_utils.get_config_path(self.channel_id) if not config_path.exists(): self.config: Dict[str, Any] = dict() return with config_path.open() as f: self.config: Dict[str, Any] = yaml.full_load(f) or dict() def get_chats(self) -> List[Chat]: locations: Tuple[ThreadLocation, ...] = (ThreadLocation.INBOX, ) if self.flag('show_pending_threads'): locations += (ThreadLocation.PENDING, ThreadLocation.OTHER) if self.flag('show_archived_threads'): locations += (ThreadLocation.ARCHIVED, ) chats: List[Chat] = [] for i in self.client.fetchThreadList(thread_location=locations): chats.append(self.chat_manager.build_and_cache_thread(i)) loaded_chats = set(i.uid for i in chats) for i in self.client.fetchAllUsers(): if i.uid not in loaded_chats: chats.append(self.chat_manager.build_and_cache_thread(i)) return chats def get_chat(self, chat_uid: str) -> Chat: try: return self.chat_manager.get_thread(thread_id=chat_uid) except FBchatException: raise EFBChatNotFound def send_message(self, msg: Message) -> Message: return self.master_message.send_message(msg) def send_status(self, status: Status): if isinstance(status, MessageRemoval): uid: str = cast(str, status.message.uid) rfind = uid.rfind('.') if rfind > 0 and uid[rfind + 1:].isdecimal(): uid = uid[:rfind] return self.client.unsend(uid) elif isinstance(status, ReactToMessage): try: self.client.reactToMessage( status.msg_id, status.reaction and MessageReaction(status.reaction)) except (FBchatException, ValueError) as e: self.logger.error(f"Error occurred while sending status: {e}") raise EFBMessageReactionNotPossible(*e.args) return # Other status types go here raise EFBOperationNotSupported() def poll(self): self.client.listen(False) def stop_polling(self): self.client.listening = False def get_chat_picture(self, chat: Chat) -> BinaryIO: self.logger.debug("Getting picture of chat %s", chat) photo_url = chat.vendor_specific.get('profile_picture_url') self.logger.debug("[%s] has photo_url from cache: %s", chat.uid, photo_url) if not photo_url: thread = self.client.get_thread_info(chat.uid) photo_url = efms_utils.get_value( thread, ('messaging_actor', 'big_image_src', 'uri')) self.logger.debug("[%s] has photo_url from GraphQL: %s", chat.uid, photo_url) if not photo_url: thread = self.client.fetchThreadInfo(chat.uid)[chat.uid] photo_url = getattr(thread, 'photo', None) self.logger.debug("[%s] has photo_url from legacy API: %s", chat.uid, photo_url) if not photo_url: raise EFBOperationNotSupported('This chat has no picture.') photo = BytesIO(requests.get(photo_url).content) photo.seek(0) return photo def get_message_by_id(self, chat: Chat, msg_id: MessageID) -> Optional['Message']: index = None if msg_id.split('.')[-1].isdecimal(): # is sub-message index = int(msg_id.split('.')[-1]) msg_id = MessageID('.'.join(msg_id.split('.')[:-1])) thread_id, thread_type = self.client._getThread(chat.uid, None) message_info = self.client._forcedFetch(thread_id, msg_id).get("message") message = Message._from_graphql(message_info) efb_msg = self.client.build_efb_msg(msg_id, chat.uid, message.author, message) attachments = message_info.get('delta', {}).get('attachments', []) if attachments: attachment = attachments[index] self.client.attach_media(efb_msg, attachment) efb_msg.uid = msg_id return efb_msg # Additional features @extra(name=_("Show threads list"), desc=_("Usage:\n" " {function_name}")) def threads_list(self, args: str) -> str: return self.extra_functions.threads_list(args) @extra(name=_("Search for users"), desc=_("Show the first 10 results.\n" "Usage:\n" " {function_name} keyword")) def search_users(self, args: str) -> str: return self.extra_functions.search_users(args) @extra(name=_("Search for groups"), desc=_("Show the first 10 results.\n" "Usage:\n" " {function_name} keyword")) def search_groups(self, args: str) -> str: return self.extra_functions.search_groups(args) @extra(name=_("Search for pages"), desc=_("Show the first 10 results.\n" "Usage:\n" " {function_name} keyword")) def search_pages(self, args: str) -> str: return self.extra_functions.search_pages(args) @extra(name=_("Search for threads"), desc=_("Show the first 10 results.\n" "Usage:\n" " {function_name} keyword")) def search_threads(self, args: str) -> str: return self.extra_functions.search_threads(args) @extra(name=_("Add to group"), desc=_("Add members to a group.\n" "Usage:\n" " {function_name} GroupID UserID [UserID ...]")) def add_to_group(self, args: str) -> str: return self.extra_functions.add_to_group(args) @extra(name=_("Remove from group"), desc=_("Remove members from a group.\n" "Usage:\n" " {function_name} GroupID UserID [UserID ...]")) def remove_from_group(self, args: str) -> str: return self.extra_functions.remove_from_group(args) @extra(name=_("Change nickname"), desc=_("Change nickname of a user.\n" "Usage:\n" " {function_name} UserID nickname")) def set_nickname(self, args: str) -> str: return self.extra_functions.set_nickname(args) @extra(name=_("Change group title"), desc=_("Change the title of a group.\n" "Usage:\n" " {function_name} GroupID title")) def set_group_title(self, args: str) -> str: return self.extra_functions.set_group_title(args) @extra(name=_("Change chat emoji"), desc=_("Change the emoji of a chat.\n" "Usage:\n" " {function_name} ChatID emoji")) def set_chat_emoji(self, args: str) -> str: return self.extra_functions.set_chat_emoji(args) @extra(name=_("Change member nickname"), desc=_("Change the nickname of a group member.\n" "Usage:\n" " {function_name} GroupID MemberID nickname")) def set_member_nickname(self, args: str) -> str: return self.extra_functions.set_member_nickname(args)