-
Notifications
You must be signed in to change notification settings - Fork 8.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #944 from zhayujie/wechatcom-app
添加企业微信应用号部署方式,支持插件,支持语音图片交互
- Loading branch information
Showing
19 changed files
with
419 additions
and
56 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
# 企业微信应用号channel | ||
|
||
企业微信官方提供了客服、应用等API,本channel使用的是企业微信的应用API的能力。 | ||
|
||
因为未来可能还会开发客服能力,所以本channel的类型名叫作`wechatcom_app`。 | ||
|
||
`wechatcom_app` channel支持插件系统和图片声音交互等能力,除了无法加入群聊,作为个人使用的私人助理已绰绰有余。 | ||
|
||
## 开始之前 | ||
|
||
- 在企业中确认自己拥有在企业内自建应用的权限。 | ||
- 如果没有权限或者是个人用户,也可创建未认证的企业。操作方式:登录手机企业微信,选择`创建/加入企业`来创建企业,类型请选择企业,企业名称可随意填写。 | ||
未认证的企业有100人的服务人数上限,其他功能与认证企业没有差异。 | ||
|
||
本channel需安装的依赖与公众号一致,需要安装`wechatpy`和`web.py`,它们包含在`requirements-optional.txt`中。 | ||
|
||
## 使用方法 | ||
|
||
1.查看企业ID | ||
|
||
- 扫码登陆[企业微信后台](https://work.weixin.qq.com) | ||
- 选择`我的企业`,点击`企业信息`,记住该`企业ID` | ||
|
||
2.创建自建应用 | ||
|
||
- 选择应用管理, 在自建区选创建应用来创建企业自建应用 | ||
- 上传应用logo,填写应用名称等项 | ||
- 创建应用后进入应用详情页面,记住`AgentId`和`Secert` | ||
|
||
3.配置应用 | ||
|
||
- 在详情页如果点击`企业可信IP`的配置(没看到可以不管),填入你服务器的公网IP | ||
- 点击`接收消息`下的启用API接收消息 | ||
- `URL`填写格式为`http://url:port/wxcomapp`,`port`是程序监听的端口,默认是9898 | ||
如果是未认证的企业,url可直接使用服务器的IP。如果是认证企业,需要使用备案的域名,可使用二级域名。 | ||
- `Token`可随意填写,停留在这个页面 | ||
- 在程序根目录`config.json`中增加配置(**去掉注释**),`wechatcomapp_aes_key`是当前页面的`wechatcomapp_aes_key` | ||
|
||
```python | ||
"channel_type": "wechatcom_app", | ||
"wechatcom_corp_id": "", # 企业微信公司的corpID | ||
"wechatcomapp_token": "", # 企业微信app的token | ||
"wechatcomapp_port": 9898, # 企业微信app的服务端口, 不需要端口转发 | ||
"wechatcomapp_secret": "", # 企业微信app的secret | ||
"wechatcomapp_agent_id": "", # 企业微信app的agent_id | ||
"wechatcomapp_aes_key": "", # 企业微信app的aes_key | ||
``` | ||
|
||
- 运行程序,在页面中点击保存,保存成功说明验证成功 | ||
|
||
4.连接个人微信 | ||
|
||
选择`我的企业`,点击`微信插件`,下面有个邀请关注的二维码。微信扫码后,即可在微信中看到对应企业,在这里你便可以和机器人沟通。 | ||
|
||
## 测试体验 | ||
|
||
AIGC开放社区中已经部署了多个可免费使用的Bot,扫描下方的二维码会自动邀请你来体验。 | ||
|
||
<img width="200" src="../../docs/images/aigcopen.png"> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,168 @@ | ||
# -*- coding=utf-8 -*- | ||
import io | ||
import os | ||
import time | ||
|
||
import requests | ||
import web | ||
from wechatpy.enterprise import create_reply, parse_message | ||
from wechatpy.enterprise.crypto import WeChatCrypto | ||
from wechatpy.enterprise.exceptions import InvalidCorpIdException | ||
from wechatpy.exceptions import InvalidSignatureException, WeChatClientException | ||
|
||
from bridge.context import Context | ||
from bridge.reply import Reply, ReplyType | ||
from channel.chat_channel import ChatChannel | ||
from channel.wechatcom.wechatcomapp_client import WechatComAppClient | ||
from channel.wechatcom.wechatcomapp_message import WechatComAppMessage | ||
from common.log import logger | ||
from common.singleton import singleton | ||
from common.utils import compress_imgfile, fsize, split_string_by_utf8_length | ||
from config import conf, subscribe_msg | ||
from voice.audio_convert import any_to_amr | ||
|
||
MAX_UTF8_LEN = 2048 | ||
|
||
|
||
@singleton | ||
class WechatComAppChannel(ChatChannel): | ||
NOT_SUPPORT_REPLYTYPE = [] | ||
|
||
def __init__(self): | ||
super().__init__() | ||
self.corp_id = conf().get("wechatcom_corp_id") | ||
self.secret = conf().get("wechatcomapp_secret") | ||
self.agent_id = conf().get("wechatcomapp_agent_id") | ||
self.token = conf().get("wechatcomapp_token") | ||
self.aes_key = conf().get("wechatcomapp_aes_key") | ||
print(self.corp_id, self.secret, self.agent_id, self.token, self.aes_key) | ||
logger.info( | ||
"[wechatcom] init: corp_id: {}, secret: {}, agent_id: {}, token: {}, aes_key: {}".format(self.corp_id, self.secret, self.agent_id, self.token, self.aes_key) | ||
) | ||
self.crypto = WeChatCrypto(self.token, self.aes_key, self.corp_id) | ||
self.client = WechatComAppClient(self.corp_id, self.secret) | ||
|
||
def startup(self): | ||
# start message listener | ||
urls = ("/wxcomapp", "channel.wechatcom.wechatcomapp_channel.Query") | ||
app = web.application(urls, globals(), autoreload=False) | ||
port = conf().get("wechatcomapp_port", 9898) | ||
web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port)) | ||
|
||
def send(self, reply: Reply, context: Context): | ||
receiver = context["receiver"] | ||
if reply.type in [ReplyType.TEXT, ReplyType.ERROR, ReplyType.INFO]: | ||
reply_text = reply.content | ||
texts = split_string_by_utf8_length(reply_text, MAX_UTF8_LEN) | ||
if len(texts) > 1: | ||
logger.info("[wechatcom] text too long, split into {} parts".format(len(texts))) | ||
for i, text in enumerate(texts): | ||
self.client.message.send_text(self.agent_id, receiver, text) | ||
if i != len(texts) - 1: | ||
time.sleep(0.5) # 休眠0.5秒,防止发送过快乱序 | ||
logger.info("[wechatcom] Do send text to {}: {}".format(receiver, reply_text)) | ||
elif reply.type == ReplyType.VOICE: | ||
try: | ||
file_path = reply.content | ||
amr_file = os.path.splitext(file_path)[0] + ".amr" | ||
any_to_amr(file_path, amr_file) | ||
response = self.client.media.upload("voice", open(amr_file, "rb")) | ||
logger.debug("[wechatcom] upload voice response: {}".format(response)) | ||
except WeChatClientException as e: | ||
logger.error("[wechatcom] upload voice failed: {}".format(e)) | ||
return | ||
try: | ||
os.remove(file_path) | ||
if amr_file != file_path: | ||
os.remove(amr_file) | ||
except Exception: | ||
pass | ||
self.client.message.send_voice(self.agent_id, receiver, response["media_id"]) | ||
logger.info("[wechatcom] sendVoice={}, receiver={}".format(reply.content, receiver)) | ||
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片 | ||
img_url = reply.content | ||
pic_res = requests.get(img_url, stream=True) | ||
image_storage = io.BytesIO() | ||
for block in pic_res.iter_content(1024): | ||
image_storage.write(block) | ||
if (sz := fsize(image_storage)) >= 10 * 1024 * 1024: | ||
logger.info("[wechatcom] image too large, ready to compress, sz={}".format(sz)) | ||
image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1) | ||
logger.info("[wechatcom] image compressed, sz={}".format(fsize(image_storage))) | ||
image_storage.seek(0) | ||
try: | ||
response = self.client.media.upload("image", image_storage) | ||
logger.debug("[wechatcom] upload image response: {}".format(response)) | ||
except WeChatClientException as e: | ||
logger.error("[wechatcom] upload image failed: {}".format(e)) | ||
return | ||
|
||
self.client.message.send_image(self.agent_id, receiver, response["media_id"]) | ||
logger.info("[wechatcom] sendImage url={}, receiver={}".format(img_url, receiver)) | ||
elif reply.type == ReplyType.IMAGE: # 从文件读取图片 | ||
image_storage = reply.content | ||
if (sz := fsize(image_storage)) >= 10 * 1024 * 1024: | ||
logger.info("[wechatcom] image too large, ready to compress, sz={}".format(sz)) | ||
image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1) | ||
logger.info("[wechatcom] image compressed, sz={}".format(fsize(image_storage))) | ||
image_storage.seek(0) | ||
try: | ||
response = self.client.media.upload("image", image_storage) | ||
logger.debug("[wechatcom] upload image response: {}".format(response)) | ||
except WeChatClientException as e: | ||
logger.error("[wechatcom] upload image failed: {}".format(e)) | ||
return | ||
self.client.message.send_image(self.agent_id, receiver, response["media_id"]) | ||
logger.info("[wechatcom] sendImage, receiver={}".format(receiver)) | ||
|
||
|
||
class Query: | ||
def GET(self): | ||
channel = WechatComAppChannel() | ||
params = web.input() | ||
logger.info("[wechatcom] receive params: {}".format(params)) | ||
try: | ||
signature = params.msg_signature | ||
timestamp = params.timestamp | ||
nonce = params.nonce | ||
echostr = params.echostr | ||
echostr = channel.crypto.check_signature(signature, timestamp, nonce, echostr) | ||
except InvalidSignatureException: | ||
raise web.Forbidden() | ||
return echostr | ||
|
||
def POST(self): | ||
channel = WechatComAppChannel() | ||
params = web.input() | ||
logger.info("[wechatcom] receive params: {}".format(params)) | ||
try: | ||
signature = params.msg_signature | ||
timestamp = params.timestamp | ||
nonce = params.nonce | ||
message = channel.crypto.decrypt_message(web.data(), signature, timestamp, nonce) | ||
except (InvalidSignatureException, InvalidCorpIdException): | ||
raise web.Forbidden() | ||
msg = parse_message(message) | ||
logger.debug("[wechatcom] receive message: {}, msg= {}".format(message, msg)) | ||
if msg.type == "event": | ||
if msg.event == "subscribe": | ||
reply_content = subscribe_msg() | ||
if reply_content: | ||
reply = create_reply(reply_content, msg).render() | ||
res = channel.crypto.encrypt_message(reply, nonce, timestamp) | ||
return res | ||
else: | ||
try: | ||
wechatcom_msg = WechatComAppMessage(msg, client=channel.client) | ||
except NotImplementedError as e: | ||
logger.debug("[wechatcom] " + str(e)) | ||
return "success" | ||
context = channel._compose_context( | ||
wechatcom_msg.ctype, | ||
wechatcom_msg.content, | ||
isgroup=False, | ||
msg=wechatcom_msg, | ||
) | ||
if context: | ||
channel.produce(context) | ||
return "success" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import threading | ||
import time | ||
|
||
from wechatpy.enterprise import WeChatClient | ||
|
||
|
||
class WechatComAppClient(WeChatClient): | ||
def __init__(self, corp_id, secret, access_token=None, session=None, timeout=None, auto_retry=True): | ||
super(WechatComAppClient, self).__init__(corp_id, secret, access_token, session, timeout, auto_retry) | ||
self.fetch_access_token_lock = threading.Lock() | ||
|
||
def fetch_access_token(self): # 重载父类方法,加锁避免多线程重复获取access_token | ||
with self.fetch_access_token_lock: | ||
access_token = self.session.get(self.access_token_key) | ||
if access_token: | ||
if not self.expires_at: | ||
return access_token | ||
timestamp = time.time() | ||
if self.expires_at - timestamp > 60: | ||
return access_token | ||
return super().fetch_access_token() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
from wechatpy.enterprise import WeChatClient | ||
|
||
from bridge.context import ContextType | ||
from channel.chat_message import ChatMessage | ||
from common.log import logger | ||
from common.tmp_dir import TmpDir | ||
|
||
|
||
class WechatComAppMessage(ChatMessage): | ||
def __init__(self, msg, client: WeChatClient, is_group=False): | ||
super().__init__(msg) | ||
self.msg_id = msg.id | ||
self.create_time = msg.time | ||
self.is_group = is_group | ||
|
||
if msg.type == "text": | ||
self.ctype = ContextType.TEXT | ||
self.content = msg.content | ||
elif msg.type == "voice": | ||
self.ctype = ContextType.VOICE | ||
self.content = TmpDir().path() + msg.media_id + "." + msg.format # content直接存临时目录路径 | ||
|
||
def download_voice(): | ||
# 如果响应状态码是200,则将响应内容写入本地文件 | ||
response = client.media.download(msg.media_id) | ||
if response.status_code == 200: | ||
with open(self.content, "wb") as f: | ||
f.write(response.content) | ||
else: | ||
logger.info(f"[wechatcom] Failed to download voice file, {response.content}") | ||
|
||
self._prepare_fn = download_voice | ||
elif msg.type == "image": | ||
self.ctype = ContextType.IMAGE | ||
self.content = TmpDir().path() + msg.media_id + ".png" # content直接存临时目录路径 | ||
|
||
def download_image(): | ||
# 如果响应状态码是200,则将响应内容写入本地文件 | ||
response = client.media.download(msg.media_id) | ||
if response.status_code == 200: | ||
with open(self.content, "wb") as f: | ||
f.write(response.content) | ||
else: | ||
logger.info(f"[wechatcom] Failed to download image file, {response.content}") | ||
|
||
self._prepare_fn = download_image | ||
else: | ||
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg.type)) | ||
|
||
self.from_user_id = msg.source | ||
self.to_user_id = msg.target | ||
self.other_user_id = msg.source |
Oops, something went wrong.