示例#1
0
    def test_components_separate(self):
        '''A state change in one component should not affect other
        components.'''
        sed = StatusEdgeDetector()
        comp1_status1 = {
            'component': 'foo',
            'status': 'ok',
            'type': 'bar',
            'message': 'test'
        }
        self.assertEqual(sed.check_status(**comp1_status1), comp1_status1)

        comp2_status1 = {
            'component': 'bar',
            'status': 'ok',
            'type': 'bar',
            'message': 'another test'
        }
        self.assertEqual(sed.check_status(**comp2_status1), comp2_status1)

        comp2_status2 = {
            'component': 'bar',
            'status': 'degraded',
            'type': 'bar',
            'message': 'another test'
        }
        self.assertEqual(sed.check_status(**comp2_status2), comp2_status2)

        comp1_status2 = {
            'component': 'foo',
            'status': 'ok',
            'type': 'bar',
            'message': 'test'
        }
        self.assertEqual(sed.check_status(**comp1_status2), None)
示例#2
0
    def test_components_separate(self):
        '''A state change in one component should not affect other
        components.'''
        sed = StatusEdgeDetector()
        comp1_status1 = {
            'component': 'foo',
            'status': 'ok',
            'type': 'bar',
            'message': 'test'}
        self.assertEqual(sed.check_status(**comp1_status1), comp1_status1)

        comp2_status1 = {
            'component': 'bar',
            'status': 'ok',
            'type': 'bar',
            'message': 'another test'}
        self.assertEqual(sed.check_status(**comp2_status1), comp2_status1)

        comp2_status2 = {
            'component': 'bar',
            'status': 'degraded',
            'type': 'bar',
            'message': 'another test'}
        self.assertEqual(sed.check_status(**comp2_status2), comp2_status2)

        comp1_status2 = {
            'component': 'foo',
            'status': 'ok',
            'type': 'bar',
            'message': 'test'}
        self.assertEqual(sed.check_status(**comp1_status2), None)
示例#3
0
    def test_status_change(self):
        '''If the status does change, the status should be returned.'''
        sed = StatusEdgeDetector()
        status1 = {
            'component': 'foo',
            'status': 'ok',
            'type': 'bar',
            'message': 'test'}
        self.assertEqual(sed.check_status(**status1), status1)

        status2 = {
            'component': 'foo',
            'status': 'degraded',
            'type': 'bar',
            'message': 'another test'}
        self.assertEqual(sed.check_status(**status2), status2)
示例#4
0
    def test_status_change(self):
        '''If the status does change, the status should be returned.'''
        sed = StatusEdgeDetector()
        status1 = {
            'component': 'foo',
            'status': 'ok',
            'type': 'bar',
            'message': 'test'}
        self.assertEqual(sed.check_status(**status1), status1)

        status2 = {
            'component': 'foo',
            'status': 'degraded',
            'type': 'bar',
            'message': 'another test'}
        self.assertEqual(sed.check_status(**status2), status2)
示例#5
0
    def test_type_change(self):
        '''A change in status type should result in the status being
        returned.'''
        sed = StatusEdgeDetector()
        status1 = {
            'component': 'foo',
            'status': 'ok',
            'type': 'bar',
            'message': 'test'}
        self.assertEqual(sed.check_status(**status1), status1)

        status2 = {
            'component': 'foo',
            'status': 'ok',
            'type': 'baz',
            'message': 'test'}
        self.assertEqual(sed.check_status(**status2), status2)
示例#6
0
    def test_type_change(self):
        '''A change in status type should result in the status being
        returned.'''
        sed = StatusEdgeDetector()
        status1 = {
            'component': 'foo',
            'status': 'ok',
            'type': 'bar',
            'message': 'test'}
        self.assertEqual(sed.check_status(**status1), status1)

        status2 = {
            'component': 'foo',
            'status': 'ok',
            'type': 'baz',
            'message': 'test'}
        self.assertEqual(sed.check_status(**status2), status2)
