├── wwbot ├── lib │ ├── __init__.py │ └── logger.py ├── msg │ ├── event │ │ ├── view_event.py │ │ ├── click_event.py │ │ ├── enter_agent_event.py │ │ ├── subscribe_event.py │ │ ├── share_agent_change_event.py │ │ ├── share_chain_change_event.py │ │ ├── scancode_push_event.py │ │ ├── scancode_waitmsg_event.py │ │ ├── upload_media_job_finish_event.py │ │ ├── location_event.py │ │ ├── pic_weixin_event.py │ │ ├── pic_sysphoto_event.py │ │ ├── pic_photo_or_album_event.py │ │ ├── batch_job_result_event.py │ │ ├── location_select_event.py │ │ └── __init__.py │ ├── file_msg.py │ ├── markdown_msg.py │ ├── link_msg.py │ ├── textcard_msg.py │ ├── location_msg.py │ ├── text_msg.py │ ├── mpnews_msg.py │ ├── image_msg.py │ ├── voice_msg.py │ ├── news_msg.py │ ├── video_msg.py │ └── __init__.py ├── chat │ └── __init__.py ├── media │ └── __init__.py └── __init__.py ├── examples ├── files │ ├── image.png │ └── video.mp4 ├── send_msg.py ├── upload_file.py ├── chat.py └── echo_bot.py ├── requirements.txt ├── config.ini ├── .github └── workflows │ └── python-publish.yml ├── setup.py ├── readme.md └── .gitignore /wwbot/lib/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from .logger import Logger 4 | -------------------------------------------------------------------------------- /examples/files/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbbatbb/WeWorkBot/HEAD/examples/files/image.png -------------------------------------------------------------------------------- /examples/files/video.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbbatbb/WeWorkBot/HEAD/examples/files/video.mp4 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask>=3.0.3 2 | pycryptodome>=3.20.0 3 | requests>=2.31.0 4 | setuptools>=58.1.0 5 | requests_toolbelt 6 | -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | [WWBot] 2 | app_name = WWBot 3 | app_host = 0.0.0.0 4 | app_port = 31231 5 | message_path = /message 6 | 7 | [WeWork] 8 | corp_id = ww23479283479 9 | corp_secret = aserWErasdfQwerasdfawer 10 | token = wradsfAWera 11 | aes_key = b2ssIG5vdyB5b3Ugc2VlIG1lLiBoYXZlIGZ1biB3aXRoIFdXQm90 -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Upload Python Package 3 | 4 | on: 5 | push: 6 | tags: 7 | - 'v*' 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | deploy: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Python 20 | uses: actions/setup-python@v3 21 | with: 22 | python-version: '3.9' 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install build 27 | - name: Build package 28 | run: python -m build 29 | - name: Publish package 30 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 31 | with: 32 | user: __token__ 33 | password: ${{ secrets.PYPI_API_TOKEN }} 34 | -------------------------------------------------------------------------------- /examples/send_msg.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from wwbot import WWBot 4 | from wwbot.msg import TextMessage, TextCardMessage 5 | 6 | corp_id:str = 'ww274398273492874' 7 | corp_secret:str = '23kjs9dufij3234WERq234rwer234' 8 | to_username:str = 'tbbatbb' 9 | agent_id = '1000004' 10 | group_bot_key:str = '6449f223-4920-2394-3aa39d939ae0' 11 | 12 | txtcard_msg = TextCardMessage( 13 | to_username, 14 | corp_id, 15 | agent_id, 16 | 'This Is A TextCard Message', 17 | f'A test message for $userName={to_username}$', 18 | 'https://www.qq.com', 19 | 'More', 20 | safe=True, 21 | enable_id_trans=True 22 | ) 23 | 24 | # WWBot does not need to be configured if only `send_to` is used 25 | 26 | # agent id is required when sending a message 27 | WWBot.send_to(corp_id, corp_secret, txtcard_msg) 28 | 29 | 30 | # sending message as group bot 31 | text_msg = TextMessage(None, None, None, 'a text message sent by group chat bot', for_group_bot=True, group_bot_key=group_bot_key) 32 | WWBot.send_to(corp_id, corp_secret, text_msg) 33 | 34 | -------------------------------------------------------------------------------- /examples/upload_file.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from wwbot import WWBot 4 | from wwbot.msg import VideoMessage 5 | from wwbot.media import Media, UploadResult 6 | 7 | corp_id:str = 'ww274398273492874' 8 | corp_secret:str = '23kjs9dufij3234WERq234rwer234' 9 | to_username:str = 'tbbatbb' 10 | agent_id = '1000004' 11 | 12 | # get the access token 13 | access_token:str = WWBot.get_access_token(corp_id, corp_secret) 14 | 15 | # upload the file and get the media_id 16 | ur:UploadResult = Media.upload(access_token, 'video', 'files/video.mp4') 17 | # ur:UploadResult = Media.upload(access_token, 'file', 'files/video.mp4') 18 | # ur:UploadResult = Media.upload(access_token, 'image', 'files/image.png') 19 | 20 | if ur is None: print('uploading failed') 21 | 22 | # construct the video message 23 | msg:VideoMessage = VideoMessage(to_username, corp_id, agent_id, ur.media_id, title='Video Message', desc='this is a video message') 24 | 25 | # send the message 26 | WWBot.send_to(corp_id, corp_secret, msg) 27 | 28 | # download file 29 | Media.download(access_token, ur.media_id, './saved_file') 30 | -------------------------------------------------------------------------------- /examples/chat.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from wwbot import WWBot 4 | from wwbot.msg import VideoMessage 5 | from wwbot.media import Media, UploadResult 6 | from wwbot.chat import Chat 7 | 8 | corp_id:str = 'ww274398273492874' 9 | corp_secret:str = '23kjs9dufij3234WERq234rwer234' 10 | 11 | # get the access token 12 | access_token:str = WWBot.get_access_token(corp_id, corp_secret) 13 | print(access_token) 14 | 15 | # create a group chat 16 | # !!!!!!!!!! make sure the bot is visible to the whole company, refer to https://developer.work.weixin.qq.com/document/path/90244 !!!!!!!!!!!!!!!!! 17 | chat_id:str = Chat.create(access_token, 'Group Chat', ['tbbatbb', 'bbtabbt'], 'tbbatbb') 18 | 19 | # upload the video 20 | ur:UploadResult = Media.upload(access_token, 'video', 'examples/files/video.mp4') 21 | 22 | # to_username and agent_id can be None in the message for chat 23 | msg:VideoMessage = VideoMessage('to_username can be none', corp_id, 'agent_id can be none', ur.media_id, title='Video Test', desc='a video message', for_chat=True, chat_id=chat_id) 24 | 25 | WWBot.send_to(corp_id, corp_secret, msg) 26 | -------------------------------------------------------------------------------- /wwbot/msg/event/view_event.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from xml.etree.ElementTree import Element 4 | from . import EventMessage 5 | 6 | class ViewEventMessage(EventMessage): 7 | 8 | # handler key 9 | key:str = 'event.view' 10 | 11 | def __init__(self, to_username: str, from_username: str, agent_id: str, event: str, event_key: str = '', **kwargs) -> None: 12 | super().__init__(to_username, from_username, agent_id, event, event_key=event_key, **kwargs) 13 | 14 | @classmethod 15 | def from_xml(cls, xml_tree: Element): 16 | '''Parse view event message from XML object''' 17 | to_username:str = xml_tree.find('ToUserName').text 18 | from_username:str = xml_tree.find('FromUserName').text 19 | create_time:str = xml_tree.find('CreateTime').text 20 | event:str = xml_tree.find('Event').text 21 | event_key:str = xml_tree.find('EventKey').text 22 | # msg_id:str = xml_tree.find('MsgId').text 23 | agent_id:str = xml_tree.find('AgentID').text 24 | 25 | return cls(to_username, from_username, agent_id, event, event_key=event_key, create_time=create_time) -------------------------------------------------------------------------------- /wwbot/msg/event/click_event.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from xml.etree.ElementTree import Element 4 | from . import EventMessage 5 | 6 | class ClickEventMessage(EventMessage): 7 | 8 | # handler key 9 | key:str = 'event.click' 10 | 11 | def __init__(self, to_username: str, from_username: str, agent_id: str, event: str, event_key: str = '', **kwargs) -> None: 12 | super().__init__(to_username, from_username, agent_id, event, event_key=event_key, **kwargs) 13 | 14 | @classmethod 15 | def from_xml(cls, xml_tree: Element): 16 | '''Parse click event message from XML object''' 17 | to_username:str = xml_tree.find('ToUserName').text 18 | from_username:str = xml_tree.find('FromUserName').text 19 | create_time:str = xml_tree.find('CreateTime').text 20 | event:str = xml_tree.find('Event').text 21 | event_key:str = xml_tree.find('EventKey').text 22 | # msg_id:str = xml_tree.find('MsgId').text 23 | agent_id:str = xml_tree.find('AgentID').text 24 | 25 | return cls(to_username, from_username, agent_id, event, event_key=event_key, create_time=create_time) -------------------------------------------------------------------------------- /wwbot/msg/event/enter_agent_event.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from xml.etree.ElementTree import Element 4 | from . import EventMessage 5 | 6 | class EnterAgentEventMessage(EventMessage): 7 | 8 | # handler key 9 | key:str = 'event.enter_agent' 10 | 11 | def __init__(self, to_username: str, from_username: str, agent_id: str, event: str, event_key: str = '', **kwargs) -> None: 12 | super().__init__(to_username, from_username, agent_id, event, event_key=event_key, **kwargs) 13 | 14 | @classmethod 15 | def from_xml(cls, xml_tree: Element): 16 | '''Parse enter agent event message from XML object''' 17 | to_username:str = xml_tree.find('ToUserName').text 18 | from_username:str = xml_tree.find('FromUserName').text 19 | create_time:str = xml_tree.find('CreateTime').text 20 | event:str = xml_tree.find('Event').text 21 | event_key:str = xml_tree.find('EventKey').text 22 | # msg_id:str = xml_tree.find('MsgId').text 23 | agent_id:str = xml_tree.find('AgentID').text 24 | 25 | return cls(to_username, from_username, agent_id, event, event_key=event_key, create_time=create_time) -------------------------------------------------------------------------------- /wwbot/msg/event/subscribe_event.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from xml.etree.ElementTree import Element 4 | from . import EventMessage 5 | 6 | class SubscribeEventMessage(EventMessage): 7 | 8 | # handler key 9 | key:str = 'event.subscribe' 10 | 11 | def __init__(self, to_username: str, from_username: str, agent_id: str, event: str, event_key: str = '', **kwargs) -> None: 12 | super().__init__(to_username, from_username, agent_id, event, event_key=event_key, **kwargs) 13 | 14 | @classmethod 15 | def from_xml(cls, xml_tree: Element): 16 | '''Parse subscribe event message from XML object''' 17 | to_username:str = xml_tree.find('ToUserName').text 18 | from_username:str = xml_tree.find('FromUserName').text 19 | create_time:str = xml_tree.find('CreateTime').text 20 | event:str = xml_tree.find('Event').text 21 | event_key:str = '' if xml_tree.find('EventKey') is None else xml_tree.find('EventKey').text 22 | # msg_id:str = xml_tree.find('MsgId').text 23 | agent_id:str = xml_tree.find('AgentID').text 24 | 25 | return cls(to_username, from_username, agent_id, event, event_key=event_key, create_time=create_time) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | import setuptools 3 | 4 | long_description = ''' 5 | Please refer to the homepage of the library 6 | ''' 7 | 8 | setuptools.setup( 9 | name="wwbot", 10 | version="0.0.17", 11 | author="tbbatbb", 12 | author_email="20682299+tbbatbb@users.noreply.github.com", 13 | description="A library for dealing with messages in WeWork", 14 | url="https://github.com/tbbatbb/WeWorkBot", 15 | long_description_content_type='text/markdown', 16 | long_description=long_description, 17 | packages=setuptools.find_packages(), 18 | classifiers=[ 19 | "Development Status :: 2 - Pre-Alpha", 20 | "Framework :: Flask", 21 | "Intended Audience :: Developers", 22 | "License :: Free for non-commercial use", 23 | "Operating System :: OS Independent", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Programming Language :: Python :: 3.13", 29 | "Programming Language :: Python :: Implementation :: CPython", 30 | "Topic :: Communications :: Chat", 31 | "Topic :: Software Development :: Libraries :: Python Modules" 32 | ], 33 | ) 34 | -------------------------------------------------------------------------------- /wwbot/msg/event/share_agent_change_event.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from xml.etree.ElementTree import Element 4 | from . import EventMessage 5 | 6 | class ShareAgentChangeEventMessage(EventMessage): 7 | 8 | # handler key 9 | key:str = 'event.share_agent_change' 10 | 11 | def __init__(self, to_username: str, from_username: str, agent_id: str, event: str, event_key: str = '', **kwargs) -> None: 12 | super().__init__(to_username, from_username, agent_id, event, event_key=event_key, **kwargs) 13 | 14 | @classmethod 15 | def from_xml(cls, xml_tree: Element): 16 | '''Parse share_agent_change event message from XML object''' 17 | to_username:str = xml_tree.find('ToUserName').text 18 | from_username:str = xml_tree.find('FromUserName').text 19 | create_time:str = xml_tree.find('CreateTime').text 20 | event:str = xml_tree.find('Event').text 21 | event_key:str = '' if xml_tree.find('EventKey') is None else xml_tree.find('EventKey').text 22 | # msg_id:str = xml_tree.find('MsgId').text 23 | agent_id:str = xml_tree.find('AgentID').text 24 | 25 | return cls(to_username, from_username, agent_id, event, event_key=event_key, create_time=create_time) -------------------------------------------------------------------------------- /wwbot/msg/event/share_chain_change_event.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from xml.etree.ElementTree import Element 4 | from . import EventMessage 5 | 6 | class ShareChainChangeEventMessage(EventMessage): 7 | 8 | # handler key 9 | key:str = 'event.share_chain_change' 10 | 11 | def __init__(self, to_username: str, from_username: str, agent_id: str, event: str, event_key: str = '', **kwargs) -> None: 12 | super().__init__(to_username, from_username, agent_id, event, event_key=event_key, **kwargs) 13 | 14 | @classmethod 15 | def from_xml(cls, xml_tree: Element): 16 | '''Parse share_chain_change event message from XML object''' 17 | to_username:str = xml_tree.find('ToUserName').text 18 | from_username:str = xml_tree.find('FromUserName').text 19 | create_time:str = xml_tree.find('CreateTime').text 20 | event:str = xml_tree.find('Event').text 21 | event_key:str = '' if xml_tree.find('EventKey') is None else xml_tree.find('EventKey').text 22 | # msg_id:str = xml_tree.find('MsgId').text 23 | agent_id:str = xml_tree.find('AgentID').text 24 | 25 | return cls(to_username, from_username, agent_id, event, event_key=event_key, create_time=create_time) -------------------------------------------------------------------------------- /wwbot/msg/event/scancode_push_event.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from xml.etree.ElementTree import Element 4 | from . import EventMessage 5 | 6 | class ScanCodePushEventMessage(EventMessage): 7 | 8 | # handler key 9 | key:str = 'event.scancode_push' 10 | 11 | def __init__(self, to_username: str, from_username: str, agent_id: str, event: str, scan_type:str, scan_result:str, event_key: str = '', **kwargs) -> None: 12 | super().__init__(to_username, from_username, agent_id, event, event_key=event_key, **kwargs) 13 | # scan type 14 | self.scan_type:str = scan_type 15 | # scan result 16 | self.scan_result:str = scan_result 17 | 18 | @classmethod 19 | def from_xml(cls, xml_tree: Element): 20 | '''Parse scancode_push event message from XML object''' 21 | to_username:str = xml_tree.find('ToUserName').text 22 | from_username:str = xml_tree.find('FromUserName').text 23 | create_time:str = xml_tree.find('CreateTime').text 24 | event:str = xml_tree.find('Event').text 25 | event_key:str = xml_tree.find('EventKey').text 26 | scan_type:str = xml_tree.find('ScanCodeInfo/ScanType').text 27 | scan_result:str = xml_tree.find('ScanCodeInfo/ScanResult').text 28 | # msg_id:str = xml_tree.find('MsgId').text 29 | agent_id:str = xml_tree.find('AgentID').text 30 | 31 | return cls(to_username, from_username, agent_id, event, scan_type, scan_result, event_key=event_key, create_time=create_time) -------------------------------------------------------------------------------- /wwbot/msg/event/scancode_waitmsg_event.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from xml.etree.ElementTree import Element 4 | from . import EventMessage 5 | 6 | class ScanCodeWaitMsgEventMessage(EventMessage): 7 | 8 | # handler key 9 | key:str = 'event.scancode_waitmsg' 10 | 11 | def __init__(self, to_username: str, from_username: str, agent_id: str, event: str, scan_type:str, scan_result:str, event_key: str = '', **kwargs) -> None: 12 | super().__init__(to_username, from_username, agent_id, event, event_key=event_key, **kwargs) 13 | # scan type 14 | self.scan_type:str = scan_type 15 | # scan result 16 | self.scan_result:str = scan_result 17 | 18 | @classmethod 19 | def from_xml(cls, xml_tree: Element): 20 | '''Parse scancode_waitmsg event message from XML object''' 21 | to_username:str = xml_tree.find('ToUserName').text 22 | from_username:str = xml_tree.find('FromUserName').text 23 | create_time:str = xml_tree.find('CreateTime').text 24 | event:str = xml_tree.find('Event').text 25 | event_key:str = xml_tree.find('EventKey').text 26 | scan_type:str = xml_tree.find('ScanCodeInfo/ScanType').text 27 | scan_result:str = xml_tree.find('ScanCodeInfo/ScanResult').text 28 | # msg_id:str = xml_tree.find('MsgId').text 29 | agent_id:str = xml_tree.find('AgentID').text 30 | 31 | return cls(to_username, from_username, agent_id, event, scan_type, scan_result, event_key=event_key, create_time=create_time) -------------------------------------------------------------------------------- /wwbot/msg/event/upload_media_job_finish_event.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from xml.etree.ElementTree import Element 4 | from . import EventMessage 5 | 6 | class UploadMediaJobFinishEventMessage(EventMessage): 7 | 8 | # handler key 9 | key:str = 'event.upload_media_job_finish' 10 | 11 | def __init__(self, to_username: str, from_username: str, agent_id: str, event: str, job_id:str, event_key: str = '', **kwargs) -> None: 12 | super().__init__(to_username, from_username, agent_id, event, event_key=event_key, **kwargs) 13 | # job id 14 | self.job_id:str = job_id 15 | 16 | @classmethod 17 | def from_xml(cls, xml_tree: Element): 18 | '''Parse upload media job finish event message from XML object''' 19 | to_username:str = xml_tree.find('ToUserName').text 20 | from_username:str = xml_tree.find('FromUserName').text 21 | create_time:str = xml_tree.find('CreateTime').text 22 | event:str = xml_tree.find('Event').text 23 | event_key:str = '' if xml_tree.find('EventKey') is None else xml_tree.find('EventKey').text 24 | job_id:str = xml_tree.find('JobId').text 25 | # job_type:str = xml_tree.find('JobType').text 26 | # err_code:str = xml_tree.find('ErrCode').text 27 | # err_msg:str = xml_tree.find('ErrMsg').text 28 | # msg_id:str = xml_tree.find('MsgId').text 29 | agent_id:str = '' if xml_tree.find('AgentID') is None else xml_tree.find('AgentID').text 30 | 31 | return cls(to_username, from_username, agent_id, event, job_id, event_key=event_key, create_time=create_time) -------------------------------------------------------------------------------- /wwbot/msg/file_msg.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | import json 4 | from . import Message 5 | from typing import Dict, Any 6 | from xml.etree.ElementTree import Element 7 | 8 | class FileMessage(Message): 9 | 10 | # message type 11 | type:str = 'file' 12 | # handler key 13 | key:str = 'file' 14 | 15 | def __init__(self, to_username: str, from_username: str, agent_id: str, media_id: str, **kwargs) -> None: 16 | super().__init__(to_username, from_username, agent_id, **kwargs) 17 | # media id for the file 18 | self.media_id:str = media_id 19 | 20 | def to_xml(self) -> str: 21 | '''Represent file message in XML format''' 22 | raise NotImplementedError('Unable to reply with a file message in current WeWork') 23 | 24 | def to_json(self) -> str: 25 | '''Represent file message in JSON format''' 26 | data:Dict[str, Any] = {"msgtype":"file","file":{"media_id":self.media_id},"safe":1 if self.safe else 0,"enable_id_trans":1 if self.enable_id_trans else 0,"enable_duplicate_check":1 if self.enable_duplicate_check else 0,"duplicate_check_interval":self.duplicate_check_interval} 27 | if self.for_chat: data.update({"chatid":self.chat_id}) 28 | elif not self.for_group_bot: data.update({"touser":self.to_username,"agentid":self.agent_id}) 29 | return json.dumps(data) 30 | 31 | @classmethod 32 | def from_xml(cls, xml_tree: Element): 33 | '''Parse file message from XML object''' 34 | raise NotImplementedError('Unable to receive and parse a file message in current WeWork') 35 | 36 | -------------------------------------------------------------------------------- /wwbot/msg/markdown_msg.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | import json 4 | from . import Message 5 | from typing import Any, Dict 6 | from xml.etree.ElementTree import Element 7 | 8 | class MarkdownMessage(Message): 9 | 10 | # message type 11 | type:str = 'markdown' 12 | # handler key 13 | key:str = 'markdown' 14 | 15 | def __init__(self, to_username: str, from_username: str, agent_id: str, content: str, **kwargs) -> None: 16 | super().__init__(to_username, from_username, agent_id, **kwargs) 17 | # the content in markdown format 18 | self.content:str = content 19 | 20 | def to_xml(self) -> str: 21 | '''Represent markdown message in XML format''' 22 | raise NotImplementedError('Unable to reply with a markdown message in current WeWork') 23 | 24 | def to_json(self) -> str: 25 | '''Represent markdown message in JSON format''' 26 | data:Dict[str, Any] = {"msgtype":"markdown","markdown":{"content":self.content},"safe":1 if self.safe else 0,"enable_id_trans":1 if self.enable_id_trans else 0,"enable_duplicate_check":1 if self.enable_duplicate_check else 0,"duplicate_check_interval":self.duplicate_check_interval} 27 | if self.for_chat: data.update({"chatid":self.chat_id}) 28 | elif not self.for_group_bot: data.update({"touser":self.to_username,"agentid":self.agent_id}) 29 | return json.dumps(data) 30 | 31 | @classmethod 32 | def from_xml(cls, xml_tree: Element): 33 | '''Parse markdown message from XML object''' 34 | raise NotImplementedError('Unable to receive and parse a markdown message in current WeWork') 35 | 36 | -------------------------------------------------------------------------------- /wwbot/msg/event/location_event.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from xml.etree.ElementTree import Element 4 | from . import EventMessage 5 | 6 | class LocationEventMessage(EventMessage): 7 | 8 | # handler key 9 | key:str = 'event.location' 10 | 11 | def __init__(self, to_username: str, from_username: str, agent_id: str, event: str, latitude:str, longitude:str, precision:str, event_key: str = '', **kwargs) -> None: 12 | super().__init__(to_username, from_username, agent_id, event, event_key=event_key, **kwargs) 13 | # latitude 14 | self.latitude:str = latitude 15 | # longitude 16 | self.longitude:str = longitude 17 | # precision 18 | self.precision:str = precision 19 | 20 | @classmethod 21 | def from_xml(cls, xml_tree: Element): 22 | '''Parse location event message from XML object''' 23 | to_username:str = xml_tree.find('ToUserName').text 24 | from_username:str = xml_tree.find('FromUserName').text 25 | create_time:str = xml_tree.find('CreateTime').text 26 | event:str = xml_tree.find('Event').text 27 | event_key:str = '' if xml_tree.find('EventKey') is None else xml_tree.find('EventKey').text 28 | latitude:str = xml_tree.find('Latitude').text 29 | longitude:str = xml_tree.find('Longitude').text 30 | precision:str = xml_tree.find('Precision').text 31 | # msg_id:str = xml_tree.find('MsgId').text 32 | agent_id:str = xml_tree.find('AgentID').text 33 | 34 | return cls(to_username, from_username, agent_id, event, latitude, longitude, precision, event_key=event_key, create_time=create_time) -------------------------------------------------------------------------------- /wwbot/msg/event/pic_weixin_event.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from . import EventMessage 4 | from xml.etree.ElementTree import Element 5 | from typing import List, NamedTuple 6 | 7 | class PicInfo(NamedTuple): 8 | pic_md5_sum:str 9 | 10 | class PicWeixinEventMessage(EventMessage): 11 | 12 | # handler key 13 | key:str = 'event.pic_weixin' 14 | 15 | def __init__(self, to_username: str, from_username: str, agent_id: str, event: str, count:str, pic_list:List[PicInfo], event_key: str = '', **kwargs) -> None: 16 | super().__init__(to_username, from_username, agent_id, event, event_key=event_key, **kwargs) 17 | # pic count 18 | self.count:str = count 19 | # pic list 20 | self.pic_list:List[PicInfo] = pic_list 21 | 22 | @classmethod 23 | def from_xml(cls, xml_tree: Element): 24 | '''Parse pic_weixin event message from XML object''' 25 | to_username:str = xml_tree.find('ToUserName').text 26 | from_username:str = xml_tree.find('FromUserName').text 27 | create_time:str = xml_tree.find('CreateTime').text 28 | event:str = xml_tree.find('Event').text 29 | event_key:str = xml_tree.find('EventKey').text 30 | count:str = xml_tree.find('SendPicsInfo/Count').text 31 | items:str = xml_tree.findall('SendPicsInfo/PicList/Item') 32 | pic_list:List[PicInfo] = list(map(lambda item: item.find('PicMd5Sum').text, items)) 33 | # msg_id:str = xml_tree.find('MsgId').text 34 | agent_id:str = xml_tree.find('AgentID').text 35 | 36 | return cls(to_username, from_username, agent_id, event, count, pic_list, event_key=event_key, create_time=create_time) -------------------------------------------------------------------------------- /wwbot/msg/event/pic_sysphoto_event.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from . import EventMessage 4 | from xml.etree.ElementTree import Element 5 | from typing import List, NamedTuple 6 | 7 | class PicInfo(NamedTuple): 8 | pic_md5_sum:str 9 | 10 | class PicSysPhotoEventMessage(EventMessage): 11 | 12 | # handler key 13 | key:str = 'event.pic_sysphoto' 14 | 15 | def __init__(self, to_username: str, from_username: str, agent_id: str, event: str, count:str, pic_list:List[PicInfo], event_key: str = '', **kwargs) -> None: 16 | super().__init__(to_username, from_username, agent_id, event, event_key=event_key, **kwargs) 17 | # pic count 18 | self.count:str = count 19 | # pic list 20 | self.pic_list:List[PicInfo] = pic_list 21 | 22 | @classmethod 23 | def from_xml(cls, xml_tree: Element): 24 | '''Parse pic_sysphoto event message from XML object''' 25 | to_username:str = xml_tree.find('ToUserName').text 26 | from_username:str = xml_tree.find('FromUserName').text 27 | create_time:str = xml_tree.find('CreateTime').text 28 | event:str = xml_tree.find('Event').text 29 | event_key:str = xml_tree.find('EventKey').text 30 | count:str = xml_tree.find('SendPicsInfo/Count').text 31 | items:str = xml_tree.findall('SendPicsInfo/PicList/Item') 32 | pic_list:List[PicInfo] = list(map(lambda item: item.find('PicMd5Sum').text, items)) 33 | # msg_id:str = xml_tree.find('MsgId').text 34 | agent_id:str = xml_tree.find('AgentID').text 35 | 36 | return cls(to_username, from_username, agent_id, event, count, pic_list, event_key=event_key, create_time=create_time) -------------------------------------------------------------------------------- /wwbot/msg/event/pic_photo_or_album_event.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from . import EventMessage 4 | from xml.etree.ElementTree import Element 5 | from typing import List, NamedTuple 6 | 7 | class PicInfo(NamedTuple): 8 | pic_md5_sum:str 9 | 10 | class PicPhotoOrAlbumEventMessage(EventMessage): 11 | 12 | # handler key 13 | key:str = 'event.pic_photo_or_album' 14 | 15 | def __init__(self, to_username: str, from_username: str, agent_id: str, event: str, count:str, pic_list:List[PicInfo], event_key: str = '', **kwargs) -> None: 16 | super().__init__(to_username, from_username, agent_id, event, event_key=event_key, **kwargs) 17 | # pic count 18 | self.count:str = count 19 | # pic list 20 | self.pic_list:List[PicInfo] = pic_list 21 | 22 | @classmethod 23 | def from_xml(cls, xml_tree: Element): 24 | '''Parse pic_photo_or_album event message from XML object''' 25 | to_username:str = xml_tree.find('ToUserName').text 26 | from_username:str = xml_tree.find('FromUserName').text 27 | create_time:str = xml_tree.find('CreateTime').text 28 | event:str = xml_tree.find('Event').text 29 | event_key:str = xml_tree.find('EventKey').text 30 | count:str = xml_tree.find('SendPicsInfo/Count').text 31 | items:str = xml_tree.findall('SendPicsInfo/PicList/Item') 32 | pic_list:List[PicInfo] = list(map(lambda item: item.find('PicMd5Sum').text, items)) 33 | # msg_id:str = xml_tree.find('MsgId').text 34 | agent_id:str = xml_tree.find('AgentID').text 35 | 36 | return cls(to_username, from_username, agent_id, event, count, pic_list, event_key=event_key, create_time=create_time) -------------------------------------------------------------------------------- /examples/echo_bot.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import base64 4 | from flask import Flask 5 | from wwbot import WWBot 6 | from wwbot.msg import Message, TextMessage 7 | from typing import List 8 | 9 | app:Flask = Flask('EchoBot') 10 | 11 | corp_id:str = 'ww274398273492874' 12 | corp_secret:str = '23kjs9dufij3234WERq234rwer234' 13 | # token and AES encoding key set in the application detail page 14 | token:str = '243posfq32ra23r' 15 | aes_key:bytes = base64.b64decode('aGVsbG8gZnJvbSB0YmJhdGJiIQ==') 16 | # the address and port on which the bot is serving on 17 | host:str = '0.0.0.0' 18 | port:int = 31231 19 | message_path:str = '/mbot' 20 | 21 | # init the WWBot 22 | WWBot.config(app, corp_id, corp_secret, token, aes_key, callback_path=message_path) 23 | 24 | # register a customized handler for text message 25 | # for the formats of RECEIVED message, refer to https://developer.work.weixin.qq.com/document/path/90239 26 | @WWBot.on(WWBot.TEXT) 27 | def text_handler(msg:TextMessage) -> Message | List[Message] | None: 28 | ''' 29 | Response to text message 30 | 31 | \param msg: an instance of TextMessage because the handler is registered to 'text' 32 | ''' 33 | # return a simple text message to reply the message 34 | return TextMessage(msg.from_username, msg.to_username, msg.agent_id, msg.content) 35 | # or return a list of messages 36 | # return [ 37 | # TextMessage(msg.from_username, msg.to_username, msg.agent_id, 'First message'), 38 | # TextMessage(msg.from_username, msg.to_username, msg.agent_id, 'Second message'), 39 | # TextMessage(msg.from_username, msg.to_username, msg.agent_id, 'Third message')] 40 | 41 | if __name__ == '__main__': 42 | app.run(host, port) -------------------------------------------------------------------------------- /wwbot/msg/event/batch_job_result_event.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from xml.etree.ElementTree import Element 4 | from . import EventMessage 5 | 6 | class BatchJobResultEventMessage(EventMessage): 7 | 8 | # handler key 9 | key:str = 'event.batch_job_result' 10 | 11 | def __init__(self, to_username: str, from_username: str, agent_id: str, event: str, job_id:str, job_type:str, err_code:str, err_msg:str, event_key: str = '', **kwargs) -> None: 12 | super().__init__(to_username, from_username, agent_id, event, event_key=event_key, **kwargs) 13 | # job id 14 | self.job_id:str = job_id 15 | # job type 16 | self.job_type:str = job_type 17 | # error code 18 | self.err_code:str = err_code 19 | # error message 20 | self.err_msg:str = err_msg 21 | 22 | @classmethod 23 | def from_xml(cls, xml_tree: Element): 24 | '''Parse batch job result event message from XML object''' 25 | to_username:str = xml_tree.find('ToUserName').text 26 | from_username:str = xml_tree.find('FromUserName').text 27 | create_time:str = xml_tree.find('CreateTime').text 28 | event:str = xml_tree.find('Event').text 29 | event_key:str = '' if xml_tree.find('EventKey') is None else xml_tree.find('EventKey').text 30 | job_id:str = xml_tree.find('BatchJob/JobId').text 31 | job_type:str = xml_tree.find('BatchJob/JobType').text 32 | err_code:str = xml_tree.find('BatchJob/ErrCode').text 33 | err_msg:str = xml_tree.find('BatchJob/ErrMsg').text 34 | # msg_id:str = xml_tree.find('MsgId').text 35 | agent_id:str = xml_tree.find('AgentID').text 36 | 37 | return cls(to_username, from_username, agent_id, event, job_id, job_type, err_code, err_msg, event_key=event_key, create_time=create_time) -------------------------------------------------------------------------------- /wwbot/msg/event/location_select_event.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from . import EventMessage 4 | from xml.etree.ElementTree import Element 5 | 6 | class LocationSelectEventMessage(EventMessage): 7 | 8 | # handler key 9 | key:str = 'event.location_select' 10 | 11 | def __init__(self, to_username: str, from_username: str, agent_id: str, event: str, loc_x:str, loc_y:str, scale:str, label:str, poi_name:str, event_key: str = '', **kwargs) -> None: 12 | super().__init__(to_username, from_username, agent_id, event, event_key=event_key, **kwargs) 13 | # location x 14 | self.location_x:str = loc_x 15 | # location y 16 | self.location_y:str = loc_y 17 | # scale 18 | self.scale:str = scale 19 | # label for the position 20 | self.label:str = label 21 | self.poiname:str = poi_name 22 | 23 | @classmethod 24 | def from_xml(cls, xml_tree: Element): 25 | '''Parse location_select event message from XML object''' 26 | to_username:str = xml_tree.find('ToUserName').text 27 | from_username:str = xml_tree.find('FromUserName').text 28 | create_time:str = xml_tree.find('CreateTime').text 29 | event:str = xml_tree.find('Event').text 30 | event_key:str = xml_tree.find('EventKey').text 31 | loc_x:str = xml_tree.find('SendLocationInfo/Location_X').text 32 | loc_y:str = xml_tree.find('SendLocationInfo/Location_Y').text 33 | scale:str = xml_tree.find('SendLocationInfo/Scale').text 34 | label:str = xml_tree.find('SendLocationInfo/Label').text 35 | poi_name:str = xml_tree.find('SendLocationInfo/Poiname').text 36 | # msg_id:str = xml_tree.find('MsgId').text 37 | agent_id:str = xml_tree.find('AgentID').text 38 | 39 | return cls(to_username, from_username, agent_id, event, loc_x, loc_y, scale, label, poi_name, event_key=event_key, create_time=create_time) -------------------------------------------------------------------------------- /wwbot/msg/link_msg.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from . import Message 4 | from xml.etree.ElementTree import Element 5 | 6 | class LinkMessage(Message): 7 | 8 | # message type 9 | type:str = 'link' 10 | # handler key 11 | key:str = 'link' 12 | 13 | def __init__(self, to_username: str, from_username: str, agent_id: str, title:str, desc:str, url:str, pic_url:str, **kwargs) -> None: 14 | super().__init__(to_username, from_username, agent_id, **kwargs) 15 | # the title of the link 16 | self.title:str = title 17 | # the description of the link 18 | self.description:str = desc 19 | # the url for the link 20 | self.url:str = url 21 | # the url for the cover image 22 | self.pic_url:str = pic_url 23 | 24 | def to_xml(self) -> str: 25 | '''Represent link message in XML format''' 26 | raise NotImplementedError('Unable to reply with a link message in current WeWork') 27 | 28 | def to_json(self) -> str: 29 | '''Represent link message in JSON format''' 30 | raise NotImplementedError('Unable to send a link message in current WeWork') 31 | 32 | @classmethod 33 | def from_xml(cls, xml_tree: Element): 34 | '''Parse link message from XML object''' 35 | to_username:str = xml_tree.find('ToUserName').text 36 | from_username:str = xml_tree.find('FromUserName').text 37 | create_time:str = xml_tree.find('CreateTime').text 38 | title:str = xml_tree.find('Title').text 39 | desc:str = xml_tree.find('Description').text 40 | url:str = xml_tree.find('Url').text 41 | pic_url:str = xml_tree.find('PicUrl').text 42 | msg_id:str = xml_tree.find('MsgId').text 43 | agent_id:str = xml_tree.find('AgentID').text 44 | 45 | return cls(to_username, from_username, agent_id, title, desc, url, pic_url, create_time=create_time, msg_id=msg_id) 46 | 47 | -------------------------------------------------------------------------------- /wwbot/msg/textcard_msg.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | import json 4 | from . import Message 5 | from typing import Any, Dict 6 | from xml.etree.ElementTree import Element 7 | 8 | class TextCardMessage(Message): 9 | 10 | # message type 11 | type:str = 'textcard' 12 | # handler key 13 | key:str = 'textcard' 14 | 15 | def __init__(self, to_username: str, from_username: str, agent_id: str, title: str, desc:str, url:str, btntext:str='', **kwargs) -> None: 16 | super().__init__(to_username, from_username, agent_id, **kwargs) 17 | # the title for the card 18 | self.title:str = title 19 | # the description for the card 20 | self.description:str = desc 21 | # the url for the card 22 | self.url:str = url 23 | # the text for the button 24 | self.btntext:str = btntext 25 | 26 | def to_xml(self) -> str: 27 | '''Represent textcard message in XML format''' 28 | raise NotImplementedError('Unable to reply with a textcard message in current WeWork') 29 | 30 | def to_json(self) -> str: 31 | '''Represent textcard message in JSON format''' 32 | data:Dict[str, Any] = {"msgtype":"textcard","textcard":{"title":self.title,"description":self.description,"url":self.url,"btntext":self.btntext},"safe":1 if self.safe else 0,"enable_id_trans":1 if self.enable_id_trans else 0,"enable_duplicate_check":1 if self.enable_duplicate_check else 0,"duplicate_check_interval":self.duplicate_check_interval} 33 | if self.for_chat: data.update({"chatid":self.chat_id}) 34 | elif not self.for_group_bot: data.update({"touser":self.to_username,"agentid":self.agent_id}) 35 | return json.dumps(data) 36 | 37 | @classmethod 38 | def from_xml(cls, xml_tree: Element): 39 | '''Parse textcard message from XML object''' 40 | raise NotImplementedError('Unable to receive and parse a textcard message in current WeWork') 41 | 42 | -------------------------------------------------------------------------------- /wwbot/msg/location_msg.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from . import Message 4 | from xml.etree.ElementTree import Element 5 | 6 | class LocationMessage(Message): 7 | 8 | # message type 9 | type:str = 'location' 10 | # handler key 11 | key:str = 'location' 12 | 13 | def __init__(self, to_username: str, from_username: str, agent_id: str, loc_x:str, loc_y:str, scale:str, label:str, **kwargs) -> None: 14 | super().__init__(to_username, from_username, agent_id, **kwargs) 15 | # the latitude value for the location 16 | self.location_x:str = loc_x 17 | # the longitude value for the location 18 | self.location_y:str = loc_y 19 | # the scale 20 | self.scale:str = scale 21 | # the lable for the location 22 | self.label:str = label 23 | 24 | def to_xml(self) -> str: 25 | '''Represent location message in XML format''' 26 | raise NotImplementedError('Unable to reply with a location message in current WeWork') 27 | 28 | def to_json(self) -> str: 29 | '''Represent location message in JSON format''' 30 | raise NotImplementedError('Unable to send a location message in current WeWork') 31 | 32 | @classmethod 33 | def from_xml(cls, xml_tree: Element): 34 | '''Parse location message from XML object''' 35 | to_username:str = xml_tree.find('ToUserName').text 36 | from_username:str = xml_tree.find('FromUserName').text 37 | create_time:str = xml_tree.find('CreateTime').text 38 | loc_x:str = xml_tree.find('Location_X').text 39 | loc_y:str = xml_tree.find('Location_Y').text 40 | scale:str = xml_tree.find('Scale').text 41 | label:str = xml_tree.find('Label').text 42 | msg_id:str = xml_tree.find('MsgId').text 43 | agent_id:str = xml_tree.find('AgentID').text 44 | 45 | return cls(to_username, from_username, agent_id, loc_x, loc_y, scale, label, create_time=create_time, msg_id=msg_id) 46 | 47 | -------------------------------------------------------------------------------- /wwbot/msg/text_msg.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | import json 4 | from . import Message 5 | from typing import Any, Dict 6 | from xml.etree.ElementTree import Element 7 | 8 | class TextMessage(Message): 9 | 10 | # message type 11 | type:str = 'text' 12 | # handler key 13 | key:str = 'text' 14 | 15 | def __init__(self, to_username: str, from_username: str, agent_id: str, content: str, **kwargs) -> None: 16 | super().__init__(to_username, from_username, agent_id, **kwargs) 17 | # the content of the message 18 | self.content:str = content 19 | 20 | def to_xml(self) -> str: 21 | '''Represent the text message in XML format''' 22 | return f'''{self.create_time}{self.message_id}{self.agent_id}''' 23 | 24 | def to_json(self) -> str: 25 | '''Represent the text message in JSON format''' 26 | data:Dict[str, Any] = {"msgtype":"text","text":{"content":self.content},"safe":1 if self.safe else 0,"enable_id_trans":1 if self.enable_id_trans else 0,"enable_duplicate_check":1 if self.enable_duplicate_check else 0,"duplicate_check_interval":self.duplicate_check_interval} 27 | if self.for_chat: data.update({"chatid":self.chat_id}) 28 | elif not self.for_group_bot: data.update({"touser":self.to_username,"agentid":self.agent_id}) 29 | return json.dumps(data) 30 | 31 | @classmethod 32 | def from_xml(cls, xml_tree: Element): 33 | '''Parse text message from XML object''' 34 | to_username:str = xml_tree.find('ToUserName').text 35 | from_username:str = xml_tree.find('FromUserName').text 36 | create_time:str = xml_tree.find('CreateTime').text 37 | content:str = xml_tree.find('Content').text 38 | msg_id:str = xml_tree.find('MsgId').text 39 | agent_id:str = xml_tree.find('AgentID').text 40 | 41 | return cls(to_username, from_username, agent_id, content, create_time=create_time, msg_id=msg_id) 42 | 43 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # WeWorkBot 2 | 3 | 为企业微信自建应用构建的机器人,能够实现对各种类型消息的被动回复和主动发送。 4 | 5 | 现支持: 6 | - 接收处理文本(`text`)、图片(`image`)、语音(`voice`)、视频(`video`)、位置(`location`)和链接(`link`)等消息类型; 7 | - 以文本(`text`)、图片(`image`)、语音(`voice`)、视频(`video`)、图文(`news`)等消息类型进行被动回复; 8 | - 以文本(`text`)、图片(`image`)、语音(`voice`)、视频(`video`)、Markdown(`markdown`)、图文(`mpnews`)、图文(`news`)、文本卡片(`textcard`)、文件(`file`)等消息类型主动发送应用消息; 9 | - 文件素材管理,包括上传临时文件、上传图片、异步上传文件、下载文件等; 10 | - 发起群聊、更改群聊信息、获取群聊会话、向群聊发送应用消息等功能(具体限制请参考[官方文档](https://developer.work.weixin.qq.com/document/path/90244)); 11 | 12 | 13 | ## 依赖 14 | 15 | 依赖的库不多,可以参考`requirements.txt`。 16 | 17 | ## 安装 18 | 19 | **目前支持`Python >= 3.9`,其余版本未经测试。** 20 | 21 | Python库现已发布,可以通过`pip install wwbot`进行安装。 22 | 23 | ## 示例代码 24 | 25 | 如下示例代码展示`WeWorkBot`的简单使用。该示例展示了如何回复**文本类型**(`text`)的消息: 26 | ```python 27 | from flask import Flask 28 | from wwbot import WWBot 29 | from wwbot.msg import Message, TextMessage 30 | 31 | # 注册一个文本消息的事件监听 32 | @WWBot.on(WWBot.TEXT) 33 | def text_handler(msg:TextMessage) -> Message: 34 | ''' 35 | msg参数代表接收到的消息被解析后的实例 36 | ''' 37 | # 从消息中提取消息内容字段 38 | # 关于接收到的消息格式具体定义,参考 https://developer.work.weixin.qq.com/document/path/90239 39 | msg_content:str = msg.content 40 | # 作为示例,直接使用接收到的消息作为回复,相当于一个 echo bot 41 | return TextMessage(msg.from_username, msg.to_username, msg.agent_id, msg_content) 42 | 43 | # WeWorkBot运行在Flask框架之上 44 | app:Flask = Flask('WWBot') 45 | # 企业ID 46 | corp_id:str = 'corp_id' 47 | # 企业自建应用的secret 可以创建自建应用后在应用详情页面查看 48 | corp_secret:str = 'corp_secret' 49 | # 自建应用启用API接收消息时,配置的“Token”参数 50 | token:str = 'token' 51 | # 自建应用启用API接收消息时,配置的“EncodingAESKey”参数 52 | aes_key:bytes = base64.b64decode('aes_key') 53 | # 接收消息回调时的url path部分 54 | callback_path:str = '/wwbot' 55 | 56 | # 配置机器人 57 | WWBot.config(app, corp_id, corp_secret, token, aes_key, callback_path=callback_path) 58 | 59 | if __name__ == '__main__': 60 | app.run('0.0.0.0', 31221) 61 | ``` 62 | 更加完整的例子请参考`exampls/echo_bot.py`。 63 | 64 | ## TODO List 65 | 66 | - [x] 将“接口验证”和“消息接收”的url部分合并,不需要两次注册 67 | - [x] 实现临时资源上传,从而能够发送任意语音或是视频等多媒体消息 68 | - [x] 支持更多的应用消息类型 69 | - [x] 将消息的定义和转换变得更加优雅 70 | - [x] 创建成python库。现在这个方式,不够优雅 71 | - [x] ~~完善Doc和Readme,包括获取corp_id等参数的方法~~(不写了,懒。总共也就几个接口,根本不够写) 72 | - [x] 支持一次性回复多条消息 73 | 74 | ## 说明 75 | 76 | 代码随缘更新,主要看有没有空。一般会在周末更新比较频繁。 -------------------------------------------------------------------------------- /wwbot/msg/mpnews_msg.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | import json 4 | from . import Message 5 | from xml.etree.ElementTree import Element 6 | from typing import Any, Dict, List, NamedTuple 7 | 8 | class MPNews(NamedTuple): 9 | '''A piece of single mpnews''' 10 | title:str 11 | thumb_media_id:str 12 | content:str 13 | author:str = None 14 | content_source_url:str = None 15 | desc:str = None 16 | 17 | class MPNewsMessage(Message): 18 | 19 | # message type 20 | type:str = 'mpnews' 21 | # handler key 22 | key:str = 'mpnews' 23 | 24 | def __init__(self, to_username: str, from_username: str, agent_id: str, articles:List[MPNews], **kwargs) -> None: 25 | super().__init__(to_username, from_username, agent_id, **kwargs) 26 | # the mpnews list 27 | self.articles:List[MPNews] = articles 28 | 29 | def to_xml(self) -> str: 30 | '''Represent mpnews message in XML format''' 31 | raise NotImplementedError('Unable to reply with a mpnews message in current WeWork') 32 | 33 | def to_json(self) -> str: 34 | '''Represent mpnews message in JSON format''' 35 | articles:List[Dict[str, str]] = [] 36 | for n in self.articles: 37 | article:Dict[str, str] = {"title":n.title,"thumb_media_id":n.thumb_media_id,"content":n.content} 38 | if n.author is not None: article["author"] = n.author 39 | if n.content_source_url is not None: article["content_source_url"] = n.content_source_url 40 | if n.desc is not None: article["digest"] = n.desc 41 | articles.append(article) 42 | data:Dict[str, Any] = {"msgtype":"mpnews","mpnews":{"articles":articles},"safe":1 if self.safe else 0,"enable_id_trans":1 if self.enable_id_trans else 0,"enable_duplicate_check":1 if self.enable_duplicate_check else 0,"duplicate_check_interval":self.duplicate_check_interval} 43 | if self.for_chat: data.update({"chatid":self.chat_id}) 44 | elif not self.for_group_bot: data.update({"touser":self.to_username,"agentid":self.agent_id}) 45 | return json.dumps(data) 46 | 47 | @classmethod 48 | def from_xml(cls, xml_tree: Element): 49 | '''Parse mpnews message from XML object''' 50 | raise NotImplementedError('Unable to receive and parse a mpnews message in current WeWork') 51 | 52 | -------------------------------------------------------------------------------- /wwbot/msg/image_msg.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | import json 4 | from . import Message 5 | from typing import Any, Dict 6 | from xml.etree.ElementTree import Element 7 | 8 | class ImageMessage(Message): 9 | 10 | # message type 11 | type:str = 'image' 12 | # handler key 13 | key:str = 'image' 14 | 15 | def __init__(self, to_username: str, from_username: str, agent_id: str, media_id: str, pic_url:str = '', **kwargs) -> None: 16 | super().__init__(to_username, from_username, agent_id, **kwargs) 17 | # media id for the image 18 | self.media_id:str = media_id 19 | # the url for the picture 20 | self.pic_url:str = pic_url 21 | 22 | def to_xml(self) -> str: 23 | '''Represent image message in XML format''' 24 | return f'''{self.create_time}{self.message_id}{self.agent_id}''' 25 | 26 | def to_json(self) -> str: 27 | '''Represent image message in JSON format''' 28 | data:Dict[str, Any] = {"msgtype":"image","image":{"media_id":self.media_id},"safe":1 if self.safe else 0,"enable_id_trans":1 if self.enable_id_trans else 0,"enable_duplicate_check":1 if self.enable_duplicate_check else 0,"duplicate_check_interval":self.duplicate_check_interval} 29 | if self.for_chat: data.update({"chatid":self.chat_id}) 30 | elif not self.for_group_bot: data.update({"touser":self.to_username,"agentid":self.agent_id}) 31 | return json.dumps(data) 32 | 33 | @classmethod 34 | def from_xml(cls, xml_tree: Element): 35 | '''Parse image message from XML object''' 36 | to_username:str = xml_tree.find('ToUserName').text 37 | from_username:str = xml_tree.find('FromUserName').text 38 | create_time:str = xml_tree.find('CreateTime').text 39 | pic_url:str = xml_tree.find('PicUrl').text 40 | media_id:str = xml_tree.find('MediaId').text 41 | msg_id:str = xml_tree.find('MsgId').text 42 | agent_id:str = xml_tree.find('AgentID').text 43 | 44 | return cls(to_username, from_username, agent_id, media_id, pic_url=pic_url, create_time=create_time, msg_id=msg_id) 45 | 46 | -------------------------------------------------------------------------------- /wwbot/msg/voice_msg.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | import json 4 | from . import Message 5 | from typing import Any, Dict 6 | from xml.etree.ElementTree import Element 7 | 8 | class VoiceMessage(Message): 9 | 10 | # message type 11 | type:str = 'voice' 12 | # handler key 13 | key:str = 'voice' 14 | 15 | def __init__(self, to_username: str, from_username: str, agent_id: str, media_id: str, format:str = 'amr', **kwargs) -> None: 16 | super().__init__(to_username, from_username, agent_id, **kwargs) 17 | # media id for the voice 18 | self.media_id:str = media_id 19 | # the format of the voice file 20 | self.format:str = format 21 | 22 | def to_xml(self) -> str: 23 | '''Represent voice message in XML format''' 24 | return f'''{self.create_time}{self.message_id}{self.agent_id}''' 25 | 26 | def to_json(self) -> str: 27 | '''Represent voice message in JSON format''' 28 | data:Dict[str, Any] = {"msgtype":"voice","voice":{"media_id":self.media_id},"safe":1 if self.safe else 0,"enable_id_trans":1 if self.enable_id_trans else 0,"enable_duplicate_check":1 if self.enable_duplicate_check else 0,"duplicate_check_interval":self.duplicate_check_interval} 29 | if self.for_chat: data.update({"chatid":self.chat_id}) 30 | elif not self.for_group_bot: data.update({"touser":self.to_username,"agentid":self.agent_id}) 31 | return json.dumps(data) 32 | 33 | @classmethod 34 | def from_xml(cls, xml_tree: Element): 35 | '''Parse voice message from XML object''' 36 | to_username:str = xml_tree.find('ToUserName').text 37 | from_username:str = xml_tree.find('FromUserName').text 38 | create_time:str = xml_tree.find('CreateTime').text 39 | media_id:str = xml_tree.find('MediaId').text 40 | format:str = xml_tree.find('Format').text 41 | msg_id:str = xml_tree.find('MsgId').text 42 | agent_id:str = xml_tree.find('AgentID').text 43 | 44 | return cls(to_username, from_username, agent_id, media_id, format=format, create_time=create_time, msg_id=msg_id) 45 | 46 | -------------------------------------------------------------------------------- /wwbot/msg/news_msg.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | import json 4 | from . import Message 5 | from xml.etree.ElementTree import Element 6 | from typing import Any, Dict, List, NamedTuple 7 | 8 | class News(NamedTuple): 9 | '''A piece of single news''' 10 | title:str 11 | description:str 12 | pic_url:str 13 | url:str = None 14 | appid:str = None 15 | pagepath:str = None 16 | 17 | class NewsMessage(Message): 18 | 19 | # message type 20 | type:str = 'news' 21 | # handler key 22 | key:str = 'news' 23 | 24 | def __init__(self, to_username: str, from_username: str, agent_id: str, articles:List[News], **kwargs) -> None: 25 | super().__init__(to_username, from_username, agent_id, **kwargs) 26 | # the news list 27 | self.articles:List[News] = articles 28 | 29 | def to_xml(self) -> str: 30 | '''Represent news message in XML format''' 31 | news_str:str = ''.join(map(lambda n:f'<![CDATA[{n.title}]]>', self.articles)) 32 | return f'''{self.create_time}{len(self.articles)}{news_str}''' 33 | 34 | def to_json(self) -> str: 35 | '''Represent news message in JSON format''' 36 | articles:List[Dict[str, str]] = [] 37 | for n in self.articles: 38 | article:Dict[str, str] = {"title":n.title,"description":n.description} 39 | if n.url is not None: article["url"] = n.url 40 | if n.appid is not None: 41 | article["appid"] = n.appid 42 | article["pagepath"] = n.pagepath 43 | articles.append(article) 44 | data:Dict[str, Any] = {"msgtype":"news","news":{"articles":articles},"safe":1 if self.safe else 0,"enable_id_trans":1 if self.enable_id_trans else 0,"enable_duplicate_check":1 if self.enable_duplicate_check else 0,"duplicate_check_interval":self.duplicate_check_interval} 45 | if self.for_chat: data.update({"chatid":self.chat_id}) 46 | elif not self.for_group_bot: data.update({"touser":self.to_username,"agentid":self.agent_id}) 47 | return json.dumps(data) 48 | 49 | @classmethod 50 | def from_xml(cls, xml_tree: Element): 51 | '''Parse news message from XML object''' 52 | raise NotImplementedError('Unable to receive and parse a news message in current WeWork') 53 | 54 | -------------------------------------------------------------------------------- /wwbot/msg/video_msg.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | import json 4 | from . import Message 5 | from typing import Any, Dict 6 | from xml.etree.ElementTree import Element 7 | 8 | class VideoMessage(Message): 9 | 10 | # message type 11 | type:str = 'video' 12 | # handler key 13 | key:str = 'video' 14 | 15 | def __init__(self, to_username: str, from_username: str, agent_id: str, media_id: str, thumb_id:str = '', title:str = '', desc:str = '', **kwargs) -> None: 16 | super().__init__(to_username, from_username, agent_id, **kwargs) 17 | # media id for the video 18 | self.media_id:str = media_id 19 | # the id for the thumb image 20 | self.thumb_media_id:str = thumb_id 21 | # title for the video 22 | self.title:str = title 23 | # description for the video 24 | self.description = desc 25 | 26 | def to_xml(self) -> str: 27 | '''Represent video message in XML format''' 28 | return f'''{self.create_time}{self.message_id}{self.agent_id}''' 29 | 30 | def to_json(self) -> str: 31 | '''Represent video message in JSON format''' 32 | data:Dict[str, Any] = {"msgtype":"video","video":{"media_id":self.media_id,"title":self.title,"description":self.description},"safe":1 if self.safe else 0,"enable_id_trans":1 if self.enable_id_trans else 0,"enable_duplicate_check":1 if self.enable_duplicate_check else 0,"duplicate_check_interval":self.duplicate_check_interval} 33 | if self.for_chat: data.update({"chatid":self.chat_id}) 34 | elif not self.for_group_bot: data.update({"touser":self.to_username,"agentid":self.agent_id}) 35 | return json.dumps(data) 36 | 37 | @classmethod 38 | def from_xml(cls, xml_tree: Element): 39 | '''Parse video message from XML object''' 40 | to_username:str = xml_tree.find('ToUserName').text 41 | from_username:str = xml_tree.find('FromUserName').text 42 | create_time:str = xml_tree.find('CreateTime').text 43 | media_id:str = xml_tree.find('MediaId').text 44 | thumb_id:str = xml_tree.find('ThumbMediaId').text 45 | msg_id:str = xml_tree.find('MsgId').text 46 | agent_id:str = xml_tree.find('AgentID').text 47 | 48 | return cls(to_username, from_username, agent_id, media_id, thumb_id=thumb_id, create_time=create_time, msg_id=msg_id) 49 | 50 | -------------------------------------------------------------------------------- /wwbot/msg/event/__init__.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | from .. import Message 4 | from xml.etree.cElementTree import Element 5 | 6 | class EventMessage(Message): 7 | 8 | # message type 9 | type:str = 'event' 10 | # handler key 11 | key:str = 'event' 12 | 13 | def __init__(self, to_username: str, from_username: str, agent_id: str, event:str, event_key:str='', **kwargs) -> None: 14 | super().__init__(to_username, from_username, agent_id, **kwargs) 15 | # event name 16 | self.event:str = event 17 | # event key 18 | self.event_key:str = event_key 19 | 20 | def to_xml(self) -> str: 21 | '''Represent event message in XML format''' 22 | raise NotImplementedError('Unable to reply with an event message in current WeWork') 23 | 24 | def to_json(self) -> str: 25 | '''Represent event message in JSON format''' 26 | raise NotImplementedError('Unable to send an event message in current WeWork') 27 | 28 | 29 | from .view_event import ViewEventMessage 30 | from .click_event import ClickEventMessage 31 | from .location_event import LocationEventMessage 32 | from .subscribe_event import SubscribeEventMessage 33 | from .pic_weixin_event import PicWeixinEventMessage 34 | from .enter_agent_event import EnterAgentEventMessage 35 | from .pic_sysphoto_event import PicSysPhotoEventMessage 36 | from .scancode_push_event import ScanCodePushEventMessage 37 | from .location_select_event import LocationSelectEventMessage 38 | from .batch_job_result_event import BatchJobResultEventMessage 39 | from .scancode_waitmsg_event import ScanCodeWaitMsgEventMessage 40 | from .pic_photo_or_album_event import PicPhotoOrAlbumEventMessage 41 | from .share_agent_change_event import ShareAgentChangeEventMessage 42 | from .share_chain_change_event import ShareChainChangeEventMessage 43 | from .upload_media_job_finish_event import UploadMediaJobFinishEventMessage 44 | 45 | def event_msg_from_xml(xml_tree:Element) -> Message: 46 | '''Parse message from xml tree''' 47 | event:str = xml_tree.find('Event').text.lower() 48 | if event in ['subscribe', 'unsubscribe']: return SubscribeEventMessage.from_xml(xml_tree) 49 | if event == 'view': return ViewEventMessage.from_xml(xml_tree) 50 | if event == 'click': return ClickEventMessage.from_xml(xml_tree) 51 | if event == 'location': return LocationEventMessage.from_xml(xml_tree) 52 | if event == 'pic_weixin': return PicWeixinEventMessage.from_xml(xml_tree) 53 | if event == 'enter_agent': return EnterAgentEventMessage.from_xml(xml_tree) 54 | if event == 'pic_sysphoto': return PicSysPhotoEventMessage.from_xml(xml_tree) 55 | if event == 'scancode_push': return ScanCodePushEventMessage.from_xml(xml_tree) 56 | if event == 'location_select': return LocationSelectEventMessage.from_xml(xml_tree) 57 | if event == 'batch_job_result': return BatchJobResultEventMessage.from_xml(xml_tree) 58 | if event == 'scancode_waitmsg': return ScanCodeWaitMsgEventMessage.from_xml(xml_tree) 59 | if event == 'pic_photo_or_album': return PicPhotoOrAlbumEventMessage.from_xml(xml_tree) 60 | if event == 'share_chain_change': return ShareChainChangeEventMessage.from_xml(xml_tree) 61 | if event == 'share_agent_change': return ShareAgentChangeEventMessage.from_xml(xml_tree) 62 | if event == 'upload_media_job_finish': return UploadMediaJobFinishEventMessage.from_xml(xml_tree) 63 | Message.logger.warn('Unregistered event type') 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # test files 10 | test* 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/#use-with-ide 112 | .pdm.toml 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /wwbot/chat/__init__.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | import requests, string, random 4 | from ..lib import Logger 5 | from requests import Response 6 | from typing import List, Dict, Any, NamedTuple 7 | 8 | class ChatInfo(NamedTuple): 9 | chat_id:str 10 | name:str 11 | owner:str 12 | user_list:List[str] 13 | 14 | class Chat: 15 | 16 | logger:Logger = Logger('Chat') 17 | 18 | # api url for create chat 19 | API_CREATE_URL:str = 'https://qyapi.weixin.qq.com/cgi-bin/appchat/create?access_token={token}' 20 | # api url for update chat 21 | API_UPDATE_URL:str = 'https://qyapi.weixin.qq.com/cgi-bin/appchat/update?access_token={token}' 22 | # api url for get chat 23 | API_GET_URL:str = 'https://qyapi.weixin.qq.com/cgi-bin/appchat/get?access_token={token}&chatid={chat_id}' 24 | 25 | @classmethod 26 | def create(cls, access_token:str, name:str, user_list:List[str], owner:str=None, chat_id:str=None) -> str: 27 | '''Create a chat with provided user list''' 28 | if len(user_list) < 2: return None 29 | if owner is None or owner not in user_list: owner = user_list[0] 30 | if chat_id is None: chat_id = ''.join(random.choices(string.ascii_letters+string.digits, k=32)) 31 | url:str = cls.API_CREATE_URL.format(token=access_token) 32 | try: 33 | resp:Response = requests.post(url, json={"name":name,"owner": owner,"userlist":user_list,"chatid":chat_id}, timeout=20) 34 | if resp.status_code != 200: 35 | cls.logger.error('WxWork Receive Response With Status Code', resp.status_code) 36 | return None 37 | result:object = resp.json() 38 | if result['errcode'] != 0: 39 | cls.logger.error(result['errmsg']) 40 | return None 41 | return result['chatid'] 42 | except Exception as e: 43 | cls.logger.error(e) 44 | return None 45 | 46 | @classmethod 47 | def update(cls, access_token:str, chat_id:str, name:str=None, add_user_list:List[str]=[], del_user_list:List[str]=[], owner:str=None) -> bool: 48 | '''Update a chat with provided information''' 49 | data:Dict[str, Any] = {"chatid": chat_id} 50 | if name is not None: data['name'] = name 51 | if owner is not None: data['owner'] = owner 52 | if len(add_user_list) > 0: data['add_user_list'] = add_user_list 53 | if len(del_user_list) > 0: data['del_user_list'] = del_user_list 54 | url:str = cls.API_UPDATE_URL.format(token=access_token) 55 | try: 56 | resp:Response = requests.post(url, json=data, timeout=20) 57 | if resp.status_code != 200: 58 | cls.logger.error('WxWork Receive Response With Status Code', resp.status_code) 59 | return False 60 | result:object = resp.json() 61 | if result['errcode'] != 0: 62 | cls.logger.error(result['errmsg']) 63 | return False 64 | return True 65 | except Exception as e: 66 | cls.logger.error(e) 67 | return False 68 | 69 | @classmethod 70 | def get(cls, access_token:str, chat_id:str) -> ChatInfo: 71 | '''Get a chat with provided chat id''' 72 | url:str = cls.API_GET_URL.format(token=access_token, chat_id=chat_id) 73 | try: 74 | resp:Response = requests.get(url, timeout=20) 75 | if resp.status_code != 200: 76 | cls.logger.error('WxWork Receive Response With Status Code', resp.status_code) 77 | return None 78 | result:object = resp.json() 79 | if result['errcode'] != 0: 80 | cls.logger.error(result['errmsg']) 81 | return None 82 | return ChatInfo(result['chat_info']['chatid'], result['chat_info']['name'], result['chat_info']['owner'], result['chat_info']['userlist']) 83 | except Exception as e: 84 | cls.logger.error(e) 85 | return None 86 | -------------------------------------------------------------------------------- /wwbot/msg/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import time, random 4 | from ..lib import Logger 5 | from abc import abstractmethod 6 | from xml.etree.cElementTree import Element 7 | 8 | class Message: 9 | 10 | logger:Logger = Logger('Message') 11 | 12 | # message type 13 | type:str = 'message' 14 | # handler key 15 | key:str = 'message' 16 | 17 | def __init__(self, to_username:str, from_username:str, agent_id:str, create_time:int=None, msg_id:int=None, safe:bool=False, enable_id_trans:bool=False, enable_duplicate_check:bool=False, duplicate_check_interval:int=1800, for_chat:bool=False, chat_id:str=None, for_group_bot:bool=False, group_bot_key:str=None) -> None: 18 | # the id of the receiver of the message 19 | self.to_username:str = to_username 20 | # the id of the sender of the message 21 | self.from_username:str = from_username 22 | # the agent id of the message 23 | # also known as the application id 24 | self.agent_id:str = agent_id 25 | # timestamp for the message 26 | self.create_time:int = int(create_time) if create_time is not None else int(time.time()) 27 | # message id 28 | self.message_id:int = msg_id or (self.create_time * 1000 + random.randint(1000, 9999)) 29 | # whether the message can be shared when it's sent by send_to 30 | self.safe:bool = safe 31 | # whether to enable the id translation 32 | self.enable_id_trans:bool = enable_id_trans 33 | # whether to check the message duplication 34 | self.enable_duplicate_check:bool = enable_duplicate_check 35 | # the interval of message duplication checking, in second 36 | self.duplicate_check_interval:int = duplicate_check_interval 37 | # whether the message is prepared for some chat 38 | self.for_chat:bool = for_chat 39 | # if the message is prepared for some chat, then it will be sent to the chat with chat_id 40 | self.chat_id:str = chat_id 41 | # whether the message is sent by a group bot 42 | self.for_group_bot:bool = for_group_bot 43 | # the key to send messages as group bots 44 | # contained in the webhook url of the group bot, like: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=6449f223-4920-2394-3aa39d939ae0 45 | self.group_bot_key:str = group_bot_key 46 | 47 | @abstractmethod 48 | def to_xml(self) -> str: 49 | '''Represent the message in XML format''' 50 | pass 51 | 52 | @abstractmethod 53 | def to_json(self) -> str: 54 | '''Represent the message in JSON format''' 55 | pass 56 | 57 | @classmethod 58 | @abstractmethod 59 | def from_xml(cls, xml_tree:Element): 60 | '''Parse different types of messages from XML object''' 61 | pass 62 | 63 | from .event import event_msg_from_xml 64 | from .text_msg import TextMessage 65 | from .link_msg import LinkMessage 66 | from .file_msg import FileMessage 67 | from .image_msg import ImageMessage 68 | from .voice_msg import VoiceMessage 69 | from .video_msg import VideoMessage 70 | from .news_msg import News, NewsMessage 71 | from .location_msg import LocationMessage 72 | from .markdown_msg import MarkdownMessage 73 | from .textcard_msg import TextCardMessage 74 | from .mpnews_msg import MPNews, MPNewsMessage 75 | 76 | def msg_from_xml(xml_tree:Element) -> Message: 77 | '''Parse message from xml tree''' 78 | msg_type:str = xml_tree.find('MsgType').text 79 | if msg_type == 'event': return event_msg_from_xml(xml_tree) 80 | if msg_type == 'text': return TextMessage.from_xml(xml_tree) 81 | if msg_type == 'image': return ImageMessage.from_xml(xml_tree) 82 | if msg_type == 'voice': return VoiceMessage.from_xml(xml_tree) 83 | if msg_type == 'video': return VideoMessage.from_xml(xml_tree) 84 | if msg_type == 'link': return LinkMessage.from_xml(xml_tree) 85 | if msg_type == 'location': return LocationMessage.from_xml(xml_tree) 86 | Message.logger.warn('Unregistered message type') 87 | -------------------------------------------------------------------------------- /wwbot/lib/logger.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | # -*- encoding: utf-8 -*- 3 | 4 | from os import path 5 | import sys, os, datetime, traceback, datetime 6 | 7 | class Logger(object): 8 | '''Logger''' 9 | 10 | F_RED = '\033[;31m' 11 | B_RED = '\033[;41m' 12 | F_GREEN = '\033[;32m' 13 | B_GREEN = '\033[;42m' 14 | F_YELLOW = '\033[;33m' 15 | B_YELLOW = '\033[;43m' 16 | F_BLUE = '\033[;34m' 17 | B_BLUE = '\033[;44m' 18 | C_DEFAULT = '\033[0m |' 19 | 20 | L_INFO:int = 1 21 | L_WARN:int = 2 22 | L_ERROR:int = 3 23 | L_DEBUG:int = 4 24 | L_NONE:int = 5 25 | CLS_LEVEL:int = L_INFO 26 | 27 | LOG_DIR:str = 'logs' 28 | TOTAL_LOG_NAME:str = (datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)).strftime('%Y%m%d%H%M%S') 29 | 30 | def __init__(self, name:str, tz:int=8, save:bool=False, level:int=L_INFO, sym_info:str='[√]', sym_warn:str='[!]', sym_err:str='[x]'): 31 | self.name:str = name 32 | self.time_delta:datetime.timedelta = datetime.timedelta(hours=tz) 33 | self.save_to_file:bool = save 34 | self.sym_info:str = sym_info 35 | self.sym_warn:str = sym_warn 36 | self.sym_error:str = sym_err 37 | self.__level:int = level 38 | 39 | def __now_time(self): 40 | now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) 41 | return (now + self.time_delta) 42 | 43 | def __prt(self, color:str, symbol:str, *args, with_stack:bool=False, **kwarg): 44 | ts = self.__now_time().strftime('%H:%M:%S') 45 | if with_stack: call_stack:str = '->'.join(map(lambda s: f'{s[2]}:{s[1]}', traceback.extract_stack()[:-2])) 46 | else: 47 | line, caller = traceback.extract_stack()[-3][1:3] 48 | call_stack:str = f'[{caller}:{line}]' 49 | print(color + symbol, ts, f'[{self.name}]', call_stack + Logger.C_DEFAULT, *args, **kwarg) 50 | sys.stdout.flush() 51 | if self.save_to_file: 52 | msg = ' '.join([str(a) for a in args]) 53 | self.__save(self.TOTAL_LOG_NAME, f'[{ts}] | {msg}{os.linesep}') 54 | self.__save(self.name, f'{symbol}[{ts}] | {msg}{os.linesep}') 55 | 56 | def __save(self, file_name:str, msg:str): 57 | if not path.isdir(self.LOG_DIR): 58 | os.mkdir(self.LOG_DIR) 59 | self.info(f'Directory {self.LOG_DIR} For Log Files Created.') 60 | log_file:str = path.join(self.LOG_DIR, f'{file_name}.log') 61 | with open(log_file, 'a') as outf: 62 | outf.write(msg) 63 | 64 | def clean(self): 65 | dirs:list = [path.join(self.LOG_DIR, d) for d in os.listdir(self.LOG_DIR)] 66 | files:list = [d for d in dirs if path.isfile(d) and d.endswith('.log')] 67 | for f in files: 68 | os.remove(f) 69 | return self 70 | 71 | def info(self, *args, highlight:bool=False, with_stack:bool=False, **kwarg): 72 | if max(self.__level, Logger.CLS_LEVEL) > Logger.L_INFO: 73 | return 74 | color = Logger.F_GREEN if not highlight else Logger.B_GREEN 75 | self.__prt(color, self.sym_info, *args, with_stack=with_stack, **kwarg) 76 | return self 77 | 78 | def dbg(self, *args, highlight:bool=False, with_stack:bool=False, **kwarg): 79 | if max(self.__level, Logger.CLS_LEVEL) > Logger.L_DEBUG: 80 | return 81 | color = Logger.F_BLUE if not highlight else Logger.B_BLUE 82 | self.__prt(color, self.sym_info, *args, with_stack=with_stack, **kwarg) 83 | return self 84 | 85 | def warn(self, *args, highlight:bool=False, with_stack:bool=False, **kwarg): 86 | if max(self.__level, Logger.CLS_LEVEL) > Logger.L_WARN: 87 | return 88 | color = Logger.F_YELLOW if not highlight else Logger.B_YELLOW 89 | self.__prt(color, self.sym_warn, *args, with_stack=with_stack, **kwarg) 90 | return self 91 | 92 | def error(self, *args, highlight:bool=False, with_stack:bool=False, **kwarg): 93 | if max(self.__level, Logger.CLS_LEVEL) > Logger.L_ERROR: 94 | return 95 | color = Logger.F_RED if not highlight else Logger.B_RED 96 | self.__prt(color, self.sym_error, *args, with_stack=with_stack, **kwarg) 97 | return self 98 | 99 | def set_level(self, v:int=0): 100 | if v not in range(self.L_NONE + 1): 101 | self.error(f'Invalid Logging Level {v}') 102 | return self 103 | self.__level = v 104 | return self 105 | 106 | @classmethod 107 | def set_global_level(cls, v:int=0): 108 | """ 109 | Set Globol Logging Level Which Can Influence All The Logger 110 | """ 111 | if v not in range(cls.L_NONE + 1): 112 | raise Exception(f'Invalid Logging Level {v}') 113 | cls.CLS_LEVEL = v 114 | -------------------------------------------------------------------------------- /wwbot/media/__init__.py: -------------------------------------------------------------------------------- 1 | #!python3.9 2 | 3 | import os, requests, mimetypes 4 | from ..lib import Logger 5 | from requests import Response 6 | from requests_toolbelt import MultipartEncoder 7 | from typing import Literal, NamedTuple, Dict, Any 8 | 9 | mimetypes.init() 10 | 11 | class UploadResult(NamedTuple): 12 | type:str 13 | media_id:str 14 | created_at:str 15 | err_code:int 16 | err_msg:str 17 | 18 | class UploadImageResult(NamedTuple): 19 | url:str 20 | err_code:int 21 | err_msg:str 22 | 23 | class UploadByURLResult(NamedTuple): 24 | job_id:str 25 | err_code:int 26 | err_msg:str 27 | 28 | class GetUploadByURLDetail(NamedTuple): 29 | media_id:str 30 | created_at:str 31 | err_code:int 32 | err_msg:str 33 | 34 | class GetUploadByURLResult(NamedTuple): 35 | detail:GetUploadByURLDetail 36 | status:int 37 | err_code:int 38 | err_msg:str 39 | 40 | class Media: 41 | 42 | logger:Logger = Logger('Media') 43 | 44 | @classmethod 45 | def __upload_file(cls, url:str, file_path:str) -> Response: 46 | '''Upload files to a specific url''' 47 | filename:str = os.path.basename(file_path) 48 | mimetype:str = mimetypes.guess_type(file_path)[0] 49 | if mimetype is None: mimetype = 'application/octet-stream' 50 | inf = open(file_path, 'rb') 51 | files = { 52 | 'media': ( 53 | filename, 54 | inf, 55 | mimetype 56 | ), 57 | 'filename': filename, 58 | 'Content-Disposition': 'form-data;' 59 | } 60 | form_data = MultipartEncoder(files) 61 | try: 62 | resp:Response = requests.post(url, data=form_data, headers={'Content-Type': form_data.content_type}) 63 | inf.close() 64 | return resp 65 | except Exception as e: 66 | cls.logger.error(e) 67 | return None 68 | 69 | @classmethod 70 | def __post_req(cls, url:str, data:Dict[str, Any]) -> Response: 71 | '''Send POST request''' 72 | try: 73 | resp:Response = requests.post(url, json=data) 74 | return resp 75 | except Exception as e: 76 | cls.logger.error(e) 77 | return None 78 | 79 | @classmethod 80 | def upload(cls, access_token:str, file_type:Literal['image', 'voice', 'video', 'file'], file_path:str) -> UploadResult: 81 | '''Upload media files synchronously''' 82 | url:str = f'https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token={access_token}&type={file_type}' 83 | resp:Response = cls.__upload_file(url, file_path) 84 | if resp is None: return resp 85 | resp_json = resp.json() 86 | return UploadResult(resp_json['type'], resp_json['media_id'], resp_json['created_at'], resp_json['errcode'], resp_json['errmsg']) 87 | 88 | @classmethod 89 | def upload_by_url(cls, access_token:str, scene:int, type:Literal['video', 'file'], filename:str, url:str, md5:str) -> UploadByURLResult: 90 | '''Upload files by providing url''' 91 | req_url:str = f'https://qyapi.weixin.qq.com/cgi-bin/media/upload_by_url?access_token={access_token}' 92 | resp:Response = cls.__post_req(req_url, { 93 | 'scene': scene, 94 | 'type': type, 95 | 'filename': filename, 96 | 'url': url, 97 | 'md5': md5 98 | }) 99 | if resp is None: return resp 100 | resp_json = resp.json() 101 | return UploadByURLResult(resp_json['jobid'], resp_json['errcode'], resp_json['errmsg']) 102 | 103 | @classmethod 104 | def get_upload_by_url_result(cls, access_token:str, job_id:str) -> GetUploadByURLResult: 105 | '''Get the result for the job "upload by url"''' 106 | req_url:str = f'https://qyapi.weixin.qq.com/cgi-bin/media/get_upload_by_url_result?access_token={access_token}' 107 | resp:Response = cls.__post_req(req_url, {'jobid': job_id}) 108 | if resp is None: return resp 109 | resp_json = resp.json() 110 | return GetUploadByURLResult( 111 | GetUploadByURLDetail(resp_json['detail']['media_id'], resp_json['detail']['created_at'], resp_json['detail']['errcode'], resp_json['detail']['errmsg']), 112 | resp_json['status'], 113 | resp_json['errcode'], 114 | resp_json['errmsg'] 115 | ) 116 | 117 | @classmethod 118 | def uploadimg(cls, access_token:str, file_path:str) -> UploadImageResult: 119 | '''Upload image''' 120 | url:str = f'https://qyapi.weixin.qq.com/cgi-bin/media/uploadimg?access_token={access_token}' 121 | resp:Response = cls.__upload_file(url, file_path) 122 | if resp is None: return resp 123 | resp_json = resp.json() 124 | return UploadImageResult(resp_json['url'], resp_json['errcode'], resp_json['errmsg']) 125 | 126 | @classmethod 127 | def download(cls, access_token:str, media_id:str, save_to:str) -> bool: 128 | '''Download an uploaded file with media_id''' 129 | url:str = f'https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token={access_token}&media_id={media_id}' 130 | try: 131 | resp:Response = requests.get(url, stream=True) 132 | with open(save_to, 'wb') as outf: 133 | for iter in resp.iter_content(chunk_size=1024*32): 134 | outf.write(iter) 135 | return True 136 | except Exception as e: 137 | cls.logger.error(e) 138 | return False -------------------------------------------------------------------------------- /wwbot/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import hashlib, base64, time, requests, json, random, string, struct, socket, logging, sys 4 | import xml.etree.cElementTree as ET 5 | from .lib import Logger 6 | from queue import Queue 7 | from threading import Thread 8 | from requests import Response 9 | from Crypto.Cipher import AES 10 | from flask import Flask, request 11 | from .msg import Message, msg_from_xml, TextMessage, ImageMessage, VoiceMessage, VideoMessage, LinkMessage, LocationMessage 12 | from xml.etree.cElementTree import Element 13 | from typing import List, Tuple, Callable, Literal, Dict, Union 14 | 15 | log = logging.getLogger('werkzeug') 16 | log.disabled = True 17 | cli = sys.modules['flask.cli'] 18 | cli.show_server_banner = lambda *x: None 19 | 20 | class WWBot: 21 | '''Bot class for self-built applications of WeWork''' 22 | 23 | logger:Logger = Logger('WWBot') 24 | # some api urls 25 | API_PUSH_URL:str = 'https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}' 26 | # api url for sending chat messages 27 | API_CHATMSG_URL:str = 'https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token={token}' 28 | # api url for sending messages as group bots 29 | API_WEBHOOK_URL:str = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={key}' 30 | API_TOKEN_URL:str = 'https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corpid}&corpsecret={appsecret}' 31 | 32 | __last_token_got_at:int = 0 33 | __cached_access_token:str = "" 34 | __access_token_lifetime:int = 7200 35 | 36 | max_retry:int = 3 37 | 38 | msg_handler:Dict[str, Callable[[Message], Message]] = {} 39 | __msg_queue:Queue[Message] = Queue() 40 | # the list of messages that have been handled 41 | __msg_handled:List[str] = [] 42 | 43 | # configurations 44 | corp_id:str = '' 45 | corp_secret:str = '' 46 | token:str = '' 47 | aes_key:bytes = b'' 48 | callback_path:str = '/mbot' 49 | flask_app:Flask = None 50 | 51 | # message types 52 | TEXT:str = 'text' 53 | IMAGE:str = 'image' 54 | VOICE:str = 'voice' 55 | VIDEO:str = 'video' 56 | LOCATION:str = 'location' 57 | LINK:str = 'link' 58 | 59 | @classmethod 60 | def msg_responder(cls) -> None: 61 | '''A thread function for sending messages in the msg_queue''' 62 | cls.logger.info('Message responder thread started') 63 | while True: 64 | msg:Message = cls.__msg_queue.get() 65 | sent:bool = cls.send_to(cls.corp_id, cls.corp_secret, msg) 66 | if sent: 67 | cls.logger.info(f'Message No. {msg.message_id} sent asynchronously') 68 | else: 69 | cls.logger.error(f'Failed to send message No. {msg.message_id} asynchronously, try next time') 70 | time.sleep(1) 71 | 72 | @classmethod 73 | def config(cls, app:Flask, corp_id:str, corp_secret:str, token:str, aes_key:bytes, callback_path:str='/mbot', wait_time:float=5): 74 | '''Configure the basic arguments for the bot''' 75 | # flask app 76 | cls.flask_app = app 77 | # corp id (in the page of corp. detail) 78 | cls.corp_id = corp_id 79 | # corp secret (maybe in the page of corp. detail too) 80 | cls.corp_secret = corp_secret 81 | # random token set in the page of application detail 82 | cls.token = token 83 | # the key for AES encryption, set in the page of application detail 84 | cls.aes_key = aes_key 85 | # the url path for the message callback 86 | cls.callback_path = callback_path 87 | # the max time that wwbot can wait for a message handler to handle a message 88 | # the response message that returned after this time will be sent asynchronously 89 | cls.wait_time = wait_time 90 | 91 | @cls.verify_handler() 92 | @cls.request_handler() 93 | def useless(): pass 94 | 95 | # start the message respnder thread 96 | msg_resoonder_thread:Thread = Thread(target=cls.msg_responder, name='cls.msg_responder') 97 | msg_resoonder_thread.daemon = True 98 | msg_resoonder_thread.start() 99 | 100 | @classmethod 101 | def cal_sig(cls, token:str, timestamp:str, nonce:str, enc_text:str) -> str: 102 | '''Calculate the SHA1 signature for the message''' 103 | args:List[str] = [token, timestamp, nonce, enc_text] 104 | args.sort() 105 | sig_str:str = ''.join(args) 106 | return hashlib.sha1(sig_str.encode('utf-8')).hexdigest() 107 | 108 | @classmethod 109 | def aes_decrypt(cls, aes_key:bytes, enc_bytes:bytes) -> bytes: 110 | '''Decrypt the text that encrypted with AES''' 111 | enc_msg:bytes = base64.b64decode(enc_bytes) 112 | aes:AES = AES.new(aes_key, AES.MODE_CBC, aes_key[:16]) 113 | return aes.decrypt(enc_msg) 114 | 115 | @classmethod 116 | def aes_encrypt(cls, aes_key:bytes, text:bytes) -> str: 117 | '''Encrypt the text with AES''' 118 | aes:AES = AES.new(aes_key, AES.MODE_CBC, aes_key[:16]) 119 | text_len:int = len(text) 120 | block_size:int = 32 121 | to_pad:int = block_size - (text_len % block_size) 122 | if to_pad == 0: to_pad = block_size 123 | return base64.b64encode(aes.encrypt(text + chr(to_pad).encode('utf-8')*to_pad)).decode('utf-8') 124 | 125 | @classmethod 126 | def get_access_token(cls, corp_id:str, corp_secret:str) -> Tuple[str, None]: 127 | '''Get access token''' 128 | now:float = time.time() 129 | if now - cls.__last_token_got_at < cls.__access_token_lifetime: return cls.__cached_access_token 130 | try: 131 | resp:Response = requests.get(cls.API_TOKEN_URL.format(corpid=corp_id, appsecret=corp_secret), timeout=20) 132 | if resp.status_code != 200: 133 | cls.logger.error('WxWork Receive Response With Status Code', resp.status_code) 134 | return None 135 | result:object = resp.json() 136 | if result['errcode'] != 0: 137 | cls.logger.error(result['errmsg']) 138 | return None 139 | cls.__access_token_lifetime = result['expires_in'] 140 | cls.__last_token_got_at = now 141 | cls.__cached_access_token = result['access_token'] 142 | return cls.__cached_access_token 143 | except Exception as e: 144 | cls.logger.error(e) 145 | return None 146 | 147 | @classmethod 148 | def send_to(cls, corp_id:str, corp_secret:str, msg:Message): 149 | '''Send a text message to a specific user''' 150 | access_token:str = cls.get_access_token(corp_id, corp_secret) 151 | if access_token is None: 152 | cls.logger.error('Failed To Get Access Token') 153 | return False 154 | url:str = cls.API_PUSH_URL.format(token=access_token) 155 | # if the message is for chat 156 | if msg.for_chat: 157 | cls.logger.info('Message sent as Chat Message') 158 | url = cls.API_CHATMSG_URL.format(token=access_token) 159 | # if the message is tended to be sent as group bot 160 | elif msg.for_group_bot: 161 | cls.logger.info('Message sent as Group Bot Message') 162 | url = cls.API_WEBHOOK_URL.format(key=msg.group_bot_key) 163 | try: 164 | resp:Response = requests.post(url, data=msg.to_json(), timeout=20) 165 | if resp.status_code != 200: 166 | cls.logger.error('WxWork Receive Response With Status Code', resp.status_code) 167 | return False 168 | result:object = resp.json() 169 | if result['errcode'] != 0: 170 | cls.logger.error(result['errmsg']) 171 | return False 172 | return True 173 | except Exception as e: 174 | cls.logger.error(e) 175 | return False 176 | 177 | @classmethod 178 | def on(cls, msg_type:str, must_reply:bool=True) -> Callable[[Callable[[Message], Message | List[Message] | None]], Callable[[Message], Message | List[Message] | None]]: 179 | '''Decorator for message dealing''' 180 | def deco(func:Callable[[Message], Message | List[Message] | None]) -> Callable[[Message], Message | List[Message] | None]: 181 | def on_wrapper(msg:Message) -> Message | List[Message] | None: 182 | return func(msg) 183 | cls.msg_handler[msg_type] = on_wrapper 184 | return on_wrapper 185 | return deco 186 | 187 | @classmethod 188 | def verify_handler(cls, methods:List[str]=['GET']): 189 | '''Decorator for url verification callback''' 190 | def deco(func:Callable): 191 | @cls.flask_app.route(cls.callback_path, methods=methods) 192 | def verify_handler_wrapper(): 193 | msg_sig:str = request.args.get('msg_signature', default='') 194 | ts:str = request.args.get('timestamp', default='') 195 | nonce:str = request.args.get('nonce', default='') 196 | echo_str:str = request.args.get('echostr', default='') 197 | 198 | sig:str = cls.cal_sig(cls.token, ts, nonce, echo_str) 199 | if msg_sig != sig: 200 | cls.logger.error('Message Signature Dismatched. Sig. Supposed: {sig}, Sig. Actual: {msg_sig}') 201 | return 'Invalid Message Signature', 403 202 | 203 | dec_msg:bytes = cls.aes_decrypt(cls.aes_key, echo_str) 204 | rdm_str:bytes = dec_msg[:16] 205 | msg_len:int = int.from_bytes(dec_msg[16:20], 'big') 206 | msg:str = dec_msg[20:20+msg_len].decode('utf-8') 207 | 208 | cls.logger.info(f'Got EchoString {msg}') 209 | 210 | return msg, 200 211 | return verify_handler_wrapper 212 | return deco 213 | 214 | @classmethod 215 | def request_handler(cls, methods:List[str]=['POST']): 216 | '''Decorator for request callback''' 217 | def deco(func:Callable): 218 | @cls.flask_app.route(cls.callback_path, methods=methods) 219 | def request_handler_wrapper(): 220 | msg_sig:str = request.args.get('msg_signature', default='') 221 | ts:str = request.args.get('timestamp', default='') 222 | nonce:str = request.args.get('nonce', default='') 223 | 224 | req_msg:str = request.get_data(as_text=True) 225 | 226 | if len(req_msg) <= 0: return 'Page not found', 404 227 | 228 | req_msg_xml:Element = ET.fromstring(req_msg) 229 | enc_msg:str = req_msg_xml.find('Encrypt').text 230 | 231 | sig:str = cls.cal_sig(cls.token, ts, nonce, enc_msg) 232 | if msg_sig != sig: 233 | cls.logger.error('Message Signature Unmatched. Sig. Supposed: {sig}, Sig. Actual: {msg_sig}') 234 | return 'Invalid Message Signature' 235 | 236 | dec_msg:bytes = cls.aes_decrypt(cls.aes_key, enc_msg) 237 | msg_len:int = int.from_bytes(dec_msg[16:20], 'big') 238 | msg_xml_str:str = dec_msg[20:20+msg_len].decode('utf-8') 239 | 240 | msg_detail_xml:Element = ET.fromstring(msg_xml_str) 241 | # parse message for detail 242 | msg:Message = msg_from_xml(msg_detail_xml) 243 | 244 | if msg is None or msg.message_id in cls.__msg_handled: return 'Not a new message', 200 245 | 246 | cls.__msg_handled.append(msg.message_id) 247 | cls.logger.info(f'Got a new {msg.__class__.__name__} message, NO. {msg.message_id}') 248 | 249 | if msg.key not in cls.msg_handler: 250 | cls.logger.warn(f'No message handler registered for messages of "{msg.key}" type') 251 | return '', 200 252 | 253 | start_at:float = time.time() 254 | resp_msg:Message | List[Message] = cls.msg_handler[msg.key](msg) 255 | time_cost:float = time.time() - start_at 256 | if isinstance(resp_msg, list) and len(resp_msg) <= 0: resp_msg = None 257 | if isinstance(resp_msg, list) and len(resp_msg) == 1: resp_msg = resp_msg[0] 258 | cls.logger.info(f'Message No. {msg.message_id} handled in {time_cost:.3f} seconds') 259 | if resp_msg is None: return 'No resonse', 200 260 | elif isinstance(resp_msg, list): 261 | cls.logger.warn(f'Multiple response messages for message No. {msg.message_id}, try to reply asynchronously') 262 | for m in resp_msg: cls.__msg_queue.put(m) 263 | return 'Reply asynchronously', 403 264 | elif time_cost >= cls.wait_time: 265 | cls.logger.warn(f'Message No. {msg.message_id} handled too slowly, try to reply asynchronously') 266 | cls.__msg_queue.put(resp_msg) 267 | return 'Reply asynchronously', 403 268 | cls.logger.info('Reply in time') 269 | 270 | resp_ts:str = str(int(time.time())) 271 | resp_nonce:str = ''.join(random.choices(string.ascii_letters, k=10)) 272 | resp_rdm_str:str = ''.join(random.choices(string.ascii_letters, k=16)) 273 | resp_recv_id:str = ''.join(random.choices(string.digits, k=16)).encode('utf-8') 274 | 275 | resp_xml:str = resp_msg.to_xml() 276 | resp_msg_encrypt:str = cls.aes_encrypt(cls.aes_key, resp_rdm_str.encode('utf-8') + struct.pack('I', socket.htonl(len(resp_xml.encode('utf-8')))) + resp_xml.encode('utf-8') + resp_recv_id) 277 | resp_msg_sig:str = cls.cal_sig(cls.token, resp_ts, resp_nonce, resp_msg_encrypt) 278 | 279 | return f'''{resp_ts}''' 280 | return request_handler_wrapper 281 | return deco 282 | 283 | @WWBot.on(WWBot.TEXT) 284 | def text_default(msg:TextMessage) -> Message: 285 | return TextMessage(msg.from_username, msg.to_username, msg.agent_id, msg.content) 286 | 287 | @WWBot.on(WWBot.IMAGE) 288 | def image_default(msg:ImageMessage) -> Message: 289 | return ImageMessage(msg.from_username, msg.to_username, msg.agent_id, msg.media_id) 290 | 291 | @WWBot.on(WWBot.VOICE) 292 | def voice_defualt(msg:VoiceMessage) -> Message: 293 | return VoiceMessage(msg.from_username, msg.to_username, msg.agent_id, msg.media_id) 294 | 295 | @WWBot.on(WWBot.VIDEO) 296 | def video_default(msg:VideoMessage) -> Message: 297 | return VideoMessage(msg.from_username, msg.to_username, msg.agent_id, msg.media_id) 298 | 299 | @WWBot.on(WWBot.LOCATION) 300 | def location_default(msg:LocationMessage) -> Message: 301 | return TextMessage(msg.from_username, msg.to_username, msg.agent_id, f'{msg.label}:({msg.location_x},{msg.location_y})\nScale:{msg.scale}') 302 | 303 | @WWBot.on(WWBot.LINK) 304 | def link_default(msg:LinkMessage) -> Message: 305 | return TextMessage(msg.from_username, msg.to_username, msg.agent_id, f'{msg.title}\n{msg.description}\n{msg.url}\n{msg.pic_url}') 306 | --------------------------------------------------------------------------------