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)
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)
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)
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)
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"
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"
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
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
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)