示例#7
0
class WeChatTransport(Transport):
    """

    A Transport for the WeChat API.

    API documentation
    ~~~~~~~~~~~~~~~~~

    http://admin.wechat.com/wiki/index.php?title=Main_Page


    Inbound Messaging
    ~~~~~~~~~~~~~~~~~

    Supported Common Message types:

        - Text Message

    Supported Event Message types:

        - Following / subscribe
        - Unfollowing / unsubscribe
        - Text Message (in response to Menu keypress events)


    Outbound Messaging
    ~~~~~~~~~~~~~~~~~~

    Supported Callback Message types:

        - Text Message
        - News Message

    Supported Customer Service Message types:

        - Text Message
        - News Message

    How it works
    ~~~~~~~~~~~~

    1) When a user subscribes to the Vumi account, and opens
     up the contact for the first time, the contact will
     send the first message.
    2) When the session ends, every time after that, the
     user has to send a text to the contact for it to respond
     (unlike when the user adds the contact for the first
     time as seen in 1.) The user can send anything to the
     contact for this to happen.

    """

    CONFIG_CLASS = WeChatConfig
    DEFAULT_MASK = 'default'
    MESSAGE_TYPES = [
        NewsMessage,
    ]
    DEFAULT_MESSAGE_TYPE = TextMessage
    # What key to store the `access_token` under in Redis
    ACCESS_TOKEN_KEY = 'access_token'
    # What key to store the `addr_mask` under in Redis
    ADDR_MASK_KEY = 'addr_mask'
    # What key to use when constructing the User Profile key
    USER_PROFILE_KEY = 'user_profile'
    # What key to use when constructing the cached reply key
    CACHED_REPLY_KEY = 'cached_reply'

    transport_type = 'wechat'
    agent_factory = None  # For swapping out the Agent we use in tests.

    def add_status_bad_req(self):
        return self.add_status(status='down',
                               component='inbound',
                               type='bad_request',
                               message='Bad request received')

    def add_status_good_req(self):
        return self.add_status(status='ok',
                               component='inbound',
                               type='good_request',
                               message='Good request received')

    @inlineCallbacks
    def setup_transport(self):
        config = self.get_static_config()
        self.request_dict = {}
        self.endpoint = config.twisted_endpoint
        self.resource = WeChatResource(self)
        self.factory = build_web_site({
            config.health_path:
            HttpRpcHealthResource(self),
            config.web_path:
            self.resource,
        })

        self.redis = yield TxRedisManager.from_config(config.redis_manager)
        self.server = yield self.endpoint.listen(self.factory)
        self.status_detect = StatusEdgeDetector()

        if config.wechat_menu:
            # not yielding because this shouldn't block startup
            d = self.get_access_token()
            d.addCallback(self.create_wechat_menu, config.wechat_menu)

    @inlineCallbacks
    def add_status(self, **kw):
        '''Publishes a status if it is not a repeat of the previously
        published status.'''
        if self.status_detect.check_status(**kw):
            yield self.publish_status(**kw)

    def http_request_full(self, *args, **kw):
        kw['agent_class'] = self.agent_factory
        return http_request_full(*args, **kw)

    @inlineCallbacks
    def create_wechat_menu(self, access_token, menu_structure):
        url = self.make_url('menu/create', {'access_token': access_token})
        response = yield self.http_request_full(
            url,
            method='POST',
            data=json.dumps(menu_structure),
            headers={'Content-Type': ['application/json']})
        if not http_ok(response):
            raise WeChatApiException(
                'Received HTTP code: %r when creating the menu.' %
                (response.code, ))
        data = json.loads(response.delivered_body)
        if data['errcode'] != 0:
            raise WeChatApiException(
                'Received errcode: %(errcode)s, errmsg: %(errmsg)s '
                'when creating WeChat Menu.' % data)
        log.info('WeChat Menu created succesfully.')

    def user_profile_key(self, open_id):
        return '@'.join([
            self.USER_PROFILE_KEY,
            open_id,
        ])

    def mask_key(self, user):
        return '@'.join([
            self.ADDR_MASK_KEY,
            user,
        ])

    def cached_reply_key(self, *parts):
        key_parts = [self.CACHED_REPLY_KEY]
        key_parts.extend(parts)
        return '@'.join(key_parts)

    def mask_addr(self, to_addr, mask):
        return '@'.join([to_addr, mask])

    def cache_addr_mask(self, user, mask):
        config = self.get_static_config()
        d = self.redis.setex(self.mask_key(user), config.wechat_mask_lifetime,
                             mask)
        d.addCallback(lambda *a: mask)
        return d

    def get_addr_mask(self, user):
        d = self.redis.get(self.mask_key(user))
        d.addCallback(lambda mask: mask or self.DEFAULT_MASK)
        return d

    def clear_addr_mask(self, user):
        return self.redis.delete(self.mask_key(user))

    def handle_raw_inbound_message(self, request, wc_msg):
        return {
            TextMessage: self.handle_inbound_text_message,
            EventMessage: self.handle_inbound_event_message,
        }.get(wc_msg.__class__)(request, wc_msg)

    def wrap_expire(self, result, key, ttl):
        d = self.redis.expire(key, ttl)
        d.addCallback(lambda _: result)
        return d

    def mark_as_seen_recently(self, wc_msg_id):
        config = self.get_static_config()
        key = self.cached_reply_key(wc_msg_id)
        d = self.redis.setnx(key, 1)
        d.addCallback(lambda result: (self.wrap_expire(
            result, key, config.double_delivery_lifetime)
                                      if result else False))
        return d

    def was_seen_recently(self, wc_msg_id):
        return self.redis.exists(self.cached_reply_key(wc_msg_id))

    def get_cached_reply(self, wc_msg_id):
        return self.redis.get(self.cached_reply_key(wc_msg_id, 'reply'))

    def set_cached_reply(self, wc_msg_id, reply):
        config = self.get_static_config()
        return self.redis.setex(self.cached_reply_key(wc_msg_id, 'reply'),
                                config.double_delivery_lifetime, reply)

    @inlineCallbacks
    def check_for_double_delivery(self, request, wc_msg_id):
        seen_recently = yield self.was_seen_recently(wc_msg_id)
        if not seen_recently:
            returnValue(False)

        cached_reply = yield self.get_cached_reply(wc_msg_id)
        if cached_reply:
            # we've got a reply still lying around, just parrot that instead.
            request.write(cached_reply)

        request.finish()
        returnValue(True)

    @inlineCallbacks
    def handle_inbound_text_message(self, request, wc_msg):
        double_delivery = yield self.check_for_double_delivery(
            request, wc_msg.msg_id)
        if double_delivery:
            log.msg('WeChat double delivery of message: %s' %
                    (wc_msg.msg_id, ))
            return

        lock = yield self.mark_as_seen_recently(wc_msg.msg_id)
        if not lock:
            log.msg('Unable to get lock for message id: %s' %
                    (wc_msg.msg_id, ))
            return

        config = self.get_static_config()
        if config.embed_user_profile:
            user_profile = yield self.get_user_profile(wc_msg.from_user_name)
        else:
            user_profile = {}

        mask = yield self.get_addr_mask(wc_msg.from_user_name)
        msg = yield self.publish_message(
            content=wc_msg.content,
            from_addr=wc_msg.from_user_name,
            to_addr=self.mask_addr(wc_msg.to_user_name, mask),
            timestamp=datetime.fromtimestamp(int(wc_msg.create_time)),
            transport_type=self.transport_type,
            transport_metadata={
                'wechat': {
                    'FromUserName': wc_msg.from_user_name,
                    'ToUserName': wc_msg.to_user_name,
                    'MsgType': 'text',
                    'MsgId': wc_msg.msg_id,
                    'UserProfile': user_profile,
                }
            })
        returnValue(msg)

    @inlineCallbacks
    def handle_inbound_event_message(self, request, wc_msg):
        if wc_msg.event.lower() in ('view', 'unsubscribe'):
            log.msg("%s clicked on %s" %
                    (wc_msg.from_user_name, wc_msg.event_key))
            request.finish()
            yield self.clear_addr_mask(wc_msg.from_user_name)
            return

        if wc_msg.event_key:
            mask = yield self.cache_addr_mask(wc_msg.from_user_name,
                                              wc_msg.event_key)
        else:
            mask = yield self.get_addr_mask(wc_msg.from_user_name)

        if wc_msg.event.lower() in ('subscribe', 'click'):
            session_event = TransportUserMessage.SESSION_NEW
        else:
            session_event = TransportUserMessage.SESSION_NONE

        msg = yield self.publish_message(
            content=None,
            from_addr=wc_msg.from_user_name,
            to_addr=self.mask_addr(wc_msg.to_user_name, mask),
            timestamp=datetime.fromtimestamp(int(wc_msg.create_time)),
            transport_type=self.transport_type,
            session_event=session_event,
            transport_metadata={
                'wechat': {
                    'FromUserName': wc_msg.from_user_name,
                    'ToUserName': wc_msg.to_user_name,
                    'MsgType': 'event',
                    'Event': wc_msg.event,
                    'EventKey': wc_msg.event_key
                }
            })
        # Close the request to ensure we fire a push message on reply.
        request.finish()
        returnValue(msg)

    def force_close(self, message):
        request = self.get_request(message['message_id'])
        request.setResponseCode(http.INTERNAL_SERVER_ERROR)
        request.finish()

    def handle_finished_request(self, request):
        for message_id, request_ in self.request_dict.items():
            if request_ == request:
                self.request_dict.pop(message_id)

    def queue_request(self, message, request):
        if message is not None:
            self.request_dict[message['message_id']] = request

    def get_request(self, message_id):
        return self.request_dict.get(message_id, None)

    def infer_message_type(self, message):
        for message_type in self.MESSAGE_TYPES:
            result = message_type.accepts(message)
            if result is not None:
                return partial(message_type.from_vumi_message, result)
        return self.DEFAULT_MESSAGE_TYPE.from_vumi_message

    def handle_outbound_message(self, message):
        """
        Read outbound message and do what needs to be done with them.
        """
        request_id = message['in_reply_to']
        request = self.get_request(request_id)

        builder = self.infer_message_type(message)
        wc_msg = builder(message)

        if request is None or request.finished:
            # There's no pending request object for this message which
            # means we need to treat this as a customer service message
            # and hit WeChat's Push API (window available for 24hrs)
            return self.push_message(wc_msg, message)

        request.write(wc_msg.to_xml())
        request.finish()

        d = self.publish_ack(user_message_id=message['message_id'],
                             sent_message_id=message['message_id'])
        wc_metadata = message["transport_metadata"].get('wechat', {})
        if wc_metadata:
            d.addCallback(lambda _: self.set_cached_reply(
                wc_metadata['MsgId'], wc_msg.to_xml()))

        if message['session_event'] == TransportUserMessage.SESSION_CLOSE:
            d.addCallback(lambda _: self.clear_addr_mask(wc_msg.to_user_name))
        return d

    def push_message(self, wc_message, vumi_message):
        d = self.get_access_token()
        d.addCallback(lambda access_token: self.make_url(
            'message/custom/send', {'access_token': access_token}))
        d.addCallback(lambda url: self.http_request_full(
            url,
            method='POST',
            data=wc_message.to_json(),
            headers={'Content-Type': ['application/json']}))
        d.addCallback(self.handle_api_response, vumi_message)
        if vumi_message['session_event'] == TransportUserMessage.SESSION_CLOSE:
            d.addCallback(
                lambda ack: self.clear_addr_mask(wc_message.from_user_name))
        return d

    @inlineCallbacks
    def handle_api_response(self, response, message):
        if http_ok(response):
            ack = yield self.publish_ack(user_message_id=message['message_id'],
                                         sent_message_id=message['message_id'])
            returnValue(ack)
        nack = yield self.publish_nack(message['message_id'],
                                       reason='Received status code: %s' %
                                       (response.code, ))
        returnValue(nack)

    @inlineCallbacks
    def get_access_token(self):
        access_token = yield self.redis.get(self.ACCESS_TOKEN_KEY)
        if access_token is None:
            access_token = yield self.request_new_access_token()
        returnValue(access_token)

    @inlineCallbacks
    def get_user_profile(self, open_id):
        config = self.get_static_config()
        up_key = self.user_profile_key(open_id)
        cached_up = yield self.redis.get(open_id)
        if cached_up:
            returnValue(json.loads(cached_up))

        access_token = yield self.get_access_token()
        response = yield self.http_request_full(self.make_url(
            'user/info', {
                'access_token': access_token,
                'openid': open_id,
                'lang': config.embed_user_profile_lang,
            }),
                                                method='GET')
        user_profile = response.delivered_body
        yield self.redis.setex(up_key, config.embed_user_profile_lifetime,
                               user_profile)
        returnValue(json.loads(user_profile))

    @inlineCallbacks
    def request_new_access_token(self):
        config = self.get_static_config()
        response = yield self.http_request_full(self.make_url(
            'token', {
                'grant_type': 'client_credential',
                'appid': config.wechat_appid,
                'secret': config.wechat_secret,
            }),
                                                method='GET')
        if not http_ok(response):
            raise WeChatApiException(
                ('Received HTTP status code %r when '
                 'requesting access token.') % (response.code, ))

        data = json.loads(response.delivered_body)
        if 'errcode' in data:
            raise WeChatApiException(
                'Error when requesting access token. '
                'Errcode: %(errcode)s, Errmsg: %(errmsg)s.' % data)

        # make sure we're always ahead of the WeChat expiry
        access_token = data['access_token']
        expiry = int(data['expires_in']) * 0.90
        yield self.redis.setex(self.ACCESS_TOKEN_KEY, int(expiry),
                               access_token)
        returnValue(access_token)

    def make_url(self, path, params):
        config = self.get_static_config()
        return '%s%s?%s' % (config.api_url, path, urllib.urlencode(params))

    def teardown_transport(self):
        return self.server.stopListening()

    def get_health_response(self):
        return "OK"
