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 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: 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, ))