From 6b140fe6dd692a061036cc487855a0492288a162 Mon Sep 17 00:00:00 2001 From: mgw <1392924357@qq.com> Date: Wed, 10 Jul 2024 15:58:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B7=A5=E5=8D=95=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=8C=E5=A2=9E=E5=8A=A0=E4=BC=81=E5=BE=AE=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jikimo_system_order/__manifest__.py | 1 + jikimo_system_order/models/__init__.py | 1 + .../models/res_config_setting.py | 32 + .../models/system_work_order.py | 2 +- .../views/res_config_settings_views.xml | 28 + .../views/yizuo_system_order_view.xml | 2 +- sg_wechat_enterprise/.gitignore | 138 ++++ sg_wechat_enterprise/__init__.py | 3 + sg_wechat_enterprise/__manifest__.py | 52 ++ .../controllers/WXBizMsgCrypt.py | 273 ++++++++ sg_wechat_enterprise/controllers/__init__.py | 3 + sg_wechat_enterprise/controllers/ierror.py | 20 + .../controllers/wechat_enterprise.py | 231 +++++++ sg_wechat_enterprise/data/data.xml | 16 + sg_wechat_enterprise/demo/we_config_demo.xml | 13 + sg_wechat_enterprise/models/__init__.py | 9 + .../models/client/__init__.py | 11 + .../models/client/api/__init__.py | 2 + .../models/client/api/jkm_oauth.py | 53 ++ .../models/client/api/jkm_user.py | 21 + sg_wechat_enterprise/models/mail.py | 121 ++++ sg_wechat_enterprise/models/res_users.py | 26 + sg_wechat_enterprise/models/we_app.py | 213 ++++++ sg_wechat_enterprise/models/we_conf.py | 62 ++ .../models/we_receive_message.py | 143 ++++ .../models/we_receive_message_process.py | 109 +++ .../models/we_send_message.py | 108 +++ sg_wechat_enterprise/models/we_tools.py | 89 +++ sg_wechat_enterprise/requirements.txt | 1 + .../security/ir.model.access.csv | 13 + sg_wechat_enterprise/static/css/loading.css | 24 + .../static/description/icon.png | Bin 0 -> 1660 bytes .../static/description/qyh.png | Bin 0 -> 3406 bytes .../static/js/url_transfers.js | 26 + sg_wechat_enterprise/tests/__init__.py | 1 + sg_wechat_enterprise/views/app_view.xml | 84 +++ sg_wechat_enterprise/views/mail_view.xml | 23 + sg_wechat_enterprise/views/menu_view.xml | 34 + sg_wechat_enterprise/views/res_users_view.xml | 20 + sg_wechat_enterprise/views/we_app_view.xml | 149 +++++ sg_wechat_enterprise/views/we_config_view.xml | 48 ++ sg_wechat_enterprise/views/we_menu.xml | 38 ++ .../views/we_message_process_view.xml | 70 ++ .../views/we_receive_message_view.xml | 98 +++ .../views/we_send_message_view.xml | 105 +++ sg_wechat_enterprise/views/we_templates.xml | 45 ++ sg_wechat_enterprise/we_api/__init__.py | 33 + sg_wechat_enterprise/we_api/_compat.py | 21 + .../we_api/client/__init__.py | 140 ++++ .../we_api/client/api/__init__.py | 23 + .../we_api/client/api/base.py | 29 + .../we_api/client/api/card.py | 431 ++++++++++++ .../we_api/client/api/customservice.py | 242 +++++++ .../we_api/client/api/datacube.py | 360 ++++++++++ .../we_api/client/api/device.py | 284 ++++++++ .../we_api/client/api/group.py | 148 +++++ .../we_api/client/api/jsapi.py | 58 ++ .../we_api/client/api/material.py | 167 +++++ .../we_api/client/api/media.py | 126 ++++ .../we_api/client/api/menu.py | 235 +++++++ .../we_api/client/api/merchant/__init__.py | 72 ++ .../we_api/client/api/merchant/category.py | 30 + .../we_api/client/api/merchant/common.py | 17 + .../we_api/client/api/merchant/express.py | 48 ++ .../we_api/client/api/merchant/group.py | 60 ++ .../we_api/client/api/merchant/order.py | 52 ++ .../we_api/client/api/merchant/shelf.py | 50 ++ .../we_api/client/api/merchant/stock.py | 26 + .../we_api/client/api/message.py | 572 ++++++++++++++++ .../we_api/client/api/misc.py | 51 ++ sg_wechat_enterprise/we_api/client/api/poi.py | 97 +++ .../we_api/client/api/qrcode.py | 87 +++ .../we_api/client/api/scan.py | 171 +++++ .../we_api/client/api/semantic.py | 59 ++ .../we_api/client/api/shakearound.py | 376 +++++++++++ .../we_api/client/api/template.py | 45 ++ .../we_api/client/api/user.py | 143 ++++ .../we_api/client/api/wifi.py | 195 ++++++ sg_wechat_enterprise/we_api/client/base.py | 251 +++++++ sg_wechat_enterprise/we_api/component.py | 431 ++++++++++++ .../we_api/crypto/__init__.py | 120 ++++ sg_wechat_enterprise/we_api/crypto/base.py | 52 ++ .../we_api/crypto/cryptography.py | 23 + sg_wechat_enterprise/we_api/crypto/pkcs7.py | 23 + .../we_api/crypto/pycrypto.py | 15 + .../we_api/enterprise/__init__.py | 7 + .../we_api/enterprise/client/__init__.py | 44 ++ .../we_api/enterprise/client/api/__init__.py | 18 + .../we_api/enterprise/client/api/agent.py | 71 ++ .../we_api/enterprise/client/api/batch.py | 134 ++++ .../we_api/enterprise/client/api/chat.py | 273 ++++++++ .../enterprise/client/api/department.py | 117 ++++ .../we_api/enterprise/client/api/jsapi.py | 48 ++ .../we_api/enterprise/client/api/material.py | 201 ++++++ .../we_api/enterprise/client/api/media.py | 73 ++ .../we_api/enterprise/client/api/menu.py | 43 ++ .../we_api/enterprise/client/api/message.py | 161 +++++ .../we_api/enterprise/client/api/misc.py | 15 + .../we_api/enterprise/client/api/oauth.py | 51 ++ .../we_api/enterprise/client/api/service.py | 45 ++ .../enterprise/client/api/shakearound.py | 25 + .../we_api/enterprise/client/api/tag.py | 61 ++ .../we_api/enterprise/client/api/user.py | 160 +++++ .../we_api/enterprise/crypto.py | 46 ++ .../we_api/enterprise/events.py | 163 +++++ .../we_api/enterprise/exceptions.py | 9 + .../we_api/enterprise/messages.py | 50 ++ .../we_api/enterprise/parser.py | 21 + .../we_api/enterprise/replies.py | 67 ++ sg_wechat_enterprise/we_api/events.py | 629 ++++++++++++++++++ sg_wechat_enterprise/we_api/exceptions.py | 130 ++++ sg_wechat_enterprise/we_api/fields.py | 237 +++++++ sg_wechat_enterprise/we_api/messages.py | 173 +++++ sg_wechat_enterprise/we_api/oauth/__init__.py | 219 ++++++ sg_wechat_enterprise/we_api/parser.py | 47 ++ sg_wechat_enterprise/we_api/pay/__init__.py | 199 ++++++ .../we_api/pay/api/__init__.py | 10 + sg_wechat_enterprise/we_api/pay/api/coupon.py | 82 +++ sg_wechat_enterprise/we_api/pay/api/jsapi.py | 48 ++ .../we_api/pay/api/micropay.py | 64 ++ sg_wechat_enterprise/we_api/pay/api/order.py | 138 ++++ .../we_api/pay/api/redpack.py | 125 ++++ sg_wechat_enterprise/we_api/pay/api/refund.py | 61 ++ sg_wechat_enterprise/we_api/pay/api/tools.py | 57 ++ .../we_api/pay/api/transfer.py | 65 ++ sg_wechat_enterprise/we_api/pay/base.py | 30 + sg_wechat_enterprise/we_api/pay/utils.py | 54 ++ sg_wechat_enterprise/we_api/replies.py | 341 ++++++++++ .../we_api/session/__init__.py | 23 + .../we_api/session/memcachedstorage.py | 35 + .../we_api/session/memorystorage.py | 20 + .../we_api/session/redisstorage.py | 35 + .../we_api/session/shovestorage.py | 34 + sg_wechat_enterprise/we_api/utils.py | 149 +++++ 134 files changed, 12830 insertions(+), 2 deletions(-) create mode 100644 jikimo_system_order/models/res_config_setting.py create mode 100644 jikimo_system_order/views/res_config_settings_views.xml create mode 100644 sg_wechat_enterprise/.gitignore create mode 100644 sg_wechat_enterprise/__init__.py create mode 100644 sg_wechat_enterprise/__manifest__.py create mode 100644 sg_wechat_enterprise/controllers/WXBizMsgCrypt.py create mode 100644 sg_wechat_enterprise/controllers/__init__.py create mode 100644 sg_wechat_enterprise/controllers/ierror.py create mode 100644 sg_wechat_enterprise/controllers/wechat_enterprise.py create mode 100644 sg_wechat_enterprise/data/data.xml create mode 100644 sg_wechat_enterprise/demo/we_config_demo.xml create mode 100644 sg_wechat_enterprise/models/__init__.py create mode 100644 sg_wechat_enterprise/models/client/__init__.py create mode 100644 sg_wechat_enterprise/models/client/api/__init__.py create mode 100644 sg_wechat_enterprise/models/client/api/jkm_oauth.py create mode 100644 sg_wechat_enterprise/models/client/api/jkm_user.py create mode 100644 sg_wechat_enterprise/models/mail.py create mode 100644 sg_wechat_enterprise/models/res_users.py create mode 100644 sg_wechat_enterprise/models/we_app.py create mode 100644 sg_wechat_enterprise/models/we_conf.py create mode 100644 sg_wechat_enterprise/models/we_receive_message.py create mode 100644 sg_wechat_enterprise/models/we_receive_message_process.py create mode 100644 sg_wechat_enterprise/models/we_send_message.py create mode 100644 sg_wechat_enterprise/models/we_tools.py create mode 100644 sg_wechat_enterprise/requirements.txt create mode 100644 sg_wechat_enterprise/security/ir.model.access.csv create mode 100644 sg_wechat_enterprise/static/css/loading.css create mode 100644 sg_wechat_enterprise/static/description/icon.png create mode 100644 sg_wechat_enterprise/static/description/qyh.png create mode 100644 sg_wechat_enterprise/static/js/url_transfers.js create mode 100644 sg_wechat_enterprise/tests/__init__.py create mode 100644 sg_wechat_enterprise/views/app_view.xml create mode 100644 sg_wechat_enterprise/views/mail_view.xml create mode 100644 sg_wechat_enterprise/views/menu_view.xml create mode 100644 sg_wechat_enterprise/views/res_users_view.xml create mode 100644 sg_wechat_enterprise/views/we_app_view.xml create mode 100644 sg_wechat_enterprise/views/we_config_view.xml create mode 100644 sg_wechat_enterprise/views/we_menu.xml create mode 100644 sg_wechat_enterprise/views/we_message_process_view.xml create mode 100644 sg_wechat_enterprise/views/we_receive_message_view.xml create mode 100644 sg_wechat_enterprise/views/we_send_message_view.xml create mode 100644 sg_wechat_enterprise/views/we_templates.xml create mode 100644 sg_wechat_enterprise/we_api/__init__.py create mode 100644 sg_wechat_enterprise/we_api/_compat.py create mode 100644 sg_wechat_enterprise/we_api/client/__init__.py create mode 100644 sg_wechat_enterprise/we_api/client/api/__init__.py create mode 100644 sg_wechat_enterprise/we_api/client/api/base.py create mode 100644 sg_wechat_enterprise/we_api/client/api/card.py create mode 100644 sg_wechat_enterprise/we_api/client/api/customservice.py create mode 100644 sg_wechat_enterprise/we_api/client/api/datacube.py create mode 100644 sg_wechat_enterprise/we_api/client/api/device.py create mode 100644 sg_wechat_enterprise/we_api/client/api/group.py create mode 100644 sg_wechat_enterprise/we_api/client/api/jsapi.py create mode 100644 sg_wechat_enterprise/we_api/client/api/material.py create mode 100644 sg_wechat_enterprise/we_api/client/api/media.py create mode 100644 sg_wechat_enterprise/we_api/client/api/menu.py create mode 100644 sg_wechat_enterprise/we_api/client/api/merchant/__init__.py create mode 100644 sg_wechat_enterprise/we_api/client/api/merchant/category.py create mode 100644 sg_wechat_enterprise/we_api/client/api/merchant/common.py create mode 100644 sg_wechat_enterprise/we_api/client/api/merchant/express.py create mode 100644 sg_wechat_enterprise/we_api/client/api/merchant/group.py create mode 100644 sg_wechat_enterprise/we_api/client/api/merchant/order.py create mode 100644 sg_wechat_enterprise/we_api/client/api/merchant/shelf.py create mode 100644 sg_wechat_enterprise/we_api/client/api/merchant/stock.py create mode 100644 sg_wechat_enterprise/we_api/client/api/message.py create mode 100644 sg_wechat_enterprise/we_api/client/api/misc.py create mode 100644 sg_wechat_enterprise/we_api/client/api/poi.py create mode 100644 sg_wechat_enterprise/we_api/client/api/qrcode.py create mode 100644 sg_wechat_enterprise/we_api/client/api/scan.py create mode 100644 sg_wechat_enterprise/we_api/client/api/semantic.py create mode 100644 sg_wechat_enterprise/we_api/client/api/shakearound.py create mode 100644 sg_wechat_enterprise/we_api/client/api/template.py create mode 100644 sg_wechat_enterprise/we_api/client/api/user.py create mode 100644 sg_wechat_enterprise/we_api/client/api/wifi.py create mode 100644 sg_wechat_enterprise/we_api/client/base.py create mode 100644 sg_wechat_enterprise/we_api/component.py create mode 100644 sg_wechat_enterprise/we_api/crypto/__init__.py create mode 100644 sg_wechat_enterprise/we_api/crypto/base.py create mode 100644 sg_wechat_enterprise/we_api/crypto/cryptography.py create mode 100644 sg_wechat_enterprise/we_api/crypto/pkcs7.py create mode 100644 sg_wechat_enterprise/we_api/crypto/pycrypto.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/__init__.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/__init__.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/__init__.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/agent.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/batch.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/chat.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/department.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/jsapi.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/material.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/media.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/menu.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/message.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/misc.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/oauth.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/service.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/shakearound.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/tag.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/client/api/user.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/crypto.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/events.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/exceptions.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/messages.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/parser.py create mode 100644 sg_wechat_enterprise/we_api/enterprise/replies.py create mode 100644 sg_wechat_enterprise/we_api/events.py create mode 100644 sg_wechat_enterprise/we_api/exceptions.py create mode 100644 sg_wechat_enterprise/we_api/fields.py create mode 100644 sg_wechat_enterprise/we_api/messages.py create mode 100644 sg_wechat_enterprise/we_api/oauth/__init__.py create mode 100644 sg_wechat_enterprise/we_api/parser.py create mode 100644 sg_wechat_enterprise/we_api/pay/__init__.py create mode 100644 sg_wechat_enterprise/we_api/pay/api/__init__.py create mode 100644 sg_wechat_enterprise/we_api/pay/api/coupon.py create mode 100644 sg_wechat_enterprise/we_api/pay/api/jsapi.py create mode 100644 sg_wechat_enterprise/we_api/pay/api/micropay.py create mode 100644 sg_wechat_enterprise/we_api/pay/api/order.py create mode 100644 sg_wechat_enterprise/we_api/pay/api/redpack.py create mode 100644 sg_wechat_enterprise/we_api/pay/api/refund.py create mode 100644 sg_wechat_enterprise/we_api/pay/api/tools.py create mode 100644 sg_wechat_enterprise/we_api/pay/api/transfer.py create mode 100644 sg_wechat_enterprise/we_api/pay/base.py create mode 100644 sg_wechat_enterprise/we_api/pay/utils.py create mode 100644 sg_wechat_enterprise/we_api/replies.py create mode 100644 sg_wechat_enterprise/we_api/session/__init__.py create mode 100644 sg_wechat_enterprise/we_api/session/memcachedstorage.py create mode 100644 sg_wechat_enterprise/we_api/session/memorystorage.py create mode 100644 sg_wechat_enterprise/we_api/session/redisstorage.py create mode 100644 sg_wechat_enterprise/we_api/session/shovestorage.py create mode 100644 sg_wechat_enterprise/we_api/utils.py diff --git a/jikimo_system_order/__manifest__.py b/jikimo_system_order/__manifest__.py index 75a20ebf..7f85ed57 100644 --- a/jikimo_system_order/__manifest__.py +++ b/jikimo_system_order/__manifest__.py @@ -30,6 +30,7 @@ 'views/notice_user_config.xml', 'views/yizuo_system_order_view.xml', 'views/work_order_number.xml', + 'views/res_config_settings_views.xml', ], # only loaded in demonstration mode 'demo': [ diff --git a/jikimo_system_order/models/__init__.py b/jikimo_system_order/models/__init__.py index ca081c2e..87b51a43 100644 --- a/jikimo_system_order/models/__init__.py +++ b/jikimo_system_order/models/__init__.py @@ -4,3 +4,4 @@ from . import constant from . import order_classify from . import system_work_order from . import work_order_template +from . import res_config_setting diff --git a/jikimo_system_order/models/res_config_setting.py b/jikimo_system_order/models/res_config_setting.py new file mode 100644 index 00000000..44a9a1d7 --- /dev/null +++ b/jikimo_system_order/models/res_config_setting.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- + +import logging +from odoo import api, fields, models, _ + +_logger = logging.getLogger(__name__) + + +class ResModelWeConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + lost_agent_id = fields.Char('企微通知应用ID') + + @api.model + def get_values(self): + """ + 重载获取参数的方法,参数都存在系统参数中 + :return: + """ + values = super(ResModelWeConfigSettings, self).get_values() + config = self.env['ir.config_parameter'].sudo() + lost_agent_id = config.get_param('lost_agent_id', default='') + values.update( + lost_agent_id=lost_agent_id, + ) + return values + + def set_values(self): + super(ResModelWeConfigSettings, self).set_values() + ir_config = self.env['ir.config_parameter'].sudo() + ir_config.set_param("lost_agent_id", self.lost_agent_id or "") + diff --git a/jikimo_system_order/models/system_work_order.py b/jikimo_system_order/models/system_work_order.py index 19bff200..1a7bff5e 100644 --- a/jikimo_system_order/models/system_work_order.py +++ b/jikimo_system_order/models/system_work_order.py @@ -85,7 +85,7 @@ class SystemWorkOrder(models.Model): # 最终解决方案 solution = fields.Text(string=u'最终解决方案') # 判断是否为技术人员 - is_technicist = fields.Boolean(string=u'是否为技术人员', default=get_is_technicist) + # is_technicist = fields.Boolean(string=u'是否为技术人员', default=get_is_technicist) # 打分 grade = fields.Selection(GRADE, string=u'评分') # 评价按钮的显示 diff --git a/jikimo_system_order/views/res_config_settings_views.xml b/jikimo_system_order/views/res_config_settings_views.xml new file mode 100644 index 00000000..40cdc443 --- /dev/null +++ b/jikimo_system_order/views/res_config_settings_views.xml @@ -0,0 +1,28 @@ + + + + + res.config.settings.we.view.form.inherit.bpm + res.config.settings + + + +
+

企微通知应用ID