示例#8
0
class WeChatTransport(Transport):
    """

    A Transport for the WeChat API.

    API documentation
    ~~~~~~~~~~~~~~~~~

    http://admin.wechat.com/wiki/index.php?title=Main_Page


    Inbound Messaging
    ~~~~~~~~~~~~~~~~~

    Supported Common Message types:

        - Text Message

    Supported Event Message types:

        - Following / subscribe
        - Unfollowing / unsubscribe
        - Text Message (in response to Menu keypress events)


    Outbound Messaging
    ~~~~~~~~~~~~~~~~~~

    Supported Callback Message types:

        - Text Message
        - News Message

    Supported Customer Service Message types:

        - Text Message
        - News Message

    How it works
    ~~~~~~~~~~~~

    1) When a user subscribes to the Vumi account, and opens
     up the contact for the first time, the contact will
     send the first message.
    2) When the session ends, every time after that, the
     user has to send a text to the contact for it to respond
     (unlike when the user adds the contact for the first
     time as seen in 1.) The user can send anything to the
     contact for this to happen.

    """

    CONFIG_CLASS = WeChatConfig
    DEFAULT_MASK = 'default'
    MESSAGE_TYPES = [
        NewsMessage,
    ]
    DEFAULT_MESSAGE_TYPE = TextMessage
    # What key to store the `access_token` under in Redis
    ACCESS_TOKEN_KEY = 'access_token'
    # What key to store the `addr_mask` under in Redis
    ADDR_MASK_KEY = 'addr_mask'
    # What key to use when constructing the User Profile key
    USER_PROFILE_KEY = 'user_profile'
    # What key to use when constructing the cached reply key
    CACHED_REPLY_KEY = 'cached_reply'

    transport_type = 'wechat'
    agent_factory = None  # For swapping out the Agent we use in tests.

    def add_status_bad_req(self):
        return self.add_status(
            status='down', component='inbound', type='bad_request',
            message='Bad request received')

    def add_status_good_req(self):
        return self.add_status(
            status='ok', component='inbound', type='good_request',
            message='Good request received')

    @inlineCallbacks
    def setup_transport(self):
        config = self.get_static_config()
        self.request_dict = {}
        self.endpoint = config.twisted_endpoint
        self.resource = WeChatResource(self)
        self.factory = build_web_site({
            config.health_path: HttpRpcHealthResource(self),
            config.web_path: self.resource,
        })

        self.redis = yield TxRedisManager.from_config(config.redis_manager)
        self.server = yield self.endpoint.listen(self.factory)
        self.status_detect = StatusEdgeDetector()

        if config.wechat_menu:
            # not yielding because this shouldn't block startup
            d = self.get_access_token()
            d.addCallback(self.create_wechat_menu, config.wechat_menu)

    @inlineCallbacks
    def add_status(self, **kw):
        '''Publishes a status if it is not a repeat of the previously
        published status.'''
        if self.status_detect.check_status(**kw):
            yield self.publish_status(**kw)

    def http_request_full(self, *args, **kw):
        kw['agent_class'] = self.agent_factory
        return http_request_full(*args, **kw)

    @inlineCallbacks
    def create_wechat_menu(self, access_token, menu_structure):
        url = self.make_url('menu/create', {'access_token': access_token})
        response = yield self.http_request_full(
            url, method='POST', data=json.dumps(menu_structure),
            headers={'Content-Type': ['application/json']})
        if not http_ok(response):
            raise WeChatApiException(
                'Received HTTP code: %r when creating the menu.' % (
                    response.code,))
        data = json.loads(response.delivered_body)
        if data['errcode'] != 0:
            raise WeChatApiException(
                'Received errcode: %(errcode)s, errmsg: %(errmsg)s '
                'when creating WeChat Menu.' % data)
        log.info('WeChat Menu created succesfully.')

    def user_profile_key(self, open_id):
        return '@'.join([
            self.USER_PROFILE_KEY,
            open_id,
        ])

    def mask_key(self, user):
        return '@'.join([
            self.ADDR_MASK_KEY,
            user,
        ])

    def cached_reply_key(self, *parts):
        key_parts = [self.CACHED_REPLY_KEY]
        key_parts.extend(parts)
        return '@'.join(key_parts)

    def mask_addr(self, to_addr, mask):
        return '@'.join([to_addr, mask])

    def cache_addr_mask(self, user, mask):
        config = self.get_static_config()
        d = self.redis.setex(
            self.mask_key(user), config.wechat_mask_lifetime, mask)
        d.addCallback(lambda *a: mask)
        return d

    def get_addr_mask(self, user):
        d = self.redis.get(self.mask_key(user))
        d.addCallback(lambda mask: mask or self.DEFAULT_MASK)
        return d

    def clear_addr_mask(self, user):
        return self.redis.delete(self.mask_key(user))

    def handle_raw_inbound_message(self, request, wc_msg):
        return {
            TextMessage: self.handle_inbound_text_message,
            EventMessage: self.handle_inbound_event_message,
        }.get(wc_msg.__class__)(request, wc_msg)

    def wrap_expire(self, result, key, ttl):
        d = self.redis.expire(key, ttl)
        d.addCallback(lambda _: result)
        return d

    def mark_as_seen_recently(self, wc_msg_id):
        config = self.get_static_config()
        key = self.cached_reply_key(wc_msg_id)
        d = self.redis.setnx(key, 1)
        d.addCallback(
            lambda result: (
                self.wrap_expire(result, key, config.double_delivery_lifetime)
                if result else False))
        return d

    def was_seen_recently(self, wc_msg_id):
        return self.redis.exists(self.cached_reply_key(wc_msg_id))

    def get_cached_reply(self, wc_msg_id):
        return self.redis.get(self.cached_reply_key(wc_msg_id, 'reply'))

    def set_cached_reply(self, wc_msg_id, reply):
        config = self.get_static_config()
        return self.redis.setex(
            self.cached_reply_key(wc_msg_id, 'reply'),
            config.double_delivery_lifetime, reply)

    @inlineCallbacks
    def check_for_double_delivery(self, request, wc_msg_id):
        seen_recently = yield self.was_seen_recently(wc_msg_id)
        if not seen_recently:
            returnValue(False)

        cached_reply = yield self.get_cached_reply(wc_msg_id)
        if cached_reply:
            # we've got a reply still lying around, just parrot that instead.
            request.write(cached_reply)

        request.finish()
        returnValue(True)

    @inlineCallbacks
    def handle_inbound_text_message(self, request, wc_msg):
        double_delivery = yield self.check_for_double_delivery(
            request, wc_msg.msg_id)
        if double_delivery:
            log.msg('WeChat double delivery of message: %s' % (wc_msg.msg_id,))
            return

        lock = yield self.mark_as_seen_recently(wc_msg.msg_id)
        if not lock:
            log.msg('Unable to get lock for message id: %s' % (wc_msg.msg_id,))
            return

        config = self.get_static_config()
        if config.embed_user_profile:
            user_profile = yield self.get_user_profile(wc_msg.from_user_name)
        else:
            user_profile = {}

        mask = yield self.get_addr_mask(wc_msg.from_user_name)
        msg = yield self.publish_message(
            content=wc_msg.content,
            from_addr=wc_msg.from_user_name,
            to_addr=self.mask_addr(wc_msg.to_user_name, mask),
            timestamp=datetime.fromtimestamp(int(wc_msg.create_time)),
            transport_type=self.transport_type,
            transport_metadata={
                'wechat': {
                    'FromUserName': wc_msg.from_user_name,
                    'ToUserName': wc_msg.to_user_name,
                    'MsgType': 'text',
                    'MsgId': wc_msg.msg_id,
                    'UserProfile': user_profile,
                }
            })
        returnValue(msg)

    @inlineCallbacks
    def handle_inbound_event_message(self, request, wc_msg):
        if wc_msg.event.lower() in ('view', 'unsubscribe'):
            log.msg("%s clicked on %s" % (
                wc_msg.from_user_name, wc_msg.event_key))
            request.finish()
            yield self.clear_addr_mask(wc_msg.from_user_name)
            return

        if wc_msg.event_key:
            mask = yield self.cache_addr_mask(
                wc_msg.from_user_name, wc_msg.event_key)
        else:
            mask = yield self.get_addr_mask(wc_msg.from_user_name)

        if wc_msg.event.lower() in ('subscribe', 'click'):
            session_event = TransportUserMessage.SESSION_NEW
        else:
            session_event = TransportUserMessage.SESSION_NONE

        msg = yield self.publish_message(
            content=None,
            from_addr=wc_msg.from_user_name,
            to_addr=self.mask_addr(wc_msg.to_user_name, mask),
            timestamp=datetime.fromtimestamp(int(wc_msg.create_time)),
            transport_type=self.transport_type,
            session_event=session_event,
            transport_metadata={
                'wechat': {
                    'FromUserName': wc_msg.from_user_name,
                    'ToUserName': wc_msg.to_user_name,
                    'MsgType': 'event',
                    'Event': wc_msg.event,
                    'EventKey': wc_msg.event_key
                }
            })
        # Close the request to ensure we fire a push message on reply.
        request.finish()
        returnValue(msg)

    def force_close(self, message):
        request = self.get_request(message['message_id'])
        request.setResponseCode(http.INTERNAL_SERVER_ERROR)
        request.finish()

    def handle_finished_request(self, request):
        for message_id, request_ in self.request_dict.items():
            if request_ == request:
                self.request_dict.pop(message_id)

    def queue_request(self, message, request):
        if message is not None:
            self.request_dict[message['message_id']] = request

    def get_request(self, message_id):
        return self.request_dict.get(message_id, None)

    def infer_message_type(self, message):
        for message_type in self.MESSAGE_TYPES:
            result = message_type.accepts(message)
            if result is not None:
                return partial(message_type.from_vumi_message, result)
        return self.DEFAULT_MESSAGE_TYPE.from_vumi_message

    def handle_outbound_message(self, message):
        """
        Read outbound message and do what needs to be done with them.
        """
        request_id = message['in_reply_to']
        request = self.get_request(request_id)

        builder = self.infer_message_type(message)
        wc_msg = builder(message)

        if request is None or request.finished:
            # There's no pending request object for this message which
            # means we need to treat this as a customer service message
            # and hit WeChat's Push API (window available for 24hrs)
            return self.push_message(wc_msg, message)

        request.write(wc_msg.to_xml())
        request.finish()

        d = self.publish_ack(user_message_id=message['message_id'],
                             sent_message_id=message['message_id'])
        wc_metadata = message["transport_metadata"].get('wechat', {})
        if wc_metadata:
            d.addCallback(lambda _: self.set_cached_reply(
                wc_metadata['MsgId'], wc_msg.to_xml()))

        if message['session_event'] == TransportUserMessage.SESSION_CLOSE:
            d.addCallback(
                lambda _: self.clear_addr_mask(wc_msg.to_user_name))
        return d

    def push_message(self, wc_message, vumi_message):
        d = self.get_access_token()
        d.addCallback(
            lambda access_token: self.make_url('message/custom/send', {
                'access_token': access_token
            }))
        d.addCallback(
            lambda url: self.http_request_full(
                url, method='POST', data=wc_message.to_json(), headers={
                    'Content-Type': ['application/json']
                }))
        d.addCallback(self.handle_api_response, vumi_message)
        if vumi_message['session_event'] == TransportUserMessage.SESSION_CLOSE:
            d.addCallback(
                lambda ack: self.clear_addr_mask(wc_message.from_user_name))
        return d

    @inlineCallbacks
    def handle_api_response(self, response, message):
        if http_ok(response):
            ack = yield self.publish_ack(user_message_id=message['message_id'],
                                         sent_message_id=message['message_id'])
            returnValue(ack)
        nack = yield self.publish_nack(
            message['message_id'],
            reason='Received status code: %s' % (response.code,))
        returnValue(nack)

    @inlineCallbacks
    def get_access_token(self):
        access_token = yield self.redis.get(self.ACCESS_TOKEN_KEY)
        if access_token is None:
            access_token = yield self.request_new_access_token()
        returnValue(access_token)

    @inlineCallbacks
    def get_user_profile(self, open_id):
        config = self.get_static_config()
        up_key = self.user_profile_key(open_id)
        cached_up = yield self.redis.get(open_id)
        if cached_up:
            returnValue(json.loads(cached_up))

        access_token = yield self.get_access_token()
        response = yield self.http_request_full(self.make_url('user/info', {
            'access_token': access_token,
            'openid': open_id,
            'lang': config.embed_user_profile_lang,
        }), method='GET')
        user_profile = response.delivered_body
        yield self.redis.setex(up_key, config.embed_user_profile_lifetime,
                               user_profile)
        returnValue(json.loads(user_profile))

    @inlineCallbacks
    def request_new_access_token(self):
        config = self.get_static_config()
        response = yield self.http_request_full(self.make_url('token', {
            'grant_type': 'client_credential',
            'appid': config.wechat_appid,
            'secret': config.wechat_secret,
        }), method='GET')
        if not http_ok(response):
            raise WeChatApiException(
                ('Received HTTP status code %r when '
                 'requesting access token.') % (response.code,))

        data = json.loads(response.delivered_body)
        if 'errcode' in data:
            raise WeChatApiException(
                'Error when requesting access token. '
                'Errcode: %(errcode)s, Errmsg: %(errmsg)s.' % data)

        # make sure we're always ahead of the WeChat expiry
        access_token = data['access_token']
        expiry = int(data['expires_in']) * 0.90
        yield self.redis.setex(
            self.ACCESS_TOKEN_KEY, int(expiry), access_token)
        returnValue(access_token)

    def make_url(self, path, params):
        config = self.get_static_config()
        return '%s%s?%s' % (
            config.api_url, path, urllib.urlencode(params))

    def teardown_transport(self):
        return self.server.stopListening()

    def get_health_response(self):
        return "OK"
