async def login_complete(self, cookies: dict) -> None: self.client = Client(cookies) await self._create_community() asyncio.ensure_future(self.start(), loop=self.loop) self.client.on_connect.add_observer(self.on_connect) self.client.on_reconnect.add_observer(self.on_reconnect) self.client.on_disconnect.add_observer(self.on_disconnect)
async def login_complete(self, cookies: dict) -> None: self.client = Client(cookies, max_retries=config['bridge.reconnect.max_retries'], retry_backoff_base=config['bridge.reconnect.retry_backoff_base']) await self._create_community() asyncio.ensure_future(self.start(), loop=self.loop) self.client.on_connect.add_observer(self.on_connect) self.client.on_reconnect.add_observer(self.on_reconnect) self.client.on_disconnect.add_observer(self.on_disconnect)
async def async_connect(self): """Login to the Google Hangouts.""" session = await self.hass.async_add_executor_job( get_auth, HangoutsCredentials(None, None, None), HangoutsRefreshToken(self._refresh_token), ) self._client = Client(session) self._client.on_connect.add_observer(self._on_connect) self._client.on_disconnect.add_observer(self._on_disconnect) self.hass.loop.create_task(self._client.connect())
def client(self): """The hangups client object.""" # Make sure the directory with cached credentials exists. ensure_directory_exists(os.path.dirname(self.cookie_file)) return Client( get_auth( GoogleAccountCredentials( email_address=self.config["email-address"], config=self.config, ), RefreshTokenCache(self.cookie_file), manual_login=True, ) )
async def async_connect(self): """Login to the Google Hangouts.""" from .hangups_utils import HangoutsRefreshToken, HangoutsCredentials from hangups import Client from hangups import get_auth session = await self.hass.async_add_executor_job( get_auth, HangoutsCredentials(None, None, None), HangoutsRefreshToken(self._refresh_token)) self._client = Client(session) self._client.on_connect.add_observer(self._on_connect) self._client.on_disconnect.add_observer(self._on_disconnect) self.hass.loop.create_task(self._client.connect())
def client(self): """The hangups client object.""" # Make sure the directory with cached credentials exists. ensure_directory_exists(os.path.dirname(self.cookie_file)) dirs = appdirs.AppDirs('hangups', 'hangups') token_path = os.path.join(dirs.user_cache_dir, 'refresh_token.txt') return Client( get_auth( GoogleAccountCredentials( email_address=self.config["email-address"], password=get_secret( options=self.config, value_option="password", name_option="password-name", description="Google account password", ), ), RefreshTokenCache(token_path), ))
class HangoutsBot: """The Hangouts Bot.""" def __init__( self, hass, refresh_token, intents, default_convs, error_suppressed_convs ): """Set up the client.""" self.hass = hass self._connected = False self._refresh_token = refresh_token self._intents = intents self._conversation_intents = None self._client = None self._user_list = None self._conversation_list = None self._default_convs = default_convs self._default_conv_ids = None self._error_suppressed_convs = error_suppressed_convs self._error_suppressed_conv_ids = None dispatcher.async_dispatcher_connect( self.hass, EVENT_HANGOUTS_MESSAGE_RECEIVED, self._async_handle_conversation_message, ) def _resolve_conversation_id(self, obj): if CONF_CONVERSATION_ID in obj: return obj[CONF_CONVERSATION_ID] if CONF_CONVERSATION_NAME in obj: conv = self._resolve_conversation_name(obj[CONF_CONVERSATION_NAME]) if conv is not None: return conv.id_ return None def _resolve_conversation_name(self, name): for conv in self._conversation_list.get_all(): if conv.name == name: return conv return None def async_update_conversation_commands(self): """Refresh the commands for every conversation.""" self._conversation_intents = {} for intent_type, data in self._intents.items(): if data.get(CONF_CONVERSATIONS): conversations = [] for conversation in data.get(CONF_CONVERSATIONS): conv_id = self._resolve_conversation_id(conversation) if conv_id is not None: conversations.append(conv_id) data["_" + CONF_CONVERSATIONS] = conversations elif self._default_conv_ids: data["_" + CONF_CONVERSATIONS] = self._default_conv_ids else: data["_" + CONF_CONVERSATIONS] = [ conv.id_ for conv in self._conversation_list.get_all() ] for conv_id in data["_" + CONF_CONVERSATIONS]: if conv_id not in self._conversation_intents: self._conversation_intents[conv_id] = {} self._conversation_intents[conv_id][intent_type] = data try: self._conversation_list.on_event.remove_observer( self._async_handle_conversation_event ) except ValueError: pass self._conversation_list.on_event.add_observer( self._async_handle_conversation_event ) def async_resolve_conversations(self, _): """Resolve the list of default and error suppressed conversations.""" self._default_conv_ids = [] self._error_suppressed_conv_ids = [] for conversation in self._default_convs: conv_id = self._resolve_conversation_id(conversation) if conv_id is not None: self._default_conv_ids.append(conv_id) for conversation in self._error_suppressed_convs: conv_id = self._resolve_conversation_id(conversation) if conv_id is not None: self._error_suppressed_conv_ids.append(conv_id) dispatcher.async_dispatcher_send( self.hass, EVENT_HANGOUTS_CONVERSATIONS_RESOLVED ) async def _async_handle_conversation_event(self, event): from hangups import ChatMessageEvent if isinstance(event, ChatMessageEvent): dispatcher.async_dispatcher_send( self.hass, EVENT_HANGOUTS_MESSAGE_RECEIVED, event.conversation_id, event.user_id, event, ) async def _async_handle_conversation_message(self, conv_id, user_id, event): """Handle a message sent to a conversation.""" user = self._user_list.get_user(user_id) if user.is_self: return message = event.text _LOGGER.debug("Handling message '%s' from %s", message, user.full_name) intents = self._conversation_intents.get(conv_id) if intents is not None: is_error = False try: intent_result = await self._async_process(intents, message, conv_id) except (intent.UnknownIntent, intent.IntentHandleError) as err: is_error = True intent_result = intent.IntentResponse() intent_result.async_set_speech(str(err)) if intent_result is None: is_error = True intent_result = intent.IntentResponse() intent_result.async_set_speech("Sorry, I didn't understand that") message = ( intent_result.as_dict().get("speech", {}).get("plain", {}).get("speech") ) if (message is not None) and not ( is_error and conv_id in self._error_suppressed_conv_ids ): await self._async_send_message( [{"text": message, "parse_str": True}], [{CONF_CONVERSATION_ID: conv_id}], None, ) async def _async_process(self, intents, text, conv_id): """Detect a matching intent.""" for intent_type, data in intents.items(): for matcher in data.get(CONF_MATCHERS, []): match = matcher.match(text) if not match: continue if intent_type == INTENT_HELP: return await self.hass.helpers.intent.async_handle( DOMAIN, intent_type, {"conv_id": {"value": conv_id}}, text ) return await self.hass.helpers.intent.async_handle( DOMAIN, intent_type, {key: {"value": value} for key, value in match.groupdict().items()}, text, ) async def async_connect(self): """Login to the Google Hangouts.""" from .hangups_utils import HangoutsRefreshToken, HangoutsCredentials from hangups import Client from hangups import get_auth session = await self.hass.async_add_executor_job( get_auth, HangoutsCredentials(None, None, None), HangoutsRefreshToken(self._refresh_token), ) self._client = Client(session) self._client.on_connect.add_observer(self._on_connect) self._client.on_disconnect.add_observer(self._on_disconnect) self.hass.loop.create_task(self._client.connect()) def _on_connect(self): _LOGGER.debug("Connected!") self._connected = True dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_CONNECTED) async def _on_disconnect(self): """Handle disconnecting.""" if self._connected: _LOGGER.debug("Connection lost! Reconnect...") await self.async_connect() else: dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_DISCONNECTED) async def async_disconnect(self): """Disconnect the client if it is connected.""" if self._connected: self._connected = False await self._client.disconnect() async def async_handle_hass_stop(self, _): """Run once when Home Assistant stops.""" await self.async_disconnect() async def _async_send_message(self, message, targets, data): conversations = [] for target in targets: conversation = None if CONF_CONVERSATION_ID in target: conversation = self._conversation_list.get(target[CONF_CONVERSATION_ID]) elif CONF_CONVERSATION_NAME in target: conversation = self._resolve_conversation_name( target[CONF_CONVERSATION_NAME] ) if conversation is not None: conversations.append(conversation) if not conversations: return False from hangups import ChatMessageSegment, hangouts_pb2 messages = [] for segment in message: if messages: messages.append( ChatMessageSegment( "", segment_type=hangouts_pb2.SEGMENT_TYPE_LINE_BREAK ) ) if "parse_str" in segment and segment["parse_str"]: messages.extend(ChatMessageSegment.from_str(segment["text"])) else: if "parse_str" in segment: del segment["parse_str"] messages.append(ChatMessageSegment(**segment)) image_file = None if data: if data.get("image_url"): uri = data.get("image_url") try: websession = async_get_clientsession(self.hass) async with websession.get(uri, timeout=5) as response: if response.status != 200: _LOGGER.error( "Fetch image failed, %s, %s", response.status, response ) image_file = None else: image_data = await response.read() image_file = io.BytesIO(image_data) image_file.name = "image.png" except (asyncio.TimeoutError, aiohttp.ClientError) as error: _LOGGER.error("Failed to fetch image, %s", type(error)) image_file = None elif data.get("image_file"): uri = data.get("image_file") if self.hass.config.is_allowed_path(uri): try: image_file = open(uri, "rb") except OSError as error: _LOGGER.error( "Image file I/O error(%s): %s", error.errno, error.strerror ) else: _LOGGER.error('Path "%s" not allowed', uri) if not messages: return False for conv in conversations: await conv.send_message(messages, image_file) async def _async_list_conversations(self): import hangups self._user_list, self._conversation_list = await hangups.build_user_conversation_list( self._client ) conversations = {} for i, conv in enumerate(self._conversation_list.get_all()): users_in_conversation = [] for user in conv.users: users_in_conversation.append(user.full_name) conversations[str(i)] = { CONF_CONVERSATION_ID: str(conv.id_), CONF_CONVERSATION_NAME: conv.name, "users": users_in_conversation, } self.hass.states.async_set( f"{DOMAIN}.conversations", len(self._conversation_list.get_all()), attributes=conversations, ) dispatcher.async_dispatcher_send( self.hass, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, conversations ) async def async_handle_send_message(self, service): """Handle the send_message service.""" await self._async_send_message( service.data[ATTR_MESSAGE], service.data[ATTR_TARGET], service.data.get(ATTR_DATA, {}), ) async def async_handle_update_users_and_conversations(self, _=None): """Handle the update_users_and_conversations service.""" await self._async_list_conversations() async def async_handle_reconnect(self, _=None): """Handle the reconnect service.""" await self.async_disconnect() await self.async_connect() def get_intents(self, conv_id): """Return the intents for a specific conversation.""" return self._conversation_intents.get(conv_id)
class HangoutsBot: """The Hangouts Bot.""" def __init__(self, hass, refresh_token, intents, default_convs, error_suppressed_convs): """Set up the client.""" self.hass = hass self._connected = False self._refresh_token = refresh_token self._intents = intents self._conversation_intents = None self._client = None self._user_list = None self._conversation_list = None self._default_convs = default_convs self._default_conv_ids = None self._error_suppressed_convs = error_suppressed_convs self._error_suppressed_conv_ids = None dispatcher.async_dispatcher_connect( self.hass, EVENT_HANGOUTS_MESSAGE_RECEIVED, self._async_handle_conversation_message) def _resolve_conversation_id(self, obj): if CONF_CONVERSATION_ID in obj: return obj[CONF_CONVERSATION_ID] if CONF_CONVERSATION_NAME in obj: conv = self._resolve_conversation_name(obj[CONF_CONVERSATION_NAME]) if conv is not None: return conv.id_ return None def _resolve_conversation_name(self, name): for conv in self._conversation_list.get_all(): if conv.name == name: return conv return None def async_update_conversation_commands(self): """Refresh the commands for every conversation.""" self._conversation_intents = {} for intent_type, data in self._intents.items(): if data.get(CONF_CONVERSATIONS): conversations = [] for conversation in data.get(CONF_CONVERSATIONS): conv_id = self._resolve_conversation_id(conversation) if conv_id is not None: conversations.append(conv_id) data['_' + CONF_CONVERSATIONS] = conversations elif self._default_conv_ids: data['_' + CONF_CONVERSATIONS] = self._default_conv_ids else: data['_' + CONF_CONVERSATIONS] = \ [conv.id_ for conv in self._conversation_list.get_all()] for conv_id in data['_' + CONF_CONVERSATIONS]: if conv_id not in self._conversation_intents: self._conversation_intents[conv_id] = {} self._conversation_intents[conv_id][intent_type] = data try: self._conversation_list.on_event.remove_observer( self._async_handle_conversation_event) except ValueError: pass self._conversation_list.on_event.add_observer( self._async_handle_conversation_event) def async_resolve_conversations(self, _): """Resolve the list of default and error suppressed conversations.""" self._default_conv_ids = [] self._error_suppressed_conv_ids = [] for conversation in self._default_convs: conv_id = self._resolve_conversation_id(conversation) if conv_id is not None: self._default_conv_ids.append(conv_id) for conversation in self._error_suppressed_convs: conv_id = self._resolve_conversation_id(conversation) if conv_id is not None: self._error_suppressed_conv_ids.append(conv_id) dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_CONVERSATIONS_RESOLVED) async def _async_handle_conversation_event(self, event): from hangups import ChatMessageEvent if isinstance(event, ChatMessageEvent): dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_MESSAGE_RECEIVED, event.conversation_id, event.user_id, event) async def _async_handle_conversation_message(self, conv_id, user_id, event): """Handle a message sent to a conversation.""" user = self._user_list.get_user(user_id) if user.is_self: return message = event.text _LOGGER.debug("Handling message '%s' from %s", message, user.full_name) intents = self._conversation_intents.get(conv_id) if intents is not None: is_error = False try: intent_result = await self._async_process(intents, message, conv_id) except (intent.UnknownIntent, intent.IntentHandleError) as err: is_error = True intent_result = intent.IntentResponse() intent_result.async_set_speech(str(err)) if intent_result is None: is_error = True intent_result = intent.IntentResponse() intent_result.async_set_speech( "Sorry, I didn't understand that") message = intent_result.as_dict().get('speech', {})\ .get('plain', {}).get('speech') if (message is not None) and not ( is_error and conv_id in self._error_suppressed_conv_ids): await self._async_send_message( [{'text': message, 'parse_str': True}], [{CONF_CONVERSATION_ID: conv_id}], None) async def _async_process(self, intents, text, conv_id): """Detect a matching intent.""" for intent_type, data in intents.items(): for matcher in data.get(CONF_MATCHERS, []): match = matcher.match(text) if not match: continue if intent_type == INTENT_HELP: return await self.hass.helpers.intent.async_handle( DOMAIN, intent_type, {'conv_id': {'value': conv_id}}, text) return await self.hass.helpers.intent.async_handle( DOMAIN, intent_type, {key: {'value': value} for key, value in match.groupdict().items()}, text) async def async_connect(self): """Login to the Google Hangouts.""" from .hangups_utils import HangoutsRefreshToken, HangoutsCredentials from hangups import Client from hangups import get_auth session = await self.hass.async_add_executor_job( get_auth, HangoutsCredentials(None, None, None), HangoutsRefreshToken(self._refresh_token)) self._client = Client(session) self._client.on_connect.add_observer(self._on_connect) self._client.on_disconnect.add_observer(self._on_disconnect) self.hass.loop.create_task(self._client.connect()) def _on_connect(self): _LOGGER.debug('Connected!') self._connected = True dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_CONNECTED) async def _on_disconnect(self): """Handle disconnecting.""" if self._connected: _LOGGER.debug('Connection lost! Reconnect...') await self.async_connect() else: dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_DISCONNECTED) async def async_disconnect(self): """Disconnect the client if it is connected.""" if self._connected: self._connected = False await self._client.disconnect() async def async_handle_hass_stop(self, _): """Run once when Home Assistant stops.""" await self.async_disconnect() async def _async_send_message(self, message, targets, data): conversations = [] for target in targets: conversation = None if CONF_CONVERSATION_ID in target: conversation = self._conversation_list.get( target[CONF_CONVERSATION_ID]) elif CONF_CONVERSATION_NAME in target: conversation = self._resolve_conversation_name( target[CONF_CONVERSATION_NAME]) if conversation is not None: conversations.append(conversation) if not conversations: return False from hangups import ChatMessageSegment, hangouts_pb2 messages = [] for segment in message: if messages: messages.append(ChatMessageSegment('', segment_type=hangouts_pb2. SEGMENT_TYPE_LINE_BREAK)) if 'parse_str' in segment and segment['parse_str']: messages.extend(ChatMessageSegment.from_str(segment['text'])) else: if 'parse_str' in segment: del segment['parse_str'] messages.append(ChatMessageSegment(**segment)) image_file = None if data: if data.get('image_url'): uri = data.get('image_url') try: websession = async_get_clientsession(self.hass) async with websession.get(uri, timeout=5) as response: if response.status != 200: _LOGGER.error( 'Fetch image failed, %s, %s', response.status, response ) image_file = None else: image_data = await response.read() image_file = io.BytesIO(image_data) image_file.name = "image.png" except (asyncio.TimeoutError, aiohttp.ClientError) as error: _LOGGER.error( 'Failed to fetch image, %s', type(error) ) image_file = None elif data.get('image_file'): uri = data.get('image_file') if self.hass.config.is_allowed_path(uri): try: image_file = open(uri, 'rb') except IOError as error: _LOGGER.error( 'Image file I/O error(%s): %s', error.errno, error.strerror ) else: _LOGGER.error('Path "%s" not allowed', uri) if not messages: return False for conv in conversations: await conv.send_message(messages, image_file) async def _async_list_conversations(self): import hangups self._user_list, self._conversation_list = \ (await hangups.build_user_conversation_list(self._client)) conversations = {} for i, conv in enumerate(self._conversation_list.get_all()): users_in_conversation = [] for user in conv.users: users_in_conversation.append(user.full_name) conversations[str(i)] = {CONF_CONVERSATION_ID: str(conv.id_), CONF_CONVERSATION_NAME: conv.name, 'users': users_in_conversation} self.hass.states.async_set("{}.conversations".format(DOMAIN), len(self._conversation_list.get_all()), attributes=conversations) dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, conversations) async def async_handle_send_message(self, service): """Handle the send_message service.""" await self._async_send_message(service.data[ATTR_MESSAGE], service.data[ATTR_TARGET], service.data.get(ATTR_DATA, {})) async def async_handle_update_users_and_conversations(self, _=None): """Handle the update_users_and_conversations service.""" await self._async_list_conversations() async def async_handle_reconnect(self, _=None): """Handle the reconnect service.""" await self.async_disconnect() await self.async_connect() def get_intents(self, conv_id): """Return the intents for a specific conversation.""" return self._conversation_intents.get(conv_id)
class User: az: AppService loop: asyncio.AbstractEventLoop log: logging.Logger = logging.getLogger("mau.user") by_mxid: Dict[UserID, 'User'] = {} client: Optional[Client] command_status: Optional[Dict[str, Any]] is_whitelisted: bool is_admin: bool _db_instance: Optional[DBUser] mxid: UserID gid: str refresh_token: str name: Optional[str] name_future: asyncio.Future connected: bool chats: ConversationList users: UserList _community_helper: CommunityHelper _community_id: Optional[CommunityID] def __init__(self, mxid: UserID, gid: str = None, refresh_token: str = None, db_instance: Optional[DBUser] = None) -> None: self.mxid = mxid self.gid = gid self.refresh_token = refresh_token self.by_mxid[mxid] = self self.command_status = None self.is_whitelisted, self.is_admin = config.get_permissions(mxid) self._db_instance = db_instance self._community_id = None self.client = None self.name = None self.name_future = asyncio.Future() self.connected = False self.log = self.log.getChild(self.mxid) # region Sessions def save(self) -> None: self.db_instance.edit(refresh_token=self.refresh_token, gid=self.gid) @property def db_instance(self) -> DBUser: if not self._db_instance: self._db_instance = DBUser(mxid=self.mxid, gid=self.gid, refresh_token=self.refresh_token) return self._db_instance @classmethod def from_db(cls, db_user: DBUser) -> 'User': return User(mxid=db_user.mxid, refresh_token=db_user.refresh_token, db_instance=db_user) @classmethod def get_all(cls) -> Iterator['User']: for db_user in DBUser.all(): yield cls.from_db(db_user) @classmethod def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['User']: if pu.Puppet.get_id_from_mxid(mxid) is not None or mxid == cls.az.bot_mxid: return None try: return cls.by_mxid[mxid] except KeyError: pass db_user = DBUser.get_by_mxid(mxid) if db_user: return cls.from_db(db_user) if create: user = cls(mxid) user.db_instance.insert() return user return None # endregion async def is_logged_in(self) -> bool: return self.client and self.connected @classmethod async def init_all(cls) -> None: users = [user for user in cls.get_all() if user.refresh_token] with futures.ThreadPoolExecutor() as pool: auth_resps: List[TryAuthResp] = await asyncio.gather( *[cls.loop.run_in_executor(pool, try_auth, user.refresh_token) for user in users], loop=cls.loop) finish = [] for user, auth_resp in zip(users, auth_resps): if auth_resp.success: finish.append(user.login_complete(auth_resp.cookies)) else: user.log.exception("Failed to resume session with stored refresh token", exc_info=auth_resp.error) await asyncio.gather(*finish, loop=cls.loop) async def login_complete(self, cookies: dict) -> None: self.client = Client(cookies) await self._create_community() asyncio.ensure_future(self.start(), loop=self.loop) self.client.on_connect.add_observer(self.on_connect) self.client.on_reconnect.add_observer(self.on_reconnect) self.client.on_disconnect.add_observer(self.on_disconnect) async def start(self) -> None: try: await self.client.connect() self.log.info("Client connection finished") except Exception: self.log.exception("Exception in connection") async def stop(self) -> None: if self.client: await self.client.disconnect() async def on_connect(self) -> None: self.connected = True asyncio.ensure_future(self.on_connect_later(), loop=self.loop) async def on_connect_later(self) -> None: try: info = await self.client.get_self_info(hangouts.GetSelfInfoRequest( request_header=self.client.get_request_header() )) except Exception: self.log.exception("Failed to get_self_info") return self.gid = info.self_entity.id.gaia_id self.name = info.self_entity.properties.display_name self.name_future.set_result(self.name) self.save() try: await self.sync() except Exception: self.log.exception("Failed to sync conversations and users") async def on_reconnect(self) -> None: self.connected = True async def on_disconnect(self) -> None: self.connected = False async def sync(self) -> None: users, chats = await hangups.build_user_conversation_list(self.client) await asyncio.gather(self.sync_users(users), self.sync_chats(chats), loop=self.loop) async def sync_users(self, users: UserList) -> None: self.users = users puppets: Dict[str, pu.Puppet] = {} update_avatars = config["bridge.update_avatar_initial_sync"] updates = [] for info in users.get_all(): if not info.id_.gaia_id: self.log.debug(f"Found user without gaia_id: {info}") continue puppet = pu.Puppet.get_by_gid(info.id_.gaia_id, create=True) puppets[puppet.gid] = puppet updates.append(puppet.update_info(self, info, update_avatar=update_avatars)) self.log.debug(f"Syncing info of {len(updates)} puppets " f"(avatars included: {update_avatars})...") await asyncio.gather(*updates, loop=self.loop) await self._sync_community_users(puppets) def _ensure_future_proxy(self, method: Callable[[Any], Awaitable[None]] ) -> Callable[[Any], Awaitable[None]]: async def try_proxy(*args, **kwargs) -> None: try: await method(*args, **kwargs) except Exception: self.log.exception("Exception in event handler") async def proxy(*args, **kwargs) -> None: asyncio.ensure_future(try_proxy(*args, **kwargs)) return proxy async def sync_chats(self, chats: ConversationList) -> None: self.chats = chats portals = {conv.id_: po.Portal.get_by_conversation(conv, self.gid) for conv in chats.get_all()} await self._sync_community_rooms(portals) self.chats.on_event.add_observer(self._ensure_future_proxy(self.on_event)) self.chats.on_typing.add_observer(self._ensure_future_proxy(self.on_typing)) self.log.debug("Fetching recent conversations to create portals for") res = await self.client.sync_recent_conversations(hangouts.SyncRecentConversationsRequest( request_header=self.client.get_request_header(), max_conversations=config["bridge.initial_chat_sync"], max_events_per_conversation=1, sync_filter=[hangouts.SYNC_FILTER_INBOX], )) res = sorted((conv_state.conversation for conv_state in res.conversation_state), reverse=True, key=lambda conv: conv.self_conversation_state.sort_timestamp) res = (chats.get(conv.conversation_id.id) for conv in res) await asyncio.gather( *[po.Portal.get_by_conversation(info, self.gid).create_matrix_room(self, info) for info in res], loop=self.loop) # region Hangouts event handling async def on_event(self, event: ConversationEvent) -> None: conv: Conversation = self.chats.get(event.conversation_id) portal = po.Portal.get_by_conversation(conv, self.gid) if not portal: return sender = pu.Puppet.get_by_gid(event.user_id.gaia_id) if isinstance(event, ChatMessageEvent): await portal.handle_hangouts_message(self, sender, event) elif isinstance(event, MembershipChangeEvent): self.log.info( f"{event.id_} by {event.user_id} in {event.conversation_id} ({conv._conversation.type}): {event.participant_ids} {event.type_}'d") else: self.log.info(f"Unrecognized event {event}") async def on_typing(self, event: TypingStatusMessage): portal = po.Portal.get_by_gid(event.conv_id, self.gid) if not portal: return sender = pu.Puppet.get_by_gid(event.user_id.gaia_id, create=False) if not sender: return await portal.handle_hangouts_typing(self, sender, event.status) # endregion # region Hangouts API calls async def set_typing(self, conversation_id: str, typing: bool) -> None: self.log.debug(f"set_typing({conversation_id}, {typing})") await self.client.set_typing(hangouts.SetTypingRequest( request_header=self.client.get_request_header(), conversation_id=hangouts.ConversationId(id=conversation_id), type=hangouts.TYPING_TYPE_STARTED if typing else hangouts.TYPING_TYPE_STOPPED, )) def _get_event_request_header(self, conversation_id: str) -> hangouts.EventRequestHeader: delivery_medium = self.chats.get(conversation_id)._get_default_delivery_medium() return hangouts.EventRequestHeader( conversation_id=hangouts.ConversationId( id=conversation_id, ), delivery_medium=delivery_medium, client_generated_id=self.client.get_client_generated_id(), ) async def send_emote(self, conversation_id: str, text: str) -> str: resp = await self.client.send_chat_message(hangouts.SendChatMessageRequest( request_header=self.client.get_request_header(), annotation=[hangouts.EventAnnotation(type=4)], event_request_header=self._get_event_request_header(conversation_id), message_content=hangouts.MessageContent( segment=[hangups.ChatMessageSegment(text).serialize()], ), )) return resp.created_event.event_id async def send_text(self, conversation_id: str, text: str) -> str: resp = await self.client.send_chat_message(hangouts.SendChatMessageRequest( request_header=self.client.get_request_header(), event_request_header=self._get_event_request_header(conversation_id), message_content=hangouts.MessageContent( segment=[hangups.ChatMessageSegment(text).serialize()], ), )) return resp.created_event.event_id async def send_image(self, conversation_id: str, id: str) -> str: resp = await self.client.send_chat_message(hangouts.SendChatMessageRequest( request_header=self.client.get_request_header(), event_request_header=self._get_event_request_header(conversation_id), existing_media=hangouts.ExistingMedia( photo=hangouts.Photo(photo_id=id), ), )) return resp.created_event.event_id async def mark_read(self, conversation_id: str, timestamp: Optional[Union[datetime.datetime, int]] = None) -> None: if isinstance(timestamp, datetime.datetime): timestamp = hangups.parsers.to_timestamp(timestamp) elif not timestamp: timestamp = int(time.time() * 1_000_000) await self.client.update_watermark(hangouts.UpdateWatermarkRequest( request_header=self.client.get_request_header(), conversation_id=hangouts.ConversationId(id=conversation_id), last_read_timestamp=timestamp, )) # endregion # region Community stuff async def _create_community(self) -> None: template = config["bridge.community_template"] if not template: return localpart, server = MxClient.parse_user_id(self.mxid) community_localpart = template.format(localpart=localpart.lower(), server=server.lower()) self.log.debug(f"Creating personal filtering community {community_localpart}...") self._community_id, created = await self._community_helper.create(community_localpart) if created: await self._community_helper.update(self._community_id, name="Hangouts", avatar_url=config["appservice.bot_avatar"], short_desc="Your Hangouts bridged chats") await self._community_helper.invite(self._community_id, self.mxid) async def _sync_community_users(self, puppets: Dict[str, 'pu.Puppet']) -> None: if not self._community_id: return self.log.debug("Syncing personal filtering community users") old_db_contacts = {contact.contact: contact.in_community for contact in self.db_instance.contacts} db_contacts = [] for puppet in puppets.values(): in_community = old_db_contacts.get(puppet.gid, None) or False if not in_community: await self._community_helper.join(self._community_id, puppet.default_mxid_intent) in_community = True db_contacts.append(Contact(user=self.gid, contact=puppet.gid, in_community=in_community)) self.db_instance.contacts = db_contacts async def _sync_community_rooms(self, portals: Dict[str, 'po.Portal']) -> None: if not self._community_id: return self.log.debug("Syncing personal filtering community rooms") old_db_portals = {portal.portal: portal.in_community for portal in self.db_instance.portals} db_portals = [] for portal in portals.values(): in_community = old_db_portals.get(portal.gid, None) or False if not in_community: await self._community_helper.add_room(self._community_id, portal.mxid) in_community = True db_portals.append(UserPortal(user=self.gid, portal=portal.gid, portal_receiver=portal.receiver, in_community=in_community)) self.db_instance.portals = db_portals
class User(BaseUser): by_mxid: Dict[UserID, 'User'] = {} client: Optional[Client] is_admin: bool _db_instance: Optional[DBUser] gid: Optional[str] refresh_token: Optional[str] notice_room: Optional[RoomID] _notice_room_lock: asyncio.Lock _intentional_disconnect: bool name: Optional[str] name_future: asyncio.Future connected: bool chats: Optional[ConversationList] chats_future: asyncio.Future users: Optional[UserList] _community_helper: CommunityHelper _community_id: Optional[CommunityID] def __init__(self, mxid: UserID, gid: Optional[str] = None, refresh_token: Optional[str] = None, notice_room: Optional[RoomID] = None, db_instance: Optional[DBUser] = None) -> None: self.mxid = mxid self.gid = gid self.refresh_token = refresh_token self.notice_room = notice_room self._notice_room_lock = asyncio.Lock() self.by_mxid[mxid] = self self.command_status = None self.is_whitelisted, self.is_admin, self.level = config.get_permissions( mxid) self._db_instance = db_instance self._community_id = None self.client = None self.name = None self.name_future = asyncio.Future() self.connected = False self.chats = None self.chats_future = asyncio.Future() self.users = None self._intentional_disconnect = False self.dm_update_lock = asyncio.Lock() self._metric_value = defaultdict(lambda: False) self.log = self.log.getChild(self.mxid) # region Sessions def save(self) -> None: self.db_instance.edit(refresh_token=self.refresh_token, gid=self.gid, notice_room=self.notice_room) @property def db_instance(self) -> DBUser: if not self._db_instance: self._db_instance = DBUser(mxid=self.mxid, gid=self.gid, notice_room=self.notice_room, refresh_token=self.refresh_token) return self._db_instance @classmethod def from_db(cls, db_user: DBUser) -> 'User': return User(mxid=db_user.mxid, refresh_token=db_user.refresh_token, notice_room=db_user.notice_room, db_instance=db_user) @classmethod def get_all(cls) -> Iterator['User']: for db_user in DBUser.all(): yield cls.from_db(db_user) @classmethod def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['User']: if pu.Puppet.get_id_from_mxid( mxid) is not None or mxid == cls.az.bot_mxid: return None try: return cls.by_mxid[mxid] except KeyError: pass db_user = DBUser.get_by_mxid(mxid) if db_user: return cls.from_db(db_user) if create: user = cls(mxid) user.db_instance.insert() return user return None # endregion async def get_notice_room(self) -> RoomID: if not self.notice_room: async with self._notice_room_lock: # If someone already created the room while this call was waiting, # don't make a new room if self.notice_room: return self.notice_room self.notice_room = await self.az.intent.create_room( is_direct=True, invitees=[self.mxid], topic="Hangouts bridge notices") self.save() return self.notice_room async def send_bridge_notice(self, text: str, important: bool = False) -> None: if not important and not config["bridge.unimportant_bridge_notices"]: return msgtype = MessageType.TEXT if important else MessageType.NOTICE try: await self.az.intent.send_text(await self.get_notice_room(), text, msgtype=msgtype) except Exception: self.log.warning("Failed to send bridge notice '%s'", text, exc_info=True) async def is_logged_in(self) -> bool: return self.client and self.connected @classmethod async def init_all(cls) -> None: users = [user for user in cls.get_all() if user.refresh_token] with futures.ThreadPoolExecutor() as pool: auth_resps: List[TryAuthResp] = await asyncio.gather(*[ cls.loop.run_in_executor(pool, try_auth, user.refresh_token) for user in users ], loop=cls.loop) finish = [] for user, auth_resp in zip(users, auth_resps): if auth_resp.success: finish.append(user.login_complete(auth_resp.cookies)) else: await user.send_bridge_notice( "Failed to resume session with stored " f"refresh token: {auth_resp.error}", important=True) user.log.exception( "Failed to resume session with stored refresh token", exc_info=auth_resp.error) await asyncio.gather(*finish, loop=cls.loop) async def login_complete(self, cookies: dict) -> None: self.client = Client( cookies, max_retries=config['bridge.reconnect.max_retries'], retry_backoff_base=config['bridge.reconnect.retry_backoff_base']) await self._create_community() asyncio.ensure_future(self.start(), loop=self.loop) self.client.on_connect.add_observer(self.on_connect) self.client.on_reconnect.add_observer(self.on_reconnect) self.client.on_disconnect.add_observer(self.on_disconnect) async def start(self) -> None: try: self._intentional_disconnect = False await self.client.connect() self._track_metric(METRIC_CONNECTED, False) if self._intentional_disconnect: self.log.info("Client connection finished") else: self.log.warning("Client connection finished unexpectedly") await self.send_bridge_notice( "Client connection finished unexpectedly", important=True) except Exception as e: self._track_metric(METRIC_CONNECTED, False) self.log.exception("Exception in connection") await self.send_bridge_notice( f"Exception in Hangouts connection: {e}", important=True) async def stop(self) -> None: if self.client: self._intentional_disconnect = True await self.client.disconnect() async def logout(self) -> None: self._track_metric(METRIC_LOGGED_IN, False) await self.stop() self.client = None self.gid = None self.refresh_token = None self.connected = False self.chats = None if not self.chats_future.done(): self.chats_future.set_exception(Exception("logged out")) self.chats_future = asyncio.Future() self.users = None self.name = None if not self.name_future.done(): self.name_future.set_exception(Exception("logged out")) self.name_future = asyncio.Future() async def on_connect(self) -> None: self.connected = True asyncio.ensure_future(self.on_connect_later(), loop=self.loop) await self.send_bridge_notice("Connected to Hangouts") async def on_connect_later(self) -> None: try: info = await self.client.get_self_info( hangouts.GetSelfInfoRequest( request_header=self.client.get_request_header())) except Exception: self.log.exception("Failed to get_self_info") return self.gid = info.self_entity.id.gaia_id self._track_metric(METRIC_CONNECTED, True) self._track_metric(METRIC_LOGGED_IN, True) self.name = info.self_entity.properties.display_name self.name_future.set_result(self.name) self.save() try: puppet = pu.Puppet.get_by_gid(self.gid) if puppet.custom_mxid != self.mxid and puppet.can_auto_login( self.mxid): self.log.info(f"Automatically enabling custom puppet") await puppet.switch_mxid(access_token="auto", mxid=self.mxid) except Exception: self.log.exception("Failed to automatically enable custom puppet") try: await self.sync() except Exception: self.log.exception("Failed to sync conversations and users") async def on_reconnect(self) -> None: self.connected = True await self.send_bridge_notice("Reconnected to Hangouts") async def on_disconnect(self) -> None: self.connected = False await self.send_bridge_notice("Disconnected from Hangouts") async def sync(self) -> None: users, chats = await hangups.build_user_conversation_list(self.client) await asyncio.gather(self.sync_users(users), self.sync_chats(chats), loop=self.loop) @async_time(METRIC_SYNC_USERS) async def sync_users(self, users: UserList) -> None: self.users = users puppets: Dict[str, pu.Puppet] = {} update_avatars = config["bridge.update_avatar_initial_sync"] updates = [] for info in users.get_all(): if not info.id_.gaia_id: self.log.debug(f"Found user without gaia_id: {info}") continue puppet = pu.Puppet.get_by_gid(info.id_.gaia_id, create=True) puppets[puppet.gid] = puppet updates.append( puppet.update_info(self, info, update_avatar=update_avatars)) self.log.debug(f"Syncing info of {len(updates)} puppets " f"(avatars included: {update_avatars})...") await asyncio.gather(*updates, loop=self.loop) await self._sync_community_users(puppets) def _ensure_future_proxy( self, method: Callable[[Any], Awaitable[None]] ) -> Callable[[Any], Awaitable[None]]: async def try_proxy(*args, **kwargs) -> None: try: await method(*args, **kwargs) except Exception: self.log.exception("Exception in event handler") async def proxy(*args, **kwargs) -> None: asyncio.ensure_future(try_proxy(*args, **kwargs)) return proxy async def get_direct_chats(self) -> Dict[UserID, List[RoomID]]: return { pu.Puppet.get_mxid_from_id(portal.other_user_id): [portal.mxid] for portal in DBPortal.get_all_by_receiver(self.gid) if portal.mxid } @async_time(METRIC_SYNC_CHATS) async def sync_chats(self, chats: ConversationList) -> None: self.chats = chats self.chats_future.set_result(None) portals = { conv.id_: po.Portal.get_by_conversation(conv, self.gid) for conv in chats.get_all() } await self._sync_community_rooms(portals) self.chats.on_watermark_notification.add_observer( self._ensure_future_proxy(self.on_receipt)) self.chats.on_event.add_observer( self._ensure_future_proxy(self.on_event)) self.chats.on_typing.add_observer( self._ensure_future_proxy(self.on_typing)) self.log.debug("Fetching recent conversations to create portals for") res = await self.client.sync_recent_conversations( hangouts.SyncRecentConversationsRequest( request_header=self.client.get_request_header(), max_conversations=config["bridge.initial_chat_sync"], max_events_per_conversation=1, sync_filter=[hangouts.SYNC_FILTER_INBOX], )) self.log.debug("Server returned %d conversations", len(res.conversation_state)) convs = sorted(res.conversation_state, reverse=True, key=lambda state: state.conversation. self_conversation_state.sort_timestamp) for state in convs: self.log.debug("Syncing %s", state.conversation_id.id) chat = chats.get(state.conversation_id.id) portal = po.Portal.get_by_conversation(chat, self.gid) if portal.mxid: await portal.update_matrix_room(self, chat) if len(state.event) > 0 and not DBMessage.get_by_gid( state.event[0].event_id): self.log.debug( "Last message %s in chat %s not found in db, backfilling...", state.event[0].event_id, state.conversation_id.id) await portal.backfill(self, is_initial=False) else: await portal.create_matrix_room(self, chat) await self.update_direct_chats() # region Hangouts event handling @async_time(METRIC_RECEIPT) async def on_receipt(self, event: WatermarkNotification) -> None: if not self.chats: self.log.debug("Received receipt event before chat list, ignoring") return conv: Conversation = self.chats.get(event.conv_id) portal = po.Portal.get_by_conversation(conv, self.gid) if not portal: return message = DBMessage.get_most_recent(portal.mxid, event.read_timestamp) if not message: return puppet = pu.Puppet.get_by_gid(event.user_id.gaia_id) await puppet.intent_for(portal).mark_read(message.mx_room, message.mxid) @async_time(METRIC_EVENT) async def on_event(self, event: ConversationEvent) -> None: if not self.chats: self.log.debug( "Received message event before chat list, waiting for chat list" ) await self.chats_future conv: Conversation = self.chats.get(event.conversation_id) portal = po.Portal.get_by_conversation(conv, self.gid) if not portal: return sender = pu.Puppet.get_by_gid(event.user_id.gaia_id) if isinstance(event, ChatMessageEvent): await portal.backfill_lock.wait(event.id_) await portal.handle_hangouts_message(self, sender, event) elif isinstance(event, MembershipChangeEvent): self.log.info( f"{event.id_} by {event.user_id} in {event.conversation_id} " f"({conv._conversation.type}): {event.participant_ids} {event.type_}'d" ) else: self.log.info(f"Unrecognized event {event}") @async_time(METRIC_TYPING) async def on_typing(self, event: TypingStatusMessage): portal = po.Portal.get_by_gid(event.conv_id, self.gid) if not portal: return sender = pu.Puppet.get_by_gid(event.user_id.gaia_id, create=False) if not sender: return await portal.handle_hangouts_typing(self, sender, event.status) # endregion # region Hangouts API calls async def set_typing(self, conversation_id: str, typing: bool) -> None: self.log.debug(f"set_typing({conversation_id}, {typing})") await self.client.set_typing( hangouts.SetTypingRequest( request_header=self.client.get_request_header(), conversation_id=hangouts.ConversationId(id=conversation_id), type=hangouts.TYPING_TYPE_STARTED if typing else hangouts.TYPING_TYPE_STOPPED, )) async def _get_event_request_header( self, conversation_id: str) -> hangouts.EventRequestHeader: if not self.chats: self.log.debug( "Tried to send message before receiving chat list, waiting") await self.chats_future delivery_medium = self.chats.get( conversation_id)._get_default_delivery_medium() return hangouts.EventRequestHeader( conversation_id=hangouts.ConversationId(id=conversation_id, ), delivery_medium=delivery_medium, client_generated_id=self.client.get_client_generated_id(), ) async def send_emote(self, conversation_id: str, text: str) -> str: resp = await self.client.send_chat_message( hangouts.SendChatMessageRequest( request_header=self.client.get_request_header(), annotation=[hangouts.EventAnnotation(type=4)], event_request_header=await self._get_event_request_header(conversation_id), message_content=hangouts.MessageContent( segment=[hangups.ChatMessageSegment(text).serialize()], ), )) return resp.created_event.event_id async def send_text(self, conversation_id: str, text: str) -> str: resp = await self.client.send_chat_message( hangouts.SendChatMessageRequest( request_header=self.client.get_request_header(), event_request_header=await self._get_event_request_header(conversation_id), message_content=hangouts.MessageContent(segment=[ segment.serialize() for segment in hangups.ChatMessageSegment.from_str(text) ], ), )) return resp.created_event.event_id async def send_image(self, conversation_id: str, id: str) -> str: resp = await self.client.send_chat_message( hangouts.SendChatMessageRequest( request_header=self.client.get_request_header(), event_request_header=await self._get_event_request_header(conversation_id), existing_media=hangouts.ExistingMedia( photo=hangouts.Photo(photo_id=id), ), )) return resp.created_event.event_id async def mark_read( self, conversation_id: str, timestamp: Optional[Union[datetime.datetime, int]] = None) -> None: if isinstance(timestamp, datetime.datetime): timestamp = hangups.parsers.to_timestamp(timestamp) elif not timestamp: timestamp = int(time.time() * 1_000_000) await self.client.update_watermark( hangouts.UpdateWatermarkRequest( request_header=self.client.get_request_header(), conversation_id=hangouts.ConversationId(id=conversation_id), last_read_timestamp=timestamp, )) # endregion # region Community stuff async def _create_community(self) -> None: template = config["bridge.community_template"] if not template: return localpart, server = MxClient.parse_user_id(self.mxid) community_localpart = template.format(localpart=localpart.lower(), server=server.lower()) self.log.debug( f"Creating personal filtering community {community_localpart}...") self._community_id, created = await self._community_helper.create( community_localpart) if created: await self._community_helper.update( self._community_id, name="Hangouts", avatar_url=config["appservice.bot_avatar"], short_desc="Your Hangouts bridged chats") await self._community_helper.invite(self._community_id, self.mxid) async def _sync_community_users(self, puppets: Dict[str, 'pu.Puppet']) -> None: if not self._community_id: return self.log.debug("Syncing personal filtering community users") old_db_contacts = { contact.contact: contact.in_community for contact in self.db_instance.contacts } db_contacts = [] for puppet in puppets.values(): in_community = old_db_contacts.get(puppet.gid, None) or False if not in_community: await self._community_helper.join(self._community_id, puppet.default_mxid_intent) in_community = True db_contacts.append( Contact(user=self.gid, contact=puppet.gid, in_community=in_community)) self.db_instance.contacts = db_contacts async def _sync_community_rooms(self, portals: Dict[str, 'po.Portal']) -> None: if not self._community_id: return self.log.debug("Syncing personal filtering community rooms") old_db_portals = { portal.portal: portal.in_community for portal in self.db_instance.portals } db_portals = [] for portal in portals.values(): in_community = old_db_portals.get(portal.gid, None) or False if not in_community: await self._community_helper.add_room(self._community_id, portal.mxid) in_community = True db_portals.append( UserPortal(user=self.gid, portal=portal.gid, portal_receiver=portal.receiver, in_community=in_community)) self.db_instance.portals = db_portals
class HangoutsBot: """The Hangouts Bot.""" def __init__(self, hass, refresh_token, commands): """Set up the client.""" self.hass = hass self._connected = False self._refresh_token = refresh_token self._commands = commands self._word_commands = None self._expression_commands = None self._client = None self._user_list = None self._conversation_list = None def _resolve_conversation_name(self, name): for conv in self._conversation_list.get_all(): if conv.name == name: return conv return None def async_update_conversation_commands(self, _): """Refresh the commands for every conversation.""" self._word_commands = {} self._expression_commands = {} for command in self._commands: if command.get(CONF_CONVERSATIONS): conversations = [] for conversation in command.get(CONF_CONVERSATIONS): if 'id' in conversation: conversations.append(conversation['id']) elif 'name' in conversation: conversations.append(self._resolve_conversation_name( conversation['name']).id_) command['_' + CONF_CONVERSATIONS] = conversations else: command['_' + CONF_CONVERSATIONS] = \ [conv.id_ for conv in self._conversation_list.get_all()] if command.get(CONF_WORD): for conv_id in command['_' + CONF_CONVERSATIONS]: if conv_id not in self._word_commands: self._word_commands[conv_id] = {} word = command[CONF_WORD].lower() self._word_commands[conv_id][word] = command elif command.get(CONF_EXPRESSION): command['_' + CONF_EXPRESSION] = re.compile( command.get(CONF_EXPRESSION)) for conv_id in command['_' + CONF_CONVERSATIONS]: if conv_id not in self._expression_commands: self._expression_commands[conv_id] = [] self._expression_commands[conv_id].append(command) try: self._conversation_list.on_event.remove_observer( self._handle_conversation_event) except ValueError: pass self._conversation_list.on_event.add_observer( self._handle_conversation_event) def _handle_conversation_event(self, event): from hangups import ChatMessageEvent if event.__class__ is ChatMessageEvent: self._handle_conversation_message( event.conversation_id, event.user_id, event) def _handle_conversation_message(self, conv_id, user_id, event): """Handle a message sent to a conversation.""" user = self._user_list.get_user(user_id) if user.is_self: return _LOGGER.debug("Handling message '%s' from %s", event.text, user.full_name) event_data = None pieces = event.text.split(' ') cmd = pieces[0].lower() command = self._word_commands.get(conv_id, {}).get(cmd) if command: event_data = { 'command': command[CONF_NAME], 'conversation_id': conv_id, 'user_id': user_id, 'user_name': user.full_name, 'data': pieces[1:] } else: # After single-word commands, check all regex commands in the room for command in self._expression_commands.get(conv_id, []): match = command['_' + CONF_EXPRESSION].match(event.text) if not match: continue event_data = { 'command': command[CONF_NAME], 'conversation_id': conv_id, 'user_id': user_id, 'user_name': user.full_name, 'data': match.groupdict() } if event_data is not None: self.hass.bus.fire(EVENT_HANGOUTS_COMMAND, event_data) async def async_connect(self): """Login to the Google Hangouts.""" from .hangups_utils import HangoutsRefreshToken, HangoutsCredentials from hangups import Client from hangups import get_auth session = await self.hass.async_add_executor_job( get_auth, HangoutsCredentials(None, None, None), HangoutsRefreshToken(self._refresh_token)) self._client = Client(session) self._client.on_connect.add_observer(self._on_connect) self._client.on_disconnect.add_observer(self._on_disconnect) self.hass.loop.create_task(self._client.connect()) def _on_connect(self): _LOGGER.debug('Connected!') self._connected = True dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_CONNECTED) def _on_disconnect(self): """Handle disconnecting.""" _LOGGER.debug('Connection lost!') self._connected = False dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_DISCONNECTED) async def async_disconnect(self): """Disconnect the client if it is connected.""" if self._connected: await self._client.disconnect() async def async_handle_hass_stop(self, _): """Run once when Home Assistant stops.""" await self.async_disconnect() async def _async_send_message(self, message, targets): conversations = [] for target in targets: conversation = None if 'id' in target: conversation = self._conversation_list.get(target['id']) elif 'name' in target: conversation = self._resolve_conversation_name(target['name']) if conversation is not None: conversations.append(conversation) if not conversations: return False from hangups import ChatMessageSegment, hangouts_pb2 messages = [] for segment in message: if 'parse_str' in segment and segment['parse_str']: messages.extend(ChatMessageSegment.from_str(segment['text'])) else: if 'parse_str' in segment: del segment['parse_str'] messages.append(ChatMessageSegment(**segment)) messages.append(ChatMessageSegment('', segment_type=hangouts_pb2. SEGMENT_TYPE_LINE_BREAK)) if not messages: return False for conv in conversations: await conv.send_message(messages) async def _async_list_conversations(self): import hangups self._user_list, self._conversation_list = \ (await hangups.build_user_conversation_list(self._client)) conversations = {} for i, conv in enumerate(self._conversation_list.get_all()): users_in_conversation = [] for user in conv.users: users_in_conversation.append(user.full_name) conversations[str(i)] = {'id': str(conv.id_), 'name': conv.name, 'users': users_in_conversation} self.hass.states.async_set("{}.conversations".format(DOMAIN), len(self._conversation_list.get_all()), attributes=conversations) dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, conversations) async def async_handle_send_message(self, service): """Handle the send_message service.""" await self._async_send_message(service.data[ATTR_MESSAGE], service.data[ATTR_TARGET]) async def async_handle_update_users_and_conversations(self, _=None): """Handle the update_users_and_conversations service.""" await self._async_list_conversations()
class HangoutsBot: """The Hangouts Bot.""" def __init__(self, hass, refresh_token, intents, error_suppressed_convs): """Set up the client.""" self.hass = hass self._connected = False self._refresh_token = refresh_token self._intents = intents self._conversation_intents = None self._client = None self._user_list = None self._conversation_list = None self._error_suppressed_convs = error_suppressed_convs self._error_suppressed_conv_ids = None dispatcher.async_dispatcher_connect( self.hass, EVENT_HANGOUTS_MESSAGE_RECEIVED, self._async_handle_conversation_message) def _resolve_conversation_id(self, obj): if CONF_CONVERSATION_ID in obj: return obj[CONF_CONVERSATION_ID] if CONF_CONVERSATION_NAME in obj: conv = self._resolve_conversation_name(obj[CONF_CONVERSATION_NAME]) if conv is not None: return conv.id_ return None def _resolve_conversation_name(self, name): for conv in self._conversation_list.get_all(): if conv.name == name: return conv return None def async_update_conversation_commands(self, _): """Refresh the commands for every conversation.""" self._conversation_intents = {} for intent_type, data in self._intents.items(): if data.get(CONF_CONVERSATIONS): conversations = [] for conversation in data.get(CONF_CONVERSATIONS): conv_id = self._resolve_conversation_id(conversation) if conv_id is not None: conversations.append(conv_id) data['_' + CONF_CONVERSATIONS] = conversations else: data['_' + CONF_CONVERSATIONS] = \ [conv.id_ for conv in self._conversation_list.get_all()] for conv_id in data['_' + CONF_CONVERSATIONS]: if conv_id not in self._conversation_intents: self._conversation_intents[conv_id] = {} self._conversation_intents[conv_id][intent_type] = data try: self._conversation_list.on_event.remove_observer( self._async_handle_conversation_event) except ValueError: pass self._conversation_list.on_event.add_observer( self._async_handle_conversation_event) def async_handle_update_error_suppressed_conversations(self, _): """Resolve the list of error suppressed conversations.""" self._error_suppressed_conv_ids = [] for conversation in self._error_suppressed_convs: conv_id = self._resolve_conversation_id(conversation) if conv_id is not None: self._error_suppressed_conv_ids.append(conv_id) async def _async_handle_conversation_event(self, event): from hangups import ChatMessageEvent if isinstance(event, ChatMessageEvent): dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_MESSAGE_RECEIVED, event.conversation_id, event.user_id, event) async def _async_handle_conversation_message(self, conv_id, user_id, event): """Handle a message sent to a conversation.""" user = self._user_list.get_user(user_id) if user.is_self: return message = event.text _LOGGER.debug("Handling message '%s' from %s", message, user.full_name) intents = self._conversation_intents.get(conv_id) if intents is not None: is_error = False try: intent_result = await self._async_process(intents, message) except (intent.UnknownIntent, intent.IntentHandleError) as err: is_error = True intent_result = intent.IntentResponse() intent_result.async_set_speech(str(err)) if intent_result is None: is_error = True intent_result = intent.IntentResponse() intent_result.async_set_speech( "Sorry, I didn't understand that") message = intent_result.as_dict().get('speech', {})\ .get('plain', {}).get('speech') if (message is not None) and not ( is_error and conv_id in self._error_suppressed_conv_ids): await self._async_send_message( [{'text': message, 'parse_str': True}], [{CONF_CONVERSATION_ID: conv_id}]) async def _async_process(self, intents, text): """Detect a matching intent.""" for intent_type, data in intents.items(): for matcher in data.get(CONF_MATCHERS, []): match = matcher.match(text) if not match: continue response = await self.hass.helpers.intent.async_handle( DOMAIN, intent_type, {key: {'value': value} for key, value in match.groupdict().items()}, text) return response async def async_connect(self): """Login to the Google Hangouts.""" from .hangups_utils import HangoutsRefreshToken, HangoutsCredentials from hangups import Client from hangups import get_auth session = await self.hass.async_add_executor_job( get_auth, HangoutsCredentials(None, None, None), HangoutsRefreshToken(self._refresh_token)) self._client = Client(session) self._client.on_connect.add_observer(self._on_connect) self._client.on_disconnect.add_observer(self._on_disconnect) self.hass.loop.create_task(self._client.connect()) def _on_connect(self): _LOGGER.debug('Connected!') self._connected = True dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_CONNECTED) def _on_disconnect(self): """Handle disconnecting.""" _LOGGER.debug('Connection lost!') self._connected = False dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_DISCONNECTED) async def async_disconnect(self): """Disconnect the client if it is connected.""" if self._connected: await self._client.disconnect() async def async_handle_hass_stop(self, _): """Run once when Home Assistant stops.""" await self.async_disconnect() async def _async_send_message(self, message, targets): conversations = [] for target in targets: conversation = None if CONF_CONVERSATION_ID in target: conversation = self._conversation_list.get( target[CONF_CONVERSATION_ID]) elif CONF_CONVERSATION_NAME in target: conversation = self._resolve_conversation_name( target[CONF_CONVERSATION_NAME]) if conversation is not None: conversations.append(conversation) if not conversations: return False from hangups import ChatMessageSegment, hangouts_pb2 messages = [] for segment in message: if 'parse_str' in segment and segment['parse_str']: messages.extend(ChatMessageSegment.from_str(segment['text'])) else: if 'parse_str' in segment: del segment['parse_str'] messages.append(ChatMessageSegment(**segment)) messages.append(ChatMessageSegment('', segment_type=hangouts_pb2. SEGMENT_TYPE_LINE_BREAK)) if not messages: return False for conv in conversations: await conv.send_message(messages) async def _async_list_conversations(self): import hangups self._user_list, self._conversation_list = \ (await hangups.build_user_conversation_list(self._client)) conversations = {} for i, conv in enumerate(self._conversation_list.get_all()): users_in_conversation = [] for user in conv.users: users_in_conversation.append(user.full_name) conversations[str(i)] = {CONF_CONVERSATION_ID: str(conv.id_), CONF_CONVERSATION_NAME: conv.name, 'users': users_in_conversation} self.hass.states.async_set("{}.conversations".format(DOMAIN), len(self._conversation_list.get_all()), attributes=conversations) dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, conversations) async def async_handle_send_message(self, service): """Handle the send_message service.""" await self._async_send_message(service.data[ATTR_MESSAGE], service.data[ATTR_TARGET]) async def async_handle_update_users_and_conversations(self, _=None): """Handle the update_users_and_conversations service.""" await self._async_list_conversations()
class User: az: AppService loop: asyncio.AbstractEventLoop log: logging.Logger = logging.getLogger("mau.user") by_mxid: Dict[UserID, 'User'] = {} client: Optional[Client] command_status: Optional[Dict[str, Any]] is_whitelisted: bool is_admin: bool _db_instance: Optional[DBUser] mxid: UserID gid: str refresh_token: str name: Optional[str] name_future: asyncio.Future connected: bool chats: ConversationList users: UserList def __init__(self, mxid: UserID, gid: str = None, refresh_token: str = None, db_instance: Optional[DBUser] = None) -> None: self.mxid = mxid self.gid = gid self.refresh_token = refresh_token self.by_mxid[mxid] = self self.command_status = None self.is_whitelisted, self.is_admin = config.get_permissions(mxid) self._db_instance = db_instance self.client = None self.name = None self.name_future = asyncio.Future() self.connected = False self.log = self.log.getChild(self.mxid) # region Sessions def save(self) -> None: self.db_instance.edit(refresh_token=self.refresh_token, gid=self.gid) @property def db_instance(self) -> DBUser: if not self._db_instance: self._db_instance = DBUser(mxid=self.mxid, refresh_token=self.refresh_token) return self._db_instance @classmethod def from_db(cls, db_user: DBUser) -> 'User': return User(mxid=db_user.mxid, refresh_token=db_user.refresh_token, db_instance=db_user) @classmethod def get_all(cls) -> Iterator['User']: for db_user in DBUser.all(): yield cls.from_db(db_user) @classmethod def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['User']: if pu.Puppet.get_id_from_mxid(mxid) is not None or mxid == cls.az.bot_mxid: return None try: return cls.by_mxid[mxid] except KeyError: pass db_user = DBUser.get_by_mxid(mxid) if db_user: return cls.from_db(db_user) if create: user = cls(mxid) user.db_instance.insert() return user return None # endregion async def is_logged_in(self) -> bool: return self.client and self.connected @classmethod async def init_all(cls) -> None: users = [user for user in cls.get_all() if user.refresh_token] with futures.ThreadPoolExecutor() as pool: auth_resps: List[TryAuthResp] = await asyncio.gather( *[cls.loop.run_in_executor(pool, try_auth, user.refresh_token) for user in users], loop=cls.loop) finish = [] for user, auth_resp in zip(users, auth_resps): if auth_resp.success: finish.append(user.login_complete(auth_resp.cookies)) else: user.log.exception("Failed to resume session with stored refresh token", exc_info=auth_resp.error) await asyncio.gather(*finish, loop=cls.loop) async def login_complete(self, cookies: dict) -> None: self.client = Client(cookies) asyncio.ensure_future(self.start(), loop=self.loop) self.client.on_connect.add_observer(self.on_connect) self.client.on_reconnect.add_observer(self.on_reconnect) self.client.on_disconnect.add_observer(self.on_disconnect) async def start(self) -> None: try: await self.client.connect() self.log.info("Client connection finished") except Exception: self.log.exception("Exception in connection") async def stop(self) -> None: await self.client.disconnect() async def on_connect(self) -> None: self.connected = True asyncio.ensure_future(self.on_connect_later(), loop=self.loop) async def on_connect_later(self) -> None: try: info = await self.client.get_self_info(hangouts.GetSelfInfoRequest( request_header=self.client.get_request_header() )) except Exception: self.log.exception("Failed to get_self_info") return self.gid = info.self_entity.id.gaia_id self.name = info.self_entity.properties.display_name self.name_future.set_result(self.name) self.save() try: await self.sync() except Exception: self.log.exception("Failed to sync conversations and users") async def on_reconnect(self) -> None: self.connected = True async def on_disconnect(self) -> None: self.connected = False async def sync(self) -> None: users, chats = await hangups.build_user_conversation_list(self.client) await asyncio.gather(*self.sync_users(users), *self.sync_chats(chats), loop=self.loop) def sync_users(self, users: UserList) -> Iterable[Awaitable[None]]: self.users = users return (pu.Puppet.get_by_gid(info.id_.gaia_id, create=True).update_info(self, info) for info in users.get_all()) def sync_chats(self, chats: ConversationList) -> Iterable[Awaitable[None]]: self.chats = chats self.chats.on_event.add_observer(self.on_event) self.chats.on_typing.add_observer(self.on_typing) quota = config["bridge.initial_chat_sync"] return (po.Portal.get_by_conversation(info).create_matrix_room(self, info) for info in self.chats.get_all(include_archived=False)[:quota]) # region Hangouts event handling async def on_event(self, event: ConversationEvent) -> None: conv: Conversation = self.chats.get(event.conversation_id) portal = po.Portal.get_by_conversation(conv) if not portal: return sender = pu.Puppet.get_by_gid(event.user_id.gaia_id) if isinstance(event, ChatMessageEvent): await portal.handle_hangouts_message(self, sender, event) elif isinstance(event, MembershipChangeEvent): self.log.info( f"{event.id_} by {event.user_id} in {event.conversation_id} ({conv._conversation.type}): {event.participant_ids} {event.type_}'d") else: self.log.info(f"Unrecognized event {event}") async def on_typing(self, event: TypingStatusMessage): portal = po.Portal.get_by_gid(event.conv_id) if not portal: return sender = pu.Puppet.get_by_gid(event.user_id.gaia_id, create=False) if not sender: return await portal.handle_hangouts_typing(self, sender, event.status) # endregion # region Hangouts API calls async def set_typing(self, conversation_id: str, typing: bool) -> None: self.log.debug(f"set_typing({conversation_id}, {typing})") await self.client.set_typing(hangouts.SetTypingRequest( request_header=self.client.get_request_header(), conversation_id=hangouts.ConversationId(id=conversation_id), type=hangouts.TYPING_TYPE_STARTED if typing else hangouts.TYPING_TYPE_STOPPED, )) def _get_event_request_header(self, conversation_id: str) -> hangouts.EventRequestHeader: return hangouts.EventRequestHeader( conversation_id=hangouts.ConversationId( id=conversation_id, ), client_generated_id=self.client.get_client_generated_id(), ) async def send_emote(self, conversation_id: str, text: str) -> str: resp = await self.client.send_chat_message(hangouts.SendChatMessageRequest( request_header=self.client.get_request_header(), annotation=[hangouts.EventAnnotation(type=4)], event_request_header=self._get_event_request_header(conversation_id), message_content=hangouts.MessageContent( segment=[hangups.ChatMessageSegment(text).serialize()], ), )) return resp.created_event.event_id async def send_text(self, conversation_id: str, text: str) -> str: resp = await self.client.send_chat_message(hangouts.SendChatMessageRequest( request_header=self.client.get_request_header(), event_request_header=self._get_event_request_header(conversation_id), message_content=hangouts.MessageContent( segment=[hangups.ChatMessageSegment(text).serialize()], ), )) return resp.created_event.event_id async def send_image(self, conversation_id: str, id: str) -> str: resp = await self.client.send_chat_message(hangouts.SendChatMessageRequest( request_header=self.client.get_request_header(), event_request_header=self._get_event_request_header(conversation_id), existing_media=hangouts.ExistingMedia( photo=hangouts.Photo(photo_id=id), ), )) return resp.created_event.event_id async def mark_read(self, conversation_id: str, timestamp: Optional[Union[datetime.datetime, int]] = None) -> None: if isinstance(timestamp, datetime.datetime): timestamp = hangups.parsers.to_timestamp(timestamp) elif not timestamp: timestamp = int(time.time() * 1_000_000) await self.client.update_watermark(hangouts.UpdateWatermarkRequest( request_header=self.client.get_request_header(), conversation_id=hangouts.ConversationId(id=conversation_id), last_read_timestamp=timestamp, ))