Пример #1
0
 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)
Пример #2
0
 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)
Пример #3
0
    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())
Пример #4
0
 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,
         )
     )
Пример #5
0
    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())
Пример #6
0
    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),
            ))
Пример #7
0
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)
Пример #8
0
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)
Пример #9
0
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
Пример #10
0
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
Пример #11
0
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()
Пример #12
0
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()
Пример #13
0
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,
        ))