示例#9
0
class HttpRpcTransport(Transport):
    """Base class for synchronous HTTP transports.

    Because a reply from an application worker is needed before the HTTP
    response can be completed, a reply needs to be returned to the same
    transport worker that generated the inbound message. This means that
    currently there many only be one transport worker for each instance
    of this transport of a given name.
    """
    content_type = 'text/plain'

    CONFIG_CLASS = HttpRpcTransportConfig
    ENCODING = 'UTF-8'
    STRICT_MODE = 'strict'
    PERMISSIVE_MODE = 'permissive'
    DEFAULT_VALIDATION_MODE = STRICT_MODE
    KNOWN_VALIDATION_MODES = [STRICT_MODE, PERMISSIVE_MODE]

    def validate_config(self):
        config = self.get_static_config()
        self.web_path = config.web_path
        self.web_port = config.web_port
        self.web_username = config.web_username
        self.web_password = config.web_password
        self.web_auth_domain = config.web_auth_domain
        self.health_path = config.health_path.lstrip('/')
        self.request_timeout = config.request_timeout
        self.request_timeout_status_code = config.request_timeout_status_code
        self.noisy = config.noisy
        self.request_timeout_body = config.request_timeout_body
        self.gc_requests_interval = config.request_cleanup_interval
        self._validation_mode = config.validation_mode
        self.response_time_down = config.response_time_down
        self.response_time_degraded = config.response_time_degraded
        if self._validation_mode not in self.KNOWN_VALIDATION_MODES:
            raise ConfigError('Invalid validation mode: %s' %
                              (self._validation_mode, ))

    def get_transport_url(self, suffix=''):
        """
        Get the URL for the HTTP resource. Requires the worker to be started.

        This is mostly useful in tests, and probably shouldn't be used
        in non-test code, because the API might live behind a load
        balancer or proxy.
        """
        addr = self.web_resource.getHost()
        return "http://%s:%s/%s" % (addr.host, addr.port, suffix.lstrip('/'))

    def get_authenticated_resource(self, resource):
        if not self.web_username:
            return resource
        realm = HttpRpcRealm(resource)
        checkers = [
            StaticAuthChecker(self.web_username, self.web_password),
        ]
        portal = Portal(realm, checkers)
        cred_factories = [
            BasicCredentialFactory(self.web_auth_domain),
        ]
        return HTTPAuthSessionWrapper(portal, cred_factories)

    @inlineCallbacks
    def setup_transport(self):
        self._requests = {}
        self.request_gc = LoopingCall(self.manually_close_requests)
        self.clock = self.get_clock()
        self.request_gc.clock = self.clock
        self.request_gc.start(self.gc_requests_interval)

        rpc_resource = HttpRpcResource(self)
        rpc_resource = self.get_authenticated_resource(rpc_resource)

        # start receipt web resource
        self.web_resource = yield self.start_web_resources([
            (rpc_resource, self.web_path),
            (HttpRpcHealthResource(self), self.health_path),
        ], self.web_port)

        self.status_detect = StatusEdgeDetector()

    def add_status(self, **kw):
        '''Publishes a status if it is not a repeat of the previously
        published status.'''
        if self.status_detect.check_status(**kw):
            return self.publish_status(**kw)
        return succeed(None)

    @inlineCallbacks
    def teardown_transport(self):
        yield self.web_resource.loseConnection()
        if self.request_gc.running:
            self.request_gc.stop()

    def get_clock(self):
        """
        For easier stubbing in tests
        """
        return reactor

    def get_field_values(self,
                         request,
                         expected_fields,
                         ignored_fields=frozenset()):
        values = {}
        errors = {}
        for field in request.args:
            if field not in (expected_fields | ignored_fields):
                if self._validation_mode == self.STRICT_MODE:
                    errors.setdefault('unexpected_parameter', []).append(field)
            else:
                values[field] = (request.args.get(field)[0].decode(
                    self.ENCODING))
        for field in expected_fields:
            if field not in values:
                errors.setdefault('missing_parameter', []).append(field)
        return values, errors

    def ensure_message_values(self, message, expected_fields):
        missing_fields = []
        for field in expected_fields:
            if not message[field]:
                missing_fields.append(field)
        return missing_fields

    def manually_close_requests(self):
        for request_id, request_data in self._requests.items():
            timestamp = request_data['timestamp']
            response_time = self.clock.seconds() - timestamp
            if response_time > self.request_timeout:
                self.on_timeout(request_id, response_time)
                self.close_request(request_id)

    def close_request(self, request_id):
        log.warning('Timing out %s' % (self.get_request_to_addr(request_id), ))
        self.finish_request(request_id, self.request_timeout_body,
                            self.request_timeout_status_code)

    def get_health_response(self):
        return json.dumps({'pending_requests': len(self._requests)})

    def set_request(self, request_id, request_object, timestamp=None):
        if timestamp is None:
            timestamp = self.clock.seconds()
        self._requests[request_id] = {
            'timestamp': timestamp,
            'request': request_object,
        }

    def get_request(self, request_id):
        if request_id in self._requests:
            return self._requests[request_id]['request']

    def remove_request(self, request_id):
        del self._requests[request_id]

    def emit(self, msg):
        if self.noisy:
            log.debug(msg)

    def handle_outbound_message(self, message):
        self.emit("HttpRpcTransport consuming %s" % (message))
        missing_fields = self.ensure_message_values(message,
                                                    ['in_reply_to', 'content'])
        if missing_fields:
            return self.reject_message(message, missing_fields)
        else:
            self.finish_request(message.payload['in_reply_to'],
                                message.payload['content'].encode('utf-8'))
            return self.publish_ack(user_message_id=message['message_id'],
                                    sent_message_id=message['message_id'])

    def reject_message(self, message, missing_fields):
        return self.publish_nack(user_message_id=message['message_id'],
                                 sent_message_id=message['message_id'],
                                 reason='Missing fields: %s' %
                                 ', '.join(missing_fields))

    def handle_raw_inbound_message(self, msgid, request):
        raise NotImplementedError("Sub-classes should implement"
                                  " handle_raw_inbound_message.")

    def finish_request(self, request_id, data, code=200, headers={}):
        self.emit("HttpRpcTransport.finish_request with data: %s" %
                  (repr(data), ))
        request = self.get_request(request_id)
        if request:
            for h_name, h_values in headers.iteritems():
                request.responseHeaders.setRawHeaders(h_name, h_values)
            request.setResponseCode(code)
            request.write(data)
            request.finish()
            self.set_request_end(request_id)
            self.remove_request(request_id)
            response_id = "%s:%s:%s" % (request.client.host,
                                        request.client.port,
                                        Transport.generate_message_id())
            return response_id

    # NOTE: This hackery is required so that we know what to_addr a message
    #       was received on. This is useful so we can log more useful debug
    #       information when something goes wrong, like a timeout for example.
    #
    #       Since all the different transports that subclass this
    #       base class have different implementations for retreiving the
    #       to_addr it's impossible to grab this information higher up
    #       in a consistent manner.
    def publish_message(self, **kwargs):
        self.set_request_to_addr(kwargs['message_id'], kwargs['to_addr'])
        return super(HttpRpcTransport, self).publish_message(**kwargs)

    def get_request_to_addr(self, request_id):
        return self._requests[request_id].get('to_addr', 'Unknown')

    def set_request_to_addr(self, request_id, to_addr):
        if request_id in self._requests:
            self._requests[request_id]['to_addr'] = to_addr

    def set_request_end(self, message_id):
        '''Checks the saved timestamp to see the response time.
        If the starting timestamp for the message cannot be found, nothing is
        done.
        If the time is more than `response_time_down`, a `down` status event
        is sent.
        If the time more than `response_time_degraded`, a `degraded` status
        event is sent.
        If the time is less than `response_time_degraded`, an `ok` status
        event is sent.
        '''
        request = self._requests.get(message_id, None)
        if request is not None:
            response_time = self.clock.seconds() - request['timestamp']
            if response_time > self.response_time_down:
                return self.on_down_response_time(message_id, response_time)
            elif response_time > self.response_time_degraded:
                return self.on_degraded_response_time(message_id,
                                                      response_time)
            else:
                return self.on_good_response_time(message_id, response_time)

    def on_down_response_time(self, message_id, time):
        '''Can be overridden by subclasses to do something when the
        response time is high enough for the transport to be considered
        non-functioning.'''
        pass

    def on_degraded_response_time(self, message_id, time):
        '''Can be overridden by subclasses to do something when the
        response time is high enough for the transport to be considered
        running in a degraded state.'''
        pass

    def on_good_response_time(self, message_id, time):
        '''Can be overridden by subclasses to do something when the
        response time is low enough for the transport to be considered
        running normally.'''
        pass

    def on_timeout(self, message_id, time):
        '''Can be overridden by subclasses to do something when the
        response times out.'''
        pass
