├── 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' ', 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 |
--------------------------------------------------------------------------------