+
+
+
+
+
+
+
+
+
+
+ + + + + diff --git a/jikimo_system_order/views/yizuo_system_order_view.xml b/jikimo_system_order/views/yizuo_system_order_view.xml index b5de3bcc..006a085d 100644 --- a/jikimo_system_order/views/yizuo_system_order_view.xml +++ b/jikimo_system_order/views/yizuo_system_order_view.xml @@ -27,7 +27,7 @@
-
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + we.send.message.tree + we.send.message + + + + + + + + + + + + + we.send.message.search + we.send.message + primary + + + + + + + + + + + + 发送消息 + ir.actions.act_window + we.send.message + tree,form + form + + + + + + diff --git a/sg_wechat_enterprise/views/we_templates.xml b/sg_wechat_enterprise/views/we_templates.xml new file mode 100644 index 00000000..6694833a --- /dev/null +++ b/sg_wechat_enterprise/views/we_templates.xml @@ -0,0 +1,45 @@ + + + + + + + + + + \ No newline at end of file diff --git a/sg_wechat_enterprise/we_api/__init__.py b/sg_wechat_enterprise/we_api/__init__.py new file mode 100644 index 00000000..e4a50359 --- /dev/null +++ b/sg_wechat_enterprise/we_api/__init__.py @@ -0,0 +1,33 @@ +from __future__ import absolute_import, unicode_literals +import logging +try: + from pkgutil import extend_path + __path__ = extend_path(__path__, __name__) +except ImportError: + from pkg_resources import declare_namespace + declare_namespace(__name__) + +from wechatpy.parser import parse_message # NOQA +from wechatpy.replies import create_reply # NOQA +from wechatpy.client import WeChatClient # NOQA +from wechatpy.exceptions import WeChatException # NOQA +from wechatpy.exceptions import WeChatClientException # NOQA +from wechatpy.oauth import WeChatOAuth # NOQA +from wechatpy.exceptions import WeChatOAuthException # NOQA +from wechatpy.pay import WeChatPay # NOQA +from wechatpy.exceptions import WeChatPayException # NOQA +from wechatpy.component import WeChatComponent # NOQA + + +__version__ = '1.3.1' +__author__ = 'messense' + +# Set default logging handler to avoid "No handler found" warnings. +try: # Python 2.7+ + from logging import NullHandler +except ImportError: + class NullHandler(logging.Handler): + def emit(self, record): + pass + +logging.getLogger(__name__).addHandler(NullHandler()) diff --git a/sg_wechat_enterprise/we_api/_compat.py b/sg_wechat_enterprise/we_api/_compat.py new file mode 100644 index 00000000..d2889243 --- /dev/null +++ b/sg_wechat_enterprise/we_api/_compat.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +""" + wechatpy._compat + ~~~~~~~~~~~~~~~~~ + + This module makes it easy for wechatpy to run on both Python 2 and 3. + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import sys +import six +import warnings + +warnings.warn("Module `wechatpy._compat` is deprecated, will be removed in 2.0" + "use `wechatpy.utils` instead", + DeprecationWarning, stacklevel=2) + +from wechatpy.utils import get_querystring +from wechatpy.utils import json diff --git a/sg_wechat_enterprise/we_api/client/__init__.py b/sg_wechat_enterprise/we_api/client/__init__.py new file mode 100644 index 00000000..02c6c525 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/__init__.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +try: + from pkgutil import extend_path + __path__ = extend_path(__path__, __name__) +except ImportError: + from pkg_resources import declare_namespace + declare_namespace(__name__) + +import time + +from wechatpy.client.base import BaseWeChatClient +from wechatpy.client import api + + +class WeChatClient(BaseWeChatClient): + + """ + 微信 API 操作类 + 通过这个类可以操作微信 API,发送主动消息、群发消息和创建自定义菜单等。 + """ + + API_BASE_URL = 'https://api.weixin.qq.com/cgi-bin/' + + menu = api.WeChatMenu() + user = api.WeChatUser() + group = api.WeChatGroup() + media = api.WeChatMedia() + card = api.WeChatCard() + qrcode = api.WeChatQRCode() + message = api.WeChatMessage() + misc = api.WeChatMisc() + merchant = api.WeChatMerchant() + customservice = api.WeChatCustomService() + datacube = api.WeChatDataCube() + jsapi = api.WeChatJSAPI() + material = api.WeChatMaterial() + semantic = api.WeChatSemantic() + shakearound = api.WeChatShakeAround() + device = api.WeChatDevice() + template = api.WeChatTemplate() + poi = api.WeChatPoi() + wifi = api.WeChatWiFi() + scan = api.WeChatScan() + + def __init__(self, appid, secret, access_token=None, + session=None, timeout=None, auto_retry=True): + super(WeChatClient, self).__init__( + appid, access_token, session, timeout, auto_retry + ) + self.appid = appid + self.secret = secret + + def fetch_access_token(self): + """ + 获取 access token + 详情请参考 http://mp.weixin.qq.com/wiki/index.php?title=通用接口文档 + + :return: 返回的 JSON 数据包 + """ + return self._fetch_access_token( + url='https://api.weixin.qq.com/cgi-bin/token', + params={ + 'grant_type': 'client_credential', + 'appid': self.appid, + 'secret': self.secret + } + ) + + +class WeChatComponentClient(WeChatClient): + + """ + 开放平台代公众号调用客户端 + """ + + def __init__(self, appid, component, access_token=None, + refresh_token=None, session=None, timeout=None): + # 未用到secret,所以这里没有 + super(WeChatComponentClient, self).__init__( + appid, '', access_token, session, timeout + ) + self.appid = appid + self.component = component + # 如果公众号是刚授权,外部还没有缓存access_token和refresh_token + # 可以传入这两个值,session 会缓存起来。 + # 如果外部已经缓存,这里只需要传入 appid,component和session即可 + if access_token: + self.session.set(self.access_token_key, access_token, 7200) + if refresh_token: + self.session.set(self.refresh_token_key, refresh_token, 7200) + + @property + def access_token_key(self): + return '{0}_access_token'.format(self.appid) + + @property + def refresh_token_key(self): + return '{0}_refresh_token'.format(self.appid) + + @property + def access_token(self): + access_token = self.session.get(self.access_token_key) + if not access_token: + self.fetch_access_token() + access_token = self.session.get(self.access_token_key) + return access_token + + @property + def refresh_token(self): + return self.session.get(self.refresh_token_key) + + def fetch_access_token(self): + """ + 获取 access token + 详情请参考 https://open.weixin.qq.com/cgi-bin/showdocument?action=dir_list\ + &t=resource/res_list&verify=1&id=open1419318587&token=&lang=zh_CN + + 这是内部刷新机制。请不要完全依赖! + 因为有可能在缓存期间没有对此公众号的操作,造成refresh_token失效。 + + :return: 返回的 JSON 数据包 + """ + expires_in = 7200 + result = self.component.refresh_authorizer_token( + self.appid, self.refresh_token) + if 'expires_in' in result: + expires_in = result['expires_in'] + self.session.set( + self.access_token_key, + result['authorizer_access_token'], + expires_in + ) + self.session.set( + self.refresh_token_key, + result['authorizer_refresh_token'], + expires_in + ) + self.expires_at = int(time.time()) + expires_in + return result diff --git a/sg_wechat_enterprise/we_api/client/api/__init__.py b/sg_wechat_enterprise/we_api/client/api/__init__.py new file mode 100644 index 00000000..30291577 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.client.api.menu import WeChatMenu # NOQA +from wechatpy.client.api.user import WeChatUser # NOQA +from wechatpy.client.api.card import WeChatCard # NOQA +from wechatpy.client.api.group import WeChatGroup # NOQA +from wechatpy.client.api.media import WeChatMedia # NOQA +from wechatpy.client.api.message import WeChatMessage # NOQA +from wechatpy.client.api.qrcode import WeChatQRCode # NOQA +from wechatpy.client.api.misc import WeChatMisc # NOQA +from wechatpy.client.api.merchant import WeChatMerchant # NOQA +from wechatpy.client.api.customservice import WeChatCustomService # NOQA +from wechatpy.client.api.datacube import WeChatDataCube # NOQA +from wechatpy.client.api.jsapi import WeChatJSAPI # NOQA +from wechatpy.client.api.material import WeChatMaterial # NOQA +from wechatpy.client.api.semantic import WeChatSemantic # NOQA +from wechatpy.client.api.shakearound import WeChatShakeAround # NOQA +from wechatpy.client.api.device import WeChatDevice # NOQA +from wechatpy.client.api.template import WeChatTemplate # NOQA +from wechatpy.client.api.poi import WeChatPoi # NOQA +from wechatpy.client.api.wifi import WeChatWiFi # NOQA +from wechatpy.client.api.scan import WeChatScan # NOQA diff --git a/sg_wechat_enterprise/we_api/client/api/base.py b/sg_wechat_enterprise/we_api/client/api/base.py new file mode 100644 index 00000000..790d9414 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/base.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + + +class BaseWeChatAPI(object): + + API_BASE_URL = '' + + """ WeChat API base class """ + def __init__(self, client=None): + self._client = client + + def _get(self, url, **kwargs): + if getattr(self, 'API_BASE_URL', None): + kwargs['api_base_url'] = self.API_BASE_URL + return self._client.get(url, **kwargs) + + def _post(self, url, **kwargs): + if getattr(self, 'API_BASE_URL', None): + kwargs['api_base_url'] = self.API_BASE_URL + return self._client.post(url, **kwargs) + + @property + def access_token(self): + return self._client.access_token + + @property + def session(self): + return self._client.session diff --git a/sg_wechat_enterprise/we_api/client/api/card.py b/sg_wechat_enterprise/we_api/client/api/card.py new file mode 100644 index 00000000..5f6ebe35 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/card.py @@ -0,0 +1,431 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatCard(BaseWeChatAPI): + + API_BASE_URL = 'https://api.weixin.qq.com/' + + def create(self, card_data): + """ + 创建卡券 + + :param card_data: 卡券信息 + :return: 创建的卡券 ID + """ + result = self._post( + 'card/create', + data=card_data, + result_processor=lambda x: x['card_id'] + ) + return result + + def batch_add_locations(self, location_data): + """ + 批量导入门店信息 + + :param location_data: 门店信息 + :return: 门店 ID 列表,插入失败的门店元素值为 -1 + """ + result = self._post( + 'card/location/batchadd', + data=location_data, + result_processor=lambda x: x['location_id_list'] + ) + return result + + def batch_get_locations(self, offset=0, count=0): + """ + 批量获取门店信息 + """ + return self._post( + 'card/location/batchget', + data={ + 'offset': offset, + 'count': count + } + ) + + def get_colors(self): + """ + 获得卡券的最新颜色列表,用于创建卡券 + :return: 颜色列表 + """ + result = self._get( + 'card/getcolors', + result_processor=lambda x: x['colors'] + ) + return result + + def create_qrcode(self, qrcode_data): + """ + 创建卡券二维码 + + :param qrcode_data: 二维码信息 + :return: 二维码 ticket,可使用 :func:show_qrcode 换取二维码文件 + """ + result = self._post( + 'card/qrcode/create', + data=qrcode_data, + result_processor=lambda x: x['ticket'] + ) + return result + + def create_landingpage(self, buffer_data): + """ + 创建货架 + """ + result = self._post( + 'card/landingpage/create', + data=buffer_data + ) + return result + + def get_html(self, card_id): + """ + 图文消息群发卡券 + """ + result = self._post( + 'card/mpnews/gethtml', + data={ + 'card_id': card_id + }, + result_processor=lambda x: x['content'] + ) + return result + + def consume_code(self, code, card_id=None): + """ + 消耗 code + """ + card_data = { + 'code': code + } + if card_id: + card_data['card_id'] = card_id + return self._post( + 'card/code/consume', + data=card_data + ) + + def decrypt_code(self, encrypt_code): + """ + 解码加密的 code + """ + result = self._post( + 'card/code/decrypt', + data={ + 'encrypt_code': encrypt_code + }, + result_processor=lambda x: x['code'] + ) + return result + + def delete(self, card_id): + """ + 删除卡券 + """ + return self._post( + 'card/delete', + data={ + 'card_id': card_id + } + ) + + def get_code(self, code, card_id=None, check_consume=True): + """ + 查询 code 信息 + """ + card_data = { + 'code': code + } + if card_id: + card_data['card_id'] = card_id + if not check_consume: + card_data['check_consume'] = check_consume + return self._post( + 'card/code/get', + data=card_data + ) + + def get_card_list(self, openid, card_id=None): + """ + 用于获取用户卡包里的,属于该appid下的卡券。 + """ + card_data = { + 'openid': openid + } + if card_id: + card_data['card_id'] = card_id + return self._post( + 'card/user/getcardlist', + data=card_data + ) + + def batch_get(self, offset=0, count=50, status_list=None): + """ + 批量查询卡券信息 + """ + card_data = { + 'offset': offset, + 'count': count + } + if status_list: + card_data['status_list'] = status_list + return self._post( + 'card/batchget', + data=card_data + ) + + def get(self, card_id): + """ + 查询卡券详情 + """ + result = self._post( + 'card/get', + data={ + 'card_id': card_id + }, + result_processor=lambda x: x['card'] + ) + return result + + def update_code(self, card_id, old_code, new_code): + """ + 更新卡券 code + """ + return self._post( + 'card/code/update', + data={ + 'card_id': card_id, + 'code': old_code, + 'new_code': new_code + } + ) + + def invalid_code(self, code, card_id=None): + """ + 设置卡券失效 + """ + card_data = { + 'code': code + } + if card_id: + card_data['card_id'] = card_id + return self._post( + 'card/code/unavailable', + data=card_data + ) + + def update(self, card_data): + """ + 更新卡券信息 + """ + return self._post( + 'card/update', + data=card_data + ) + + def set_paycell(self, card_id, is_open): + """ + 更新卡券信息 + """ + return self._post( + 'card/paycell/set', + data={ + 'card_id': card_id, + 'is_open': is_open + } + ) + + def set_test_whitelist(self, openids=None, usernames=None): + """ + 设置卡券测试用户白名单 + """ + openids = openids or [] + usernames = usernames or [] + return self._post( + 'card/testwhitelist/set', + data={ + 'openid': openids, + 'username': usernames + } + ) + + def activate_membercard(self, membership_number, code, init_bonus=0, + init_balance=0, card_id=None): + """ + 激活/绑定会员卡 + """ + card_data = { + 'membership_number': membership_number, + 'code': code, + 'init_bonus': init_bonus, + 'init_balance': init_balance + } + if card_id: + card_data['card_id'] = card_id + return self._post( + 'card/membercard/activate', + data=card_data + ) + + def update_membercard(self, code, add_bonus=0, record_bonus='', + add_balance=0, record_balance='', card_id=None): + """ + 会员卡交易更新信息 + """ + card_data = { + 'code': code, + 'add_bonus': add_bonus, + 'add_balance': add_balance, + 'record_bonus': record_bonus, + 'record_balance': record_balance + } + if card_id: + card_data['card_id'] = card_id + return self._post( + 'card/membercard/updateuser', + data=card_data + ) + + def update_movie_ticket(self, code, ticket_class, show_time, duration, + screening_room, seat_number, card_id=None): + """ + 更新电影票 + """ + ticket = { + 'code': code, + 'ticket_class': ticket_class, + 'show_time': show_time, + 'duration': duration, + 'screening_room': screening_room, + 'seat_number': seat_number + } + if card_id: + ticket['card_id'] = card_id + return self._post( + 'card/movieticket/updateuser', + data=ticket + ) + + def checkin_boardingpass(self, code, passenger_name, seat_class, + etkt_bnr, seat='', gate='', boarding_time=None, + is_cancel=False, qrcode_data=None, card_id=None): + """ + 飞机票接口 + """ + data = { + 'code': code, + 'passenger_name': passenger_name, + 'class': seat_class, + 'etkt_bnr': etkt_bnr, + 'seat': seat, + 'gate': gate, + 'is_cancel': is_cancel + } + if boarding_time: + data['boarding_time'] = boarding_time + if qrcode_data: + data['qrcode_data'] = qrcode_data + if card_id: + data['card_id'] = card_id + return self._post( + 'card/boardingpass/checkin', + data=data + ) + + def update_luckymoney_balance(self, code, balance, card_id=None): + """ + 更新红包余额 + """ + card_data = { + 'code': code, + 'balance': balance + } + if card_id: + card_data['card_id'] = card_id + return self._post( + 'card/luckymoney/updateuserbalance', + data=card_data + ) + + def get_redirect_url(self, url, encrypt_code, card_id): + """ + 获取卡券跳转外链 + """ + from wechatpy.utils import WeChatSigner + + code = self.decrypt_code(encrypt_code) + + signer = WeChatSigner() + signer.add_data(self.secret) + signer.add_data(code) + signer.add_data(card_id) + signature = signer.signature + + r = '{url}?encrypt_code={code}&card_id={card_id}&signature={signature}' + return r.format( + url=url, + code=encrypt_code, + card_id=card_id, + signature=signature + ) + + def deposit_code(self, card_id, codes): + """ + 导入code + """ + card_data = { + 'card_id': card_id, + 'code': codes + } + return self._post( + 'card/code/deposit', + data=card_data + ) + + def get_deposit_count(self, card_id): + """ + 查询导入code数目 + """ + card_data = { + 'card_id': card_id, + } + return self._post( + 'card/code/getdepositcount', + data=card_data + ) + + def check_code(self, card_id, codes): + """ + 核查code + """ + card_data = { + 'card_id': card_id, + 'code': codes + } + return self._post( + 'card/code/checkcode', + data=card_data + ) + + def modify_stock(self, card_id, n): + """ + 修改库存 + """ + if n == 0: + return + card_data = { + 'card_id': card_id, + } + if n > 0: + card_data['increase_stock_value'] = n + elif n < 0: + card_data['reduce_stock_value'] = -n + return self._post( + 'card/modifystock', + data=card_data + ) diff --git a/sg_wechat_enterprise/we_api/client/api/customservice.py b/sg_wechat_enterprise/we_api/client/api/customservice.py new file mode 100644 index 00000000..0a2db30a --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/customservice.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import hashlib +import time +import datetime + +from six.moves.urllib.parse import quote +from optionaldict import optionaldict +from wechatpy.utils import to_binary +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatCustomService(BaseWeChatAPI): + + def add_account(self, account, nickname, password): + """ + 添加客服账号 + 详情请参考 + http://mp.weixin.qq.com/wiki/1/70a29afed17f56d537c833f89be979c9.html + + :param account: 完整客服账号,格式为:账号前缀@公众号微信号 + :param nickname: 客服昵称,最长6个汉字或12个英文字符 + :param password: 客服账号登录密码 + :return: 返回的 JSON 数据包 + """ + password = to_binary(password) + password = hashlib.md5(password).hexdigest() + return self._post( + 'https://api.weixin.qq.com/customservice/kfaccount/add', + data={ + 'kf_account': account, + 'nickname': nickname, + 'password': password + } + ) + + def update_account(self, account, nickname, password): + """ + 更新客服账号 + 详情请参考 + http://mp.weixin.qq.com/wiki/1/70a29afed17f56d537c833f89be979c9.html + + :param account: 完整客服账号,格式为:账号前缀@公众号微信号 + :param nickname: 客服昵称,最长6个汉字或12个英文字符 + :param password: 客服账号登录密码 + :return: 返回的 JSON 数据包 + """ + password = to_binary(password) + password = hashlib.md5(password).hexdigest() + return self._post( + 'https://api.weixin.qq.com/customservice/kfaccount/update', + data={ + 'kf_account': account, + 'nickname': nickname, + 'password': password + } + ) + + def delete_account(self, account): + """ + 删除客服账号 + 详情请参考 + http://mp.weixin.qq.com/wiki/1/70a29afed17f56d537c833f89be979c9.html + + :param account: 完整客服账号,格式为:账号前缀@公众号微信号 + :return: 返回的 JSON 数据包 + """ + params_data = [ + 'access_token={0}'.format(quote(self.access_token)), + 'kf_account={0}'.format(quote(to_binary(account), safe=b'/@')), + ] + params = '&'.join(params_data) + return self._get( + 'https://api.weixin.qq.com/customservice/kfaccount/del', + params=params + ) + + def get_accounts(self): + """ + 获取客服账号列表 + 详情请参考 + http://mp.weixin.qq.com/wiki/1/70a29afed17f56d537c833f89be979c9.html + + :return: 客服账号列表 + """ + res = self._get( + 'customservice/getkflist', + result_processor=lambda x: x['kf_list'] + ) + return res + + def upload_headimg(self, account, media_file): + """ + 上传客服账号头像 + 详情请参考 + http://mp.weixin.qq.com/wiki/1/70a29afed17f56d537c833f89be979c9.html + + :param account: 完整客服账号 + :param media_file: 要上传的头像文件,一个 File-Object + :return: 返回的 JSON 数据包 + """ + return self._post( + 'https://api.weixin.qq.com/customservice/kfaccount/uploadheadimg', + params={ + 'kf_account': account + }, + files={ + 'media': media_file + } + ) + + def get_online_accounts(self): + """ + 获取在线客服接待信息 + 详情请参考 + http://mp.weixin.qq.com/wiki/9/6fff6f191ef92c126b043ada035cc935.html + + :return: 客服接待信息列表 + """ + res = self._get( + 'customservice/getonlinekflist', + result_processor=lambda x: x['kf_online_list'] + ) + return res + + def create_session(self, openid, account, text=None): + """ + 多客服创建会话 + 详情请参考 + http://mp.weixin.qq.com/wiki/2/6c20f3e323bdf5986cfcb33cbd3b829a.html + + :param openid: 客户 openid + :param account: 完整客服账号 + :param text: 附加信息,可选 + :return: 返回的 JSON 数据包 + """ + data = optionaldict( + openid=openid, + kf_account=account, + text=text + ) + return self._post( + 'https://api.weixin.qq.com/customservice/kfsession/create', + data=data + ) + + def close_session(self, openid, account, text=None): + """ + 多客服关闭会话 + 详情请参考 + http://mp.weixin.qq.com/wiki/2/6c20f3e323bdf5986cfcb33cbd3b829a.html + + :param openid: 客户 openid + :param account: 完整客服账号 + :param text: 附加信息,可选 + :return: 返回的 JSON 数据包 + """ + data = optionaldict( + openid=openid, + kf_account=account, + text=text + ) + return self._post( + 'https://api.weixin.qq.com/customservice/kfsession/close', + data=data + ) + + def get_session(self, openid): + """ + 获取客户的会话状态 + 详情请参考 + http://mp.weixin.qq.com/wiki/2/6c20f3e323bdf5986cfcb33cbd3b829a.html + + :param openid: 客户 openid + :return: 返回的 JSON 数据包 + """ + return self._get( + 'https://api.weixin.qq.com/customservice/kfsession/getsession', + params={'openid': openid} + ) + + def get_session_list(self, account): + """ + 获取客服的会话列表 + 详情请参考 + http://mp.weixin.qq.com/wiki/2/6c20f3e323bdf5986cfcb33cbd3b829a.html + + :param account: 完整客服账号 + :return: 客服的会话列表 + """ + res = self._get( + 'https://api.weixin.qq.com/customservice/kfsession/getsessionlist', + params={'kf_account': account}, + result_processor=lambda x: x['sessionlist'] + ) + return res + + def get_wait_case(self): + """ + 获取未接入会话列表 + 详情请参考 + http://mp.weixin.qq.com/wiki/2/6c20f3e323bdf5986cfcb33cbd3b829a.html + + :return: 返回的 JSON 数据包 + """ + return self._get( + 'https://api.weixin.qq.com/customservice/kfsession/getwaitcase' + ) + + def get_records(self, start_time, end_time, page_index, + page_size=10, user_id=None): + """ + 获取客服聊天记录 + 详情请参考 + http://mp.weixin.qq.com/wiki/19/7c129ec71ddfa60923ea9334557e8b23.html + + :param start_time: 查询开始时间,UNIX 时间戳 + :param end_time: 查询结束时间,UNIX 时间戳,每次查询不能跨日查询 + :param page_index: 查询第几页,从 1 开始 + :param page_size: 每页大小,每页最多拉取 1000 条 + :param user_id: 普通用户的标识,对当前公众号唯一 + + :return: 返回的 JSON 数据包 + """ + if isinstance(start_time, datetime.datetime): + start_time = time.mktime(start_time.timetuple()) + if isinstance(end_time, datetime.datetime): + end_time = time.mktime(end_time.timetuple()) + record_data = { + 'starttime': int(start_time), + 'endtime': int(end_time), + 'pageindex': page_index, + 'pagesize': page_size + } + if user_id: + record_data['openid'] = user_id + res = self._post( + 'https://api.weixin.qq.com/customservice/msgrecord/getrecord', + data=record_data, + result_processor=lambda x: x['recordlist'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/datacube.py b/sg_wechat_enterprise/we_api/client/api/datacube.py new file mode 100644 index 00000000..f6d679c8 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/datacube.py @@ -0,0 +1,360 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import datetime + +import six + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatDataCube(BaseWeChatAPI): + + API_BASE_URL = 'https://api.weixin.qq.com/datacube/' + + @classmethod + def _to_date_str(cls, date): + if isinstance(date, (datetime.datetime, datetime.date)): + return date.strftime('%Y-%m-%d') + elif isinstance(date, six.string_types): + return date + else: + raise ValueError('Can not convert %s type to str', type(date)) + + def get_user_summary(self, begin_date, end_date): + """ + 获取用户增减数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/3/ecfed6e1a0a03b5f35e5efac98e864b7.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getusersummary', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + } + ) + return res['list'] + + def get_user_cumulate(self, begin_date, end_date): + """ + 获取累计用户数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/3/ecfed6e1a0a03b5f35e5efac98e864b7.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getusercumulate', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_interface_summary(self, begin_date, end_date): + """ + 获取接口分析数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/30ed81ae38cf4f977194bf1a5db73668.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getinterfacesummary', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_interface_summary_hour(self, begin_date, end_date): + """ + 获取接口分析分时数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/30ed81ae38cf4f977194bf1a5db73668.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getinterfacesummaryhour', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_article_summary(self, begin_date, end_date): + """ + 获取图文群发每日数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/c0453610fb5131d1fcb17b4e87c82050.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getarticlesummary', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_article_total(self, begin_date, end_date): + """ + 获取图文群发总数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/c0453610fb5131d1fcb17b4e87c82050.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getarticletotal', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_user_read(self, begin_date, end_date): + """ + 获取图文统计数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/c0453610fb5131d1fcb17b4e87c82050.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getuserread', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_user_read_hour(self, begin_date, end_date): + """ + 获取图文分时统计数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/c0453610fb5131d1fcb17b4e87c82050.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getuserreadhour', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_user_share(self, begin_date, end_date): + """ + 获取图文分享转发数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/c0453610fb5131d1fcb17b4e87c82050.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getusershare', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_user_share_hour(self, begin_date, end_date): + """ + 获取图文分享转发分时数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/c0453610fb5131d1fcb17b4e87c82050.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getusersharehour', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_upstream_msg(self, begin_date, end_date): + """ + 获取消息发送概况数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/32d42ad542f2e4fc8a8aa60e1bce9838.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getupstreammsg', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_upstream_msg_hour(self, begin_date, end_date): + """ + 获取消息发送分时数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/32d42ad542f2e4fc8a8aa60e1bce9838.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getupstreammsghour', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_upstream_msg_week(self, begin_date, end_date): + """ + 获取消息发送周数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/32d42ad542f2e4fc8a8aa60e1bce9838.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getupstreammsgweek', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_upstream_msg_month(self, begin_date, end_date): + """ + 获取消息发送月数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/32d42ad542f2e4fc8a8aa60e1bce9838.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getupstreammsgmonth', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_upstream_msg_dist(self, begin_date, end_date): + """ + 获取消息发送分布数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/32d42ad542f2e4fc8a8aa60e1bce9838.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getupstreammsgdist', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_upstream_msg_dist_week(self, begin_date, end_date): + """ + 获取消息发送分布数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/32d42ad542f2e4fc8a8aa60e1bce9838.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getupstreammsgdistweek', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_upstream_msg_dist_month(self, begin_date, end_date): + """ + 获取消息发送分布数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/32d42ad542f2e4fc8a8aa60e1bce9838.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getupstreammsgdistmonth', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/device.py b/sg_wechat_enterprise/we_api/client/api/device.py new file mode 100644 index 00000000..56b5a133 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/device.py @@ -0,0 +1,284 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import base64 +import urllib + +from wechatpy.utils import to_text, to_binary +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatDevice(BaseWeChatAPI): + + API_BASE_URL = 'https://api.weixin.qq.com/device/' + + def send_message(self, device_type, device_id, user_id, content): + """ + 主动发送消息给设备 + 详情请参考 + http://iot.weixin.qq.com/document-2_3.html + + :param device_type: 设备类型,目前为“公众账号原始ID” + :param device_id: 设备ID + :param user_id: 微信用户账号的openid + :param content: 消息内容,BASE64编码 + :return: 返回的 JSON 数据包 + """ + content = to_text(base64.b64encode(to_binary(content))) + return self._post( + 'transmsg', + data={ + 'device_type': device_type, + 'device_id': device_id, + 'openid': user_id, + 'content': content + } + ) + + def create_qrcode(self, device_ids): + """ + 获取设备二维码 + 详情请参考 + http://iot.weixin.qq.com/document-2_5.html + + :param device_ids: 设备id的列表 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'create_qrcode', + data={ + 'device_num': len(device_ids), + 'device_id_list': device_ids + } + ) + + def get_qrcode_url(self, ticket, data=None): + """ + 通过 ticket 换取二维码地址 + 详情请参考 + http://iot.weixin.qq.com/document-2_5.html + + :param ticket: 二维码 ticket + :param data: 额外数据 + :return: 二维码地址 + """ + url = 'http://we.qq.com/d/{ticket}'.format(ticket=ticket) + if data: + if isinstance(data, (dict, tuple, list)): + data = urllib.urlencode(data) + data = to_text(base64.b64encode(to_binary(data))) + url = '{base}#{data}'.format(base=url, data=data) + return url + + def bind(self, ticket, device_id, user_id): + """ + 绑定设备 + 详情请参考 + http://iot.weixin.qq.com/document-2_12.html + + :param ticket: 绑定操作合法性的凭证(由微信后台生成,第三方H5通过客户端jsapi获得) + :param device_id: 设备id + :param user_id: 用户对应的openid + :return: 返回的 JSON 数据包 + """ + return self._post( + 'bind', + data={ + 'ticket': ticket, + 'device_id': device_id, + 'openid': user_id + } + ) + + def unbind(self, ticket, device_id, user_id): + """ + 解绑设备 + 详情请参考 + http://iot.weixin.qq.com/document-2_12.html + + :param ticket: 绑定操作合法性的凭证(由微信后台生成,第三方H5通过客户端jsapi获得) + :param device_id: 设备id + :param user_id: 用户对应的openid + :return: 返回的 JSON 数据包 + """ + return self._post( + 'unbind', + data={ + 'ticket': ticket, + 'device_id': device_id, + 'openid': user_id + } + ) + + def compel_bind(self, device_id, user_id): + """ + 强制绑定用户和设备 + 详情请参考 + http://iot.weixin.qq.com/document-2_12.html + + :param device_id: 设备id + :param user_id: 用户对应的openid + :return: 返回的 JSON 数据包 + """ + return self._post( + 'compel_bind', + data={ + 'device_id': device_id, + 'openid': user_id + } + ) + + force_bind = compel_bind + + def compel_unbind(self, device_id, user_id): + """ + 强制解绑用户和设备 + 详情请参考 + http://iot.weixin.qq.com/document-2_12.html + + :param device_id: 设备id + :param user_id: 用户对应的openid + :return: 返回的 JSON 数据包 + """ + return self._post( + 'compel_unbind', + data={ + 'device_id': device_id, + 'openid': user_id + } + ) + + force_unbind = compel_unbind + + def get_stat(self, device_id): + """ + 设备状态查询 + 详情请参考 + http://iot.weixin.qq.com/document-2_7.html + + :param device_id: 设备id + :return: 返回的 JSON 数据包 + """ + return self._post( + 'get_stat', + data={'device_id': device_id} + ) + + def verify_qrcode(self, ticket): + """ + 验证二维码 + 详情请参考 + http://iot.weixin.qq.com/document-2_9.html + + :param ticket: 设备二维码的ticket + :return: 返回的 JSON 数据包 + """ + return self._post( + 'verify_qrcode', + data={'ticket': ticket} + ) + + def get_user_id(self, device_type, device_id): + """ + 获取设备绑定openID + 详情请参考 + http://iot.weixin.qq.com/document-2_4.html + + :param device_type: 设备类型,目前为“公众账号原始ID” + :param device_id: 设备id + :return: 返回的 JSON 数据包 + """ + return self._post( + 'get_openid', + data={ + 'device_type': device_type, + 'device_id': device_id + } + ) + + get_open_id = get_user_id + + def get_binded_devices(self, user_id): + """ + 通过openid获取用户在当前devicetype下绑定的deviceid列表 + 详情请参考 + http://iot.weixin.qq.com/document-2_13.html + + :param user_id: 要查询的用户的openid + :return: 返回的 JSON 数据包 + """ + return self._post( + 'get_bind_device', + data={'openid': user_id} + ) + + get_bind_device = get_binded_devices + + def send_status_message(self, device_type, device_id, user_id, status): + """ + 主动发送设备状态消息给微信终端 + 详情请参考 + http://iot.weixin.qq.com/document-2_10.html + + :param device_type: 设备类型,目前为“公众账号原始ID” + :param device_id: 设备ID + :param user_id: 微信用户账号的openid + :param status: 设备状态:0--未连接, 1--已连接 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'transmsg', + data={ + 'device_type': device_type, + 'device_id': device_id, + 'open_id': user_id, + 'device_status': status + } + ) + + def authorize(self, devices, op_type=0): + """ + 设备授权 + 详情请参考 + http://iot.weixin.qq.com/document-2_6.html + + :param devices: 设备信息的列表 + :param op_type: 请求操作的类型,限定取值为:0:设备授权 1:设备更新 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'authorize', + data={ + 'device_num': len(devices), + 'device_list': devices, + 'op_type': op_type + } + ) + + def get_qrcode(self): + """ + 获取deviceid和二维码 + 详情请参考 + http://iot.weixin.qq.com/document-2_11.html + + :return: 返回的 JSON 数据包 + """ + return self._get('getqrcode') + + def authorize_device(self, devices, op_type=1): + """ + 设备授权 + 详情请参考 + http://iot.weixin.qq.com/document-2_6.html + + :param devices: 设备信息的列表 + :param op_type: 请求操作的类型,限定取值为:0:设备授权 1:设备更新 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'authorize_device', + data={ + 'device_num': len(devices), + 'device_list': devices, + 'op_type': op_type + } + ) diff --git a/sg_wechat_enterprise/we_api/client/api/group.py b/sg_wechat_enterprise/we_api/client/api/group.py new file mode 100644 index 00000000..90600916 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/group.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.utils import to_text +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatGroup(BaseWeChatAPI): + + def create(self, name): + """ + 创建分组 + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/56d992c605a97245eb7e617854b169fc.html + + :param name: 分组名字(30个字符以内) + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.group.create('New Group') + + """ + name = to_text(name) + return self._post( + 'groups/create', + data={'group': {'name': name}} + ) + + def get(self, user_id=None): + """ + 查询所有分组或查询用户所在分组 ID + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/56d992c605a97245eb7e617854b169fc.html + + :param user_id: 用户 ID,提供时查询该用户所在分组,否则查询所有分组 + :return: 所有分组列表或用户所在分组 ID + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + group = client.group.get('openid') + + """ + if user_id is None: + res = self._get( + 'groups/get', + result_processor=lambda x: x['groups'] + ) + else: + res = self._post( + 'groups/getid', + data={'openid': user_id}, + result_processor=lambda x: x['groupid'] + ) + return res + + def update(self, group_id, name): + """ + 修改分组名 + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/56d992c605a97245eb7e617854b169fc.html + + :param group_id: 分组id,由微信分配 + :param name: 分组名字(30个字符以内) + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.group.update(1234, 'New Name') + + """ + name = to_text(name) + return self._post( + 'groups/update', + data={ + 'group': { + 'id': int(group_id), + 'name': name + } + } + ) + + def move_user(self, user_id, group_id): + """ + 移动用户分组 + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/56d992c605a97245eb7e617854b169fc.html + + :param user_id: 用户 ID, 可以是单个或者列表,为列表时为批量移动用户分组 + :param group_id: 分组 ID + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.group.move_user('openid', 1234) + + """ + data = {'to_groupid': group_id} + if isinstance(user_id, (tuple, list)): + endpoint = 'groups/members/batchupdate' + data['openid_list'] = user_id + else: + endpoint = 'groups/members/update' + data['openid'] = user_id + return self._post(endpoint, data=data) + + def delete(self, group_id): + """ + 删除分组 + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/56d992c605a97245eb7e617854b169fc.html + + :param group_id: 分组 ID + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.group.delete(1234) + + """ + return self._post( + 'groups/delete', + data={ + 'group': { + 'id': group_id + } + } + ) diff --git a/sg_wechat_enterprise/we_api/client/api/jsapi.py b/sg_wechat_enterprise/we_api/client/api/jsapi.py new file mode 100644 index 00000000..9c444176 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/jsapi.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.client.jsapi + ~~~~~~~~~~~~~~~~~~~~ + + This module provides some APIs for JS SDK + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import time + +from wechatpy.utils import WeChatSigner +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatJSAPI(BaseWeChatAPI): + + def get_ticket(self, type='jsapi'): + """ + 获取微信 JS-SDK ticket + + :return: 返回的 JSON 数据包 + """ + return self._get( + 'ticket/getticket', + params={'type': type} + ) + + def get_jsapi_ticket(self): + """ + 获取微信 JS-SDK ticket + + 该方法会通过 session 对象自动缓存管理 ticket + + :return: ticket + """ + ticket = self.session.get('jsapi_ticket') + expires_at = self.session.get('jsapi_ticket_expires_at', 0) + if not ticket or expires_at < int(time.time()): + jsapi_ticket = self.get_ticket('jsapi') + ticket = jsapi_ticket['ticket'] + expires_at = int(time.time()) + int(jsapi_ticket['expires_in']) + self.session.set('jsapi_ticket', ticket) + self.session.set('jsapi_ticket_expires_at', expires_at) + return ticket + + def get_jsapi_signature(self, noncestr, ticket, timestamp, url): + data = [ + 'noncestr={noncestr}'.format(noncestr=noncestr), + 'jsapi_ticket={ticket}'.format(ticket=ticket), + 'timestamp={timestamp}'.format(timestamp=timestamp), + 'url={url}'.format(url=url), + ] + signer = WeChatSigner(delimiter=b'&') + signer.add_data(*data) + return signer.signature diff --git a/sg_wechat_enterprise/we_api/client/api/material.py b/sg_wechat_enterprise/we_api/client/api/material.py new file mode 100644 index 00000000..cf40fde0 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/material.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.utils import json +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMaterial(BaseWeChatAPI): + + def add_articles(self, articles): + """ + 新增永久图文素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/14/7e6c03263063f4813141c3e17dd4350a.html + + :param articles: 图文素材数组 + :return: 返回的 JSON 数据包 + """ + articles_data = [] + for article in articles: + articles_data.append({ + 'thumb_media_id': article['thumb_media_id'], + 'title': article['title'], + 'content': article['content'], + 'author': article.get('author', ''), + 'content_source_url': article.get('content_source_url', ''), + 'digest': article.get('digest', ''), + 'show_cover_pic': article.get('show_cover_pic', '0') + }) + return self._post( + 'material/add_news', + data={ + 'articles': articles_data + } + ) + + def add(self, media_type, media_file, title=None, introduction=None): + """ + 新增其它类型永久素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/14/7e6c03263063f4813141c3e17dd4350a.html + + :param media_type: 媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和缩略图(thumb) + :param media_file: 要上传的文件,一个 File-object + :param title: 视频素材标题,仅上传视频素材时需要 + :param introduction: 视频素材简介,仅上传视频素材时需要 + :return: 返回的 JSON 数据包 + """ + params = { + 'access_token': self.access_token, + 'type': media_type + } + if media_type == 'video': + assert title, 'Video title must be set' + assert introduction, 'Video introduction must be set' + description = { + 'title': title, + 'introduction': introduction + } + params['description'] = json.dumps(description) + return self._post( + 'material/add_material', + params=params, + files={ + 'media': media_file + } + ) + + def get(self, media_id): + """ + 获取永久素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/4/b3546879f07623cb30df9ca0e420a5d0.html + + :param media_id: 素材的 media_id + :return: 图文素材返回图文列表,其它类型为素材的内容 + """ + def _processor(res): + if isinstance(res, dict): + # 图文素材 + return res.get('news_item', []) + return res + + res = self._post( + 'material/get_material', + data={ + 'media_id': media_id + }, + result_processor=_processor + ) + return res + + def delete(self, media_id): + """ + 删除永久素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/5/e66f61c303db51a6c0f90f46b15af5f5.html + + :param media_id: 素材的 media_id + :return: 返回的 JSON 数据包 + """ + return self._post( + 'material/del_material', + data={ + 'media_id': media_id + } + ) + + def update_articles(self, media_id, index, articles): + """ + 修改永久图文素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/4/19a59cba020d506e767360ca1be29450.html + + :param media_id: 要修改的图文消息的 id + :param index: 要更新的文章在图文消息中的位置(多图文消息时,此字段才有意义),第一篇为 0 + :param articles: 图文素材数组 + :return: 返回的 JSON 数据包 + """ + articles_data = [] + for article in articles: + articles_data.append({ + 'thumb_media_id': article['thumb_media_id'], + 'title': article['title'], + 'content': article['content'], + 'author': article.get('author', ''), + 'content_source_url': article.get('content_source_url', ''), + 'digest': article.get('digest', ''), + 'show_cover_pic': article.get('show_cover_pic', '0') + }) + return self._post( + 'material/update_news', + data={ + 'media_id': media_id, + 'index': index, + 'articles': articles_data + } + ) + + def batchget(self, media_type, offset=0, count=20): + """ + 批量获取永久素材列表 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/2108cd7aafff7f388f41f37efa710204.html + + :param media_type: 媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和缩略图(news) + :param offset: 从全部素材的该偏移位置开始返回,0 表示从第一个素材返回 + :param count: 返回素材的数量,取值在1到20之间 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'material/batchget_material', + data={ + 'type': media_type, + 'offset': offset, + 'count': count + } + ) + + def get_count(self): + """ + 获取素材总数 + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8cc64f8c189674b421bee3ed403993b8.html + + :return: 返回的 JSON 数据包 + """ + return self._get('material/get_materialcount') diff --git a/sg_wechat_enterprise/we_api/client/api/media.py b/sg_wechat_enterprise/we_api/client/api/media.py new file mode 100644 index 00000000..e04317b2 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/media.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMedia(BaseWeChatAPI): + + def upload(self, media_type, media_file): + """ + 上传临时素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/5/963fc70b80dc75483a271298a76a8d59.html + + :param media_type: 媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和缩略图(thumb) + :param media_file: 要上传的文件,一个 File-object + + :return: 返回的 JSON 数据包 + """ + return self._post( + url='http://file.api.weixin.qq.com/cgi-bin/media/upload', + params={ + 'type': media_type + }, + files={ + 'media': media_file + } + ) + + def download(self, media_id): + """ + 获取临时素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/10/78b15308b053286e2a66b33f0f0f5fb6.html + + :param media_id: 媒体文件 ID + + :return: requests 的 Response 实例 + """ + return self._get( + 'http://file.api.weixin.qq.com/cgi-bin/media/get', + params={ + 'media_id': media_id + } + ) + + def get_url(self, media_id): + """ + 获取临时素材下载地址 + + :param media_id: 媒体文件 ID + :return: 临时素材下载地址 + """ + parts = ( + 'http://file.api.weixin.qq.com/cgi-bin/media/get', + '?access_token=', + self.access_token, + '&media_id=', + media_id + ) + return ''.join(parts) + + def upload_video(self, media_id, title, description): + """ + 群发视频消息时获取视频 media_id + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param media_id: 需通过基础支持中的上传下载多媒体文件 :func:`upload` 来得到 + :param title: 视频标题 + :param description: 视频描述 + + :return: 返回的 JSON 数据包 + """ + return self._post( + url='https://file.api.weixin.qq.com/cgi-bin/media/uploadvideo', + data={ + 'media_id': media_id, + 'title': title, + 'description': description + } + ) + + def upload_articles(self, articles): + """ + 上传图文消息素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param articles: 图文消息数组 + :return: 返回的 JSON 数据包 + """ + articles_data = [] + for article in articles: + articles_data.append({ + 'thumb_media_id': article['thumb_media_id'], + 'title': article['title'], + 'content': article['content'], + 'author': article.get('author', ''), + 'content_source_url': article.get('content_source_url', ''), + 'digest': article.get('digest', ''), + 'show_cover_pic': article.get('show_cover_pic', '0') + }) + return self._post( + 'media/uploadnews', + data={ + 'articles': articles_data + } + ) + + def upload_mass_image(self, media_file): + """ + 上传群发消息内的图片 + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param media_file: 要上传的文件,一个 File-object + :return: 上传成功时返回图片 URL + """ + res = self._post( + url='https://api.weixin.qq.com/cgi-bin/media/uploadimg', + files={ + 'media': media_file + }, + result_processor=lambda x: x['url'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/menu.py b/sg_wechat_enterprise/we_api/client/api/menu.py new file mode 100644 index 00000000..e355e8ad --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/menu.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.exceptions import WeChatClientException +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMenu(BaseWeChatAPI): + + def get(self): + """ + 查询自定义菜单。 + 详情请参考 + http://mp.weixin.qq.com/wiki/16/ff9b7b85220e1396ffa16794a9d95adc.html + + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + menu = client.menu.get() + + """ + try: + return self._get('menu/get') + except WeChatClientException as e: + if e.errcode == 46003: + # menu not exist + return None + else: + raise e + + def create(self, menu_data): + """ + 创建自定义菜单 :: + + from wechatpy import WeChatClient + + client = WeChatClient("appid", "secret") + client.menu.create({ + "button":[ + { + "type":"click", + "name":"今日歌曲", + "key":"V1001_TODAY_MUSIC" + }, + { + "type":"click", + "name":"歌手简介", + "key":"V1001_TODAY_SINGER" + }, + { + "name":"菜单", + "sub_button":[ + { + "type":"xml", + "name":"搜索", + "url":"http://www.soso.com/" + }, + { + "type":"xml", + "name":"视频", + "url":"http://v.qq.com/" + }, + { + "type":"click", + "name":"赞一下我们", + "key":"V1001_GOOD" + } + ] + } + ] + }) + + 详情请参考 + https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421141013 + + :param menu_data: Python 字典 + + :return: 返回的 JSON 数据包 + """ + return self._post( + 'menu/create', + data=menu_data + ) + + update = create + + def delete(self): + """ + 删除自定义菜单。 + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8ed41ba931e4845844ad6d1eeb8060c8.html + + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.menu.delete() + + """ + return self._get('menu/delete') + + def get_menu_info(self): + """ + 获取自定义菜单配置 + 详情请参考 + http://mp.weixin.qq.com/wiki/17/4dc4b0514fdad7a5fbbd477aa9aab5ed.html + + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + menu_info = client.menu.get_menu_info() + + """ + return self._get('get_current_selfmenu_info') + + def add_conditional(self, menu_data): + """ + 创建个性化菜单 :: + + from wechatpy import WeChatClient + + client = WeChatClient("appid", "secret") + client.menu.add_conditional({ + "button":[ + { + "type":"click", + "name":"今日歌曲", + "key":"V1001_TODAY_MUSIC" + }, + { + "type":"click", + "name":"歌手简介", + "key":"V1001_TODAY_SINGER" + }, + { + "name":"菜单", + "sub_button":[ + { + "type":"xml", + "name":"搜索", + "url":"http://www.soso.com/" + }, + { + "type":"xml", + "name":"视频", + "url":"http://v.qq.com/" + }, + { + "type":"click", + "name":"赞一下我们", + "key":"V1001_GOOD" + } + ] + } + ], + "matchrule":{ + "group_id":"2", + "sex":"1", + "country":"中国", + "province":"广东", + "city":"广州", + "client_platform_type":"2" + } + }) + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/c48ccd12b69ae023159b4bfaa7c39c20.html + + :param menu_data: Python 字典 + + :return: 返回的 JSON 数据包 + """ + return self._post( + 'menu/addconditional', + data=menu_data + ) + + def del_conditional(self, menu_id): + """ + 删除个性化菜单 + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/c48ccd12b69ae023159b4bfaa7c39c20.html + + :param menu_id: 菜单ID + + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.menu.del_conditional('menu_id') + + """ + return self._post( + 'menu/delconditional', + data={'menuid': menu_id} + ) + + def try_match(self, user_id): + """ + 测试个性化菜单匹配结果 + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/c48ccd12b69ae023159b4bfaa7c39c20.html + + :param user_id: 可以是粉丝的OpenID,也可以是粉丝的微信号。 + + :return: 该接口将返回菜单配置 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.menu.try_match('openid') + + """ + return self._post( + 'menu/trymatch', + data={'user_id': user_id} + ) diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/__init__.py b/sg_wechat_enterprise/we_api/client/api/merchant/__init__.py new file mode 100644 index 00000000..93899db7 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/__init__.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + +from wechatpy.client.api.merchant.category import MerchantCategory +from wechatpy.client.api.merchant.stock import MerchantStock +from wechatpy.client.api.merchant.express import MerchantExpress +from wechatpy.client.api.merchant.group import MerchantGroup +from wechatpy.client.api.merchant.shelf import MerchantShelf +from wechatpy.client.api.merchant.order import MerchantOrder +from wechatpy.client.api.merchant.common import MerchantCommon + + +class WeChatMerchant(BaseWeChatAPI): + + def __init__(self, *args, **kwargs): + super(WeChatMerchant, self).__init__(*args, **kwargs) + + # sub APIs + self.category = MerchantCategory(self._client) + self.stock = MerchantStock(self._client) + self.express = MerchantExpress(self._client) + self.group = MerchantGroup(self._client) + self.shelf = MerchantShelf(self._client) + self.order = MerchantOrder(self._client) + self.common = MerchantCommon(self._client) + + def create(self, product_data): + return self._post( + 'merchant/create', + data=product_data + ) + + def delete(self, product_id): + return self._post( + 'merchant/del', + data={ + 'product_id': product_id + } + ) + + def update(self, product_id, product_data): + product_data['product_id'] = product_id + return self._post( + 'merchant/update', + data=product_data + ) + + def get(self, product_id): + return self._post( + 'merchant/get', + data={ + 'product_id': product_id + } + ) + + def get_by_status(self, status): + return self._post( + 'merchant/getbystatus', + data={ + 'status': status + } + ) + + def update_product_status(self, product_id, status): + return self._post( + 'merchant/modproductstatus', + data={ + 'product_id': product_id, + 'status': status + } + ) diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/category.py b/sg_wechat_enterprise/we_api/client/api/merchant/category.py new file mode 100644 index 00000000..b26377ed --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/category.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class MerchantCategory(BaseWeChatAPI): + + def get_sub_categories(self, cate_id): + res = self._post( + 'merchant/category/getsub', + data={'cate_id': cate_id}, + result_processor=lambda x: x['cate_list'] + ) + return res + + def get_sku_list(self, cate_id): + res = self._post( + 'merchant/category/getsku', + data={'cate_id': cate_id}, + result_processor=lambda x: x['sku_table'] + ) + return res + + def get_properties(self, cate_id): + res = self._post( + 'merchant/category/getproperty', + data={'cate_id': cate_id}, + result_processor=lambda x: x['properties'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/common.py b/sg_wechat_enterprise/we_api/client/api/merchant/common.py new file mode 100644 index 00000000..9730a311 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/common.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class MerchantCommon(BaseWeChatAPI): + + def upload_image(self, filename, image_data): + res = self._post( + 'merchant/common/upload_img', + params={ + 'filename': filename + }, + data=image_data, + result_processor=lambda x: x['image_url'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/express.py b/sg_wechat_enterprise/we_api/client/api/merchant/express.py new file mode 100644 index 00000000..27fe2b22 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/express.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class MerchantExpress(BaseWeChatAPI): + + def add(self, delivery_template): + return self._post( + 'merchant/express/add', + data={ + 'delivery_template': delivery_template + } + ) + + def delete(self, template_id): + return self._post( + 'merchant/express/del', + data={ + 'template_id': template_id + } + ) + + def update(self, template_id, delivery_template): + return self._post( + 'merchant/express/update', + data={ + 'template_id': template_id, + 'delivery_template': delivery_template + } + ) + + def get(self, template_id): + res = self._post( + 'merchant/express/getbyid', + data={ + 'template_id': template_id + }, + result_processor=lambda x: x['template_info'] + ) + return res + + def get_all(self): + res = self._get( + 'merchant/express/getall', + result_processor=lambda x: x['template_info'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/group.py b/sg_wechat_enterprise/we_api/client/api/merchant/group.py new file mode 100644 index 00000000..593b3751 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/group.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class MerchantGroup(BaseWeChatAPI): + + def add(self, name, product_list): + return self._post( + 'merchant/group/add', + data={ + 'group_detail': { + 'group_name': name, + 'product_list': product_list + } + } + ) + + def delete(self, group_id): + return self._post( + 'merchant/group/del', + data={ + 'group_id': group_id + } + ) + + def update(self, group_id, name): + return self._post( + 'merchant/group/propertymod', + data={ + 'group_id': group_id, + 'group_name': name + } + ) + + def update_product(self, group_id, product): + return self._post( + 'merchant/group/productmod', + data={ + 'group_id': group_id, + 'product': product + } + ) + + def get_all(self): + res = self._get( + 'merchant/group/getall', + result_processor=lambda x: x['group_detail'] + ) + return res + + def get(self, group_id): + res = self._post( + 'merchant/group/getbyid', + data={ + 'group_id': group_id + }, + result_processor=lambda x: x['group_detail'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/order.py b/sg_wechat_enterprise/we_api/client/api/merchant/order.py new file mode 100644 index 00000000..09d4b503 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/order.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict +from wechatpy.client.api.base import BaseWeChatAPI + + +class MerchantOrder(BaseWeChatAPI): + + def get(self, order_id): + res = self._post( + 'merchant/order/getbyid', + data={ + 'order_id': order_id + }, + result_processor=lambda x: x['order'] + ) + return res + + def get_by_filter(self, status=None, begin_time=None, end_time=None): + filter_dict = optionaldict( + status=status, + begintime=begin_time, + endtime=end_time + ) + + res = self._post( + 'merchant/order/getbyfilter', + data=dict(filter_dict), + result_processor=lambda x: x['order_list'] + ) + return res + + def set_delivery(self, order_id, company, track_no, + need_delivery=1, is_others=0): + return self._post( + 'merchant/order/setdelivery', + data={ + 'order_id': order_id, + 'delivery_company': company, + 'delivery_track_no': track_no, + 'need_delivery': need_delivery, + 'is_others': is_others + } + ) + + def close(self, order_id): + return self._post( + 'merchant/order/close', + data={ + 'order_id': order_id + } + ) diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/shelf.py b/sg_wechat_enterprise/we_api/client/api/merchant/shelf.py new file mode 100644 index 00000000..79354951 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/shelf.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class MerchantShelf(BaseWeChatAPI): + + def add(self, name, banner, shelf_data): + return self._post( + 'merchant/shelf/add', + data={ + 'shelf_name': name, + 'shelf_banner': banner, + 'shelf_data': shelf_data + } + ) + + def delete(self, shelf_id): + return self._post( + 'merchant/shelf/del', + data={ + 'shelf_id': shelf_id + } + ) + + def update(self, shelf_id, name, banner, shelf_data): + return self._post( + 'merchant/shelf/add', + data={ + 'shelf_id': shelf_id, + 'shelf_name': name, + 'shelf_banner': banner, + 'shelf_data': shelf_data + } + ) + + def get_all(self): + res = self._get( + 'merchant/shelf/getall', + result_processor=lambda x: x['shelves'] + ) + return res + + def get(self, shelf_id): + return self._post( + 'merchant/shelf/getbyid', + data={ + 'shelf_id': shelf_id + } + ) diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/stock.py b/sg_wechat_enterprise/we_api/client/api/merchant/stock.py new file mode 100644 index 00000000..bfe09de6 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/stock.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class MerchantStock(BaseWeChatAPI): + + def add(self, product_id, quantity, sku_info=''): + return self._post( + 'merchant/stock/add', + data={ + 'product_id': product_id, + 'quantity': quantity, + 'sku_info': sku_info + } + ) + + def reduce(self, product_id, quantity, sku_info=''): + return self._post( + 'merchant/stock/reduce', + data={ + 'product_id': product_id, + 'quantity': quantity, + 'sku_info': sku_info + } + ) diff --git a/sg_wechat_enterprise/we_api/client/api/message.py b/sg_wechat_enterprise/we_api/client/api/message.py new file mode 100644 index 00000000..e1fb3e44 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/message.py @@ -0,0 +1,572 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import re +import six + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMessage(BaseWeChatAPI): + OPENID_RE = re.compile(r'^[\w\-]{28}$', re.I) + + def _send_custom_message(self, data, account=None): + data = data or {} + if account: + data['customservice'] = {'kf_account': account} + return self._post( + 'message/custom/send', + data=data + ) + + def send_text(self, user_id, content, account=None): + """ + 发送文本消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/12a5a320ae96fecdf0e15cb06123de9f.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param content: 消息正文 + :param account: 可选,客服账号 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.message.send_text('openid', 'text') + + """ + data = { + 'touser': user_id, + 'msgtype': 'text', + 'text': {'content': content} + } + return self._send_custom_message(data, account=account) + + def send_image(self, user_id, media_id, account=None): + """ + 发送图片消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/12a5a320ae96fecdf0e15cb06123de9f.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param media_id: 图片的媒体ID。 可以通过 :func:`upload_media` 上传。 + :param account: 可选,客服账号 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.message.send_image('openid', 'media_id') + + """ + data = { + 'touser': user_id, + 'msgtype': 'image', + 'image': { + 'media_id': media_id + } + } + return self._send_custom_message(data, account=account) + + def send_voice(self, user_id, media_id, account=None): + """ + 发送语音消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/12a5a320ae96fecdf0e15cb06123de9f.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param media_id: 发送的语音的媒体ID。 可以通过 :func:`upload_media` 上传。 + :param account: 可选,客服账号 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.message.send_voice('openid', 'media_id') + + """ + data = { + 'touser': user_id, + 'msgtype': 'voice', + 'voice': { + 'media_id': media_id + } + } + return self._send_custom_message(data, account=account) + + def send_video(self, user_id, media_id, title=None, + description=None, account=None): + """ + 发送视频消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/12a5a320ae96fecdf0e15cb06123de9f.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param media_id: 发送的视频的媒体ID。 可以通过 :func:`upload_media` 上传。 + :param title: 视频消息的标题 + :param description: 视频消息的描述 + :param account: 可选,客服账号 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.message.send_video('openid', 'media_id', 'title', 'description') + """ + video_data = { + 'media_id': media_id, + } + if title: + video_data['title'] = title + if description: + video_data['description'] = description + + data = { + 'touser': user_id, + 'msgtype': 'video', + 'video': video_data + } + return self._send_custom_message(data, account=account) + + def send_music(self, user_id, url, hq_url, thumb_media_id, + title=None, description=None, account=None): + """ + 发送音乐消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/12a5a320ae96fecdf0e15cb06123de9f.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param url: 音乐链接 + :param hq_url: 高品质音乐链接,wifi环境优先使用该链接播放音乐 + :param thumb_media_id: 缩略图的媒体ID。 可以通过 :func:`upload_media` 上传。 + :param title: 音乐标题 + :param description: 音乐描述 + :param account: 可选,客服账号 + :return: 返回的 JSON 数据包 + """ + music_data = { + 'musicurl': url, + 'hqmusicurl': hq_url, + 'thumb_media_id': thumb_media_id + } + if title: + music_data['title'] = title + if description: + music_data['description'] = description + + data = { + 'touser': user_id, + 'msgtype': 'music', + 'music': music_data + } + return self._send_custom_message(data, account=account) + + def send_articles(self, user_id, articles, account=None): + """ + 发送图文消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/12a5a320ae96fecdf0e15cb06123de9f.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param articles: 一个包含至多10个图文的数组, 或者微信图文消息素材 media_id + :param account: 可选,客服账号 + :return: 返回的 JSON 数据包 + """ + if isinstance(articles, (tuple, list)): + articles_data = [] + for article in articles: + articles_data.append({ + 'title': article['title'], + 'description': article['description'], + 'url': article['url'], + 'picurl': article.get('image', article.get('picurl')), + }) + data = { + 'touser': user_id, + 'msgtype': 'news', + 'news': { + 'articles': articles_data + } + } + else: + data = { + 'touser': user_id, + 'msgtype': 'mpnews', + 'mpnews': { + 'media_id': articles, + } + } + return self._send_custom_message(data, account=account) + + def send_card(self, user_id, card_id, card_ext, account=None): + """ + 发送卡券消息 + + 详情请参参考 + http://mp.weixin.qq.com/wiki/1/70a29afed17f56d537c833f89be979c9.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param card_id: 卡券 ID + :param card_ext: 卡券扩展信息 + :param account: 可选,客服账号 + :return: 返回的 JSON 数据包 + """ + data = { + 'touser': user_id, + 'msgtype': 'wxcard', + 'wxcard': { + 'card_id': card_id, + 'card_ext': card_ext + } + } + return self._send_custom_message(data, account=account) + + def delete_mass(self, msg_id): + """ + 删除群发消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param msg_id: 要删除的群发消息 ID + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.message.delete_mass('message id') + + """ + return self._post( + 'message/mass/delete', + data={ + 'msg_id': msg_id + } + ) + + def _send_mass_message(self, group_or_users, msg_type, msg, + is_to_all=False, preview=False): + data = { + 'msgtype': msg_type + } + if not preview: + if isinstance(group_or_users, (tuple, list)): + # send by user ids + data['touser'] = group_or_users + endpoint = 'message/mass/send' + else: + # send by group id + data['filter'] = { + 'group_id': group_or_users, + 'is_to_all': is_to_all, + } + endpoint = 'message/mass/sendall' + else: + if not isinstance(group_or_users, six.string_types): + raise ValueError('group_or_users should be string types') + # 预览接口 + if self.OPENID_RE.match(group_or_users): + # 按照 openid 预览群发 + data['touser'] = group_or_users + else: + # 按照微信号预览群发 + data['towxname'] = group_or_users + endpoint = 'message/mass/preview' + + data.update(msg) + return self._post( + endpoint, + data=data + ) + + def send_mass_text(self, group_or_users, content, + is_to_all=False, preview=False): + """ + 群发文本消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param group_or_users: 值为整型数字时为按分组群发,值为列表/元组时为按 OpenID 列表群发 + :param content: 消息正文 + :param is_to_all: 用于设定是否向全部用户发送,值为true或false,选择true该消息群发给所有用户 + 选择false可根据group_id发送给指定群组的用户 + :param preview: 是否发送预览,此时 group_or_users 参数应为一个openid字符串 + + :return: 返回的 JSON 数据包 + """ + return self._send_mass_message( + group_or_users, + 'text', + { + 'text': { + 'content': content + } + }, + is_to_all, + preview + ) + + def send_mass_image(self, group_or_users, media_id, + is_to_all=False, preview=False): + """ + 群发图片消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param group_or_users: 值为整型数字时为按分组群发,值为列表/元组时为按 OpenID 列表群发 + :param media_id: 图片的媒体 ID。 可以通过 :func:`upload_media` 上传。 + :param is_to_all: 用于设定是否向全部用户发送,值为true或false,选择true该消息群发给所有用户 + 选择false可根据group_id发送给指定群组的用户 + :param preview: 是否发送预览,此时 group_or_users 参数应为一个openid字符串 + + :return: 返回的 JSON 数据包 + """ + return self._send_mass_message( + group_or_users, + 'image', + { + 'image': { + 'media_id': media_id + } + }, + is_to_all, + preview + ) + + def send_mass_voice(self, group_or_users, media_id, + is_to_all=False, preview=False): + """ + 群发语音消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param group_or_users: 值为整型数字时为按分组群发,值为列表/元组时为按 OpenID 列表群发 + :param media_id: 语音的媒体 ID。可以通过 :func:`upload_media` 上传。 + :param is_to_all: 用于设定是否向全部用户发送,值为true或false,选择true该消息群发给所有用户 + 选择false可根据group_id发送给指定群组的用户 + :param preview: 是否发送预览,此时 group_or_users 参数应为一个openid字符串 + + :return: 返回的 JSON 数据包 + """ + return self._send_mass_message( + group_or_users, + 'voice', + { + 'voice': { + 'media_id': media_id + } + }, + is_to_all, + preview + ) + + def send_mass_video(self, group_or_users, media_id, title=None, + description=None, is_to_all=False, preview=False): + """ + 群发视频消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param group_or_users: 值为整型数字时为按分组群发,值为列表/元组时为按 OpenID 列表群发 + :param media_id: 视频的媒体 ID。可以通过 :func:`upload_video` 上传。 + :param title: 视频标题 + :param description: 视频描述 + :param is_to_all: 用于设定是否向全部用户发送,值为true或false,选择true该消息群发给所有用户 + 选择false可根据group_id发送给指定群组的用户 + :param preview: 是否发送预览,此时 group_or_users 参数应为一个openid字符串 + + :return: 返回的 JSON 数据包 + """ + video_data = { + 'media_id': media_id + } + if title: + video_data['title'] = title + if description: + video_data['description'] = description + return self._send_mass_message( + group_or_users, + 'mpvideo', + { + 'mpvideo': video_data + }, + is_to_all, + preview + ) + + def send_mass_article(self, group_or_users, media_id, + is_to_all=False, preview=False): + """ + 群发图文消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param group_or_users: 值为整型数字时为按分组群发,值为列表/元组时为按 OpenID 列表群发 + :param media_id: 图文的媒体 ID。可以通过 :func:`upload_articles` 上传。 + :param is_to_all: 用于设定是否向全部用户发送,值为true或false,选择true该消息群发给所有用户 + 选择false可根据group_id发送给指定群组的用户 + :param preview: 是否发送预览,此时 group_or_users 参数应为一个openid字符串 + + :return: 返回的 JSON 数据包 + """ + return self._send_mass_message( + group_or_users, + 'mpnews', + { + 'mpnews': { + 'media_id': media_id + } + }, + is_to_all, + preview + ) + + def get_mass(self, msg_id): + """ + 查询群发消息发送状态 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param msg_id: 群发消息后返回的消息id + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.message.get_mass('mass message id') + + """ + return self._post( + 'message/mass/get', + data={ + 'msg_id': msg_id + } + ) + + def send_template(self, user_id, template_id, url, top_color, data): + """ + 发送模板消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/17/304c1885ea66dbedf7dc170d84999a9d.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param template_id: 模板 ID。在公众平台线上模板库中选用模板获得 + :param url: 链接地址 + :param top_color: 消息顶部颜色 + :param data: 模板消息数据 + + :return: 返回的 JSON 数据包 + """ + return self._post( + 'message/template/send', + data={ + 'touser': user_id, + 'template_id': template_id, + 'url': url, + 'topcolor': top_color, + 'data': data + } + ) + + def send_template_applet(self, user_id, template_id, appid, pagepath, data): + """ + 发送模板消息,点击跳转到小程序 + + 详情请参考 + https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1433751277 + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param template_id: 模板 ID。在公众平台线上模板库中选用模板获得 + :param appid: 所需跳转到的小程序appid(该小程序appid必须与发模板消息的公众号是绑定关联关系,暂不支持小游戏) + :param pagepath: 所需跳转到小程序的具体页面路径,支持带参数,(示例index?foo=bar),要求该小程序已发布,暂不支持小游戏 不必填 + :param data: 模板消息数据 + + :return: 返回的 JSON 数据包 + """ + data_all = { + 'touser': user_id, + 'template_id': template_id, + "miniprogram": { + "appid": appid, + # "pagepath": pagepath + }, + 'data': data + } + if pagepath: + data_all['miniprogram']['pagepath'] = pagepath + return self._post( + 'message/template/send', + data=data_all + ) + + def get_autoreply_info(self): + """ + 获取自动回复规则 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/7b5789bb1262fb866d01b4b40b0efecb.html + + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + info = client.message.get_autoreply_info() + + """ + return self._get('get_current_autoreply_info') + + def send_mass_card(self, group_or_users, card_id, + is_to_all=False, preview=False): + """ + 群发卡券消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param group_or_users: 值为整型数字时为按分组群发,值为列表/元组时为按 OpenID 列表群发 + :param card_id: 卡券 ID + :param is_to_all: 用于设定是否向全部用户发送,值为true或false,选择true该消息群发给所有用户 + 选择false可根据group_id发送给指定群组的用户 + :param preview: 是否发送预览,此时 group_or_users 参数应为一个openid字符串 + + :return: 返回的 JSON 数据包 + """ + return self._send_mass_message( + group_or_users, + 'wxcard', + { + 'wxcard': { + 'card_id': card_id + } + }, + is_to_all, + preview + ) diff --git a/sg_wechat_enterprise/we_api/client/api/misc.py b/sg_wechat_enterprise/we_api/client/api/misc.py new file mode 100644 index 00000000..75623e10 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/misc.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMisc(BaseWeChatAPI): + + def short_url(self, long_url): + """ + 将一条长链接转成短链接 + 详情请参考 + http://mp.weixin.qq.com/wiki/10/165c9b15eddcfbd8699ac12b0bd89ae6.html + + :param long_url: 长链接地址 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.misc.short_url('http://www.qq.com') + + """ + return self._post( + 'shorturl', + data={ + 'action': 'long2short', + 'long_url': long_url + } + ) + + def get_wechat_ips(self): + """ + 获取微信服务器 IP 地址列表 + + :return: IP 地址列表 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + ips = client.misc.get_wechat_ips() + + """ + res = self._get( + 'getcallbackip', + result_processor=lambda x: x['ip_list'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/poi.py b/sg_wechat_enterprise/we_api/client/api/poi.py new file mode 100644 index 00000000..a25ffc02 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/poi.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatPoi(BaseWeChatAPI): + + def add_picture(self, access_token, buffer): + """ + 上传图片接口 + :param access_token: 接口凭证 + :param buffer: buffer + :return: + 详情请参考: https://mp.weixin.qq.com/wiki/16/8f182af4d8dcea02c56506306bdb2f4c.html + """ + + def add(self, poi_data): + """ + 创建门店 + + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8f182af4d8dcea02c56506306bdb2f4c.html + + :param poi_data: 门店信息字典 + :return: 返回的 JSON 数据包 + """ + return self._post('poi/addpoi', data=poi_data) + + def get(self, poi_id): + """ + 查询门店信息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8f182af4d8dcea02c56506306bdb2f4c.html + + :param poi_id: 门店 ID + :return: 返回的 JSON 数据包 + """ + return self._post('poi/getpoi', data={'poi_id': poi_id}) + + def list(self, begin=0, limit=20): + """ + 查询门店列表 + + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8f182af4d8dcea02c56506306bdb2f4c.html + + :param begin: 开始位置,0 即为从第一条开始查询 + :param limit: 返回数据条数,最大允许50,默认为20 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'poi/getpoilist', + data={ + 'begin': begin, + 'limit': limit, + } + ) + + def update(self, poi_data): + """ + 修改门店 + + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8f182af4d8dcea02c56506306bdb2f4c.html + + :param poi_data: 门店信息字典 + :return: 返回的 JSON 数据包 + """ + return self._post('poi/updatepoi', data=poi_data) + + def delete(self, poi_id): + """ + 删除门店 + + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8f182af4d8dcea02c56506306bdb2f4c.html + + :param poi_id: 门店 ID + :return: 返回的 JSON 数据包 + """ + return self._post('poi/delpoi', data={'poi_id': poi_id}) + + def get_categories(self): + """ + 获取微信门店类目表 + + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8f182af4d8dcea02c56506306bdb2f4c.html + + :return: 门店类目表 + """ + res = self._get( + 'api_getwxcategory', + result_processor=lambda x: x['category_list'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/qrcode.py b/sg_wechat_enterprise/we_api/client/api/qrcode.py new file mode 100644 index 00000000..3edb7894 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/qrcode.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import requests +import six + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatQRCode(BaseWeChatAPI): + + def create(self, qrcode_data): + """ + 创建二维码 + 详情请参考 + http://mp.weixin.qq.com/wiki/18/28fc21e7ed87bec960651f0ce873ef8a.html + + :param data: 你要发送的参数 dict + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.qrcode.create({ + 'expire_seconds': 1800, + 'action_name': 'QR_SCENE', + 'action_info': { + 'scene': {'scene_id': 123}, + } + }) + + """ + return self._post( + 'qrcode/create', + data=qrcode_data + ) + + def show(self, ticket): + """ + 通过ticket换取二维码 + 详情请参考 + http://mp.weixin.qq.com/wiki/18/28fc21e7ed87bec960651f0ce873ef8a.html + + :param ticket: 二维码 ticket 。可以通过 :func:`create` 获取到 + :return: 返回的 Request 对象 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.qrcode.show('ticket data') + + """ + if isinstance(ticket, dict): + ticket = ticket['ticket'] + return requests.get( + url='https://mp.weixin.qq.com/cgi-bin/showqrcode', + params={ + 'ticket': ticket + } + ) + + @classmethod + def get_url(cls, ticket): + """ + 通过ticket换取二维码地址 + 详情请参考 + http://mp.weixin.qq.com/wiki/18/28fc21e7ed87bec960651f0ce873ef8a.html + + :param ticket: 二维码 ticket 。可以通过 :func:`create` 获取到 + :return: 返回的二维码地址 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + url = client.qrcode.get_url('ticket data') + + """ + url = 'https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={ticket}' + if isinstance(ticket, dict): + ticket = ticket['ticket'] + ticket = six.moves.urllib.parse.quote(ticket) + return url.format(ticket=ticket) diff --git a/sg_wechat_enterprise/we_api/client/api/scan.py b/sg_wechat_enterprise/we_api/client/api/scan.py new file mode 100644 index 00000000..6bbf80f6 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/scan.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatScan(BaseWeChatAPI): + API_BASE_URL = 'https://api.weixin.qq.com/scan/' + + def get_merchant_info(self): + """ + 获取商户信息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/6/c61604ff6890d386d6227945ad4a68d2.html + + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + info = client.scan.get_merchant_info() + """ + return self._get('merchantinfo/get') + + def create_product(self, product_data): + """ + 创建商品 + + 详情请参考 + http://mp.weixin.qq.com/wiki/6/c61604ff6890d386d6227945ad4a68d2.html + + :return: 返回的 JSON 数据包 + """ + return self._post('product/create', data=product_data) + + def modify_product_status(self, standard, key, status): + """ + 提交审核/取消发布商品 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/1007691d0f1c10a0588c6517f12ed70f.html + + :param standard: 商品编码标准 + :param key: 商品编码内容 + :param status: 设置发布状态。on 为提交审核,off 为取消发布 + :return: 返回的 JSON 数据包 + """ + data = { + 'keystandard': standard, + 'keystr': key, + 'status': status, + } + return self._post('product/modstatus', data=data) + + def publish_product(self, standard, key): + """ + 提交审核商品 shortcut 接口 + + 等同于调用 ``modify_product_status(standard, key, 'on')`` + """ + return self.modify_product_status(standard, key, 'on') + + def unpublish_product(self, standard, key): + """ + 取消发布商品 shortcut 接口 + + 等同于调用 ``modify_product_status(standard, key, 'off')`` + """ + return self.modify_product_status(standard, key, 'off') + + def set_test_whitelist(self, userids=None, usernames=None): + """ + 设置测试人员白名单 + + 注意:每次设置均被视为一次重置,而非增量设置。openid、微信号合计最多设置10个。 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/1007691d0f1c10a0588c6517f12ed70f.html + + :param userids: 可选,测试人员的 openid 列表 + :param usernames: 可选,测试人员的微信号列表 + :return: 返回的 JSON 数据包 + """ + data = optionaldict( + openid=userids, + username=usernames + ) + return self._post('testwhitelist/set', data=data) + + def get_product(self, standard, key): + """ + 查询商品信息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/7fa787701295b884410b5163e13313af.html + + :param standard: 商品编码标准 + :param key: 商品编码内容 + :return: 返回的 JSON 数据包 + """ + data = { + 'keystandard': standard, + 'keystr': key, + } + return self._post('product/get', data=data) + + def list_product(self, offset=0, limit=10, status=None, key=None): + """ + 批量查询商品信息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/7fa787701295b884410b5163e13313af.html + + :param offset: 可选,批量查询的起始位置,从 0 开始,包含该起始位置 + :param limit: 可选,批量查询的数量,默认为 10 + :param status: 可选,支持按状态拉取。on为发布状态,off为未发布状态, + check为审核中状态,reject为审核未通过状态,all为所有状态 + :param key: 支持按部分编码内容拉取。填写该参数后,可将编码内容中包含所传参数的商品信息拉出 + :return: 返回的 JSON 数据包 + """ + data = optionaldict( + offset=offset, + limit=limit, + status=status, + keystr=key, + ) + return self._post('product/getlist', data=data) + + def update_product(self, product_data): + """ + 更新商品信息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/7fa787701295b884410b5163e13313af.html + + :return: 返回的 JSON 数据包 + """ + return self._post('product/update', data=product_data) + + def clear_product(self, standard, key): + """ + 清除商品信息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/7fa787701295b884410b5163e13313af.html + + :param standard: 商品编码标准 + :param key: 商品编码内容 + :return: 返回的 JSON 数据包 + """ + data = { + 'keystandard': standard, + 'keystr': key, + } + return self._post('product/clear', data=data) + + def check_ticket(self, ticket): + """ + 检查 wxticket 参数有效性 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/7fa787701295b884410b5163e13313af.html + + :param ticket: 请求 URL 中带上的 wxticket 参数 + :return: 返回的 JSON 数据包 + """ + return self._post('scanticket/check', data={'ticket': ticket}) diff --git a/sg_wechat_enterprise/we_api/client/api/semantic.py b/sg_wechat_enterprise/we_api/client/api/semantic.py new file mode 100644 index 00000000..6dc61252 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/semantic.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatSemantic(BaseWeChatAPI): + + def search(self, + query, + category, + uid=None, + latitude=None, + longitude=None, + city=None, + region=None): + """ + 发送语义理解请求 + 详情请参考 + http://mp.weixin.qq.com/wiki/0/0ce78b3c9524811fee34aba3e33f3448.html + + :param query: 输入文本串 + :param category: 需要使用的服务类型,多个可传入列表 + :param uid: 可选,用户唯一id(非开发者id),用户区分公众号下的不同用户(建议填入用户openid) + :param latitude: 可选,纬度坐标,与经度同时传入;与城市二选一传入 + :param longitude: 可选,经度坐标,与纬度同时传入;与城市二选一传入 + :param city: 可选,城市名称,与经纬度二选一传入 + :param region: 可选,区域名称,在城市存在的情况下可省;与经纬度二选一传入 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.semantic.search( + '查一下明天从北京到上海的南航机票', + 'flight,hotel', + city='北京' + ) + + """ + if isinstance(category, (tuple, list)): + category = ','.join(category) + + data = optionaldict() + data['query'] = query + data['category'] = category + data['uid'] = uid + data['latitude'] = latitude + data['longitude'] = longitude + data['city'] = city + data['region'] = region + data['appid'] = self._client.appid + return self._post( + url='https://api.weixin.qq.com/semantic/semproxy/search', + data=data + ) diff --git a/sg_wechat_enterprise/we_api/client/api/shakearound.py b/sg_wechat_enterprise/we_api/client/api/shakearound.py new file mode 100644 index 00000000..6f63dd9a --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/shakearound.py @@ -0,0 +1,376 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import time +from datetime import datetime + +import six +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatShakeAround(BaseWeChatAPI): + + API_BASE_URL = 'https://api.weixin.qq.com/' + + @classmethod + def _to_timestamp(cls, date): + if isinstance(date, six.string_types): + date = datetime.strptime(date, '%Y-%m-%d %H:%M:%S') + if isinstance(date, datetime): + timestamp = int(time.mktime(date.timetuple())) + return timestamp + return int(date) + + def apply_device_id(self, quantity, reason, poi_id=None, comment=None): + """ + 申请设备ID + 详情请参考 + http://mp.weixin.qq.com/wiki/15/b9e012f917e3484b7ed02771156411f3.html + + :param quantity: 申请的设备ID的数量,单次新增设备超过500个,需走人工审核流程 + :param reason: 申请理由,不超过100个字 + :param poi_id: 可选,设备关联的门店ID + :param comment: 可选,备注,不超过15个汉字或30个英文字母 + :return: 申请的设备信息 + """ + data = optionaldict() + data['quantity'] = quantity + data['apply_reason'] = reason + data['poi_id'] = poi_id + data['comment'] = comment + res = self._post( + 'shakearound/device/applyid', + data=data, + result_processor=lambda x: x['data'] + ) + return res + + def update_device(self, device_id=None, uuid=None, major=None, + minor=None, comment=None): + """ + 更新设备信息 + 详情请参考 + http://mp.weixin.qq.com/wiki/15/b9e012f917e3484b7ed02771156411f3.html + + :param device_id: 设备编号,若填了UUID、major、minor,则可不填设备编号,若二者都填,则以设备编号为优先 + :param uuid: UUID + :param major: major + :param minor: minor + :param comment: 设备的备注信息,不超过15个汉字或30个英文字母。 + :return: 返回的 JSON 数据包 + """ + data = optionaldict() + data['comment'] = comment + data['device_identifier'] = { + 'device_id': device_id, + 'uuid': uuid, + 'major': major, + 'minor': minor + } + return self._post( + 'shakearound/device/update', + data=data + ) + + def bind_device_location(self, poi_id, device_id=None, uuid=None, + major=None, minor=None): + """ + 配置设备与门店的关联关系 + 详情请参考 + http://mp.weixin.qq.com/wiki/15/b9e012f917e3484b7ed02771156411f3.html + + :param poi_id: 待关联的门店ID + :param device_id: 设备编号,若填了UUID、major、minor,则可不填设备编号,若二者都填,则以设备编号为优先 + :param uuid: UUID + :param major: major + :param minor: minor + :return: 返回的 JSON 数据包 + """ + data = optionaldict() + data['poi_id'] = poi_id + data['device_identifier'] = { + 'device_id': device_id, + 'uuid': uuid, + 'major': major, + 'minor': minor + } + return self._post( + 'shakearound/device/bindlocation', + data=data + ) + + def search_device(self, identifiers=None, apply_id=None, + begin=0, count=10): + """ + 查询设备列表 + 详情请参考 + http://mp.weixin.qq.com/wiki/15/b9e012f917e3484b7ed02771156411f3.html + + :param identifiers: 设备 ID 信息列表 + :param apply_id: 批次ID,申请设备ID超出500个时所返回批次ID + :param begin: 设备列表的起始索引值 + :param count: 待查询的设备个数 + :return: 设备列表 + """ + data = optionaldict() + data['begin'] = begin + data['count'] = count + data['apply_id'] = apply_id + if identifiers: + data['device_identifiers'] = identifiers + res = self._post( + 'shakearound/device/search', + data=data, + result_processor=lambda x: x['data'] + ) + return res + + def add_page(self, title, description, icon_url, page_url, comment=None): + """ + 新增页面 + 详情请参考 + http://mp.weixin.qq.com/wiki/5/6626199ea8757c752046d8e46cf13251.html + + :param title: 在摇一摇页面展示的主标题,不超过6个字 + :param description: 在摇一摇页面展示的副标题,不超过7个字 + :param icon_url: 在摇一摇页面展示的图片。图片需先上传至微信侧服务器, + 用“素材管理-上传图片素材”接口上传图片,返回的图片URL再配置在此处 + :param page_url: 跳转链接 + :param comment: 可选,页面的备注信息,不超过15个字 + :return: 页面信息 + """ + data = optionaldict() + data['title'] = title + data['description'] = description + data['icon_url'] = icon_url + data['page_url'] = page_url + data['comment'] = comment + res = self._post( + 'shakearound/page/add', + data=data, + result_processor=lambda x: x['data'] + ) + return res + + def update_page(self, page_id, title, description, + icon_url, page_url, comment=None): + """ + 编辑页面信息 + 详情请参考 + http://mp.weixin.qq.com/wiki/5/6626199ea8757c752046d8e46cf13251.html + + :param page_id: 摇周边页面唯一ID + :param title: 在摇一摇页面展示的主标题,不超过6个字 + :param description: 在摇一摇页面展示的副标题,不超过7个字 + :param icon_url: 在摇一摇页面展示的图片。图片需先上传至微信侧服务器, + 用“素材管理-上传图片素材”接口上传图片,返回的图片URL再配置在此处 + :param page_url: 跳转链接 + :param comment: 可选,页面的备注信息,不超过15个字 + :return: 页面信息 + """ + data = optionaldict() + data['page_id'] = page_id + data['title'] = title + data['description'] = description + data['icon_url'] = icon_url + data['page_url'] = page_url + data['comment'] = comment + res = self._post( + 'shakearound/page/update', + data=data, + result_processor=lambda x: x['data'] + ) + return res + + def search_pages(self, page_ids=None, begin=0, count=10): + """ + 查询页面列表 + 详情请参考 + http://mp.weixin.qq.com/wiki/5/6626199ea8757c752046d8e46cf13251.html + + :param page_ids: 指定页面的id列表 + :param begin: 页面列表的起始索引值 + :param count: 待查询的页面个数 + :return: 页面查询结果信息 + """ + if not page_ids: + data = { + 'type': 2, + 'begin': begin, + 'count': count + } + else: + if not isinstance(page_ids, (tuple, list)): + page_ids = [page_ids] + data = { + 'type': 1, + 'page_ids': page_ids + } + + res = self._post( + 'shakearound/page/search', + data=data, + result_processor=lambda x: x['data'] + ) + return res + + def delete_page(self, page_id): + """ + 删除页面 + 详情请参考 + http://mp.weixin.qq.com/wiki/5/6626199ea8757c752046d8e46cf13251.html + + :param page_id: 指定页面的id列表 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'shakearound/page/delete', + data={ + 'page_id': page_id + } + ) + + def add_material(self, media_file, media_type='icon'): + """ + 上传图片素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/5/e997428269ff189d8f9a4b9e177be2d9.html + + :param media_file: 要上传的文件,一个 File-object + :param media_type: 摇一摇素材类型, 取值为 icon或者 license, 默认 icon. + :return: 上传的素材信息 + """ + res = self._post( + 'shakearound/material/add', + files={ + 'media': media_file + }, + params={ + 'type': media_type + }, + result_processor=lambda x: x['data'] + ) + return res + + def bind_device_pages(self, page_ids, bind, append, device_id=None, + uuid=None, major=None, minor=None): + """ + 配置设备与页面的关联关系 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/c8120214ec0ba08af5dfcc0da1a11400.html + + :param page_ids: 待关联的页面列表 + :param bind: 关联操作标志位, 0为解除关联关系,1为建立关联关系 + :param append: 新增操作标志位, 0为覆盖,1为新增 + :param device_id: 设备编号,若填了UUID、major、minor,则可不填设备编号,若二者都填,则以设备编号为优先 + :param uuid: UUID + :param major: major + :param minor: minor + :return: 返回的 JSON 数据包 + """ + if not isinstance(page_ids, (tuple, list)): + page_ids = [page_ids] + data = { + 'page_ids': page_ids, + 'bind': int(bind), + 'append': int(append), + 'device_identifier': { + 'device_id': device_id, + 'uuid': uuid, + 'major': major, + 'minor': minor + } + } + return self._post( + 'shakearound/device/bindpage', + data=data + ) + + def get_shake_info(self, ticket): + """ + 获取摇周边的设备及用户信息 + 详情请参考 + http://mp.weixin.qq.com/wiki/3/34904a5db3d0ec7bb5306335b8da1faf.html + + :param ticket: 摇周边业务的ticket,可在摇到的URL中得到,ticket生效时间为30分钟 + :return: 设备及用户信息 + """ + res = self._post( + 'shakearound/user/getshakeinfo', + data={ + 'ticket': ticket + }, + result_processor=lambda x: x['data'] + ) + return res + + def get_device_statistics(self, begin_date, end_date, device_id=None, + uuid=None, major=None, minor=None): + """ + 以设备为维度的数据统计接口 + http://mp.weixin.qq.com/wiki/0/8a24bcacad40fe7ee98d1573cb8a6764.html + + :param begin_date: 起始时间,最长时间跨度为30天 + :param end_date: 结束时间,最长时间跨度为30天 + :param device_id: 设备编号,若填了UUID、major、minor,则可不填设备编号,若二者都填,则以设备编号为优先 + :param uuid: UUID + :param major: major + :param minor: minor + """ + data = { + 'device_identifier': { + 'device_id': device_id, + 'uuid': uuid, + 'major': major, + 'minor': minor + }, + 'begin_date': self._to_timestamp(begin_date), + 'end_date': self._to_timestamp(end_date) + } + res = self._post( + 'shakearound/statistics/device', + data=data, + result_processor=lambda x: x['data'] + ) + return res + + def get_page_statistics(self, page_id, begin_date, end_date): + """ + 以页面为维度的数据统计接口 + 详情请参考 + http://mp.weixin.qq.com/wiki/0/8a24bcacad40fe7ee98d1573cb8a6764.html + + :param page_id: 页面 ID + :param begin_date: 起始时间,最长时间跨度为30天 + :param end_date: 结束时间,最长时间跨度为30天 + :return: 统计数据 + """ + res = self._post( + 'shakearound/statistics/page', + data={ + 'page_id': page_id, + 'begin_date': self._to_timestamp(begin_date), + 'end_date': self._to_timestamp(end_date), + }, + result_processor=lambda x: x['data'] + ) + return res + + def get_apply_status(self, apply_id): + """ + 查询设备ID申请审核状态 + 详情请参考 + http://mp.weixin.qq.com/wiki/15/b9e012f917e3484b7ed02771156411f3.html + + :param apply_id: 批次ID,申请设备ID时所返回的批次ID + :return: 批次状态信息 + """ + res = self._post( + 'shakearound/device/applystatus', + data={ + 'apply_id': apply_id, + }, + result_processor=lambda x: x['data'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/template.py b/sg_wechat_enterprise/we_api/client/api/template.py new file mode 100644 index 00000000..46daa41b --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/template.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatTemplate(BaseWeChatAPI): + + def set_industry(self, industry_id1, industry_id2): + """ + 设置所属行业 + 详情请参考 + http://mp.weixin.qq.com/wiki/17/304c1885ea66dbedf7dc170d84999a9d.html + + :param industry_id1: 公众号模板消息所属行业编号 + :param industry_id2: 公众号模板消息所属行业编号 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'template/api_set_industry', + data={ + 'industry_id1': industry_id1, + 'industry_id2': industry_id2 + } + ) + + def get(self, template_id_short): + """ + 获得模板ID + 详情请参考 + http://mp.weixin.qq.com/wiki/17/304c1885ea66dbedf7dc170d84999a9d.html + + :param template_id_short: 模板库中模板的编号,有“TM**”和“OPENTMTM**”等形式 + :return: 模板 ID + """ + res = self._post( + 'template/api_add_template', + data={ + 'template_id_short': template_id_short + }, + result_processor=lambda x: x['template_id'] + ) + return res + + add = get diff --git a/sg_wechat_enterprise/we_api/client/api/user.py b/sg_wechat_enterprise/we_api/client/api/user.py new file mode 100644 index 00000000..320c905e --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/user.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import six + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatUser(BaseWeChatAPI): + + def get(self, user_id, lang='zh_CN'): + """ + 获取用户基本信息 + 详情请参考 + http://mp.weixin.qq.com/wiki/14/bb5031008f1494a59c6f71fa0f319c66.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param lang: 返回国家地区语言版本,zh_CN 简体,zh_TW 繁体,en 英语 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + user = client.user.get('openid') + + """ + assert lang in ('zh_CN', 'zh_TW', 'en'), 'lang can only be one of \ + zh_CN, zh_TW, en language codes' + return self._get( + 'user/info', + params={ + 'openid': user_id, + 'lang': lang + } + ) + + def get_followers(self, first_user_id=None): + """ + 获取关注者列表 + 详情请参考 + http://mp.weixin.qq.com/wiki/3/17e6919a39c1c53555185907acf70093.html + + :param first_user_id: 可选。第一个拉取的 OPENID,不填默认从头开始拉取 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + followers = client.user.get_followers() + + """ + params = {} + if first_user_id: + params['next_openid'] = first_user_id + return self._get( + 'user/get', + params=params + ) + + def update_remark(self, user_id, remark): + """ + 设置用户备注名 + 详情请参考 + http://mp.weixin.qq.com/wiki/10/bf8f4e3074e1cf91eb6518b6d08d223e.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param remark: 备注名 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + client.user.update_remark('openid', 'Remark') + + """ + return self._post( + 'user/info/updateremark', + data={ + 'openid': user_id, + 'remark': remark + } + ) + + def get_group_id(self, user_id): + """ + 获取用户所在分组 ID + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/56d992c605a97245eb7e617854b169fc.html + + :param user_id: 用户 ID + :return: 用户所在分组 ID + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + group_id = client.user.get_group_id('openid') + + """ + res = self._post( + 'groups/getid', + data={'openid': user_id}, + result_processor=lambda x: x['groupid'] + ) + return res + + def get_batch(self, user_list): + """ + 批量获取用户基本信息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/14/bb5031008f1494a59c6f71fa0f319c66.html#.E6.89.B9.E9.87.8F.E8.8E.B7.E5.8F.96.E7.94.A8.E6.88.B7.E5.9F.BA.E6.9C.AC.E4.BF.A1.E6.81.AF + + :param user_id: user_list + :return: 用户信息的 list + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + users = client.user.get_batch(['openid1', 'openid2']) + users = client.user.get_batch([ + {'openid': 'openid1', 'lang': 'zh-CN'}, + {'openid': 'openid2', 'lang': 'en'}, + ]) + + """ + if all((isinstance(x, six.string_types) for x in user_list)): + user_list = [{'openid': oid} for oid in user_list] + res = self._post( + 'user/info/batchget', + data={'user_list': user_list}, + result_processor=lambda x: x['user_info_list'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/wifi.py b/sg_wechat_enterprise/we_api/client/api/wifi.py new file mode 100644 index 00000000..1e8149e4 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/wifi.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from datetime import datetime, date + +from optionaldict import optionaldict +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatWiFi(BaseWeChatAPI): + + API_BASE_URL = 'https://api.weixin.qq.com/bizwifi/' + + def list_shops(self, page_index=1, page_size=20): + """ + 获取门店列表 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/bcfb5d4578ea818b89913472cf2bbf8f.html + + :param page_index: 可选,分页下标,默认从1开始 + :param page_size: 可选,每页的个数,默认20个,最大20个 + :return: 返回的 JSON 数据包 + """ + res = self._post( + 'shop/list', + data={ + 'pageindex': page_index, + 'pagesize': page_size, + }, + result_processor=lambda x: x['data'] + ) + return res + + def get_shop(self, shop_id=0): + """ + 查询门店的WiFi信息 + http://mp.weixin.qq.com/wiki/15/bcfb5d4578ea818b89913472cf2bbf8f.html + + :param shop_id: 门店 ID + :return: 返回的 JSON 数据包 + """ + res = self._post( + 'shop/get', + data={ + 'shop_id': shop_id, + }, + result_processor=lambda x: x['data'] + ) + return res + + def add_device(self, shop_id, ssid, password, bssid): + """ + 添加设备 + + 详情请参考 + http://mp.weixin.qq.com/wiki/10/6232005bdc497f7cf8e19d4e843c70d2.html + + :param shop_id: 门店 ID + :param ssid: 无线网络设备的ssid。非认证公众号添加的ssid必需是“WX”开头(“WX”为大写字母), + 认证公众号和第三方平台无此限制;所有ssid均不能包含中文字符 + :param password: 无线网络设备的密码,大于8个字符,不能包含中文字符 + :param bssid: 无线网络设备无线mac地址,格式冒号分隔,字符长度17个,并且字母小写 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'device/add', + data={ + 'shop_id': shop_id, + 'ssid': ssid, + 'password': password, + 'bssid': bssid, + } + ) + + def list_devices(self, shop_id=None, page_index=1, page_size=20): + """ + 查询设备 + + 详情请参考 + http://mp.weixin.qq.com/wiki/10/6232005bdc497f7cf8e19d4e843c70d2.html + + :param shop_id: 可选,门店 ID + :param page_index: 可选,分页下标,默认从1开始 + :param page_size: 可选,每页的个数,默认20个,最大20个 + :return: 返回的 JSON 数据包 + """ + data = optionaldict( + shop_id=shop_id, + pageindex=page_index, + pagesize=page_size + ) + res = self._post( + 'device/list', + data=data, + result_processor=lambda x: x['data'] + ) + return res + + def delete_device(self, bssid): + """ + 删除设备 + + 详情请参考 + http://mp.weixin.qq.com/wiki/10/6232005bdc497f7cf8e19d4e843c70d2.html + + :param bssid: 无线网络设备无线mac地址,格式冒号分隔,字符长度17个,并且字母小写 + :return: 返回的 JSON 数据包 + """ + return self._post('device/delete', data={'bssid': bssid}) + + def get_qrcode_url(self, shop_id, img_id): + """ + 获取物料二维码图片网址 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/fcd0378ef00617fc276be2b3baa80973.html + + :param shop_id: 门店 ID + :param img_id: 物料样式编号:0-二维码,可用于自由设计宣传材料; + 1-桌贴(二维码),100mm×100mm(宽×高),可直接张贴 + :return: 二维码图片网址 + """ + res = self._post( + 'qrcode/get', + data={ + 'shop_id': shop_id, + 'img_id': img_id, + }, + result_processor=lambda x: x['data']['qrcode_url'] + ) + return res + + def set_homepage(self, shop_id, template_id, url=None): + """ + 设置商家主页 + + 详情请参考 + http://mp.weixin.qq.com/wiki/6/2732f3cf83947e0e4971aa8797ee9d6a.html + + :param shop_id: 门店 ID + :param template_id: 模板ID,0-默认模板,1-自定义url + :param url: 自定义链接,当template_id为1时必填 + :return: 返回的 JSON 数据包 + """ + data = { + 'shop_id': shop_id, + 'template_id': template_id, + } + if url: + data['struct'] = {'url': url} + return self._post('homepage/set', data=data) + + def get_homepage(self, shop_id): + """ + 查询商家主页 + + 详情请参考 + http://mp.weixin.qq.com/wiki/6/2732f3cf83947e0e4971aa8797ee9d6a.html + + :param shop_id: 门店 ID + :return: 返回的 JSON 数据包 + """ + res = self._post( + 'homepage/get', + data={'shop_id': shop_id}, + result_processor=lambda x: x['data'] + ) + return res + + def list_statistics(self, begin_date, end_date, shop_id=-1): + """ + Wi-Fi数据统计 + + 详情请参考 + http://mp.weixin.qq.com/wiki/8/dfa2b756b66fca5d9b1211bc18812698.html + + :param begin_date: 起始日期时间,最长时间跨度为30天 + :param end_date: 结束日期时间戳,最长时间跨度为30天 + :param shop_id: 可选,门店 ID,按门店ID搜索,-1为总统计 + :return: 返回的 JSON 数据包 + """ + if isinstance(begin_date, (datetime, date)): + begin_date = begin_date.strftime('%Y-%m-%d') + if isinstance(end_date, (datetime, date)): + end_date = end_date.strftime('%Y-%m-%d') + res = self._post( + 'statistics/list', + data={ + 'begin_date': begin_date, + 'end_date': end_date, + 'shop_id': shop_id + }, + result_processor=lambda x: x['data'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/base.py b/sg_wechat_enterprise/we_api/client/base.py new file mode 100644 index 00000000..3a8edc49 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/base.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import sys +import time +import inspect +import logging + +import six +import requests +from wechatpy.utils import json, get_querystring +from wechatpy.session.memorystorage import MemoryStorage +from wechatpy.exceptions import WeChatClientException, APILimitedException +from wechatpy.client.api.base import BaseWeChatAPI + + +logger = logging.getLogger(__name__) + + +def _is_api_endpoint(obj): + return isinstance(obj, BaseWeChatAPI) + + +class BaseWeChatClient(object): + + API_BASE_URL = '' + + def __new__(cls, *args, **kwargs): + self = super(BaseWeChatClient, cls).__new__(cls) + if sys.version_info[:2] == (2, 6): + # Python 2.6 inspect.gemembers bug workaround + # http://bugs.python.org/issue1785 + for _class in cls.__mro__: + if issubclass(_class, BaseWeChatClient): + for name, api in _class.__dict__.items(): + if isinstance(api, BaseWeChatAPI): + api_cls = type(api) + api = api_cls(self) + setattr(self, name, api) + else: + api_endpoints = inspect.getmembers(self, _is_api_endpoint) + for name, api in api_endpoints: + api_cls = type(api) + api = api_cls(self) + setattr(self, name, api) + return self + + def __init__(self, appid, access_token=None, session=None, timeout=None, auto_retry=True): + self.appid = appid + self.expires_at = None + self.session = session or MemoryStorage() + self.timeout = timeout + self.auto_retry = auto_retry + + if isinstance(session, six.string_types): + from shove import Shove + from wechatpy.session.shovestorage import ShoveStorage + + querystring = get_querystring(session) + prefix = querystring.get('prefix', ['wechatpy'])[0] + + shove = Shove(session) + storage = ShoveStorage(shove, prefix) + self.session = storage + + if access_token: + self.session.set(self.access_token_key, access_token) + + @property + def access_token_key(self): + return '{0}_access_token'.format(self.appid) + + def _request(self, method, url_or_endpoint, **kwargs): + if not url_or_endpoint.startswith(('http://', 'https://')): + api_base_url = kwargs.pop('api_base_url', self.API_BASE_URL) + url = '{base}{endpoint}'.format( + base=api_base_url, + endpoint=url_or_endpoint + ) + else: + url = url_or_endpoint + + # 群发消息上传视频接口地址 HTTPS 证书错误,暂时忽略证书验证 + if url.startswith('https://file.api.weixin.qq.com'): + kwargs['verify'] = False + + if 'params' not in kwargs: + kwargs['params'] = {} + if isinstance(kwargs['params'], dict) and \ + 'access_token' not in kwargs['params']: + kwargs['params']['access_token'] = self.access_token + if isinstance(kwargs.get('data', ''), dict): + body = json.dumps(kwargs['data'], ensure_ascii=False) + body = body.encode('utf-8') + kwargs['data'] = body + + kwargs['timeout'] = kwargs.get('timeout', self.timeout) + result_processor = kwargs.pop('result_processor', None) + res = requests.request( + method=method, + url=url, + **kwargs + ) + try: + res.raise_for_status() + except requests.RequestException as reqe: + raise WeChatClientException( + errcode=None, + errmsg=None, + client=self, + request=reqe.request, + response=reqe.response + ) + + return self._handle_result( + res, method, url, result_processor, **kwargs + ) + + def _decode_result(self, res): + try: + result = json.loads(res.content.decode('utf-8', 'ignore'), strict=False) + except (TypeError, ValueError): + # Return origin response object if we can not decode it as JSON + logger.debug('Can not decode response as JSON', exc_info=True) + return res + return result + + def _handle_result(self, res, method=None, url=None, + result_processor=None, **kwargs): + if not isinstance(res, dict): + # Dirty hack around asyncio based AsyncWeChatClient + result = self._decode_result(res) + else: + result = res + + if not isinstance(result, dict): + return result + + if 'base_resp' in result: + # Different response in device APIs. Fuck tencent! + result = result['base_resp'] + if 'errcode' in result: + result['errcode'] = int(result['errcode']) + + if 'errcode' in result and result['errcode'] != 0: + errcode = result['errcode'] + errmsg = result.get('errmsg', errcode) + if errcode in (40001, 40014, 42001) and self.auto_retry: + logger.info('Access token expired, fetch a new one and retry request') + self.fetch_access_token() + access_token = self.session.get(self.access_token_key) + kwargs['params']['access_token'] = access_token + return self._request( + method=method, + url_or_endpoint=url, + result_processor=result_processor, + **kwargs + ) + elif errcode == 45009: + # api freq out of limit + raise APILimitedException( + errcode, + errmsg, + client=self, + request=res.request, + response=res + ) + else: + raise WeChatClientException( + errcode, + errmsg, + client=self, + request=res.request, + response=res + ) + + return result if not result_processor else result_processor(result) + + def get(self, url, **kwargs): + return self._request( + method='get', + url_or_endpoint=url, + **kwargs + ) + + _get = get + + def post(self, url, **kwargs): + return self._request( + method='post', + url_or_endpoint=url, + **kwargs + ) + + _post = post + + def _fetch_access_token(self, url, params): + """ The real fetch access token """ + logger.info('Fetching access token') + res = requests.get( + url=url, + params=params + ) + try: + res.raise_for_status() + except requests.RequestException as reqe: + raise WeChatClientException( + errcode=None, + errmsg=None, + client=self, + request=reqe.request, + response=reqe.response + ) + result = res.json() + if 'errcode' in result and result['errcode'] != 0: + raise WeChatClientException( + result['errcode'], + result['errmsg'], + client=self, + request=res.request, + response=res + ) + + expires_in = 7200 + if 'expires_in' in result: + expires_in = result['expires_in'] + self.session.set( + self.access_token_key, + result['access_token'], + expires_in + ) + self.expires_at = int(time.time()) + expires_in + return result + + def fetch_access_token(self): + raise NotImplementedError() + + @property + def access_token(self): + """ WeChat access token """ + access_token = self.session.get(self.access_token_key) + if access_token: + if not self.expires_at: + # user provided access_token, just return it + return access_token + + timestamp = time.time() + if self.expires_at - timestamp > 60: + return access_token + + self.fetch_access_token() + return self.session.get(self.access_token_key) diff --git a/sg_wechat_enterprise/we_api/component.py b/sg_wechat_enterprise/we_api/component.py new file mode 100644 index 00000000..79792961 --- /dev/null +++ b/sg_wechat_enterprise/we_api/component.py @@ -0,0 +1,431 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.component + ~~~~~~~~~~~~~~~ + + This module provides client library for WeChat Open Platform + + :copyright: (c) 2015 by hunter007. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import time +import logging + +import six +import requests +import xmltodict + +from wechatpy.utils import to_text, to_binary, get_querystring, json +from wechatpy.fields import StringField, DateTimeField +from wechatpy.messages import MessageMetaClass +from wechatpy.session.memorystorage import MemoryStorage +from wechatpy.exceptions import WeChatClientException, APILimitedException +from wechatpy.crypto import WeChatCrypto +from wechatpy.client import WeChatComponentClient + + +logger = logging.getLogger(__name__) + + +class BaseComponentMessage(six.with_metaclass(MessageMetaClass)): + """Base class for all component messages and events""" + type = 'unknown' + appid = StringField('AppId') + create_time = DateTimeField('CreateTime') + + def __init__(self, message): + self._data = message + + def __repr__(self): + _repr = "{klass}({msg})".format( + klass=self.__class__.__name__, + msg=repr(self._data) + ) + if six.PY2: + return to_binary(_repr) + else: + return to_text(_repr) + + +class ComponentVerifyTicketMessage(BaseComponentMessage): + """ + component_verify_ticket协议 + """ + type = 'component_verify_ticket' + verify_ticket = StringField('ComponentVerifyTicket') + + +class ComponentUnauthorizedMessage(BaseComponentMessage): + """ + 取消授权通知 + """ + type = 'unauthorized' + authorizer_appid = StringField('AuthorizerAppid') + + +class BaseWeChatComponent(object): + + API_BASE_URL = 'https://api.weixin.qq.com/cgi-bin' + + def __init__(self, + component_appid, + component_appsecret, + component_token, + encoding_aes_key, + session=None): + """ + :param component_appid: 第三方平台appid + :param component_appsecret: 第三方平台appsecret + :param component_token: 公众号消息校验Token + :param encoding_aes_key: 公众号消息加解密Key + """ + self.component_appid = component_appid + self.component_appsecret = component_appsecret + self.expires_at = None + self.crypto = WeChatCrypto( + component_token, encoding_aes_key, component_appid) + self.session = session or MemoryStorage() + + if isinstance(session, six.string_types): + from shove import Shove + from wechatpy.session.shovestorage import ShoveStorage + + querystring = get_querystring(session) + prefix = querystring.get('prefix', ['wechatpy'])[0] + + shove = Shove(session) + storage = ShoveStorage(shove, prefix) + self.session = storage + + @property + def component_verify_ticket(self): + return self.session.get('component_verify_ticket') + + def _request(self, method, url_or_endpoint, **kwargs): + if not url_or_endpoint.startswith(('http://', 'https://')): + api_base_url = kwargs.pop('api_base_url', self.API_BASE_URL) + url = '{base}{endpoint}'.format( + base=api_base_url, + endpoint=url_or_endpoint + ) + else: + url = url_or_endpoint + + if 'params' not in kwargs: + kwargs['params'] = {} + if isinstance(kwargs['params'], dict) and \ + 'component_access_token' not in kwargs['params']: + kwargs['params'][ + 'component_access_token'] = self.access_token + if isinstance(kwargs['data'], dict): + kwargs['data'] = json.dumps(kwargs['data']) + + res = requests.request( + method=method, + url=url, + **kwargs + ) + try: + res.raise_for_status() + except requests.RequestException as reqe: + raise WeChatClientException( + errcode=None, + errmsg=None, + client=self, + request=reqe.request, + response=reqe.response + ) + + return self._handle_result(res, method, url, **kwargs) + + def _handle_result(self, res, method=None, url=None, **kwargs): + result = json.loads(res.content.decode('utf-8', 'ignore'), strict=False) + if 'errcode' in result: + result['errcode'] = int(result['errcode']) + + if 'errcode' in result and result['errcode'] != 0: + errcode = result['errcode'] + errmsg = result['errmsg'] + if errcode == 42001: + logger.info('Component access token expired, fetch a new one and retry request') + self.fetch_component_access_token() + kwargs['params']['component_access_token'] = self.session.get( + 'component_access_token' + ) + return self._request( + method=method, + url_or_endpoint=url, + **kwargs + ) + elif errcode == 45009: + # api freq out of limit + raise APILimitedException( + errcode, + errmsg, + client=self, + request=res.request, + response=res + ) + else: + raise WeChatClientException( + errcode, + errmsg, + client=self, + request=res.request, + response=res + ) + + return result + + def fetch_access_token(self): + """ + 获取 component_access_token + 详情请参考 https://open.weixin.qq.com/cgi-bin/showdocument?action=dir_list\ + &t=resource/res_list&verify=1&id=open1419318587&token=&lang=zh_CN + + :return: 返回的 JSON 数据包 + """ + url = '{0}{1}'.format( + self.API_BASE_URL, + '/component/api_component_token' + ) + return self._fetch_access_token( + url=url, + data=json.dumps({ + 'component_appid': self.component_appid, + 'component_appsecret': self.component_appsecret, + 'component_verify_ticket': self.component_verify_ticket + }) + ) + + def _fetch_access_token(self, url, data): + """ The real fetch access token """ + logger.info('Fetching component access token') + res = requests.post( + url=url, + data=data + ) + try: + res.raise_for_status() + except requests.RequestException as reqe: + raise WeChatClientException( + errcode=None, + errmsg=None, + client=self, + request=reqe.request, + response=reqe.response + ) + result = res.json() + if 'errcode' in result and result['errcode'] != 0: + raise WeChatClientException( + result['errcode'], + result['errmsg'], + client=self, + request=res.request, + response=res + ) + + expires_in = 7200 + if 'expires_in' in result: + expires_in = result['expires_in'] + self.session.set( + 'component_access_token', + result['component_access_token'], + expires_in + ) + self.expires_at = int(time.time()) + expires_in + return result + + @property + def access_token(self): + """ WeChat component access token """ + access_token = self.session.get('component_access_token') + if access_token: + if not self.expires_at: + # user provided access_token, just return it + return access_token + + timestamp = time.time() + if self.expires_at - timestamp > 60: + return access_token + + self.fetch_access_token() + return self.session.get('component_access_token') + + def get(self, url, **kwargs): + return self._request( + method='get', + url_or_endpoint=url, + **kwargs + ) + + def post(self, url, **kwargs): + return self._request( + method='post', + url_or_endpoint=url, + **kwargs + ) + + +class WeChatComponent(BaseWeChatComponent): + + def create_preauthcode(self): + """ + 获取预授权码 + """ + return self.post( + '/component/api_create_preauthcode', + data={ + 'component_appid': self.component_appid + } + ) + + def query_auth(self, authorization_code): + """ + 使用授权码换取公众号的授权信息 + + :params authorization_code: 授权code,会在授权成功时返回给第三方平台,详见第三方平台授权流程说明 + """ + return self.post( + '/component/api_query_auth', + data={ + 'component_appid': self.component_appid, + 'authorization_code': authorization_code + } + ) + + def refresh_authorizer_token( + self, authorizer_appid, authorizer_refresh_token): + """ + 获取(刷新)授权公众号的令牌 + + :params authorizer_appid: 授权方appid + :params authorizer_refresh_token: 授权方的刷新令牌 + """ + return self.post( + '/component/api_authorizer_token', + data={ + 'component_appid': self.component_appid, + 'authorizer_appid': authorizer_appid, + 'authorizer_refresh_token': authorizer_refresh_token + } + ) + + def get_authorizer_info(self, authorizer_appid): + """ + 获取授权方的账户信息 + + :params authorizer_appid: 授权方appid + """ + return self.post( + '/component/api_get_authorizer_info', + data={ + 'component_appid': self.component_appid, + 'authorizer_appid': authorizer_appid, + } + ) + + def get_authorizer_option(self, authorizer_appid, option_name): + """ + 获取授权方的选项设置信息 + + :params authorizer_appid: 授权公众号appid + :params option_name: 选项名称 + """ + return self.post( + '/component/api_get_authorizer_option', + data={ + 'component_appid': self.component_appid, + 'authorizer_appid': authorizer_appid, + 'option_name': option_name + } + ) + + def set_authorizer_option( + self, authorizer_appid, option_name, option_value): + """ + 设置授权方的选项信息 + + :params authorizer_appid: 授权公众号appid + :params option_name: 选项名称 + :params option_value: 设置的选项值 + """ + return self.post( + '/component/api_set_authorizer_option', + data={ + 'component_appid': self.component_appid, + 'authorizer_appid': authorizer_appid, + 'option_name': option_name, + 'option_value': option_value + } + ) + + def get_client_by_authorization_code(self, authorization_code): + """ + 通过授权码直接获取 Client 对象 + + :params authorization_code: 授权code,会在授权成功时返回给第三方平台,详见第三方平台授权流程说明 + """ + result = self.query_auth(authorization_code) + access_token = result['authorization_info']['authorizer_access_token'] + refresh_token = result['authorization_info']['authorizer_refresh_token'] # NOQA + authorizer_appid = result['authorization_info']['authorizer_appid'] # noqa + return WeChatComponentClient( + authorizer_appid, self, access_token, refresh_token, + session=self.session + ) + + def get_client_by_appid(self, authorizer_appid): + """ + 通过 authorizer_appid 获取 Client 对象 + + :params authorizer_appid: 授权公众号appid + """ + access_token_key = '{0}_access_token'.format(authorizer_appid) + refresh_token_key = '{0}_refresh_token'.format(authorizer_appid) + access_token = self.session.get(access_token_key) + refresh_token = self.session.get(refresh_token_key) + + if not access_token: + ret = self.refresh_authorizer_token( + authorizer_appid, + refresh_token + ) + access_token = ret['authorizer_access_token'] + refresh_token = ret['authorizer_refresh_token'] + + return WeChatComponentClient( + authorizer_appid, + self, + access_token, + refresh_token, + session=self.session + ) + + def cache_component_verify_ticket(self, msg, signature, timestamp, nonce): + """ + 处理 wechat server 推送的 component_verify_ticket消息 + + :params msg: 加密内容 + :params signature: 消息签名 + :params timestamp: 时间戳 + :params nonce: 随机数 + """ + content = self.crypto.decrypt_message(msg, signature, timestamp, nonce) + message = xmltodict.parse(to_text(content))['xml'] + o = ComponentVerifyTicketMessage(message) + self.session.set(o.type, o.verify_ticket, 600) + + def get_unauthorized(self, msg, signature, timestamp, nonce): + """ + 处理取消授权通知 + + :params msg: 加密内容 + :params signature: 消息签名 + :params timestamp: 时间戳 + :params nonce: 随机数 + """ + content = self.crypto.decrypt_message(msg, signature, timestamp, nonce) + message = xmltodict.parse(to_text(content))['xml'] + return ComponentUnauthorizedMessage(message) diff --git a/sg_wechat_enterprise/we_api/crypto/__init__.py b/sg_wechat_enterprise/we_api/crypto/__init__.py new file mode 100644 index 00000000..ecdeb77c --- /dev/null +++ b/sg_wechat_enterprise/we_api/crypto/__init__.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.crypto + ~~~~~~~~~~~~~~~~ + + This module provides some crypto tools for WeChat and WeChat enterprise + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import time +import base64 + +from wechatpy.utils import to_text, to_binary, WeChatSigner +from wechatpy.exceptions import ( + InvalidAppIdException, + InvalidSignatureException +) +from wechatpy.crypto.base import BasePrpCrypto + + +def _get_signature(token, timestamp, nonce, encrypt): + signer = WeChatSigner() + signer.add_data(token, timestamp, nonce, encrypt) + return signer.signature + + +class PrpCrypto(BasePrpCrypto): + + def encrypt(self, text, app_id): + return self._encrypt(text, app_id) + + def decrypt(self, text, app_id): + return self._decrypt(text, app_id, InvalidAppIdException) + + +class BaseWeChatCrypto(object): + + def __init__(self, token, encoding_aes_key, _id): + encoding_aes_key = to_binary(encoding_aes_key + '=') + self.key = base64.b64decode(encoding_aes_key) + assert len(self.key) == 32 + self.token = token + self._id = _id + + def _check_signature(self, + signature, + timestamp, + nonce, + echo_str, + crypto_class=None): + _signature = _get_signature(self.token, timestamp, nonce, echo_str) + if _signature != signature: + raise InvalidSignatureException() + pc = crypto_class(self.key) + return pc.decrypt(echo_str, self._id) + + def _encrypt_message(self, + msg, + nonce, + timestamp=None, + crypto_class=None): + from wechatpy.replies import BaseReply + + xml = """ + + +{timestamp} + +""" + if isinstance(msg, BaseReply): + msg = msg.render() + timestamp = timestamp or to_binary(int(time.time())) + pc = crypto_class(self.key) + encrypt = to_text(pc.encrypt(msg, self._id)) + signature = _get_signature(self.token, timestamp, nonce, encrypt) + return to_text(xml.format( + encrypt=encrypt, + signature=signature, + timestamp=timestamp, + nonce=nonce + )) + + def _decrypt_message(self, + msg, + signature, + timestamp, + nonce, + crypto_class=None): + if not isinstance(msg, dict): + import xmltodict + + msg = xmltodict.parse(to_text(msg))['xml'] + + encrypt = msg['Encrypt'] + _signature = _get_signature(self.token, timestamp, nonce, encrypt) + if _signature != signature: + raise InvalidSignatureException() + pc = crypto_class(self.key) + return pc.decrypt(encrypt, self._id) + + +class WeChatCrypto(BaseWeChatCrypto): + + def __init__(self, token, encoding_aes_key, app_id): + super(WeChatCrypto, self).__init__(token, encoding_aes_key, app_id) + self.app_id = app_id + + def encrypt_message(self, msg, nonce, timestamp=None): + return self._encrypt_message(msg, nonce, timestamp, PrpCrypto) + + def decrypt_message(self, msg, signature, timestamp, nonce): + return self._decrypt_message( + msg, + signature, + timestamp, + nonce, + PrpCrypto + ) diff --git a/sg_wechat_enterprise/we_api/crypto/base.py b/sg_wechat_enterprise/we_api/crypto/base.py new file mode 100644 index 00000000..7f27fc06 --- /dev/null +++ b/sg_wechat_enterprise/we_api/crypto/base.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import struct +import socket +import base64 + +from wechatpy.utils import to_text, to_binary, random_string, byte2int +from wechatpy.crypto.pkcs7 import PKCS7Encoder +try: + from wechatpy.crypto.cryptography import WeChatCipher +except ImportError: + try: + from wechatpy.crypto.pycrypto import WeChatCipher + except ImportError: + raise Exception('You must install either cryptography or PyCrypto!') + + +class BasePrpCrypto(object): + + def __init__(self, key): + self.cipher = WeChatCipher(key) + + def get_random_string(self): + return random_string(16) + + def _encrypt(self, text, _id): + text = to_binary(text) + tmp_list = [] + tmp_list.append(to_binary(self.get_random_string())) + length = struct.pack(b'I', socket.htonl(len(text))) + tmp_list.append(length) + tmp_list.append(text) + tmp_list.append(to_binary(_id)) + + text = b''.join(tmp_list) + text = PKCS7Encoder.encode(text) + + ciphertext = to_binary(self.cipher.encrypt(text)) + return base64.b64encode(ciphertext) + + def _decrypt(self, text, _id, exception=None): + text = to_binary(text) + plain_text = self.cipher.decrypt(base64.b64decode(text)) + padding = byte2int(plain_text[-1]) + content = plain_text[16:-padding] + xml_length = socket.ntohl(struct.unpack(b'I', content[:4])[0]) + xml_content = to_text(content[4:xml_length + 4]) + from_id = to_text(content[xml_length + 4:]) + if from_id != _id: + exception = exception or Exception + raise exception() + return xml_content diff --git a/sg_wechat_enterprise/we_api/crypto/cryptography.py b/sg_wechat_enterprise/we_api/crypto/cryptography.py new file mode 100644 index 00000000..d9f60a03 --- /dev/null +++ b/sg_wechat_enterprise/we_api/crypto/cryptography.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.backends import default_backend + + +class WeChatCipher(object): + + def __init__(self, key): + backend = default_backend() + self.cipher = Cipher( + algorithms.AES(key), + modes.CBC(key[:16]), + backend=backend + ) + + def encrypt(self, plaintext): + encryptor = self.cipher.encryptor() + return encryptor.update(plaintext) + encryptor.finalize() + + def decrypt(self, ciphertext): + decryptor = self.cipher.decryptor() + return decryptor.update(ciphertext) + decryptor.finalize() diff --git a/sg_wechat_enterprise/we_api/crypto/pkcs7.py b/sg_wechat_enterprise/we_api/crypto/pkcs7.py new file mode 100644 index 00000000..5278fdda --- /dev/null +++ b/sg_wechat_enterprise/we_api/crypto/pkcs7.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.utils import to_binary, byte2int + + +class PKCS7Encoder(object): + block_size = 32 + + @classmethod + def encode(cls, text): + length = len(text) + padding_count = cls.block_size - length % cls.block_size + if padding_count == 0: + padding_count = cls.block_size + padding = to_binary(chr(padding_count)) + return text + padding * padding_count + + @classmethod + def decode(cls, decrypted): + padding = byte2int(decrypted[-1]) + if padding < 1 or padding > 32: + padding = 0 + return decrypted[:-padding] diff --git a/sg_wechat_enterprise/we_api/crypto/pycrypto.py b/sg_wechat_enterprise/we_api/crypto/pycrypto.py new file mode 100644 index 00000000..3a1e14a5 --- /dev/null +++ b/sg_wechat_enterprise/we_api/crypto/pycrypto.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from Crypto.Cipher import AES + + +class WeChatCipher(object): + + def __init__(self, key): + self.cipher = AES.new(key, AES.MODE_CBC, key[:16]) + + def encrypt(self, plaintext): + return self.cipher.encrypt(plaintext) + + def decrypt(self, ciphertext): + return self.cipher.decrypt(ciphertext) diff --git a/sg_wechat_enterprise/we_api/enterprise/__init__.py b/sg_wechat_enterprise/we_api/enterprise/__init__.py new file mode 100644 index 00000000..d28f7a91 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.enterprise.parser import parse_message # NOQA +from wechatpy.enterprise.replies import create_reply # NOQA +from wechatpy.enterprise.crypto import WeChatCrypto # NOQA +from wechatpy.enterprise.client import WeChatClient # NOQA diff --git a/sg_wechat_enterprise/we_api/enterprise/client/__init__.py b/sg_wechat_enterprise/we_api/enterprise/client/__init__.py new file mode 100644 index 00000000..5198a9d3 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/__init__.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.client.base import BaseWeChatClient +from wechatpy.enterprise.client import api + + +class WeChatClient(BaseWeChatClient): + + API_BASE_URL = 'https://qyapi.weixin.qq.com/cgi-bin/' + + user = api.WeChatUser() + department = api.WeChatDepartment() + menu = api.WeChatMenu() + message = api.WeChatMessage() + tag = api.WeChatTag() + media = api.WeChatMedia() + misc = api.WeChatMisc() + agent = api.WeChatAgent() + batch = api.WeChatBatch() + jsapi = api.WeChatJSAPI() + material = api.WeChatMaterial() + oauth = api.WeChatOAuth() + shakearound = api.WeChatShakeAround() + service = api.WeChatService() + chat = api.WeChatChat() + + def __init__(self, corp_id, secret, access_token=None, + session=None, timeout=None, auto_retry=True): + super(WeChatClient, self).__init__( + corp_id, access_token, session, timeout, auto_retry + ) + self.corp_id = corp_id + self.secret = secret + + def fetch_access_token(self): + """ Fetch access token""" + return self._fetch_access_token( + url='https://qyapi.weixin.qq.com/cgi-bin/gettoken', + params={ + 'corpid': self.corp_id, + 'corpsecret': self.secret + } + ) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/__init__.py b/sg_wechat_enterprise/we_api/enterprise/client/api/__init__.py new file mode 100644 index 00000000..4c6232ec --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.enterprise.client.api.department import WeChatDepartment # NOQA +from wechatpy.enterprise.client.api.media import WeChatMedia # NOQA +from wechatpy.enterprise.client.api.message import WeChatMessage # NOQA +from wechatpy.enterprise.client.api.menu import WeChatMenu # NOQA +from wechatpy.enterprise.client.api.tag import WeChatTag # NOQA +from wechatpy.enterprise.client.api.user import WeChatUser # NOQA +from wechatpy.enterprise.client.api.misc import WeChatMisc # NOQA +from wechatpy.enterprise.client.api.agent import WeChatAgent # NOQA +from wechatpy.enterprise.client.api.batch import WeChatBatch # NOQA +from wechatpy.enterprise.client.api.jsapi import WeChatJSAPI # NOQA +from wechatpy.enterprise.client.api.material import WeChatMaterial # NOQA +from wechatpy.enterprise.client.api.oauth import WeChatOAuth # NOQA +from wechatpy.enterprise.client.api.shakearound import WeChatShakeAround # NOQA +from wechatpy.enterprise.client.api.service import WeChatService # NOQA +from wechatpy.enterprise.client.api.chat import WeChatChat # NOQA diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/agent.py b/sg_wechat_enterprise/we_api/enterprise/client/api/agent.py new file mode 100644 index 00000000..e9d3b0bd --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/agent.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatAgent(BaseWeChatAPI): + + def get(self, agent_id): + """ + 获取企业号应用 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=获取企业号应用 + + :param agent_id: 授权方应用 id + :return: 返回的 JSON 数据包 + """ + return self._get( + 'agent/get', + params={ + 'agentid': agent_id + } + ) + + def set(self, + agent_id, + name=None, + description=None, + redirect_domain=None, + logo_media_id=None, + report_location_flag=0, + is_report_user=True, + is_report_enter=True): + """ + 设置企业号应用 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=设置企业号应用 + + :param agent_id: 企业应用的 id + :param name: 企业应用名称 + :param description: 企业应用详情 + :param redirect_domain: 企业应用可信域名 + :param logo_media_id: 企业应用头像的mediaid,通过多媒体接口上传图片获得mediaid + :param report_location_flag: 企业应用是否打开地理位置上报 0:不上报;1:进入会话上报;2:持续上报 + :param is_report_user: 是否接收用户变更通知 + :param is_report_enter: 是否上报用户进入应用事件 + :return: 返回的 JSON 数据包 + """ + agent_data = optionaldict() + agent_data['agentid'] = agent_id + agent_data['name'] = name + agent_data['description'] = description + agent_data['redirect_domain'] = redirect_domain + agent_data['logo_mediaid'] = logo_media_id + agent_data['report_location_flag'] = report_location_flag + agent_data['isreportuser'] = 1 if is_report_user else 0 + agent_data['isreportenter'] = 1 if is_report_enter else 0 + return self._post( + 'agent/set', + data=agent_data + ) + + def list(self): + """ + 获取应用概况列表 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=获取应用概况列表 + + :return: 应用概况列表 + """ + res = self._get('agent/list') + return res['agentlist'] diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/batch.py b/sg_wechat_enterprise/we_api/enterprise/client/api/batch.py new file mode 100644 index 00000000..db775f5f --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/batch.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI +from wechatpy.utils import to_text + + +class WeChatBatch(BaseWeChatAPI): + + def invite_user(self, url, token, encoding_aes_key, user_ids=None, + party_ids=None, tag_ids=None, invite_tips=None): + """ + 邀请成员关注 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=异步任务接口 + + :param url: 企业应用接收企业号推送请求的访问协议和地址,支持http或https协议 + :param token: 用于生成签名 + :param encoding_aes_key: 用于消息体的加密,是AES密钥的Base64编码 + :param user_ids: 可选,成员ID列表,多个接收者用‘|’分隔,最多支持1000个。 + :param party_ids: 可选,部门ID列表,多个接收者用‘|’分隔,最多支持100个。 + :param tag_ids: 可选,标签ID列表,多个接收者用‘|’分隔。 + :param invite_tips: 可选,推送到微信上的提示语 + :return: 返回的 JSON 数据包 + """ + data = optionaldict() + data['callback'] = { + 'url': url, + 'token': token, + 'encodingaeskey': encoding_aes_key + } + if isinstance(user_ids, (tuple, list)): + user_ids = '|'.join(map(to_text, user_ids)) + if isinstance(party_ids, (tuple, list)): + party_ids = '|'.join(map(to_text, party_ids)) + if isinstance(tag_ids, (tuple, list)): + tag_ids = '|'.join(map(to_text, tag_ids)) + data['touser'] = user_ids + data['toparty'] = party_ids + data['totag'] = tag_ids + data['invite_tips'] = invite_tips + return self._post( + 'batch/inviteuser', + data=data + ) + + def sync_user(self, url, token, encoding_aes_key, media_id): + """ + 增量更新成员 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=异步任务接口 + + :param url: 企业应用接收企业号推送请求的访问协议和地址,支持http或https协议 + :param token: 用于生成签名 + :param encoding_aes_key: 用于消息体的加密,是AES密钥的Base64编码 + :param media_id: 上传的csv文件的media_id + :return: 返回的 JSON 数据包 + """ + return self._post( + 'batch/syncuser', + data={ + 'media_id': media_id, + 'callback': { + 'url': url, + 'token': token, + 'encodingaeskey': encoding_aes_key + } + } + ) + + def replace_user(self, url, token, encoding_aes_key, media_id): + """ + 全量覆盖成员 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=异步任务接口 + + :param url: 企业应用接收企业号推送请求的访问协议和地址,支持http或https协议 + :param token: 用于生成签名 + :param encoding_aes_key: 用于消息体的加密,是AES密钥的Base64编码 + :param media_id: 上传的csv文件的media_id + :return: 返回的 JSON 数据包 + """ + return self._post( + 'batch/replaceuser', + data={ + 'media_id': media_id, + 'callback': { + 'url': url, + 'token': token, + 'encodingaeskey': encoding_aes_key + } + } + ) + + def replace_party(self, url, token, encoding_aes_key, media_id): + """ + 全量覆盖部门 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=异步任务接口 + + :param url: 企业应用接收企业号推送请求的访问协议和地址,支持http或https协议 + :param token: 用于生成签名 + :param encoding_aes_key: 用于消息体的加密,是AES密钥的Base64编码 + :param media_id: 上传的csv文件的media_id + :return: 返回的 JSON 数据包 + """ + return self._post( + 'batch/replaceparty', + data={ + 'media_id': media_id, + 'callback': { + 'url': url, + 'token': token, + 'encodingaeskey': encoding_aes_key + } + } + ) + + def get_result(self, job_id): + """ + 获取异步任务结果 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=异步任务接口 + + :param job_id: 异步任务id,最大长度为64字符 + :return: 返回的 JSON 数据包 + """ + return self._get( + 'batch/getresult', + params={ + 'jobid': job_id + } + ) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/chat.py b/sg_wechat_enterprise/we_api/enterprise/client/api/chat.py new file mode 100644 index 00000000..86c5eb18 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/chat.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatChat(BaseWeChatAPI): + + def create(self, chat_id, name, owner, user_list): + """ + 创建会话 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param chat_id: 会话id。字符串类型,最长32个字符。只允许字符0-9及字母a-zA-Z, + 如果值内容为64bit无符号整型:要求值范围在[1, 2^63)之间, + [2^63, 2^64)为系统分配会话id区间 + :param name: 会话标题 + :param owner: 管理员userid,必须是该会话userlist的成员之一 + :param user_list: 会话成员列表,成员用userid来标识。会话成员必须在3人或以上,1000人以下 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'chat/create', + data={ + 'chatid': chat_id, + 'name': name, + 'owner': owner, + 'userlist': user_list, + } + ) + + def get(self, chat_id): + """ + 获取会话 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param chat_id: 会话 ID + :return: 会话信息 + """ + res = self._get('chat/get', params={'chatid': chat_id}) + return res['chat_info'] + + def update(self, chat_id, op_user, name=None, owner=None, + add_user_list=None, del_user_list=None): + """ + 修改会话 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param chat_id: 会话 ID + :param op_user: 操作人 userid + :param name: 会话标题 + :param owner: 管理员userid,必须是该会话userlist的成员之一 + :param add_user_list: 会话新增成员列表,成员用userid来标识 + :param del_user_list: 会话退出成员列表,成员用userid来标识 + :return: 返回的 JSON 数据包 + """ + data = optionaldict( + chatid=chat_id, + op_user=op_user, + name=name, + owner=owner, + add_user_list=add_user_list, + del_user_list=del_user_list, + ) + return self._post('chat/update', data=data) + + def quit(self, chat_id, op_user): + """ + 退出会话 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param chat_id: 会话 ID + :param op_user: 操作人 userid + :return: 返回的 JSON 数据包 + """ + return self._post( + 'chat/quit', + data={ + 'chatid': chat_id, + 'op_user': op_user, + } + ) + + def clear_notify(self, op_user, type, id): + """ + 清除会话未读状态 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param op_user: 会话所有者的userid + :param type: 会话类型:single|group,分别表示:单聊|群聊 + :param id: 会话值,为userid|chatid,分别表示:成员id|会话id + :return: 返回的 JSON 数据包 + """ + return self._post( + 'chat/clearnotify', + data={ + 'op_user': op_user, + 'chat': { + 'type': type, + 'id': id, + } + } + ) + + def set_mute(self, user_mute_list): + """ + 设置成员新消息免打扰 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param user_mute_list: 成员新消息免打扰参数,数组,最大支持10000个成员 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'chat/setmute', + data={'user_mute_list': user_mute_list} + ) + + def send_text(self, sender, receiver_type, receiver_id, content): + """ + 发送文本消息 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param sender: 发送人 + :param receiver_type: 接收人类型:single|group,分别表示:单聊|群聊 + :param receiver_id: 接收人的值,为userid|chatid,分别表示:成员id|会话id + :param content: 消息内容 + :return: 返回的 JSON 数据包 + """ + data = { + 'receiver': { + 'type': receiver_type, + 'id': receiver_id, + }, + 'sender': sender, + 'msgtype': 'text', + 'text': { + 'content': content, + } + } + return self._post('chat/send', data=data) + + def send_single_text(self, sender, receiver, content): + """ + 发送单聊文本消息 + + :param sender: 发送人 + :param receiver: 接收人成员 ID + :param content: 消息内容 + :return: 返回的 JSON 数据包 + """ + return self.send_text(sender, 'single', receiver, content) + + def send_group_text(self, sender, receiver, content): + """ + 发送群聊文本消息 + + :param sender: 发送人 + :param receiver: 会话 ID + :param content: 消息内容 + :return: 返回的 JSON 数据包 + """ + return self.send_text(sender, 'group', receiver, content) + + def send_image(self, sender, receiver_type, receiver_id, media_id): + """ + 发送图片消息 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param sender: 发送人 + :param receiver_type: 接收人类型:single|group,分别表示:单聊|群聊 + :param receiver_id: 接收人的值,为userid|chatid,分别表示:成员id|会话id + :param media_id: 图片媒体文件id,可以调用上传素材文件接口获取 + :return: 返回的 JSON 数据包 + """ + data = { + 'receiver': { + 'type': receiver_type, + 'id': receiver_id, + }, + 'sender': sender, + 'msgtype': 'image', + 'image': { + 'media_id': media_id, + } + } + return self._post('chat/send', data=data) + + def send_single_image(self, sender, receiver, media_id): + """ + 发送单聊图片消息 + + :param sender: 发送人 + :param receiver: 接收人成员 ID + :param media_id: 图片媒体文件id,可以调用上传素材文件接口获取 + :return: 返回的 JSON 数据包 + """ + return self.send_image(sender, 'single', receiver, media_id) + + def send_group_image(self, sender, receiver, media_id): + """ + 发送群聊图片消息 + + :param sender: 发送人 + :param receiver: 会话 ID + :param media_id: 图片媒体文件id,可以调用上传素材文件接口获取 + :return: 返回的 JSON 数据包 + """ + return self.send_image(sender, 'group', receiver, media_id) + + def send_file(self, sender, receiver_type, receiver_id, media_id): + """ + 发送文件消息 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param sender: 发送人 + :param receiver_type: 接收人类型:single|group,分别表示:单聊|群聊 + :param receiver_id: 接收人的值,为userid|chatid,分别表示:成员id|会话id + :param media_id: 文件id,可以调用上传素材文件接口获取, 文件须大于4字节 + :return: 返回的 JSON 数据包 + """ + data = { + 'receiver': { + 'type': receiver_type, + 'id': receiver_id, + }, + 'sender': sender, + 'msgtype': 'file', + 'file': { + 'media_id': media_id, + } + } + return self._post('chat/send', data=data) + + def send_single_file(self, sender, receiver, media_id): + """ + 发送单聊文件消息 + + :param sender: 发送人 + :param receiver: 接收人成员 ID + :param media_id: 文件id,可以调用上传素材文件接口获取, 文件须大于4字节 + :return: 返回的 JSON 数据包 + """ + return self.send_file(sender, 'single', receiver, media_id) + + def send_group_file(self, sender, receiver, media_id): + """ + 发送群聊文件消息 + + :param sender: 发送人 + :param receiver: 会话 ID + :param media_id: 文件id,可以调用上传素材文件接口获取, 文件须大于4字节 + :return: 返回的 JSON 数据包 + """ + return self.send_file(sender, 'group', receiver, media_id) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/department.py b/sg_wechat_enterprise/we_api/enterprise/client/api/department.py new file mode 100644 index 00000000..096d6870 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/department.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatDepartment(BaseWeChatAPI): + + def create(self, name, parent_id=1, order=None, id=None): + """ + 创建部门 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理部门 + + :param name: 部门名称,长度限制为 1~64 个字符 + :param parent_id: 父亲部门 id ,根部门 id 为 1 + :return: 返回的 JSON 数据包 + """ + department_data = optionaldict() + department_data['name'] = name + department_data['parentid'] = parent_id + department_data['order'] = order + department_data['id'] = id + return self._post( + 'department/create', + data=dict(department_data) + ) + + def update(self, id, name=None, parent_id=None, order=None): + """ + 更新部门 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理部门 + + :param id: 部门 id + :param name: 部门名称 + :param parent_id: 父亲部门 id + :param order: 在父部门中的次序,从 1 开始,数字越大排序越靠后 + :return: 返回的 JSON 数据包 + """ + department_data = optionaldict() + department_data['id'] = id + department_data['name'] = name + department_data['parentid'] = parent_id + department_data['order'] = order + return self._post( + 'department/update', + data=dict(department_data) + ) + + def delete(self, id): + """ + 删除部门 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理部门 + + :param id: 部门 id + :return: 返回的 JSON 数据包 + """ + return self._get( + 'department/delete', + params={ + 'id': id + } + ) + + def get(self): + """ + 获取部门列表 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理部门 + + :return: 部门列表 + """ + res = self._get('department/list') + return res['department'] + + def get_users(self, id, status=0, fetch_child=0): + """ + 获取部门成员列表 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + + :param id: 部门 id + :param status: 0 获取全部员工,1 获取已关注成员列表, + 2 获取禁用成员列表,4 获取未关注成员列表。可叠加 + :param fetch_child: 1/0:是否递归获取子部门下面的成员 + :return: 部门成员列表 + """ + fetch_child = 1 if fetch_child else 0 + res = self._get( + 'user/simplelist', + params={ + 'department_id': id, + 'status': status, + 'fetch_child': fetch_child + } + ) + return res['userlist'] + + def get_users_list(self, id, status=0, fetch_child=0): + """ + 获取部门成员详情列表 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + + :param id: 部门 id + :param status: 0 获取全部员工,1 获取已关注成员列表, + 2 获取禁用成员列表,4 获取未关注成员列表。可叠加 + :param fetch_child: 1/0:是否递归获取子部门下面的成员 + :return: 部门成员列表 + """ + fetch_child = 1 if fetch_child else 0 + res = self._get( + 'user/list', + params={ + 'department_id': id, + 'status': status, + 'fetch_child': fetch_child + } + ) + return res['userlist'] \ No newline at end of file diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/jsapi.py b/sg_wechat_enterprise/we_api/enterprise/client/api/jsapi.py new file mode 100644 index 00000000..b0661ea2 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/jsapi.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import time + +from wechatpy.client.api.base import BaseWeChatAPI +from wechatpy.utils import WeChatSigner + + +class WeChatJSAPI(BaseWeChatAPI): + + def get_ticket(self): + """ + 获取微信 JS-SDK ticket + + http://qydev.weixin.qq.com/wiki/index.php?title=%E5%BE%AE%E4%BF%A1JS%E6%8E%A5%E5%8F%A3 + + :return: 返回的 JSON 数据包 + """ + return self._get('get_jsapi_ticket') + + def get_jsapi_ticket(self): + """ + 获取微信 JS-SDK ticket + + 该方法会通过 session 对象自动缓存管理 ticket + + :return: ticket + """ + ticket = self.session.get('jsapi_ticket') + expires_at = self.session.get('jsapi_ticket_expires_at', 0) + if not ticket or expires_at < int(time.time()): + jsapi_ticket = self.get_ticket() + ticket = jsapi_ticket['ticket'] + expires_at = int(time.time()) + int(jsapi_ticket['expires_in']) + self.session.set('jsapi_ticket', ticket) + self.session.set('jsapi_ticket_expires_at', expires_at) + return ticket + + def get_jsapi_signature(self, noncestr, ticket, timestamp, url): + data = [ + 'noncestr={noncestr}'.format(noncestr=noncestr), + 'jsapi_ticket={ticket}'.format(ticket=ticket), + 'timestamp={timestamp}'.format(timestamp=timestamp), + 'url={url}'.format(url=url), + ] + signer = WeChatSigner(delimiter=b'&') + signer.add_data(*data) + return signer.signature diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/material.py b/sg_wechat_enterprise/we_api/enterprise/client/api/material.py new file mode 100644 index 00000000..b5547904 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/material.py @@ -0,0 +1,201 @@ +# encoding: utf-8 +from __future__ import absolute_import, unicode_literals +import requests + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMaterial(BaseWeChatAPI): + + def add_articles(self, articles): + """ + 新增永久图文素材 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E4%B8%8A%E4%BC%A0%E6%B0%B8%E4%B9%85%E7%B4%A0%E6%9D%90 + + :param articles: 图文素材数组 + :return: 返回的 JSON 数据包 + """ + articles_data = [] + for article in articles: + articles_data.append({ + 'thumb_media_id': article['thumb_media_id'], + 'title': article['title'], + 'content': article['content'], + 'author': article.get('author', ''), + 'content_source_url': article.get('content_source_url', ''), + 'digest': article.get('digest', ''), + 'show_cover_pic': article.get('show_cover_pic', '0') + }) + return self._post( + 'material/add_mpnews', + data={ + "mpnews": { + "articles": articles_data + } + } + ) + + def add(self, agent_id, media_type, media_file): + """ + 新增其它类型永久素材 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E4%B8%8A%E4%BC%A0%E6%B0%B8%E4%B9%85%E7%B4%A0%E6%9D%90 + + :param agent_id: 企业应用的id + :param media_type: 媒体文件类型,分别有图片(image)、语音(voice)、视频(video)普通文件(file) + :param media_file: 要上传的文件,一个 File-object + :return: 返回的 JSON 数据包 + """ + params = { + 'agentid': agent_id, + 'type': media_type, + } + return self._post( + url='material/add_material', + params=params, + files={ + 'media': media_file + } + ) + + def get_url(self, agent_id, media_id): + """ + 获取永久素材下载地址 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E6%B0%B8%E4%B9%85%E7%B4%A0%E6%9D%90 + + :param agent_id: 企业应用的id + :param media_id: 媒体文件 ID + :return: 临时素材下载地址 + """ + parts = ( + 'https://qyapi.weixin.qq.com/cgi-bin/material/get', + '?access_token=', + self.access_token, + '&media_id=', + media_id, + '&agentid=', + agent_id, + ) + return ''.join(parts) + + def get(self, agent_id, media_id): + """ + 获取永久素材 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E6%B0%B8%E4%B9%85%E7%B4%A0%E6%9D%90 + + :param agent_id: 企业应用的id + :param media_id: 媒体文件 ID + :return: requests 的 Response 实例 + """ + res = requests.get(self.get_url(agent_id, media_id)) + + return res + + def get_articles(self, agent_id, media_id): + """ + 获取永久素材:图文消息素材 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E6%B0%B8%E4%B9%85%E7%B4%A0%E6%9D%90 + + :param agent_id: 企业应用的id + :param media_id: 媒体文件 ID + :return: 返回的 JSON 数据包 + """ + return self._get( + 'material/get', + params={ + 'agentid': agent_id, + 'media_id': media_id, + } + ) + + def delete(self, agent_id, media_id): + """ + 删除永久素材 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E5%88%A0%E9%99%A4%E6%B0%B8%E4%B9%85%E7%B4%A0%E6%9D%90 + + :param agent_id: 企业应用的id + :param media_id: 媒体文件 ID + :return: 返回的 JSON 数据包 + """ + return self._get( + 'material/del', + params={ + 'agentid': agent_id, + 'media_id': media_id, + } + ) + + def update_articles(self, agent_id, media_id, articles): + """ + 修改永久图文素材 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E4%BF%AE%E6%94%B9%E6%B0%B8%E4%B9%85%E5%9B%BE%E6%96%87%E7%B4%A0%E6%9D%90 + + :param media_id: 要修改的图文消息的 id + :param index: 要更新的文章在图文消息中的位置(多图文消息时,此字段才有意义),第一篇为 0 + :param articles: 图文素材数组 + :return: 返回的 JSON 数据包 + """ + articles_data = [] + for article in articles: + articles_data.append({ + 'thumb_media_id': article['thumb_media_id'], + 'title': article['title'], + 'content': article['content'], + 'author': article.get('author', ''), + 'content_source_url': article.get('content_source_url', ''), + 'digest': article.get('digest', ''), + 'show_cover_pic': article.get('show_cover_pic', '0') + }) + return self._post( + 'material/update_news', + data={ + 'agentid': agent_id, + 'media_id': media_id, + 'articles': articles_data + } + ) + + def get_count(self, agent_id): + """ + 获取素材总数 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E7%B4%A0%E6%9D%90%E6%80%BB%E6%95%B0 + + :param agent_id: 企业应用的id + :return: 返回的 JSON 数据包 + """ + return self._get( + 'material/get_count', + params={ + 'agent_id': agent_id, + } + ) + + def batchget(self, agent_id, media_type, offset=0, count=20): + """ + 批量获取永久素材列表 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E7%B4%A0%E6%9D%90%E5%88%97%E8%A1%A8 + + :param agent_id: 企业应用的id + :param media_type: 媒体文件类型,分别有图文(mpnews)、图片(image)、 + 语音(voice)、视频(video)和文件(file) + :param offset: 从全部素材的该偏移位置开始返回,0 表示从第一个素材返回 + :param count: 返回素材的数量,取值在1到20之间 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'material/batchget', + data={ + 'agent_id': agent_id, + 'type': media_type, + 'offset': offset, + 'count': count + } + ) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/media.py b/sg_wechat_enterprise/we_api/enterprise/client/api/media.py new file mode 100644 index 00000000..0541ee54 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/media.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import requests + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMedia(BaseWeChatAPI): + + def upload(self, media_type, media_file): + """ + 上传临时素材文件 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E4%B8%8A%E4%BC%A0%E4%B8%B4%E6%97%B6%E7%B4%A0%E6%9D%90%E6%96%87%E4%BB%B6 + + :param media_type: 媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和普通文件(file) + :param media_file: 要上传的文件,一个 File-object + :return: 返回的 JSON 数据包 + """ + return self._post( + 'media/upload', + params={ + 'type': media_type + }, + files={ + 'media': media_file + } + ) + + def upload_image(self, media_file): + """ + 上传卡券logo + :param media_file: 要上传的文件 + :return: + """ + return self._post( + 'media/uploadimg', + params={ + 'type': 'card_logo' + }, + files={ + 'media': media_file + } + ) + + def download(self, media_id): + """ + 获取临时素材文件 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E4%B8%B4%E6%97%B6%E7%B4%A0%E6%9D%90%E6%96%87%E4%BB%B6 + + :param media_id: 媒体文件 ID + :return: requests 的 Response 实例 + """ + return requests.get(self.get_url(media_id)) + + def get_url(self, media_id): + """ + 获取临时素材下载地址 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E4%B8%B4%E6%97%B6%E7%B4%A0%E6%9D%90%E6%96%87%E4%BB%B6 + + :param media_id: 媒体文件 ID + :return: 临时素材下载地址 + """ + parts = ( + 'https://qyapi.weixin.qq.com/cgi-bin/media/get', + '?access_token=', + self.access_token, + '&media_id=', + media_id + ) + return ''.join(parts) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/menu.py b/sg_wechat_enterprise/we_api/enterprise/client/api/menu.py new file mode 100644 index 00000000..2b41f882 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/menu.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI +from wechatpy.exceptions import WeChatClientException + + +class WeChatMenu(BaseWeChatAPI): + + def create(self, agent_id, menu_data): + return self._post( + 'menu/create', + params={ + 'agentid': agent_id + }, + data=menu_data + ) + + def get(self, agent_id): + try: + return self._get( + 'menu/get', + params={ + 'agentid': agent_id + } + ) + except WeChatClientException as e: + if e.errcode == 46003: + # menu not exist + return None + else: + raise e + + def delete(self, agent_id): + return self._get( + 'menu/delete', + params={ + 'agentid': agent_id + } + ) + + def update(self, agent_id, menu_data): + self.delete(agent_id) + return self.create(agent_id, menu_data) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/message.py b/sg_wechat_enterprise/we_api/enterprise/client/api/message.py new file mode 100644 index 00000000..b6a93e28 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/message.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMessage(BaseWeChatAPI): + + def _send_message(self, agent_id, user_ids, party_ids='', + tag_ids='', msg=None): + msg = msg or {} + if isinstance(user_ids, (tuple, list)): + user_ids = '|'.join(user_ids) + if isinstance(party_ids, (tuple, list)): + party_ids = '|'.join(party_ids) + if isinstance(tag_ids, (tuple, list)): + tag_ids = '|'.join(tag_ids) + + data = { + 'touser': user_ids, + 'toparty': party_ids, + 'totag': tag_ids, + 'agentid': agent_id + } + data.update(msg) + return self._post( + 'message/send', + data=data + ) + + def send_text(self, agent_id, user_ids, content, + party_ids='', tag_ids='', safe=0): + return self._send_message( + agent_id, + user_ids, + party_ids, + tag_ids, + msg={ + 'msgtype': 'text', + 'text': {'content': content}, + 'safe': safe + } + ) + + def send_image(self, agent_id, user_ids, media_id, + party_ids='', tag_ids='', safe=0): + return self._send_message( + agent_id, + user_ids, + party_ids, + tag_ids, + msg={ + 'msgtype': 'image', + 'image': { + 'media_id': media_id + }, + 'safe': safe + } + ) + + def send_voice(self, agent_id, user_ids, media_id, + party_ids='', tag_ids='', safe=0): + return self._send_message( + agent_id, + user_ids, + party_ids, + tag_ids, + msg={ + 'msgtype': 'voice', + 'voice': { + 'media_id': media_id + }, + 'safe': safe + } + ) + + def send_video(self, agent_id, user_ids, media_id, title=None, + description=None, party_ids='', tag_ids='', safe=0): + video_data = optionaldict() + video_data['media_id'] = media_id + video_data['title'] = title + video_data['description'] = description + + return self._send_message( + agent_id, + user_ids, + party_ids, + tag_ids, + msg={ + 'msgtype': 'video', + 'video': dict(video_data), + 'safe': safe + } + ) + + def send_file(self, agent_id, user_ids, media_id, + party_ids='', tag_ids='', safe=0): + return self._send_message( + agent_id, + user_ids, + party_ids, + tag_ids, + msg={ + 'msgtype': 'file', + 'file': { + 'media_id': media_id + }, + 'safe': safe + } + ) + + def send_articles(self, agent_id, user_ids, articles, + party_ids='', tag_ids=''): + articles_data = [] + for article in articles: + articles_data.append({ + 'title': article['title'], + 'description': article['description'], + 'url': article['url'], + 'picurl': article['image'] + }) + return self._send_message( + agent_id, + user_ids, + party_ids, + tag_ids, + msg={ + 'msgtype': 'news', + 'news': { + 'articles': articles_data + } + } + ) + + def send_mp_articles(self, agent_id, user_ids, articles, + party_ids='', tag_ids='', safe=0): + articles_data = [] + for article in articles: + articles_data.append({ + 'thumb_media_id': article['thumb_media_id'], + 'author': article['author'], + 'title': article['title'], + 'content': article['content'], + 'content_source_url': article['content_source_url'], + 'digest': article['digest'], + 'show_cover_pic': article['show_cover_pic'] + }) + return self._send_message( + agent_id, + user_ids, + party_ids, + tag_ids, + msg={ + 'msgtype': 'mpnews', + 'mpnews': { + 'articles': articles_data + }, + 'safe': safe + } + ) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/misc.py b/sg_wechat_enterprise/we_api/enterprise/client/api/misc.py new file mode 100644 index 00000000..bec63870 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/misc.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMisc(BaseWeChatAPI): + + def get_wechat_ips(self): + """ + 获取微信服务器 IP 列表 + + :return: IP 地址列表 + """ + res = self._get('getcallbackip') + return res['ip_list'] diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/oauth.py b/sg_wechat_enterprise/we_api/enterprise/client/api/oauth.py new file mode 100644 index 00000000..cf9e40d7 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/oauth.py @@ -0,0 +1,51 @@ +# encoding: utf-8 +from __future__ import absolute_import, unicode_literals +import six + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatOAuth(BaseWeChatAPI): + + OAUTH_BASE_URL = 'https://open.weixin.qq.com/connect/oauth2/authorize' + + def authorize_url(self, redirect_uri, state=None): + """ + 获取授权地址 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=OAuth%E9%AA%8C%E8%AF%81%E6%8E%A5%E5%8F%A3 + + :param redirect_url: 授权后重定向的回调链接地址 + :param state: 重定向后会带上 state 参数 + :return: 返回的 JSON 数据包 + """ + redirect_uri = six.moves.urllib.parse.quote(redirect_uri) + url_list = [ + self.OAUTH_BASE_URL, + '?appid=', + self._client.corp_id, + '&redirect_uri=', + redirect_uri, + '&response_type=code&scope=snsapi_base', + ] + if state: + url_list.extend(['&state=', state]) + url_list.append('#wechat_redirect') + return ''.join(url_list) + + def get_user_info(self, code): + """ + 根据 code 获取用户信息 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=OAuth%E9%AA%8C%E8%AF%81%E6%8E%A5%E5%8F%A3 + + :param code: 通过成员授权获取到的code + :return: 返回的 JSON 数据包 + """ + + return self._get( + 'user/getuserinfo', + params={ + 'code': code, + } + ) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/service.py b/sg_wechat_enterprise/we_api/enterprise/client/api/service.py new file mode 100644 index 00000000..07e2bcd8 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/service.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatService(BaseWeChatAPI): + + def get_provider_token(self, provider_secret): + """ + 获取应用提供商凭证 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=获取应用提供商凭证 + + :param provider_secret: 提供商的secret,在提供商管理页面可见 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'service/get_provider_token', + data={ + 'corpid': self._client.corp_id, + 'provider_secret': provider_secret, + } + ) + + def get_login_info(self, provider_access_token, auth_code): + """ + 获取企业号管理员登录信息 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=获取企业号管理员登录信息 + + :param provider_access_token: 服务提供商的 accesstoken + :param auth_code: OAuth 2.0 授权企业号管理员登录产生的 code + :return: 返回的 JSON 数据包 + """ + return self._post( + 'service/get_login_info', + params={ + 'provider_access_token': provider_access_token, + }, + data={ + 'auth_code': auth_code, + } + ) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/shakearound.py b/sg_wechat_enterprise/we_api/enterprise/client/api/shakearound.py new file mode 100644 index 00000000..27716498 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/shakearound.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# encoding: utf-8 +from __future__ import absolute_import, unicode_literals + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatShakeAround(BaseWeChatAPI): + + def get_shake_info(self, ticket): + """ + 获取摇周边的设备及用户信息 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=获取设备及用户信息 + + :param ticket: 摇周边业务的ticket,可在摇到的 URL 中得到,ticket 生效时间为30分钟 + :return: 设备及用户信息 + """ + res = self._post( + 'shakearound/getshakeinfo', + data={ + 'ticket': ticket + } + ) + return res['data'] diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/tag.py b/sg_wechat_enterprise/we_api/enterprise/client/api/tag.py new file mode 100644 index 00000000..c6be2733 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/tag.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatTag(BaseWeChatAPI): + + def create(self, name): + return self._post( + 'tag/create', + data={ + 'tagname': name + } + ) + + def update(self, tag_id, name): + return self._post( + 'tag/update', + data={ + 'tagid': tag_id, + 'tagname': name + } + ) + + def delete(self, tag_id): + return self._get( + 'tag/delete', + params={ + 'tagid': tag_id + } + ) + + def get_users(self, tag_id): + return self._get( + 'tag/get', + params={ + 'tagid': tag_id + } + ) + + def add_users(self, tag_id, user_ids): + return self._post( + 'tag/addtagusers', + data={ + 'tagid': tag_id, + 'userlist': user_ids + } + ) + + def delete_users(self, tag_id, user_ids): + return self._post( + 'tag/deltagusers', + data={ + 'tagid': tag_id, + 'userlist': user_ids + } + ) + + def list(self): + res = self._get('tag/list') + return res['taglist'] diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/user.py b/sg_wechat_enterprise/we_api/enterprise/client/api/user.py new file mode 100644 index 00000000..97b53b86 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/user.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatUser(BaseWeChatAPI): + + def create(self, user_id, name, department=None, position=None, + mobile=None, gender=0, tel=None, email=None, + weixin_id=None, extattr=None, avatar_mediaid=None, to_invite=True): + """ + 创建成员 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + """ + user_data = optionaldict() + user_data['userid'] = user_id + user_data['name'] = name + user_data['gender'] = gender + user_data['department'] = department + user_data['position'] = position + user_data['mobile'] = mobile + user_data['tel'] = tel + user_data['email'] = email + user_data['weixinid'] = weixin_id + user_data['extattr'] = extattr + user_data['avatar_mediaid'] = avatar_mediaid + user_data['to_invite'] = to_invite + + return self._post( + 'user/create', + data=user_data + ) + + def update(self, user_id, name=None, department=None, position=None, + mobile=None, gender=None, tel=None, email=None, + weixin_id=None, enable=None, extattr=None, avatar_mediaid=None): + """ + 更新成员 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + """ + user_data = optionaldict() + user_data['userid'] = user_id + user_data['name'] = name + user_data['gender'] = gender + user_data['department'] = department + user_data['position'] = position + user_data['mobile'] = mobile + user_data['tel'] = tel + user_data['email'] = email + user_data['weixinid'] = weixin_id + user_data['extattr'] = extattr + user_data['enable'] = enable + user_data['avatar_mediaid'] = avatar_mediaid + + return self._post( + 'user/update', + data=user_data + ) + + def delete(self, user_id): + """ + 删除成员 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + """ + return self._get( + 'user/delete', + params={ + 'userid': user_id + } + ) + + def get(self, user_id): + """ + 获取成员 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + """ + return self._get( + 'user/get', + params={ + 'userid': user_id + } + ) + + def verify(self, user_id): + return self._get( + 'user/authsucc', + params={ + 'userid': user_id + } + ) + + def get_info(self, agent_id, code): + return self._get( + 'user/getuserinfo', + params={ + 'agentid': agent_id, + 'code': code + } + ) + + def batch_delete(self, user_ids): + """ + 批量删除成员 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + """ + return self._post( + 'user/batchdelete', + data={ + 'useridlist': user_ids + } + ) + + def list(self, department_id, fetch_child=False, status=0): + """ + 批量获取部门成员 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + """ + res = self._get( + 'user/list', + params={ + 'department_id': department_id, + 'fetch_child': 1 if fetch_child else 0, + 'status': status + } + ) + return res['userlist'] + + def convert_to_openid(self, user_id, agent_id=None): + """ + user_id 转成 openid + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=Userid%E4%B8%8Eopenid%E4%BA%92%E6%8D%A2%E6%8E%A5%E5%8F%A3 + + :param user_id: 企业号内的成员 ID + :param agent_id: 可选,需要发送红包的应用ID,若只是使用微信支付和企业转账,则无需该参数 + :return: 返回的 JSON 数据包 + """ + data = optionaldict() + data['userid'] = user_id + data['agentid'] = agent_id + return self._post('user/convert_to_openid', data=data) + + def convert_to_user_id(self, openid): + """ + openid 转成 user_id + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=Userid%E4%B8%8Eopenid%E4%BA%92%E6%8D%A2%E6%8E%A5%E5%8F%A3 + + :param openid: 在使用微信支付、微信红包和企业转账之后,返回结果的openid + :return: 该 openid 在企业号中对应的成员 user_id + """ + res = self._post( + 'user/convert_to_userid', + data={'openid': openid} + ) + return res['userid'] diff --git a/sg_wechat_enterprise/we_api/enterprise/crypto.py b/sg_wechat_enterprise/we_api/enterprise/crypto.py new file mode 100644 index 00000000..7bd46f9c --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/crypto.py @@ -0,0 +1,46 @@ +from __future__ import absolute_import, unicode_literals + +from wechatpy.crypto import BasePrpCrypto, BaseWeChatCrypto +from wechatpy.enterprise.exceptions import InvalidCorpIdException + + +class PrpCrypto(BasePrpCrypto): + + def encrypt(self, text, corp_id): + return self._encrypt(text, corp_id) + + def decrypt(self, text, corp_id): + return self._decrypt(text, corp_id, InvalidCorpIdException) + + +class WeChatCrypto(BaseWeChatCrypto): + + def __init__(self, token, encoding_aes_key, corp_id): + super(WeChatCrypto, self).__init__(token, encoding_aes_key, corp_id) + self.corp_id = corp_id + + def check_signature(self, signature, timestamp, nonce, echo_str): + return self._check_signature( + signature, + timestamp, + nonce, + echo_str, + PrpCrypto + ) + + def encrypt_message(self, msg, nonce, timestamp=None): + return self._encrypt_message( + msg, + nonce, + timestamp, + PrpCrypto + ) + + def decrypt_message(self, msg, signature, timestamp, nonce): + return self._decrypt_message( + msg, + signature, + timestamp, + nonce, + PrpCrypto + ) diff --git a/sg_wechat_enterprise/we_api/enterprise/events.py b/sg_wechat_enterprise/we_api/enterprise/events.py new file mode 100644 index 00000000..6b3ef752 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/events.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.fields import IntegerField, BaseField +from wechatpy import events + + +EVENT_TYPES = {} + + +def register_event(event_type): + def register(cls): + EVENT_TYPES[event_type] = cls + return cls + return register + + +@register_event('subscribe') +class SubscribeEvent(events.SubscribeEvent): + """ + 成员关注事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('unsubscribe') +class UnsubscribeEvent(events.UnsubscribeEvent): + """ + 成员取消关注事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('click') +class ClickEvent(events.ClickEvent): + """ + 点击菜单拉取消息事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('xml') +class ViewEvent(events.ViewEvent): + """ + 点击菜单跳转链接事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('location') +class LocationEvent(events.LocationEvent): + """ + 上报地理位置事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('scancode_push') +class ScanCodePushEvent(events.ScanCodePushEvent): + """ + 扫码推事件的事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('scancode_waitmsg') +class ScanCodeWaitMsgEvent(events.ScanCodeWaitMsgEvent): + """ + 扫码推事件且弹出“消息接收中”提示框的事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('pic_sysphoto') +class PicSysPhotoEvent(events.PicSysPhotoEvent): + """ + 弹出系统拍照发图事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('pic_photo_or_album') +class PicPhotoOrAlbumEvent(events.PicPhotoOrAlbumEvent): + """ + 弹出拍照或相册发图事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('pic_weixin') +class PicWeChatEvent(events.PicWeChatEvent): + """ + 弹出微信相册发图器事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('location_select') +class LocationSelectEvent(events.LocationSelectEvent): + """ + 弹出地理位置选择器事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('enter_agent') +class EnterAgentEvent(events.BaseEvent): + """ + 用户进入应用的事件推送 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + event = 'enter_agent' + + +@register_event('batch_job_result') +class BatchJobResultEvent(events.BaseEvent): + """ + 异步任务完成事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + event = 'batch_job_result' + batch_job = BaseField('BatchJob') + + @property + def job_id(self): + return self.batch_job['JobId'] + + @property + def job_type(self): + return self.batch_job['JobType'] + + @property + def err_code(self): + return self.batch_job['ErrCode'] + + @property + def err_msg(self): + return self.batch_job['ErrMsg'] diff --git a/sg_wechat_enterprise/we_api/enterprise/exceptions.py b/sg_wechat_enterprise/we_api/enterprise/exceptions.py new file mode 100644 index 00000000..80c136d8 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/exceptions.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.exceptions import WeChatException + + +class InvalidCorpIdException(WeChatException): + + def __init__(self, errcode=-40005, errmsg='Invalid corp_id'): + super(InvalidCorpIdException, self).__init__(errcode, errmsg) diff --git a/sg_wechat_enterprise/we_api/enterprise/messages.py b/sg_wechat_enterprise/we_api/enterprise/messages.py new file mode 100644 index 00000000..8ff46f89 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/messages.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.fields import IntegerField, StringField +from wechatpy import messages + + +MESSAGE_TYPES = {} + + +def register_message(msg_type): + def register(cls): + MESSAGE_TYPES[msg_type] = cls + return cls + return register + + +@register_message('text') +class TextMessage(messages.TextMessage): + agent = IntegerField('AgentID', 0) + + +@register_message('image') +class ImageMessage(messages.ImageMessage): + agent = IntegerField('AgentID', 0) + + +@register_message('voice') +class VoiceMessage(messages.VoiceMessage): + agent = IntegerField('AgentID', 0) + + +@register_message('shortvideo') +class ShortVideoMessage(messages.ShortVideoMessage): + agent = IntegerField('AgentID', 0) + + +@register_message('video') +class VideoMessage(messages.VideoMessage): + agent = IntegerField('AgentID', 0) + + +@register_message('location') +class LocationMessage(messages.LocationMessage): + agent = IntegerField('AgentID', 0) + +@register_message('link') +class LinkMessage(messages.LinkMessage): + agent = IntegerField('AgentID', 0) + pic_url = StringField('PicUrl') diff --git a/sg_wechat_enterprise/we_api/enterprise/parser.py b/sg_wechat_enterprise/we_api/enterprise/parser.py new file mode 100644 index 00000000..a4b8b4e0 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/parser.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import xmltodict + +from wechatpy.messages import UnknownMessage +from wechatpy.utils import to_text +from wechatpy.enterprise.messages import MESSAGE_TYPES +from wechatpy.enterprise.events import EVENT_TYPES + + +def parse_message(xml): + if not xml: + return + message = xmltodict.parse(to_text(xml))['xml'] + message_type = message['MsgType'].lower() + if message_type == 'event': + event_type = message['Event'].lower() + message_class = EVENT_TYPES.get(event_type, UnknownMessage) + else: + message_class = MESSAGE_TYPES.get(message_type, UnknownMessage) + return message_class(message) diff --git a/sg_wechat_enterprise/we_api/enterprise/replies.py b/sg_wechat_enterprise/we_api/enterprise/replies.py new file mode 100644 index 00000000..0597f13a --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/replies.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import six + +from wechatpy import replies +from wechatpy.fields import IntegerField + + +REPLY_TYPES = {} + + +def register_reply(reply_type): + def register(cls): + REPLY_TYPES[reply_type] = cls + return cls + return register + + +@register_reply('text') +class TextReply(replies.TextReply): + agent = IntegerField('AgentID', 0) + + +@register_reply('image') +class ImageReply(replies.ImageReply): + agent = IntegerField('AgentID', 0) + + +@register_reply('voice') +class VoiceReply(replies.VoiceReply): + agent = IntegerField('AgentID', 0) + + +@register_reply('video') +class VideoReply(replies.VideoReply): + agent = IntegerField('AgentID', 0) + + +@register_reply('news') +class ArticlesReply(replies.ArticlesReply): + agent = IntegerField('AgentID', 0) + + +def create_reply(reply, message=None, render=False): + r = None + if isinstance(reply, replies.BaseReply): + r = reply + if message: + r.source = message.target + r.target = message.source + r.agent = message.agent + elif isinstance(reply, six.string_types): + r = TextReply( + message=message, + content=reply + ) + elif isinstance(reply, (tuple, list)): + if len(reply) > 10: + raise AttributeError("Can't add more than 10 articles" + " in an ArticlesReply") + r = ArticlesReply( + message=message, + articles=reply + ) + if r and render: + return r.render() + return r diff --git a/sg_wechat_enterprise/we_api/events.py b/sg_wechat_enterprise/we_api/events.py new file mode 100644 index 00000000..ac2d217f --- /dev/null +++ b/sg_wechat_enterprise/we_api/events.py @@ -0,0 +1,629 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.events + ~~~~~~~~~~~~~~~~ + + This module contains all the events WeChat callback uses. + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals + +from wechatpy.fields import ( + StringField, + FloatField, + IntegerField, + BaseField, + Base64DecodeField, + DateTimeField +) +from wechatpy.messages import BaseMessage + + +EVENT_TYPES = {} + + +def register_event(event_type): + """ + Register the event class so that they can be accessed from EVENT_TYPES + + :param event_type: Event type + """ + def register(cls): + EVENT_TYPES[event_type] = cls + return cls + return register + + +class BaseEvent(BaseMessage): + """Base class for all events""" + type = 'event' + event = '' + + +@register_event('subscribe') +class SubscribeEvent(BaseEvent): + """ + 用户关注事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/2/5baf56ce4947d35003b86a9805634b1e.html + """ + event = 'subscribe' + + +@register_event('unsubscribe') +class UnsubscribeEvent(BaseEvent): + """ + 用户取消关注事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/2/5baf56ce4947d35003b86a9805634b1e.html + """ + event = 'unsubscribe' + + +@register_event('subscribe_scan') +class SubscribeScanEvent(BaseEvent): + """ + 用户扫描二维码关注事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/2/5baf56ce4947d35003b86a9805634b1e.html + """ + event = 'subscribe_scan' + scene_id = StringField('EventKey') + ticket = StringField('Ticket') + + +@register_event('scan') +class ScanEvent(BaseEvent): + """ + 用户扫描二维码事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/2/5baf56ce4947d35003b86a9805634b1e.html + """ + event = 'scan' + scene_id = StringField('EventKey') + ticket = StringField('Ticket') + + +@register_event('location') +class LocationEvent(BaseEvent): + """ + 上报地理位置事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/2/5baf56ce4947d35003b86a9805634b1e.html + """ + event = 'location' + latitude = FloatField('Latitude', 0.0) + longitude = FloatField('Longitude', 0.0) + precision = FloatField('Precision', 0.0) + + +@register_event('click') +class ClickEvent(BaseEvent): + """ + 点击菜单拉取消息事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/2/5baf56ce4947d35003b86a9805634b1e.html + """ + event = 'click' + key = StringField('EventKey') + + +@register_event('xml') +class ViewEvent(BaseEvent): + """ + 点击菜单跳转链接事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/2/5baf56ce4947d35003b86a9805634b1e.html + """ + event = 'xml' + url = StringField('EventKey') + + +@register_event('masssendjobfinish') +class MassSendJobFinishEvent(BaseEvent): + """ + 群发消息任务完成事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + """ + id = IntegerField('MsgID', 0) + event = 'masssendjobfinish' + status = StringField('Status') + total_count = IntegerField('TotalCount', 0) + filter_count = IntegerField('FilterCount', 0) + sent_count = IntegerField('SentCount', 0) + error_count = IntegerField('ErrorCount', 0) + + +@register_event('templatesendjobfinish') +class TemplateSendJobFinishEvent(BaseEvent): + """ + 模板消息任务完成事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/17/304c1885ea66dbedf7dc170d84999a9d.html + """ + id = IntegerField('MsgID') + event = 'templatesendjobfinish' + status = StringField('Status') + + +class BaseScanCodeEvent(BaseEvent): + key = StringField('EventKey') + scan_code_info = BaseField('ScanCodeInfo', {}) + + @property + def scan_type(self): + return self.scan_code_info['ScanType'] + + @property + def scan_result(self): + return self.scan_code_info['ScanResult'] + + +@register_event('scancode_push') +class ScanCodePushEvent(BaseScanCodeEvent): + """ + 扫码推事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/981d772286d10d153a3dc4286c1ee5b5.html + """ + event = 'scancode_push' + + +@register_event('scancode_waitmsg') +class ScanCodeWaitMsgEvent(BaseScanCodeEvent): + """ + 扫码推事件且弹出“消息接收中”提示框的事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/981d772286d10d153a3dc4286c1ee5b5.html + """ + event = 'scancode_waitmsg' + + +class BasePictureEvent(BaseEvent): + key = StringField('EventKey') + pictures_info = BaseField('SendPicsInfo', {}) + + @property + def count(self): + return int(self.pictures_info['Count']) + + @property + def pictures(self): + if self.pictures_info['PicList']: + items = self.pictures_info['PicList']['item'] + if self.count > 1: + return items + return [items] + return [] + + +@register_event('pic_sysphoto') +class PicSysPhotoEvent(BasePictureEvent): + """ + 弹出系统拍照发图的事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/981d772286d10d153a3dc4286c1ee5b5.html + """ + event = 'pic_sysphoto' + + +@register_event('pic_photo_or_album') +class PicPhotoOrAlbumEvent(BasePictureEvent): + """ + 弹出拍照或者相册发图的事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/981d772286d10d153a3dc4286c1ee5b5.html + """ + event = 'pic_photo_or_album' + + +@register_event('pic_weixin') +class PicWeChatEvent(BasePictureEvent): + """ + 弹出微信相册发图器的事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/981d772286d10d153a3dc4286c1ee5b5.html + """ + event = 'pic_weixin' + + +@register_event('location_select') +class LocationSelectEvent(BaseEvent): + """ + 弹出地理位置选择器的事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/981d772286d10d153a3dc4286c1ee5b5.html + """ + event = 'location_select' + key = StringField('EventKey') + location_info = BaseField('SendLocationInfo', {}) + + @property + def location_x(self): + return self.location_info['Location_X'] + + @property + def location_y(self): + return self.location_info['Location_Y'] + + @property + def location(self): + return self.location_x, self.location_y + + @property + def scale(self): + return self.location_info['Scale'] + + @property + def label(self): + return self.location_info['Label'] + + @property + def poiname(self): + return self.location_info['Poiname'] + + +@register_event('card_pass_check') +class CardPassCheckEvent(BaseEvent): + event = 'card_pass_check' + card_id = StringField('CardId') + + +@register_event('card_not_pass_check') +class CardNotPassCheckEvent(BaseEvent): + event = 'card_not_pass_check' + card_id = StringField('CardId') + + +@register_event('user_get_card') +class UserGetCardEvent(BaseEvent): + """ + 领取事件推送 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/16/28b34ee91675a04cb24853768debded4.html#.E9.A2.86.E5.8F.96.E4.BA.8B.E4.BB.B6.E6.8E.A8.E9.80.81 + """ + event = 'user_get_card' + card_id = StringField('CardId') + is_given_by_friend = IntegerField('IsGiveByFriend') + friend = StringField('FriendUserName') + code = StringField('UserCardCode') + old_code = StringField('OldUserCardCode') + outer_id = StringField('OuterId') + + +@register_event('user_del_card') +class UserDeleteCardEvent(BaseEvent): + """ + 卡券删除事件推送 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/16/28b34ee91675a04cb24853768debded4.html#.E5.88.A0.E9.99.A4.E4.BA.8B.E4.BB.B6.E6.8E.A8.E9.80.81 + """ + event = 'user_del_card' + card_id = StringField('CardId') + code = StringField('UserCardCode') + + +@register_event('user_consume_card') +class UserConsumeCardEvent(BaseEvent): + """ + 卡券核销事件推送 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/16/28b34ee91675a04cb24853768debded4.html#.E6.A0.B8.E9.94.80.E4.BA.8B.E4.BB.B6.E6.8E.A8.E9.80.81 + """ + event = 'user_consume_card' + card_id = StringField('CardId') + code = StringField('UserCardCode') + consume_source = StringField('ConsumeSource') + location_id = StringField('LocationId') + staff = StringField('StaffOpenId') + + +@register_event('merchant_order') +class MerchantOrderEvent(BaseEvent): + event = 'merchant_order' + order_id = StringField('OrderId') + order_status = IntegerField('OrderStatus') + product_id = StringField('ProductId') + sku_info = StringField('SkuInfo') + + +@register_event('kf_create_session') +class KfCreateSessionEvent(BaseEvent): + event = 'kf_create_session' + account = StringField('KfAccount') + + +@register_event('kf_close_session') +class KfCloseSessionEvent(BaseEvent): + event = 'kf_close_session' + account = StringField('KfAccount') + + +@register_event('kf_switch_session') +class KfSwitchSessionEvent(BaseEvent): + event = 'kf_switch_session' + from_account = StringField('FromKfAccount') + to_account = StringField('ToKfAccount') + + +@register_event('device_text') +class DeviceTextEvent(BaseEvent): + event = 'device_text' + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + session_id = StringField('SessionID') + content = Base64DecodeField('Content') + + +@register_event('device_bind') +class DeviceBindEvent(BaseEvent): + event = 'bind' + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + session_id = StringField('SessionID') + content = Base64DecodeField('Content') + open_id = StringField('OpenID') + + +@register_event('device_unbind') +class DeviceUnbindEvent(BaseEvent): + event = 'unbind' + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + session_id = StringField('SessionID') + content = Base64DecodeField('Content') + open_id = StringField('OpenID') + + +@register_event('device_subscribe_status') +class DeviceSubscribeStatusEvent(BaseEvent): + event = 'subscribe_status' + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + open_id = StringField('OpenID') + op_type = IntegerField('OpType') + + +@register_event('device_unsubscribe_status') +class DeviceUnsubscribeStatusEvent(BaseEvent): + event = 'subscribe_status' + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + open_id = StringField('OpenID') + op_type = IntegerField('OpType') + + +@register_event('shakearoundusershake') +class ShakearoundUserShakeEvent(BaseEvent): + event = 'shakearound_user_shake' + _chosen_beacon = BaseField('ChosenBeacon', {}) + _around_beacons = BaseField('AroundBeacons', {}) + + @property + def chosen_beacon(self): + beacon = self._chosen_beacon + if not beacon: + return {} + return { + 'uuid': beacon['Uuid'], + 'major': beacon['Major'], + 'minor': beacon['Minor'], + 'distance': float(beacon['Distance']), + } + + @property + def around_beacons(self): + beacons = self._around_beacons + if not beacons: + return [] + + ret = [] + for beacon in beacons['AroundBeacon']: + ret.append({ + 'uuid': beacon['Uuid'], + 'major': beacon['Major'], + 'minor': beacon['Minor'], + 'distance': float(beacon['Distance']), + }) + return ret + + +@register_event('poi_check_notify') +class PoiCheckNotifyEvent(BaseEvent): + event = 'poi_check_notify' + poi_id = StringField('PoiId') + uniq_id = StringField('UniqId') + result = StringField('Result') + message = StringField('Msg') + + +@register_event('wificonnected') +class WiFiConnectedEvent(BaseEvent): + event = 'wificconnected' + connect_time = IntegerField('ConnectTime') + expire_time = IntegerField('ExpireTime') + vendor_id = StringField('VendorId') + shop_id = StringField('PlaceId') + bssid = StringField('DeviceNo') + + +# ============================================================================ +# 微信认证事件推送 +# ============================================================================ +@register_event('qualification_verify_success') +class QualificationVerifySuccessEvent(BaseEvent): + """ + 资质认证成功事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/1/7f81dec16b801b34629091094c099439.html + """ + event = 'qualification_verify_success' + expired_time = DateTimeField('ExpiredTime') + + +@register_event('qualification_verify_fail') +class QualificationVerifyFailEvent(BaseEvent): + """ + 资质认证失败事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/1/7f81dec16b801b34629091094c099439.html + """ + event = 'qualification_verify_fail' + fail_time = DateTimeField('FailTime') + fail_reason = StringField('FailReason') + + +@register_event('naming_verify_success') +class NamingVerifySuccessEvent(BaseEvent): + """ + 名称认证成功事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/1/7f81dec16b801b34629091094c099439.html + """ + event = 'naming_verify_success' + expired_time = DateTimeField('ExpiredTime') + + +@register_event('naming_verify_fail') +class NamingVerifyFailEvent(BaseEvent): + """ + 名称认证失败事件 + + 客户端不打勾,但仍有接口权限。详情请参阅 + http://mp.weixin.qq.com/wiki/1/7f81dec16b801b34629091094c099439.html + """ + event = 'naming_verify_fail' + fail_time = DateTimeField('FailTime') + fail_reason = StringField('FailReason') + + +@register_event('annual_renew') +class AnnualRenewEvent(BaseEvent): + """ + 年审通知事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/1/7f81dec16b801b34629091094c099439.html + """ + event = 'annual_renew' + expired_time = DateTimeField('ExpiredTime') + + +@register_event('verify_expired') +class VerifyExpiredEvent(BaseEvent): + """ + 认证过期失效通知 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/1/7f81dec16b801b34629091094c099439.html + """ + event = 'verify_expired' + expired_time = DateTimeField('ExpiredTime') + + +@register_event('user_scan_product') +class UserScanProductEvent(BaseEvent): + """ + 打开商品主页事件 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/f4109a5e44b4bfbc7eb1337eb739f3e3.html + """ + event = 'user_scan_product' + standard = StringField('KeyStandard') + key = StringField('KeyStr') + country = StringField('Country') + province = StringField('Province') + city = StringField('City') + sex = IntegerField('Sex') + scene = IntegerField('Scene') + + +@register_event('user_scan_product_enter_session') +class UserScanProductEnterSessionEvent(BaseEvent): + """ + 进入公众号事件 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/f4109a5e44b4bfbc7eb1337eb739f3e3.html + """ + event = 'user_scan_product_enter_session' + standard = StringField('KeyStandard') + key = StringField('KeyStr') + + +@register_event('user_scan_product_async') +class UserScanProductAsyncEvent(BaseEvent): + """ + 地理位置信息异步推送事件 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/f4109a5e44b4bfbc7eb1337eb739f3e3.html + """ + event = 'user_scan_product_async' + standard = StringField('KeyStandard') + key = StringField('KeyStr') + region_code = StringField('RegionCode') + + +@register_event('user_scan_product_verify_action') +class UserScanProductVerifyActionEvent(BaseEvent): + """ + 商品审核结果事件 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/f4109a5e44b4bfbc7eb1337eb739f3e3.html + """ + event = 'user_scan_product_verify_action' + standard = StringField('KeyStandard') + key = StringField('KeyStr') + result = StringField('Result') + reason = StringField('ReasonMsg') + + +@register_event('subscribe_scan_product') +class SubscribeScanProductEvent(BaseEvent): + """ + 用户在商品主页中关注公众号事件 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/f4109a5e44b4bfbc7eb1337eb739f3e3.html + """ + event = 'subscribe_scan_product' + event_key = StringField('EventKey') + + @property + def scene(self): + return self.event_key.split('|', 1)[0] + + @property + def standard(self): + return self.event_key.split('|')[1] + + @property + def key(self): + return self.event_key.split('|')[2] diff --git a/sg_wechat_enterprise/we_api/exceptions.py b/sg_wechat_enterprise/we_api/exceptions.py new file mode 100644 index 00000000..891079c8 --- /dev/null +++ b/sg_wechat_enterprise/we_api/exceptions.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.exceptions + ~~~~~~~~~~~~~~~~~~~~ + + Basic exceptions definition. + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import six + +from wechatpy.utils import to_binary, to_text + + +class WeChatException(Exception): + """Base exception for wechatpy""" + + def __init__(self, errcode, errmsg): + """ + :param errcode: Error code + :param errmsg: Error message + """ + self.errcode = errcode + self.errmsg = errmsg + + def __str__(self): + if six.PY2: + return to_binary('Error code: {code}, message: {msg}'.format( + code=self.errcode, + msg=self.errmsg + )) + else: + return to_text('Error code: {code}, message: {msg}'.format( + code=self.errcode, + msg=self.errmsg + )) + + def __repr__(self): + _repr = '{klass}({code}, {msg}'.format( + klass=self.__class__.__name__, + code=self.errcode, + msg=self.errmsg + ) + if six.PY2: + return to_binary(_repr) + else: + return to_text(_repr) + + +class WeChatClientException(WeChatException): + """WeChat API client exception class""" + def __init__(self, errcode, errmsg, client=None, + request=None, response=None): + super(WeChatClientException, self).__init__(errcode, errmsg) + self.client = client + self.request = request + self.response = response + + +class InvalidSignatureException(WeChatException): + """Invalid signature exception class""" + + def __init__(self, errcode=-40001, errmsg='Invalid signature'): + super(InvalidSignatureException, self).__init__(errcode, errmsg) + + +class APILimitedException(WeChatClientException): + """WeChat API call limited exception class""" + pass + + +class InvalidAppIdException(WeChatException): + """Invalid app_id exception class""" + + def __init__(self, errcode=-40005, errmsg='Invalid AppId'): + super(InvalidAppIdException, self).__init__(errcode, errmsg) + + +class WeChatOAuthException(WeChatClientException): + """WeChat OAuth API exception class""" + pass + + +class WeChatPayException(WeChatClientException): + """WeChat Pay API exception class""" + def __init__(self, return_code, result_code=None, return_msg=None, + errcode=None, errmsg=None, client=None, + request=None, response=None): + """ + :param return_code: 返回状态码 + :param result_code: 业务结果 + :param return_msg: 返回信息 + :param errcode: 错误代码 + :param errmsg: 错误代码描述 + """ + super(WeChatPayException, self).__init__( + errcode, + errmsg, + client, + request, + response + ) + self.return_code = return_code + self.result_code = result_code + self.return_msg = return_msg + + def __str__(self): + if six.PY2: + return to_binary('Error code: {code}, message: {msg}'.format( + code=self.return_code, + msg=self.return_msg + )) + else: + return to_text('Error code: {code}, message: {msg}'.format( + code=self.return_code, + msg=self.return_msg + )) + + def __repr__(self): + _repr = '{klass}({code}, {msg})'.format( + klass=self.__class__.__name__, + code=self.return_code, + msg=self.return_msg + ) + if six.PY2: + return to_binary(_repr) + else: + return to_text(_repr) diff --git a/sg_wechat_enterprise/we_api/fields.py b/sg_wechat_enterprise/we_api/fields.py new file mode 100644 index 00000000..3797a6a3 --- /dev/null +++ b/sg_wechat_enterprise/we_api/fields.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.fields + ~~~~~~~~~~~~~~~~ + + This module defines some useful field types for parse WeChat messages + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import time +from datetime import datetime +import base64 +import copy + +import six + +from wechatpy.utils import to_text, to_binary, ObjectDict, timezone + +default_timezone = timezone('Asia/Shanghai') + + +class FieldDescriptor(object): + def __init__(self, field): + self.field = field + self.attr_name = field.name + + def __get__(self, instance, instance_type=None): + if instance is not None: + value = instance._data.get(self.attr_name) + if value is None: + value = copy.deepcopy(self.field.default) + instance._data[self.attr_name] = value + if isinstance(value, dict): + value = ObjectDict(value) + if value and not isinstance(value, (dict, list, tuple)) and \ + six.callable(self.field.converter): + value = self.field.converter(value) + return value + return self.field + + def __set__(self, instance, value): + instance._data[self.attr_name] = value + + +class BaseField(object): + converter = None + + def __init__(self, name, default=None): + self.name = name + self.default = default + + def to_xml(self, value): + raise NotImplementedError() + + def __repr__(self): + _repr = '{klass}({name})'.format( + klass=self.__class__.__name__, + name=repr(self.name) + ) + if six.PY2: + return to_binary(_repr) + else: + return to_text(_repr) + + def add_to_class(self, klass, name): + self.klass = klass + klass._fields[name] = self + setattr(klass, name, FieldDescriptor(self)) + + +class StringField(BaseField): + def __to_text(self, value): + return to_text(value) + + converter = __to_text + + def to_xml(self, value): + value = self.converter(value) + tpl = '<{name}>' + return tpl.format(name=self.name, value=value) + + +class IntegerField(BaseField): + converter = int + + def to_xml(self, value): + value = self.converter(value) if value is not None else self.default + tpl = '<{name}>{value}' + return tpl.format(name=self.name, value=value) + + +class DateTimeField(BaseField): + def __converter(self, value): + v = int(value) + return datetime.fromtimestamp(v, tz=default_timezone) + + converter = __converter + + def to_xml(self, value): + value = time.mktime(datetime.timetuple(value)) + value = int(value) + tpl = '<{name}>{value}' + return tpl.format(name=self.name, value=value) + + +class FloatField(BaseField): + converter = float + + def to_xml(self, value): + value = self.converter(value) if value is not None else self.default + tpl = '<{name}>{value}' + return tpl.format(name=self.name, value=value) + + +class ImageField(StringField): + def to_xml(self, value): + value = self.converter(value) + tpl = """ + + """ + return tpl.format(value=value) + + +class VoiceField(StringField): + def to_xml(self, value): + value = self.converter(value) + tpl = """ + + """ + return tpl.format(value=value) + + +class VideoField(StringField): + def to_xml(self, value): + media_id = self.converter(value['media_id']) + if 'title' in value: + title = self.converter(value['title']) + if 'description' in value: + description = self.converter(value['description']) + tpl = """""" + return tpl.format( + media_id=media_id, + title=title, + description=description + ) + + +class MusicField(StringField): + def to_xml(self, value): + thumb_media_id = self.converter(value['thumb_media_id']) + if 'title' in value: + title = self.converter(value['title']) + if 'description' in value: + description = self.converter(value['description']) + if 'music_url' in value: + music_url = self.converter(value['music_url']) + if 'hq_music_url' in value: + hq_music_url = self.converter(value['hq_music_url']) + tpl = """ + + <![CDATA[{title}]]> + + + + """ + return tpl.format( + thumb_media_id=thumb_media_id, + title=title, + description=description, + music_url=music_url, + hq_music_url=hq_music_url + ) + + +class ArticlesField(StringField): + def to_xml(self, articles): + article_count = len(articles) + items = [] + for article in articles: + title = self.converter(article.get('title', '')) + description = self.converter(article.get('description', '')) + image = self.converter(article.get('image', '')) + url = self.converter(article.get('url', '')) + item_tpl = """ + <![CDATA[{title}]]> + + + + """ + item = item_tpl.format( + title=title, + description=description, + image=image, + url=url + ) + items.append(item) + items_str = '\n'.join(items) + tpl = """{article_count} + {items}""" + return tpl.format( + article_count=article_count, + items=items_str + ) + + +class Base64EncodeField(StringField): + def __base64_encode(self, text): + return to_text(base64.b64encode(to_binary(text))) + + converter = __base64_encode + + +class Base64DecodeField(StringField): + def __base64_decode(self, text): + return to_text(base64.b64decode(to_binary(text))) + + converter = __base64_decode + + +class HardwareField(StringField): + def to_xml(self, value=None): + value = value or {'xml': 'myrank', 'action': 'ranklist'} + tpl = """<{name}> + + + """ + return tpl.format( + name=self.name, + view=value.get('xml'), + action=value.get('action') + ) diff --git a/sg_wechat_enterprise/we_api/messages.py b/sg_wechat_enterprise/we_api/messages.py new file mode 100644 index 00000000..b53c6d1b --- /dev/null +++ b/sg_wechat_enterprise/we_api/messages.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.messages + ~~~~~~~~~~~~~~~~~~ + + This module defines all the messages you can get from WeChat server + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import copy +import six + +from wechatpy.fields import ( + BaseField, + StringField, + IntegerField, + DateTimeField, + FieldDescriptor +) +from wechatpy.utils import to_text, to_binary + + +MESSAGE_TYPES = {} + + +def register_message(msg_type): + def register(cls): + MESSAGE_TYPES[msg_type] = cls + return cls + return register + + +class MessageMetaClass(type): + """Metaclass for all messages""" + def __new__(cls, name, bases, attrs): + for b in bases: + if not hasattr(b, '_fields'): + continue + + for k, v in b.__dict__.items(): + if k in attrs: + continue + if isinstance(v, FieldDescriptor): + attrs[k] = copy.deepcopy(v.field) + + cls = super(MessageMetaClass, cls).__new__(cls, name, bases, attrs) + cls._fields = {} + + for name, field in cls.__dict__.items(): + if isinstance(field, BaseField): + field.add_to_class(cls, name) + return cls + + +class BaseMessage(six.with_metaclass(MessageMetaClass)): + """Base class for all messages and events""" + type = 'unknown' + id = IntegerField('MsgId', 0) + source = StringField('FromUserName') + target = StringField('ToUserName') + create_time = DateTimeField('CreateTime') + time = IntegerField('CreateTime') + + def __init__(self, message): + self._data = message + + def __repr__(self): + _repr = "{klass}({msg})".format( + klass=self.__class__.__name__, + msg=repr(self._data) + ) + if six.PY2: + return to_binary(_repr) + else: + return to_text(_repr) + + +@register_message('text') +class TextMessage(BaseMessage): + """ + 文本消息 + 详情请参阅 + http://mp.weixin.qq.com/wiki/10/79502792eef98d6e0c6e1739da387346.html + """ + type = 'text' + content = StringField('Content') + + +@register_message('image') +class ImageMessage(BaseMessage): + """ + 图片消息 + 详情请参阅 + http://mp.weixin.qq.com/wiki/10/79502792eef98d6e0c6e1739da387346.html + """ + type = 'image' + media_id = StringField('MediaId') + image = StringField('PicUrl') + + +@register_message('voice') +class VoiceMessage(BaseMessage): + """ + 语音消息 + 详情请参阅 + http://mp.weixin.qq.com/wiki/10/79502792eef98d6e0c6e1739da387346.html + """ + type = 'voice' + media_id = StringField('MediaId') + format = StringField('Format') + recognition = StringField('Recognition') + + +@register_message('shortvideo') +class ShortVideoMessage(BaseMessage): + """ + 短视频消息 + 详情请参阅 + http://mp.weixin.qq.com/wiki/10/79502792eef98d6e0c6e1739da387346.html + """ + type = 'shortvideo' + media_id = StringField('MediaId') + thumb_media_id = StringField('ThumbMediaId') + + +@register_message('video') +class VideoMessage(BaseMessage): + """ + 视频消息 + 详情请参阅 + http://mp.weixin.qq.com/wiki/10/79502792eef98d6e0c6e1739da387346.html + """ + type = 'video' + media_id = StringField('MediaId') + thumb_media_id = StringField('ThumbMediaId') + + +@register_message('location') +class LocationMessage(BaseMessage): + """ + 地理位置消息 + 详情请参阅 + http://mp.weixin.qq.com/wiki/10/79502792eef98d6e0c6e1739da387346.html + """ + type = 'location' + location_x = StringField('Location_X') + location_y = StringField('Location_Y') + scale = StringField('Scale') + label = StringField('Label') + + @property + def location(self): + return self.location_x, self.location_y + + +@register_message('link') +class LinkMessage(BaseMessage): + """ + 链接消息 + 详情请参阅 + http://mp.weixin.qq.com/wiki/10/79502792eef98d6e0c6e1739da387346.html + """ + type = 'link' + title = StringField('Title') + description = StringField('Description') + url = StringField('Url') + + +class UnknownMessage(BaseMessage): + """未知消息类型""" + pass diff --git a/sg_wechat_enterprise/we_api/oauth/__init__.py b/sg_wechat_enterprise/we_api/oauth/__init__.py new file mode 100644 index 00000000..e58aef38 --- /dev/null +++ b/sg_wechat_enterprise/we_api/oauth/__init__.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.oauth + ~~~~~~~~~~~~~~~ + + This module provides OAuth2 library for WeChat + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +try: + from pkgutil import extend_path + __path__ = extend_path(__path__, __name__) +except ImportError: + from pkg_resources import declare_namespace + declare_namespace(__name__) + +import requests +from six.moves.urllib.parse import quote +# from urllib import quote +from wechatpy.utils import json +from wechatpy.exceptions import WeChatOAuthException + + +class WeChatOAuth(object): + """微信公众平台 OAuth 网页授权 """ + + API_BASE_URL = 'https://api.weixin.qq.com/' + OAUTH_BASE_URL = 'https://open.weixin.qq.com/connect/' + + def __init__(self, app_id, secret, redirect_uri, + scope='snsapi_base', state=''): + """ + + :param app_id: 微信公众号 app_id + :param secret: 微信公众号 secret + :param redirect_uri: OAuth2 redirect URI + :param scope: 可选,微信公众号 OAuth2 scope,默认为 ``snsapi_base`` + :param state: 可选,微信公众号 OAuth2 state + """ + self.app_id = app_id + self.secret = secret + self.redirect_uri = redirect_uri + self.scope = scope + self.state = state + + def _request(self, method, url_or_endpoint, **kwargs): + if not url_or_endpoint.startswith(('http://', 'https://')): + url = '{base}{endpoint}'.format( + base=self.API_BASE_URL, + endpoint=url_or_endpoint + ) + else: + url = url_or_endpoint + + if isinstance(kwargs.get('data', ''), dict): + body = json.dumps(kwargs['data'], ensure_ascii=False) + body = body.encode('utf-8') + kwargs['data'] = body + + res = requests.request( + method=method, + url=url, + **kwargs + ) + try: + res.raise_for_status() + except requests.RequestException as reqe: + raise WeChatOAuthException( + errcode=None, + errmsg=None, + client=self, + request=reqe.request, + response=reqe.response + ) + result = json.loads(res.content.decode('utf-8', 'ignore'), strict=False) + + if 'errcode' in result and result['errcode'] != 0: + errcode = result['errcode'] + errmsg = result['errmsg'] + raise WeChatOAuthException( + errcode, + errmsg, + client=self, + request=res.request, + response=res + ) + + return result + + def _get(self, url, **kwargs): + return self._request( + method='get', + url_or_endpoint=url, + **kwargs + ) + + @property + def authorize_url(self): + """获取授权跳转地址 + + :return: URL 地址 + """ + redirect_uri = quote(self.redirect_uri) + url_list = [ + self.OAUTH_BASE_URL, + 'oauth2/authorize?appid=', + self.app_id, + '&redirect_uri=', + redirect_uri, + '&response_type=code&scope=', + self.scope + ] + if self.state: + url_list.extend(['&state=', self.state]) + url_list.append('#wechat_redirect') + return ''.join(url_list) + + @property + def qrconnect_url(self): + """生成扫码登录地址 + + :return: URL 地址 + """ + redirect_uri = quote(self.redirect_uri) + url_list = [ + self.OAUTH_BASE_URL, + 'qrconnect?appid=', + self.app_id, + '&redirect_uri=', + redirect_uri, + '&response_type=code&scope=', + 'snsapi_login' # scope + ] + if self.state: + url_list.extend(['&state=', self.state]) + url_list.append('#wechat_redirect') + return ''.join(url_list) + + def fetch_access_token(self, code): + """获取 access_token + + :param code: 授权完成跳转回来后 URL 中的 code 参数 + :return: JSON 数据包 1 + """ + res = self._get( + 'sns/oauth2/access_token', + params={ + 'appid': self.app_id, + 'secret': self.secret, + 'code': code, + 'grant_type': 'authorization_code' + } + ) + self.access_token = res['access_token'] + self.open_id = res['openid'] + self.refresh_token = res['refresh_token'] + self.expires_in = res['expires_in'] + return res + + def refresh_access_token(self, refresh_token): + """刷新 access token + + :param refresh_token: OAuth2 refresh token + :return: JSON 数据包 + """ + res = self._get( + 'sns/oauth2/refresh_token', + params={ + 'appid': self.app_id, + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token + } + ) + self.access_token = res['access_token'] + self.open_id = res['openid'] + self.refresh_token = res['refresh_token'] + self.expires_in = res['expires_in'] + return res + + def get_user_info(self, openid=None, access_token=None, lang='zh_CN'): + """获取用户信息 + + :param openid: 可选,微信 openid,默认获取当前授权用户信息 + :param access_token: 可选,access_token,默认使用当前授权用户的 access_token + :param lang: 可选,语言偏好, 默认为 ``zh_CN`` + :return: JSON 数据包 + """ + openid = openid or self.open_id + access_token = access_token or self.access_token + return self._get( + 'sns/userinfo', + params={ + 'access_token': access_token, + 'openid': openid, + 'lang': lang + } + ) + + def check_access_token(self, openid=None, access_token=None): + """检查 access_token 有效性 + + :param openid: 可选,微信 openid,默认获取当前授权用户信息 + :param access_token: 可选,access_token,默认使用当前授权用户的 access_token + :return: 有效返回 True,否则 False + """ + openid = openid or self.open_id + access_token = access_token or self.access_token + res = self._get( + 'sns/auth', + params={ + 'access_token': access_token, + 'openid': openid + } + ) + if res['errcode'] == 0: + return True + return False diff --git a/sg_wechat_enterprise/we_api/parser.py b/sg_wechat_enterprise/we_api/parser.py new file mode 100644 index 00000000..c9e4ab76 --- /dev/null +++ b/sg_wechat_enterprise/we_api/parser.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.parser + ~~~~~~~~~~~~~~~~ + This module provides functions for parsing WeChat messages + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import xmltodict + +from wechatpy.messages import MESSAGE_TYPES, UnknownMessage +from wechatpy.events import EVENT_TYPES +from wechatpy.utils import to_text + + +def parse_message(xml): + """ + 解析微信服务器推送的 XML 消息 + + :param xml: XML 消息 + :return: 解析成功返回对应的消息或事件,否则返回 ``UnknownMessage`` + """ + if not xml: + return + message = xmltodict.parse(to_text(xml))['xml'] + message_type = message['MsgType'].lower() + if message_type in ('event', 'device_event'): + event_type = message['Event'].lower() + # special event type for device_event + if message_type == 'device_event': + event_type = 'device_{event}'.format(event=event_type) + if event_type == 'subscribe' and message.get('EventKey'): + event_key = message['EventKey'] + if event_key.startswith(('scanbarcode|', 'scanimage|')): + event_type = 'subscribe_scan_product' + message['Event'] = event_type + else: + # Scan to subscribe with scene id event + event_type = 'subscribe_scan' + message['Event'] = event_type + message['EventKey'] = event_key.replace('qrscene_', '') + message_class = EVENT_TYPES.get(event_type, UnknownMessage) + else: + message_class = MESSAGE_TYPES.get(message_type, UnknownMessage) + return message_class(message) diff --git a/sg_wechat_enterprise/we_api/pay/__init__.py b/sg_wechat_enterprise/we_api/pay/__init__.py new file mode 100644 index 00000000..c88a5fc3 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/__init__.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import sys +import inspect +import logging + +import requests +import xmltodict +from xml.parsers.expat import ExpatError +from optionaldict import optionaldict + +from wechatpy.utils import random_string +from wechatpy.exceptions import WeChatPayException, InvalidSignatureException +from wechatpy.pay.utils import ( + calculate_signature, _check_signature, dict_to_xml +) +from wechatpy.pay.base import BaseWeChatPayAPI +from wechatpy.pay import api + + +logger = logging.getLogger(__name__) + + +def _is_api_endpoint(obj): + return isinstance(obj, BaseWeChatPayAPI) + + +class WeChatPay(object): + """ + 微信红包接口 + + :param appid: 微信公众号 appid + :param api_key: 商户 key + :param mch_id: 商户号 + :param sub_mch_id: 可选,子商户号,受理模式下必填 + :param mch_cert: 必填,商户证书路径 + :param mch_key: 必填,商户证书私钥路径 + """ + redpack = api.WeChatRedpack() + """红包接口""" + transfer = api.WeChatTransfer() + """企业付款接口""" + coupon = api.WeChatCoupon() + """代金券接口""" + order = api.WeChatOrder() + """订单接口""" + refund = api.WeChatRefund() + """退款接口""" + micropay = api.WeChatMicroPay() + """刷卡支付接口""" + tools = api.WeChatTools() + """工具类接口""" + jsapi = api.WeChatJSAPI() + + API_BASE_URL = 'https://api.mch.weixin.qq.com/' + + def __new__(cls, *args, **kwargs): + self = super(WeChatPay, cls).__new__(cls) + if sys.version_info[:2] == (2, 6): + import copy + # Python 2.6 inspect.gemembers bug workaround + # http://bugs.python.org/issue1785 + for name, _api in self.__class__.__dict__.items(): + if isinstance(_api, BaseWeChatPayAPI): + _api = copy.deepcopy(_api) + _api._client = self + setattr(self, name, _api) + else: + api_endpoints = inspect.getmembers(self, _is_api_endpoint) + for name, _api in api_endpoints: + api_cls = type(_api) + _api = api_cls(self) + setattr(self, name, _api) + return self + + def __init__(self, appid, api_key, mch_id, sub_mch_id=None, + mch_cert=None, mch_key=None): + """ + :param appid: 微信公众号 appid + :param api_key: 商户 key + :param mch_id: 商户号 + :param sub_mch_id: 可选,子商户号,受理模式下必填 + :param mch_cert: 商户证书路径 + :param mch_key: 商户证书私钥路径 + """ + self.appid = appid + self.api_key = api_key + self.mch_id = mch_id + self.sub_mch_id = sub_mch_id + self.mch_cert = mch_cert + self.mch_key = mch_key + + def _request(self, method, url_or_endpoint, **kwargs): + if not url_or_endpoint.startswith(('http://', 'https://')): + api_base_url = kwargs.pop('api_base_url', self.API_BASE_URL) + url = '{base}{endpoint}'.format( + base=api_base_url, + endpoint=url_or_endpoint + ) + else: + url = url_or_endpoint + + if isinstance(kwargs.get('data', ''), dict): + data = optionaldict(kwargs['data']) + if 'mchid' not in data: + # Fuck Tencent + data.setdefault('mch_id', self.mch_id) + data.setdefault('sub_mch_id', self.sub_mch_id) + data.setdefault('nonce_str', random_string(32)) + sign = calculate_signature(data, self.api_key) + body = dict_to_xml(data, sign) + body = body.encode('utf-8') + kwargs['data'] = body + + # 商户证书 + if self.mch_cert and self.mch_key: + kwargs['cert'] = (self.mch_cert, self.mch_key) + + res = requests.request( + method=method, + url=url, + **kwargs + ) + try: + res.raise_for_status() + except requests.RequestException as reqe: + raise WeChatPayException( + return_code=None, + client=self, + request=reqe.request, + response=reqe.response + ) + + return self._handle_result(res) + + def _handle_result(self, res): + res.encoding = 'utf-8' + xml = res.text + try: + data = xmltodict.parse(xml)['xml'] + except (xmltodict.ParsingInterrupted, ExpatError): + # 解析 XML 失败 + logger.debug('WeChat payment result xml parsing error', exc_info=True) + return xml + + return_code = data['return_code'] + return_msg = data.get('return_msg') + result_code = data.get('result_code') + errcode = data.get('err_code') + errmsg = data.get('err_code_des') + if return_code != 'SUCCESS' or result_code != 'SUCCESS': + # 返回状态码不为成功 + raise WeChatPayException( + return_code, + result_code, + return_msg, + errcode, + errmsg, + client=self, + request=res.request, + response=res + ) + return data + + def get(self, url, **kwargs): + return self._request( + method='get', + url_or_endpoint=url, + **kwargs + ) + + def post(self, url, **kwargs): + return self._request( + method='post', + url_or_endpoint=url, + **kwargs + ) + + def check_signature(self, params): + return _check_signature(params, self.api_key) + + def parse_payment_result(self, xml): + """解析微信支付结果通知""" + try: + data = xmltodict.parse(xml) + except (xmltodict.ParsingInterrupted, ExpatError): + raise InvalidSignatureException() + + if not data or 'xml' not in data: + raise InvalidSignatureException() + + data = data['xml'] + sign = data.pop('sign', None) + real_sign = calculate_signature(data, self.api_key) + if sign != real_sign: + raise InvalidSignatureException() + + data['sign'] = sign + return data diff --git a/sg_wechat_enterprise/we_api/pay/api/__init__.py b/sg_wechat_enterprise/we_api/pay/api/__init__.py new file mode 100644 index 00000000..75e80fd8 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.pay.api.redpack import WeChatRedpack # NOQA +from wechatpy.pay.api.transfer import WeChatTransfer # NOQA +from wechatpy.pay.api.coupon import WeChatCoupon # NOQA +from wechatpy.pay.api.order import WeChatOrder # NOQA +from wechatpy.pay.api.refund import WeChatRefund # NOQA +from wechatpy.pay.api.tools import WeChatTools # NOQA +from wechatpy.pay.api.jsapi import WeChatJSAPI # NOQA +from wechatpy.pay.api.micropay import WeChatMicroPay # NOQA diff --git a/sg_wechat_enterprise/we_api/pay/api/coupon.py b/sg_wechat_enterprise/we_api/pay/api/coupon.py new file mode 100644 index 00000000..6be5d5d6 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/coupon.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import random +from datetime import datetime + +from wechatpy.pay.base import BaseWeChatPayAPI + + +class WeChatCoupon(BaseWeChatPayAPI): + + def send(self, user_id, stock_id, op_user_id=None, device_info=None, + out_trade_no=None): + """ + 发放代金券 + + :param user_id: 用户在公众号下的 openid + :param stock_id: 代金券批次 ID + :param op_user_id: 可选,操作员账号,默认为商户号 + :param device_info: 可选,微信支付分配的终端设备号 + :param out_trade_no: 可选,商户订单号,需保持唯一性,默认自动生成 + :return: 返回的结果信息 + """ + if not out_trade_no: + now = datetime.now() + out_trade_no = '{0}{1}{2}'.format( + self.mch_id, + now.strftime('%Y%m%d%H%M%S'), + random.randint(1000, 10000) + ) + data = { + 'appid': self.appid, + 'coupon_stock_id': stock_id, + 'openid': user_id, + 'openid_count': 1, + 'partner_trade_no': out_trade_no, + 'op_user_id': op_user_id, + 'device_info': device_info, + 'version': '1.0', + 'type': 'XML', + } + return self._post('mmpaymkttransfers/send_coupon', data=data) + + def query_stock(self, stock_id, op_user_id=None, device_info=None): + """ + 查询代金券批次 + + :param stock_id: 代金券批次 ID + :param op_user_id: 可选,操作员账号,默认为商户号 + :param device_info: 可选,微信支付分配的终端设备号 + :return: 返回的结果信息 + """ + data = { + 'appid': self.appid, + 'coupon_stock_id': stock_id, + 'op_user_id': op_user_id, + 'device_info': device_info, + 'version': '1.0', + 'type': 'XML', + } + return self._post('mmpaymkttransfers/query_coupon_stock', data=data) + + def query_coupon(self, coupon_id, user_id, + op_user_id=None, device_info=None): + """ + 查询代金券信息 + + :param coupon_id: 代金券 ID + :param user_id: 用户在公众号下的 openid + :param op_user_id: 可选,操作员账号,默认为商户号 + :param device_info: 可选,微信支付分配的终端设备号 + :return: 返回的结果信息 + """ + data = { + 'coupon_id': coupon_id, + 'openid': user_id, + 'appid': self.appid, + 'op_user_id': op_user_id, + 'device_info': device_info, + 'version': '1.0', + 'type': 'XML', + } + return self._post('promotion/query_coupon', data=data) diff --git a/sg_wechat_enterprise/we_api/pay/api/jsapi.py b/sg_wechat_enterprise/we_api/pay/api/jsapi.py new file mode 100644 index 00000000..6f3196c4 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/jsapi.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import time + +from wechatpy.utils import random_string, to_text +from wechatpy.pay.base import BaseWeChatPayAPI +from wechatpy.pay.utils import calculate_signature + + +class WeChatJSAPI(BaseWeChatPayAPI): + + def get_jsapi_signature(self, prepay_id, timestamp=None, nonce_str=None): + """ + 获取 JSAPI 签名 + + :param prepay_id: 统一下单接口返回的 prepay_id 参数值 + :param timestamp: 可选,时间戳,默认为当前时间戳 + :param nonce_str: 可选,随机字符串,默认自动生成 + :return: 签名 + """ + data = { + 'appId': self.appid, + 'timeStamp': timestamp or to_text(int(time.time())), + 'nonceStr': nonce_str or random_string(32), + 'signType': 'MD5', + 'package': 'prepay_id={0}'.format(prepay_id), + } + return calculate_signature(data, self._client.api_key) + + def get_jsapi_params(self, prepay_id, timestamp=None, nonce_str=None): + """ + 获取 JSAPI 参数 + + :param prepay_id: 统一下单接口返回的 prepay_id 参数值 + :param timestamp: 可选,时间戳,默认为当前时间戳 + :param nonce_str: 可选,随机字符串,默认自动生成 + :return: 签名 + """ + data = { + 'appId': self.appid, + 'timeStamp': timestamp or to_text(int(time.time())), + 'nonceStr': nonce_str or random_string(32), + 'signType': 'MD5', + 'package': 'prepay_id={0}'.format(prepay_id), + } + sign = calculate_signature(data, self._client.api_key) + data['paySign'] = sign + return data diff --git a/sg_wechat_enterprise/we_api/pay/api/micropay.py b/sg_wechat_enterprise/we_api/pay/api/micropay.py new file mode 100644 index 00000000..5a77bf0d --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/micropay.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import random +from datetime import datetime + +from wechatpy.pay.utils import get_external_ip +from wechatpy.pay.base import BaseWeChatPayAPI + + +class WeChatMicroPay(BaseWeChatPayAPI): + def create(self, body, total_fee, auth_code, client_ip=None, out_trade_no=None, detail=None, attach=None, + fee_type='CNY', goods_tag=None, device_info=None, limit_pay=None): + """ + 刷卡支付接口 + :param device_info: 可选,终端设备号(商户自定义,如门店编号) + :param body: 商品描述 + :param detail: 可选,商品详情 + :param attach: 可选,附加数据,在查询API和支付通知中原样返回,该字段主要用于商户携带订单的自定义数据 + :param client_ip: 可选,APP和网页支付提交用户端ip,Native支付填调用微信支付API的机器IP + :param out_trade_no: 可选,商户订单号,默认自动生成 + :param total_fee: 总金额,单位分 + :param fee_type: 可选,符合ISO 4217标准的三位字母代码,默认人民币:CNY + :param goods_tag: 可选,商品标记,代金券或立减优惠功能的参数 + :param limit_pay: 可选,指定支付方式,no_credit--指定不能使用信用卡支付 + :param auth_code: 授权码,扫码支付授权码,设备读取用户微信中的条码或者二维码信息 + :return: 返回的结果数据 + """ + now = datetime.now() + if not out_trade_no: + out_trade_no = '{0}{1}{2}'.format( + self.mch_id, + now.strftime('%Y%m%d%H%M%S'), + random.randint(1000, 10000) + ) + data = { + 'appid': self.appid, + 'device_info': device_info, + 'body': body, + 'detail': detail, + 'attach': attach, + 'out_trade_no': out_trade_no, + 'total_fee': total_fee, + 'fee_type': fee_type, + 'spbill_create_ip': client_ip or get_external_ip(), + 'goods_tag': goods_tag, + 'limit_pay': limit_pay, + 'auth_code': auth_code, + } + return self._post('pay/micropay', data=data) + + def query(self, transaction_id=None, out_trade_no=None): + """ + 查询订单 + + :param transaction_id: 微信的订单号,优先使用 + :param out_trade_no: 商户系统内部的订单号,当没提供transaction_id时需要传这个。 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'transaction_id': transaction_id, + 'out_trade_no': out_trade_no, + } + return self._post('pay/orderquery', data=data) diff --git a/sg_wechat_enterprise/we_api/pay/api/order.py b/sg_wechat_enterprise/we_api/pay/api/order.py new file mode 100644 index 00000000..fb233845 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/order.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import time +import random +from datetime import datetime, timedelta + +from wechatpy.utils import timezone +from wechatpy.pay.utils import get_external_ip +from wechatpy.pay.base import BaseWeChatPayAPI +from wechatpy.utils import random_string, to_text +from wechatpy.pay.utils import calculate_signature + + +class WeChatOrder(BaseWeChatPayAPI): + + def create(self, trade_type, body, total_fee, notify_url, client_ip=None, + user_id=None, out_trade_no=None, detail=None, attach=None, + fee_type='CNY', time_start=None, time_expire=None, + goods_tag=None, product_id=None, device_info=None, limit_pay=None): + """ + 统一下单接口 + + :param trade_type: 交易类型,取值如下:JSAPI,NATIVE,APP,WAP + :param body: 商品描述 + :param total_fee: 总金额,单位分 + :param notify_url: 接收微信支付异步通知回调地址 + :param client_ip: 可选,APP和网页支付提交用户端ip,Native支付填调用微信支付API的机器IP + :param user_id: 可选,用户在商户appid下的唯一标识。trade_type=JSAPI,此参数必传 + :param out_trade_no: 可选,商户订单号,默认自动生成 + :param detail: 可选,商品详情 + :param attach: 可选,附加数据,在查询API和支付通知中原样返回,该字段主要用于商户携带订单的自定义数据 + :param fee_type: 可选,符合ISO 4217标准的三位字母代码,默认人民币:CNY + :param time_start: 可选,订单生成时间,默认为当前时间 + :param time_expire: 可选,订单失效时间,默认为订单生成时间后两小时 + :param goods_tag: 可选,商品标记,代金券或立减优惠功能的参数 + :param product_id: 可选,trade_type=NATIVE,此参数必传。此id为二维码中包含的商品ID,商户自行定义 + :param device_info: 可选,终端设备号(门店号或收银设备ID),注意:PC网页或公众号内支付请传"WEB" + :param limit_pay: 可选,指定支付方式,no_credit--指定不能使用信用卡支付 + :return: 返回的结果数据 + """ + now = datetime.fromtimestamp(time.time(), tz=timezone('Asia/Shanghai')) + hours_later = now + timedelta(hours=2) + if time_start is None: + time_start = now + if time_expire is None: + time_expire = hours_later + if not out_trade_no: + out_trade_no = '{0}{1}{2}'.format( + self.mch_id, + now.strftime('%Y%m%d%H%M%S'), + random.randint(1000, 10000) + ) + data = { + 'appid': self.appid, + 'device_info': device_info, + 'body': body, + 'detail': detail, + 'attach': attach, + 'out_trade_no': out_trade_no, + 'fee_type': fee_type, + 'total_fee': total_fee, + 'spbill_create_ip': client_ip or get_external_ip(), + 'time_start': time_start.strftime('%Y%m%d%H%M%S'), + 'time_expire': time_expire.strftime('%Y%m%d%H%M%S'), + 'goods_tag': goods_tag, + 'notify_url': notify_url, + 'trade_type': trade_type, + 'limit_pay': limit_pay, + 'product_id': product_id, + 'openid': user_id, + } + return self._post('pay/unifiedorder', data=data) + + def query(self, transaction_id=None, out_trade_no=None): + """ + 查询订单 + + :param transaction_id: 微信的订单号,优先使用 + :param out_trade_no: 商户系统内部的订单号,当没提供transaction_id时需要传这个。 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'transaction_id': transaction_id, + 'out_trade_no': out_trade_no, + } + return self._post('pay/orderquery', data=data) + + def close(self, out_trade_no): + """ + 关闭订单 + + :param out_trade_no: 商户系统内部的订单号 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'out_trade_no': out_trade_no, + } + return self._post('pay/closeorder', data=data) + + def get_appapi_params(self, prepay_id, timestamp=None, nonce_str=None): + """ + 获取 APP 支付参数 + + :param prepay_id: 统一下单接口返回的 prepay_id 参数值 + :param timestamp: 可选,时间戳,默认为当前时间戳 + :param nonce_str: 可选,随机字符串,默认自动生成 + :return: 签名 + """ + data = { + 'appid': self.appid, + 'partnerid': self.mch_id, + 'prepayid': prepay_id, + 'package': 'Sign=WXPay', + 'timestamp': timestamp or to_text(int(time.time())), + 'noncestr': nonce_str or random_string(32) + } + sign = calculate_signature(data, self._client.api_key) + data['sign'] = sign + return data + + def reverse(self, transaction_id=None, out_trade_no=None): + """ + 撤销订单 + + :param transaction_id: 可选,微信的订单号,优先使用 + :param out_trade_no: 可选,商户系统内部的订单号, + transaction_id、out_trade_no二选一, + 如果同时存在优先级:transaction_id> out_trade_no + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'transaction_id': transaction_id, + 'out_trade_no': out_trade_no, + } + return self._post('secapi/pay/reverse', data=data) diff --git a/sg_wechat_enterprise/we_api/pay/api/redpack.py b/sg_wechat_enterprise/we_api/pay/api/redpack.py new file mode 100644 index 00000000..6c5a1b25 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/redpack.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import random +from datetime import datetime + +from wechatpy.pay.utils import get_external_ip +from wechatpy.pay.base import BaseWeChatPayAPI + + +class WeChatRedpack(BaseWeChatPayAPI): + + def send(self, user_id, total_amount, send_name, act_name, + wishing, remark, total_num=1, client_ip=None, + nick_name=None, min_value=None, + max_value=None, out_trade_no=None, logo_imgurl=None): + """ + 发送现金红包 + + :param user_id: 接收红包的用户在公众号下的 openid + :param total_amount: 红包金额,单位分 + :param send_name: 商户名称 + :param nick_name: 可选,提供方名称,默认和商户名称相同 + :param act_name: 活动名称 + :param wishing: 红包祝福语 + :param remark: 备注 + :param client_ip: 可选,调用接口的机器 IP 地址 + :param total_num: 可选,红包发放总人数,默认为 1 + :param min_value: 可选,最小红包金额,单位分 + :param max_value: 可选,最大红包金额,单位分 + :param out_trade_no: 可选,商户订单号,默认会自动生成 + :param logo_imgurl: 可选,商户 Logo 的 URL + :return: 返回的结果数据字典 + """ + if not out_trade_no: + now = datetime.now() + out_trade_no = '{0}{1}{2}'.format( + self.mch_id, + now.strftime('%Y%m%d%H%M%S'), + random.randint(1000, 10000) + ) + data = { + 'wxappid': self.appid, + 're_openid': user_id, + 'total_amount': total_amount, + 'nick_name': nick_name or send_name, + 'send_name': send_name, + 'act_name': act_name, + 'wishing': wishing, + 'remark': remark, + 'client_ip': client_ip or get_external_ip(), + 'total_num': total_num, + 'min_value': min_value or total_amount, + 'max_value': max_value or total_amount, + 'mch_billno': out_trade_no, + 'logo_imgurl': logo_imgurl, + } + return self._post('mmpaymkttransfers/sendredpack', data=data) + + def send_group(self, user_id, total_amount, send_name, act_name, wishing, + remark, total_num, client_ip=None, amt_type="ALL_RAND", + amt_list=None, out_trade_no=None, + logo_imgurl=None, watermark_imgurl=None, + banner_imgurl=None): + """ + 发送裂变红包 + + :param user_id: 接收红包的用户在公众号下的 openid + :param total_amount: 红包金额,单位分 + :param send_name: 商户名称 + :param act_name: 活动名称 + :param wishing: 红包祝福语 + :param remark: 备注 + :param total_num: 红包发放总人数 + :param client_ip: 可选,调用接口的机器 IP 地址 + :param amt_type: 可选,红包金额设置方式 + ALL_RAND—全部随机,商户指定总金额和红包发放总人数,由微信支付随机计算出各红包金额 + ALL_SPECIFIED—全部自定义 + SEED_SPECIFIED—种子红包自定义,其他随机 + :param amt_list: 可选,各红包具体金额,自定义金额时必须设置,单位分 + :param out_trade_no: 可选,商户订单号,默认会自动生成 + :param logo_imgurl: 可选,商户 Logo 的 URL + :param watermark_imgurl: 可选,背景水印图片 URL + :param banner_imgurl: 红包详情页面的 banner 图片 URL + :return: 返回的结果数据字典 + """ + if not out_trade_no: + now = datetime.now() + out_trade_no = '{0}{1}{2}'.format( + self._client.mch_id, + now.strftime('%Y%m%d%H%M%S'), + random.randint(1000, 10000) + ) + data = { + 'wxappid': self.appid, + 're_openid': user_id, + 'total_amount': total_amount, + 'send_name': send_name, + 'act_name': act_name, + 'wishing': wishing, + 'remark': remark, + 'total_num': total_num, + 'client_ip': client_ip or get_external_ip(), + 'amt_type': amt_type, + 'amt_list': amt_list, + 'mch_billno': out_trade_no, + 'logo_imgurl': logo_imgurl, + 'watermark_imgurl': watermark_imgurl, + 'banner_imgurl': banner_imgurl, + } + return self._post('mmpaymkttransfers/sendgroupredpack', data=data) + + def query(self, out_trade_no, bill_type='MCHT'): + """ + 查询红包发放记录 + + :param out_trade_no: 商户订单号 + :param bill_type: 可选,订单类型,目前固定为 MCHT + :return: 返回的红包发放记录信息 + """ + data = { + 'mch_billno': out_trade_no, + 'bill_type': bill_type, + 'appid': self.appid, + } + return self._post('mmpaymkttransfers/gethbinfo', data=data) diff --git a/sg_wechat_enterprise/we_api/pay/api/refund.py b/sg_wechat_enterprise/we_api/pay/api/refund.py new file mode 100644 index 00000000..4e6bbc00 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/refund.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.pay.base import BaseWeChatPayAPI + + +class WeChatRefund(BaseWeChatPayAPI): + + def apply(self, total_fee, refund_fee, out_refund_no, transaction_id=None, + out_trade_no=None, fee_type='CNY', op_user_id=None, + device_info=None,notify_url=None,refund_account='REFUND_SOURCE_UNSETTLED_FUNDS'): + """ + 申请退款 + + :param total_fee: 订单总金额,单位为分 + :param refund_fee: 退款总金额,单位为分 + :param out_refund_no: 商户系统内部的退款单号,商户系统内部唯一,同一退款单号多次请求只退一笔 + :param transaction_id: 可选,微信订单号 + :param out_trade_no: 可选,商户系统内部的订单号,与 transaction_id 二选一 + :param fee_type: 可选,货币类型,符合ISO 4217标准的三位字母代码,默认人民币:CNY + :param op_user_id: 可选,操作员帐号, 默认为商户号 + :param refund_account: 可选,退款资金来源,仅针对老资金流商户使用,默认使用未结算资金退款 + :param device_info: 可选,终端设备号 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'device_info': device_info, + 'transaction_id': transaction_id, + 'out_trade_no': out_trade_no, + 'out_refund_no': out_refund_no, + 'total_fee': total_fee, + 'refund_fee': refund_fee, + 'refund_fee_type': fee_type, + 'op_user_id': op_user_id if op_user_id else self.mch_id, + "notify_url":notify_url, + 'refund_account': refund_account, + + } + return self._post('secapi/pay/refund', data=data) + + def query(self, refund_id=None, out_refund_no=None, transaction_id=None, + out_trade_no=None, device_info=None): + """ + 查询退款 + + :param refund_id: 微信退款单号 + :param out_refund_no: 商户退款单号 + :param transaction_id: 微信订单号 + :param out_trade_no: 商户系统内部的订单号 + :param device_info: 可选,终端设备号 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'device_info': device_info, + 'transaction_id': transaction_id, + 'out_trade_no': out_trade_no, + 'out_refund_no': out_refund_no, + 'refund_id': refund_id, + } + return self._post('pay/refundquery', data=data) diff --git a/sg_wechat_enterprise/we_api/pay/api/tools.py b/sg_wechat_enterprise/we_api/pay/api/tools.py new file mode 100644 index 00000000..ab794081 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/tools.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from datetime import datetime, date + +from wechatpy.pay.base import BaseWeChatPayAPI + + +class WeChatTools(BaseWeChatPayAPI): + + def short_url(self, long_url): + """ + 长链接转短链接 + + :param long_url: 长链接 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'long_url': long_url, + } + return self._post('tools/shorturl', data=data) + + def download_bill(self, bill_date, bill_type='ALL', device_info=None): + """ + 下载对账单 + + :param bill_date: 下载对账单的日期 + :param bill_type: 账单类型,ALL,返回当日所有订单信息,默认值 + SUCCESS,返回当日成功支付的订单, + REFUND,返回当日退款订单, + REVOKED,已撤销的订单 + :param device_info: 微信支付分配的终端设备号,填写此字段,只下载该设备号的对账单 + :return: 返回的结果数据 + """ + if isinstance(bill_date, (datetime, date)): + bill_date = bill_date.strftime('%Y%m%d') + + data = { + 'appid': self.appid, + 'bill_date': bill_date, + 'bill_type': bill_type, + 'device_info': device_info, + } + return self._post('pay/downloadbill', data=data) + + def auto_code_to_openid(self, auth_code): + """ + 授权码查询 openid 接口 + + :param auth_code: 扫码支付授权码,设备读取用户微信中的条码或者二维码信息 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'auth_code': auth_code, + } + return self._post('tools/authcodetoopenid', data=data) diff --git a/sg_wechat_enterprise/we_api/pay/api/transfer.py b/sg_wechat_enterprise/we_api/pay/api/transfer.py new file mode 100644 index 00000000..8e207635 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/transfer.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import random +from datetime import datetime + +from wechatpy.pay.utils import get_external_ip +from wechatpy.pay.base import BaseWeChatPayAPI + + +class WeChatTransfer(BaseWeChatPayAPI): + + def transfer(self, user_id, amount, desc, client_ip=None, + check_name='OPTION_CHECK', real_name=None, + out_trade_no=None, device_info=None): + """ + 企业付款接口 + + :param user_id: 接受收红包的用户在公众号下的 openid + :param amount: 付款金额,单位分 + :param desc: 付款说明 + :param client_ip: 可选,调用接口机器的 IP 地址 + :param check_name: 可选,校验用户姓名选项, + NO_CHECK:不校验真实姓名, + FORCE_CHECK:强校验真实姓名(未实名认证的用户会校验失败,无法转账), + OPTION_CHECK:针对已实名认证的用户才校验真实姓名(未实名认证用户不校验,可以转账成功), + 默认为 OPTION_CHECK + :param real_name: 可选,收款用户真实姓名, + 如果check_name设置为FORCE_CHECK或OPTION_CHECK,则必填用户真实姓名 + :param out_trade_no: 可选,商户订单号,需保持唯一性,默认自动生成 + :param device_info: 可选,微信支付分配的终端设备号 + :return: 返回的结果信息 + """ + if not out_trade_no: + now = datetime.now() + out_trade_no = '{0}{1}{2}'.format( + self.mch_id, + now.strftime('%Y%m%d%H%M%S'), + random.randint(1000, 10000) + ) + data = { + 'mch_appid': self.appid, + 'mchid': self.mch_id, + 'device_info': device_info, + 'partner_trade_no': out_trade_no, + 'openid': user_id, + 'check_name': check_name, + 're_user_name': real_name, + 'amount': amount, + 'desc': desc, + 'spbill_create_ip': client_ip or get_external_ip(), + } + return self._post('mmpaymkttransfers/promotion/transfers', data=data) + + def query(self, out_trade_no): + """ + 企业付款查询接口 + + :param out_trade_no: 商户调用企业付款API时使用的商户订单号 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'partner_trade_no': out_trade_no, + } + return self._post('mmpaymkttransfers/gettransferinfo', data=data) diff --git a/sg_wechat_enterprise/we_api/pay/base.py b/sg_wechat_enterprise/we_api/pay/base.py new file mode 100644 index 00000000..e1d17c28 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/base.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + + +class BaseWeChatPayAPI(object): + """ WeChat Pay API base class """ + def __init__(self, client=None): + self._client = client + + def _get(self, url, **kwargs): + if getattr(self, 'API_BASE_URL', None): + kwargs['api_base_url'] = self.API_BASE_URL + return self._client.get(url, **kwargs) + + def _post(self, url, **kwargs): + if getattr(self, 'API_BASE_URL', None): + kwargs['api_base_url'] = self.API_BASE_URL + return self._client.post(url, **kwargs) + + @property + def appid(self): + return self._client.appid + + @property + def mch_id(self): + return self._client.mch_id + + @property + def sub_mch_id(self): + return self._client.sub_mch_id diff --git a/sg_wechat_enterprise/we_api/pay/utils.py b/sg_wechat_enterprise/we_api/pay/utils.py new file mode 100644 index 00000000..18691ca3 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/utils.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import copy +import hashlib +import socket + +import six + +from wechatpy.utils import to_binary, to_text + + +def format_url(params, api_key=None): + data = [to_binary('{0}={1}'.format(k, params[k])) for k in sorted(params) if params[k]] + if api_key: + data.append(to_binary('key={0}'.format(api_key))) + return b"&".join(data) + + +def calculate_signature(params, api_key): + url = format_url(params, api_key) + return to_text(hashlib.md5(url).hexdigest().upper()) + + +def _check_signature(params, api_key): + _params = copy.deepcopy(params) + sign = _params.pop('sign', '') + return sign == calculate_signature(_params, api_key) + + +def dict_to_xml(d, sign): + xml = ['\n'] + for k in sorted(d): + # use sorted to avoid test error on Py3k + v = d[k] + if isinstance(v, six.integer_types) or v.isdigit(): + xml.append('<{0}>{1}\n'.format(to_text(k), to_text(v))) + else: + xml.append( + '<{0}>\n'.format(to_text(k), to_text(v)) + ) + xml.append('\n'.format(to_text(sign))) + return ''.join(xml) + + +def get_external_ip(): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + wechat_ip = socket.gethostbyname('api.mch.weixin.qq.com') + sock.connect((wechat_ip, 80)) + addr, port = sock.getsockname() + sock.close() + return addr + except socket.error: + return '127.0.0.1' diff --git a/sg_wechat_enterprise/we_api/replies.py b/sg_wechat_enterprise/we_api/replies.py new file mode 100644 index 00000000..cdcef25d --- /dev/null +++ b/sg_wechat_enterprise/we_api/replies.py @@ -0,0 +1,341 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.replies + ~~~~~~~~~~~~~~~~~~ + This module defines all kinds of replies you can send to WeChat + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import time +import six + +from wechatpy.fields import( + StringField, + IntegerField, + ImageField, + VoiceField, + VideoField, + MusicField, + ArticlesField, + Base64EncodeField, + HardwareField, +) +from wechatpy.messages import BaseMessage, MessageMetaClass +from wechatpy.utils import to_text, to_binary + + +REPLY_TYPES = {} + + +def register_reply(reply_type): + def register(cls): + REPLY_TYPES[reply_type] = cls + return cls + return register + + +class BaseReply(six.with_metaclass(MessageMetaClass)): + """Base class for all replies""" + source = StringField('FromUserName') + target = StringField('ToUserName') + time = IntegerField('CreateTime', time.time()) + type = 'unknown' + + def __init__(self, **kwargs): + self._data = {} + message = kwargs.pop('message', None) + if message and isinstance(message, BaseMessage): + if 'source' not in kwargs: + kwargs['source'] = message.target + if 'target' not in kwargs: + kwargs['target'] = message.source + if hasattr(message, 'agent') and 'agent' not in kwargs: + kwargs['agent'] = message.agent + if 'time' not in kwargs: + kwargs['time'] = time.time() + for name, value in kwargs.items(): + field = self._fields.get(name) + if field: + self._data[field.name] = value + else: + setattr(self, name, value) + + def render(self): + """Render reply from Python object to XML string""" + tpl = '\n{data}\n' + nodes = [] + msg_type = ''.format( + msg_type=self.type + ) + nodes.append(msg_type) + for name, field in self._fields.items(): + value = getattr(self, name, field.default) + node_xml = field.to_xml(value) + nodes.append(node_xml) + data = '\n'.join(nodes) + return tpl.format(data=data) + + def __str__(self): + if six.PY2: + return to_binary(self.render()) + else: + return to_text(self.render()) + + +@register_reply('empty') +class EmptyReply(BaseReply): + """ + 回复空串 + + 微信服务器不会对此作任何处理,并且不会发起重试 + """ + def __init__(self): + pass + + def render(self): + return '' + + +@register_reply('text') +class TextReply(BaseReply): + """ + 文本回复 + 详情请参阅 http://mp.weixin.qq.com/wiki/9/2c15b20a16019ae613d413e30cac8ea1.html + """ + type = 'text' + content = StringField('Content') + + +@register_reply('image') +class ImageReply(BaseReply): + """ + 图片回复 + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/2c15b20a16019ae613d413e30cac8ea1.html + """ + type = 'image' + image = ImageField('Image') + + @property + def media_id(self): + return self.image + + @media_id.setter + def media_id(self, value): + self.image = value + + +@register_reply('voice') +class VoiceReply(BaseReply): + """ + 语音回复 + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/2c15b20a16019ae613d413e30cac8ea1.html + """ + type = 'voice' + voice = VoiceField('Voice') + + @property + def media_id(self): + return self.voice + + @media_id.setter + def media_id(self, value): + self.voice = value + + +@register_reply('video') +class VideoReply(BaseReply): + """ + 视频回复 + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/2c15b20a16019ae613d413e30cac8ea1.html + """ + type = 'video' + video = VideoField('Video', {}) + + @property + def media_id(self): + return self.video.get('media_id') + + @media_id.setter + def media_id(self, value): + video = self.video + video['media_id'] = value + self.video = video + + @property + def title(self): + return self.video.get('title') + + @title.setter + def title(self, value): + video = self.video + video['title'] = value + self.video = video + + @property + def description(self): + return self.video.get('description') + + @description.setter + def description(self, value): + video = self.video + video['description'] = value + self.video = video + + +@register_reply('music') +class MusicReply(BaseReply): + """ + 音乐回复 + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/2c15b20a16019ae613d413e30cac8ea1.html + """ + type = 'music' + music = MusicField('Music', {}) + + @property + def thumb_media_id(self): + return self.music.get('thumb_media_id') + + @thumb_media_id.setter + def thumb_media_id(self, value): + music = self.music + music['thumb_media_id'] = value + self.music = music + + @property + def title(self): + return self.music.get('title') + + @title.setter + def title(self, value): + music = self.music + music['title'] = value + self.music = music + + @property + def description(self): + return self.music.get('description') + + @description.setter + def description(self, value): + music = self.music + music['description'] = value + self.music = music + + @property + def music_url(self): + return self.music.get('music_url') + + @music_url.setter + def music_url(self, value): + music = self.music + music['music_url'] = value + self.music = music + + @property + def hq_music_url(self): + return self.music.get('hq_music_url') + + @hq_music_url.setter + def hq_music_url(self, value): + music = self.music + music['hq_music_url'] = value + self.music = music + + +@register_reply('news') +class ArticlesReply(BaseReply): + """ + 图文回复 + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/2c15b20a16019ae613d413e30cac8ea1.html + """ + type = 'news' + articles = ArticlesField('Articles', []) + + def add_article(self, article): + if len(self.articles) == 10: + raise AttributeError("Can't add more than 10 articles" + " in an ArticlesReply") + articles = self.articles + articles.append(article) + self.articles = articles + + +@register_reply('transfer_customer_service') +class TransferCustomerServiceReply(BaseReply): + """ + 将消息转发到多客服 + 详情请参阅 + http://mp.weixin.qq.com/wiki/5/ae230189c9bd07a6b221f48619aeef35.html + """ + type = 'transfer_customer_service' + + +@register_reply('device_text') +class DeviceTextReply(BaseReply): + type = 'device_text' + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + session_id = StringField('SessionID') + content = Base64EncodeField('Content') + + +@register_reply('device_event') +class DeviceEventReply(BaseReply): + type = 'device_event' + event = StringField('Event') + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + session_id = StringField('SessionID') + content = Base64EncodeField('Content') + + +@register_reply('device_status') +class DeviceStatusReply(BaseReply): + type = 'device_status' + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + status = StringField('DeviceStatus') + + +@register_reply('hardware') +class HardwareReply(BaseReply): + type = 'hardware' + func_flag = IntegerField('FuncFlag', 0) + hardware = HardwareField('HardWare') + + +def create_reply(reply, message=None, render=False): + """ + Create a reply quickly + """ + r = None + if not reply: + r = EmptyReply() + elif isinstance(reply, BaseReply): + r = reply + if message: + r.source = message.target + r.target = message.source + elif isinstance(reply, six.string_types): + r = TextReply( + message=message, + content=reply + ) + elif isinstance(reply, (tuple, list)): + if len(reply) > 10: + raise AttributeError("Can't add more than 10 articles" + " in an ArticlesReply") + r = ArticlesReply( + message=message, + articles=reply + ) + if r and render: + return r.render() + return r diff --git a/sg_wechat_enterprise/we_api/session/__init__.py b/sg_wechat_enterprise/we_api/session/__init__.py new file mode 100644 index 00000000..197397bc --- /dev/null +++ b/sg_wechat_enterprise/we_api/session/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + + +class SessionStorage(object): + + def get(self, key, default=None): + raise NotImplementedError() + + def set(self, key, value, ttl=None): + raise NotImplementedError() + + def delete(self, key): + raise NotImplementedError() + + def __getitem__(self, key): + self.get(key) + + def __setitem__(self, key, value): + self.set(key, value) + + def __delitem__(self, key): + self.delete(key) diff --git a/sg_wechat_enterprise/we_api/session/memcachedstorage.py b/sg_wechat_enterprise/we_api/session/memcachedstorage.py new file mode 100644 index 00000000..dda13bc8 --- /dev/null +++ b/sg_wechat_enterprise/we_api/session/memcachedstorage.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.session import SessionStorage +from wechatpy.utils import to_text +from wechatpy.utils import json + + +class MemcachedStorage(SessionStorage): + + def __init__(self, mc, prefix='wechatpy'): + for method_name in ('get', 'set', 'delete'): + assert hasattr(mc, method_name) + self.mc = mc + self.prefix = prefix + + def key_name(self, key): + return '{0}:{1}'.format(self.prefix, key) + + def get(self, key, default=None): + key = self.key_name(key) + value = self.mc.get(key) + if value is None: + return default + return json.loads(to_text(value)) + + def set(self, key, value, ttl=None): + if value is None: + return + key = self.key_name(key) + value = json.dumps(value) + self.mc.set(key, value) + + def delete(self, key): + key = self.key_name(key) + self.mc.delete(key) diff --git a/sg_wechat_enterprise/we_api/session/memorystorage.py b/sg_wechat_enterprise/we_api/session/memorystorage.py new file mode 100644 index 00000000..7eb2e4e6 --- /dev/null +++ b/sg_wechat_enterprise/we_api/session/memorystorage.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.session import SessionStorage + + +class MemoryStorage(SessionStorage): + + def __init__(self): + self._data = {} + + def get(self, key, default=None): + return self._data.get(key, default) + + def set(self, key, value, ttl=None): + if value is None: + return + self._data[key] = value + + def delete(self, key): + self._data.pop(key, None) diff --git a/sg_wechat_enterprise/we_api/session/redisstorage.py b/sg_wechat_enterprise/we_api/session/redisstorage.py new file mode 100644 index 00000000..42171e6f --- /dev/null +++ b/sg_wechat_enterprise/we_api/session/redisstorage.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.session import SessionStorage +from wechatpy.utils import to_text +from wechatpy.utils import json + + +class RedisStorage(SessionStorage): + + def __init__(self, redis, prefix='wechatpy'): + for method_name in ('get', 'set', 'delete'): + assert hasattr(redis, method_name) + self.redis = redis + self.prefix = prefix + + def key_name(self, key): + return '{0}:{1}'.format(self.prefix, key) + + def get(self, key, default=None): + key = self.key_name(key) + value = self.redis.get(key) + if value is None: + return default + return json.loads(to_text(value)) + + def set(self, key, value, ttl=None): + if value is None: + return + key = self.key_name(key) + value = json.dumps(value) + self.redis.set(key, value, ex=ttl) + + def delete(self, key): + key = self.key_name(key) + self.redis.delete(key) diff --git a/sg_wechat_enterprise/we_api/session/shovestorage.py b/sg_wechat_enterprise/we_api/session/shovestorage.py new file mode 100644 index 00000000..7742d71c --- /dev/null +++ b/sg_wechat_enterprise/we_api/session/shovestorage.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.session import SessionStorage + + +class ShoveStorage(SessionStorage): + + def __init__(self, shove, prefix='wechatpy'): + self.shove = shove + self.prefix = prefix + + def key_name(self, key): + return '{0}:{1}'.format(self.prefix, key) + + def get(self, key, default=None): + key = self.key_name(key) + try: + return self.shove[key] + except KeyError: + return default + + def set(self, key, value, ttl=None): + if value is None: + return + + key = self.key_name(key) + self.shove[key] = value + + def delete(self, key): + key = self.key_name(key) + try: + del self.shove[key] + except KeyError: + pass diff --git a/sg_wechat_enterprise/we_api/utils.py b/sg_wechat_enterprise/we_api/utils.py new file mode 100644 index 00000000..7e54e1a3 --- /dev/null +++ b/sg_wechat_enterprise/we_api/utils.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.utils + ~~~~~~~~~~~~~~~ + + This module provides some useful utilities. + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import six +import six.moves.urllib.parse as urlparse +import sys +import string +import random +import hashlib + +try: + '''Use simplejson if we can, fallback to json otherwise.''' + import simplejson as json +except ImportError: + import json # NOQA + + +class ObjectDict(dict): + """Makes a dictionary behave like an object, with attribute-style access. + """ + + def __getattr__(self, key): + if key in self: + return self[key] + return None + + def __setattr__(self, key, value): + self[key] = value + + +class WeChatSigner(object): + """WeChat data signer""" + + def __init__(self, delimiter=b''): + self._data = [] + self._delimiter = to_binary(delimiter) + + def add_data(self, *args): + """Add data to signer""" + for data in args: + self._data.append(to_binary(data)) + + @property + def signature(self): + """Get data signature""" + self._data.sort() + str_to_sign = self._delimiter.join(self._data) + return hashlib.sha1(str_to_sign).hexdigest() + + +def check_signature(token, signature, timestamp, nonce): + """Check WeChat callback signature, raises InvalidSignatureException + if check failed. + + :param token: WeChat callback token + :param signature: WeChat callback signature sent by WeChat server + :param timestamp: WeChat callback timestamp sent by WeChat server + :param nonce: WeChat callback nonce sent by WeChat sever + """ + signer = WeChatSigner() + signer.add_data(token, timestamp, nonce) + if signer.signature != signature: + from wechatpy.exceptions import InvalidSignatureException + + raise InvalidSignatureException() + + +def to_text(value, encoding='utf-8'): + """Convert value to unicode, default encoding is utf-8 + + :param value: Value to be converted + :param encoding: Desired encoding + """ + if not value: + return '' + if isinstance(value, six.text_type): + return value + if isinstance(value, six.binary_type): + return value.decode(encoding) + return six.text_type(value) + + +def to_binary(value, encoding='utf-8'): + """Convert value to binary string, default encoding is utf-8 + + :param value: Value to be converted + :param encoding: Desired encoding + """ + if not value: + return b'' + if isinstance(value, six.binary_type): + return value + if isinstance(value, six.text_type): + return value.encode(encoding) + return six.binary_type(value) + + +def timezone(zone): + """Try to get timezone using pytz or python-dateutil + + :param zone: timezone str + :return: timezone tzinfo or None + """ + try: + import pytz + return pytz.timezone(zone) + except ImportError: + pass + try: + from dateutil.tz import gettz + return gettz(zone) + except ImportError: + return None + + +def random_string(length=16): + rule = string.ascii_letters + string.digits + rand_list = random.sample(rule, length) + return ''.join(rand_list) + + +def get_querystring(uri): + """Get Qeruystring information from uri. + + :param uri: uri + :return: querystring info or {} + """ + parts = urlparse.urlsplit(uri) + if sys.version_info[:2] == (2, 6): + query = parts.path + if query.startswith('?'): + query = query[1:] + else: + query = parts.query + return urlparse.parse_qs(query) + + +def byte2int(c): + if six.PY2: + return ord(c) + return c