示例#10
0
class HttpRpcTransport(Transport):
    """Base class for synchronous HTTP transports.

    Because a reply from an application worker is needed before the HTTP
    response can be completed, a reply needs to be returned to the same
    transport worker that generated the inbound message. This means that
    currently there many only be one transport worker for each instance
    of this transport of a given name.
    """
    content_type = 'text/plain'

    CONFIG_CLASS = HttpRpcTransportConfig
    ENCODING = 'UTF-8'
    STRICT_MODE = 'strict'
    PERMISSIVE_MODE = 'permissive'
    DEFAULT_VALIDATION_MODE = STRICT_MODE
    KNOWN_VALIDATION_MODES = [STRICT_MODE, PERMISSIVE_MODE]

    def validate_config(self):
        config = self.get_static_config()
        self.web_path = config.web_path
        self.web_port = config.web_port
        self.web_username = config.web_username
        self.web_password = config.web_password
        self.web_auth_domain = config.web_auth_domain
        self.health_path = config.health_path.lstrip('/')
        self.request_timeout = config.request_timeout
        self.request_timeout_status_code = config.request_timeout_status_code
        self.noisy = config.noisy
        self.request_timeout_body = config.request_timeout_body
        self.gc_requests_interval = config.request_cleanup_interval
        self._validation_mode = config.validation_mode
        self.response_time_down = config.response_time_down
        self.response_time_degraded = config.response_time_degraded
        if self._validation_mode not in self.KNOWN_VALIDATION_MODES:
            raise ConfigError('Invalid validation mode: %s' % (
                self._validation_mode,))

    def get_transport_url(self, suffix=''):
        """
        Get the URL for the HTTP resource. Requires the worker to be started.

        This is mostly useful in tests, and probably shouldn't be used
        in non-test code, because the API might live behind a load
        balancer or proxy.
        """
        addr = self.web_resource.getHost()
        return "http://%s:%s/%s" % (addr.host, addr.port, suffix.lstrip('/'))

    def get_authenticated_resource(self, resource):
        if not self.web_username:
            return resource
        realm = HttpRpcRealm(resource)
        checkers = [
            StaticAuthChecker(self.web_username, self.web_password),
        ]
        portal = Portal(realm, checkers)
        cred_factories = [
            BasicCredentialFactory(self.web_auth_domain),
        ]
        return HTTPAuthSessionWrapper(portal, cred_factories)

    @inlineCallbacks
    def setup_transport(self):
        self._requests = {}
        self.request_gc = LoopingCall(self.manually_close_requests)
        self.clock = self.get_clock()
        self.request_gc.clock = self.clock
        self.request_gc.start(self.gc_requests_interval)

        rpc_resource = HttpRpcResource(self)
        rpc_resource = self.get_authenticated_resource(rpc_resource)

        # start receipt web resource
        self.web_resource = yield self.start_web_resources(
            [
                (rpc_resource, self.web_path),
                (HttpRpcHealthResource(self), self.health_path),
            ],
            self.web_port)

        self.status_detect = StatusEdgeDetector()

    def add_status(self, **kw):
        '''Publishes a status if it is not a repeat of the previously
        published status.'''
        if self.status_detect.check_status(**kw):
            return self.publish_status(**kw)
        return succeed(None)

    @inlineCallbacks
    def teardown_transport(self):
        yield self.web_resource.loseConnection()
        if self.request_gc.running:
            self.request_gc.stop()

    def get_clock(self):
        """
        For easier stubbing in tests
        """
        return reactor

    def get_field_values(self, request, expected_fields,
                            ignored_fields=frozenset()):
        values = {}
        errors = {}
        for field in request.args:
            if field not in (expected_fields | ignored_fields):
                if self._validation_mode == self.STRICT_MODE:
                    errors.setdefault('unexpected_parameter', []).append(field)
            else:
                values[field] = (
                    request.args.get(field)[0].decode(self.ENCODING))
        for field in expected_fields:
            if field not in values:
                errors.setdefault('missing_parameter', []).append(field)
        return values, errors

    def ensure_message_values(self, message, expected_fields):
        missing_fields = []
        for field in expected_fields:
            if not message[field]:
                missing_fields.append(field)
        return missing_fields

    def manually_close_requests(self):
        for request_id, request_data in self._requests.items():
            timestamp = request_data['timestamp']
            response_time = self.clock.seconds() - timestamp
            if response_time > self.request_timeout:
                self.on_timeout(request_id, response_time)
                self.close_request(request_id)

    def close_request(self, request_id):
        log.warning('Timing out %s' % (self.get_request_to_addr(request_id),))
        self.finish_request(request_id, self.request_timeout_body,
                            self.request_timeout_status_code)

    def get_health_response(self):
        return json.dumps({
            'pending_requests': len(self._requests)
        })

    def set_request(self, request_id, request_object, timestamp=None):
        if timestamp is None:
            timestamp = self.clock.seconds()
        self._requests[request_id] = {
            'timestamp': timestamp,
            'request': request_object,
        }

    def get_request(self, request_id):
        if request_id in self._requests:
            return self._requests[request_id]['request']

    def remove_request(self, request_id):
        del self._requests[request_id]

    def emit(self, msg):
        if self.noisy:
            log.debug(msg)

    def handle_outbound_message(self, message):
        self.emit("HttpRpcTransport consuming %s" % (message))
        missing_fields = self.ensure_message_values(message,
                            ['in_reply_to', 'content'])
        if missing_fields:
            return self.reject_message(message, missing_fields)
        else:
            self.finish_request(
                    message.payload['in_reply_to'],
                    message.payload['content'].encode('utf-8'))
            return self.publish_ack(user_message_id=message['message_id'],
                sent_message_id=message['message_id'])

    def reject_message(self, message, missing_fields):
        return self.publish_nack(user_message_id=message['message_id'],
            sent_message_id=message['message_id'],
            reason='Missing fields: %s' % ', '.join(missing_fields))

    def handle_raw_inbound_message(self, msgid, request):
        raise NotImplementedError("Sub-classes should implement"
                                  " handle_raw_inbound_message.")

    def finish_request(self, request_id, data, code=200, headers={}):
        self.emit("HttpRpcTransport.finish_request with data: %s" % (
            repr(data),))
        request = self.get_request(request_id)
        if request:
            for h_name, h_values in headers.iteritems():
                request.responseHeaders.setRawHeaders(h_name, h_values)
            request.setResponseCode(code)
            request.write(data)
            request.finish()
            self.set_request_end(request_id)
            self.remove_request(request_id)
            response_id = "%s:%s:%s" % (request.client.host,
                                        request.client.port,
                                        Transport.generate_message_id())
            return response_id

    # NOTE: This hackery is required so that we know what to_addr a message
    #       was received on. This is useful so we can log more useful debug
    #       information when something goes wrong, like a timeout for example.
    #
    #       Since all the different transports that subclass this
    #       base class have different implementations for retreiving the
    #       to_addr it's impossible to grab this information higher up
    #       in a consistent manner.
    def publish_message(self, **kwargs):
        self.set_request_to_addr(kwargs['message_id'], kwargs['to_addr'])
        return super(HttpRpcTransport, self).publish_message(**kwargs)

    def get_request_to_addr(self, request_id):
        return self._requests[request_id].get('to_addr', 'Unknown')

    def set_request_to_addr(self, request_id, to_addr):
        if request_id in self._requests:
            self._requests[request_id]['to_addr'] = to_addr

    def set_request_end(self, message_id):
        '''Checks the saved timestamp to see the response time.
        If the starting timestamp for the message cannot be found, nothing is
        done.
        If the time is more than `response_time_down`, a `down` status event
        is sent.
        If the time more than `response_time_degraded`, a `degraded` status
        event is sent.
        If the time is less than `response_time_degraded`, an `ok` status
        event is sent.
        '''
        request = self._requests.get(message_id, None)
        if request is not None:
            response_time = self.clock.seconds() - request['timestamp']
            if response_time > self.response_time_down:
                return self.on_down_response_time(message_id, response_time)
            elif response_time > self.response_time_degraded:
                return self.on_degraded_response_time(message_id, response_time)
            else:
                return self.on_good_response_time(message_id, response_time)

    def on_down_response_time(self, message_id, time):
        '''Can be overridden by subclasses to do something when the
        response time is high enough for the transport to be considered
        non-functioning.'''
        pass

    def on_degraded_response_time(self, message_id, time):
        '''Can be overridden by subclasses to do something when the
        response time is high enough for the transport to be considered
        running in a degraded state.'''
        pass

    def on_good_response_time(self, message_id, time):
        '''Can be overridden by subclasses to do something when the
        response time is low enough for the transport to be considered
        running normally.'''
        pass

    def on_timeout(self, message_id, time):
        '''Can be overridden by subclasses to do something when the
        response times out.'''
        pass
示例#11
0
class WhatsAppTransport(Transport):

    CONFIG_CLASS = WhatsAppTransportConfig
    transport_type = 'whatsapp'

    @defer.inlineCallbacks
    def setup_transport(self):
        config = self.config = self.get_static_config()
        self.log.info('Transport starting with: %s' % (config,))

        self.redis = yield TxRedisManager.from_config(config.redis_manager)
        self.redis = self.redis.sub_manager(self.transport_name)

        self.our_msisdn = "+" + config.phone
        CREDENTIALS = (config.phone, config.password)

        self.stack_client = StackClient(CREDENTIALS, self)
        self.client_d = deferToThread(self.stack_client.client_start)
        self.client_d.addErrback(self.catch_exit)
        self.client_d.addErrback(self.log_error)

        self.status_detect = StatusEdgeDetector()

        # Wait for the WhatsApp client to connect before continuing.
        yield self.stack_client.connect_d

    @defer.inlineCallbacks
    def teardown_transport(self):
        self.log.info("Stopping client ...")
        if hasattr(self, 'stack_client'):
            self.stack_client.client_stop()
            yield self.client_d

        if hasattr(self, 'redis'):
            yield self.redis._close()

        self.log.info("Loop done.")

    def add_status(self, **kw):
        '''Publishes a status if it is not a repeat of the previously
        published status.'''
        if self.status_detect.check_status(**kw):
            return self.publish_status(**kw)
        return defer.succeed(None)

    def handle_outbound_message(self, message):
        # message is a vumi.message.TransportUserMessage
        self.log.info('Sending message: %s' % (message.to_json(),))
        msg = TextMessageProtocolEntity(
            message['content'].encode("UTF-8"),
            to=msisdn_to_whatsapp(message['to_addr']).encode("UTF-8"))
        self.redis.setex(
            msg.getId(), self.config.ack_timeout, message['message_id'])
        self.stack_client.send_to_stack(msg)

    @defer.inlineCallbacks
    def _send_ack(self, whatsapp_id):
        vumi_id = yield self.redis.get(whatsapp_id)
        if vumi_id is None:
            defer.returnValue(None)
        yield self.publish_ack(
            user_message_id=vumi_id, sent_message_id=whatsapp_id)

    @defer.inlineCallbacks
    def _send_delivery_report(self, whatsapp_id):
        vumi_id = yield self.redis.get(whatsapp_id)
        if vumi_id:
            yield self.publish_delivery_report(
                user_message_id=vumi_id, delivery_status='delivered')
            yield self.redis.delete(whatsapp_id)

    def catch_exit(self, f):
        f.trap(WhatsAppClientDone)
        self.log.info("Yowsup client killed.")

    def log_error(self, f):
        self.log.error(f)
        return f

    def handle_inbound_error(self, error, message):
        return self.add_status(
            component='inbound', status='degraded', type='inbound_error',
            message=error, details={'message': message})

    def handle_inbound_success(self):
        return self.add_status(
            component='inbound', status='ok', type='inbound_success',
            message='Inbound message successfully processed')

    def handle_connected(self):
        return self.add_status(
            component='connection', status='ok', type='connected',
            message='Successfully connected to server')

    def handle_disconnected(self, reason):
        return self.add_status(
            component='connection', status='down', type='disconnected',
            message=reason)

    def handle_unknown_event(self, name):
        self.log.info('Unhandled event received: %s' % name)