Skip to content

Commit 3973df2

Browse files
authored
Merge pull request zhayujie#944 from zhayujie/wechatcom-app
添加企业微信应用号部署方式,支持插件,支持语音图片交互
2 parents d4ffc25 + 62aa518 commit 3973df2

19 files changed

+419
-56
lines changed

README.md

+5-2
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ pip3 install azure-cognitiveservices-speech
9797
cp config-template.json config.json
9898
```
9999

100-
然后在`config.json`中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改:
100+
然后在`config.json`中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改(请去掉注释)
101101

102102
```bash
103103
# config.json文件内容示例
@@ -115,7 +115,9 @@ pip3 install azure-cognitiveservices-speech
115115
"speech_recognition": false, # 是否开启语音识别
116116
"group_speech_recognition": false, # 是否开启群组语音识别
117117
"use_azure_chatgpt": false, # 是否使用Azure ChatGPT service代替openai ChatGPT service. 当设置为true时需要设置 open_ai_api_base,如 https://xxx.openai.azure.com/
118-
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", # 人格描述,
118+
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", # 人格描述
119+
# 订阅消息,公众号和企业微信channel中请填写,当被订阅时会自动回复,可使用特殊占位符。目前支持的占位符有{trigger_prefix},在程序中它会自动替换成bot的触发词。
120+
"subscribe_msg": "感谢您的关注!\n这里是ChatGPT,可以自由对话。\n支持语音对话。\n支持图片输出,画字开头的消息将按要求创作图片。\n支持角色扮演和文字冒险等丰富插件。\n输入{trigger_prefix}#help 查看详细指令。"
119121
}
120122
```
121123
**配置说明:**
@@ -150,6 +152,7 @@ pip3 install azure-cognitiveservices-speech
150152
+ `clear_memory_commands`: 对话内指令,主动清空前文记忆,字符串数组可自定义指令别名。
151153
+ `hot_reload`: 程序退出后,暂存微信扫码状态,默认关闭。
152154
+ `character_desc` 配置中保存着你对机器人说的一段话,他会记住这段话并作为他的设定,你可以为他定制任何人格 (关于会话上下文的更多内容参考该 [issue](https://github.com/zhayujie/chatgpt-on-wechat/issues/43))
155+
+ `subscribe_msg`:订阅消息,公众号和企业微信channel中请填写,当被订阅时会自动回复, 可使用特殊占位符。目前支持的占位符有{trigger_prefix},在程序中它会自动替换成bot的触发词。
153156

154157
**所有可选的配置项均在该[文件](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py)中列出。**
155158

app.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def run():
4343
# os.environ['WECHATY_PUPPET_SERVICE_ENDPOINT'] = '127.0.0.1:9001'
4444

4545
channel = channel_factory.create_channel(channel_name)
46-
if channel_name in ["wx", "wxy", "terminal", "wechatmp", "wechatmp_service"]:
46+
if channel_name in ["wx", "wxy", "terminal", "wechatmp", "wechatmp_service", "wechatcom_app"]:
4747
PluginManager().load_plugins()
4848

4949
# startup channel

channel/channel_factory.py

+4
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,8 @@ def create_channel(channel_type):
2929
from channel.wechatmp.wechatmp_channel import WechatMPChannel
3030

3131
return WechatMPChannel(passive_reply=False)
32+
elif channel_type == "wechatcom_app":
33+
from channel.wechatcom.wechatcomapp_channel import WechatComAppChannel
34+
35+
return WechatComAppChannel()
3236
raise RuntimeError

channel/wechat/wechat_channel.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
@itchat.msg_register([TEXT, VOICE, PICTURE, NOTE])
3030
def handler_single_msg(msg):
3131
try:
32-
cmsg = WeChatMessage(msg, False)
32+
cmsg = WechatMessage(msg, False)
3333
except NotImplementedError as e:
3434
logger.debug("[WX]single message {} skipped: {}".format(msg["MsgId"], e))
3535
return None
@@ -40,7 +40,7 @@ def handler_single_msg(msg):
4040
@itchat.msg_register([TEXT, VOICE, PICTURE, NOTE], isGroupChat=True)
4141
def handler_group_msg(msg):
4242
try:
43-
cmsg = WeChatMessage(msg, True)
43+
cmsg = WechatMessage(msg, True)
4444
except NotImplementedError as e:
4545
logger.debug("[WX]group message {} skipped: {}".format(msg["MsgId"], e))
4646
return None

channel/wechat/wechat_message.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from lib.itchat.content import *
99

1010

11-
class WeChatMessage(ChatMessage):
11+
class WechatMessage(ChatMessage):
1212
def __init__(self, itchat_msg, is_group=False):
1313
super().__init__(itchat_msg)
1414
self.msg_id = itchat_msg["MsgId"]

channel/wechatcom/README.md

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# 企业微信应用号channel
2+
3+
企业微信官方提供了客服、应用等API,本channel使用的是企业微信的应用API的能力。
4+
5+
因为未来可能还会开发客服能力,所以本channel的类型名叫作`wechatcom_app`
6+
7+
`wechatcom_app` channel支持插件系统和图片声音交互等能力,除了无法加入群聊,作为个人使用的私人助理已绰绰有余。
8+
9+
## 开始之前
10+
11+
- 在企业中确认自己拥有在企业内自建应用的权限。
12+
- 如果没有权限或者是个人用户,也可创建未认证的企业。操作方式:登录手机企业微信,选择`创建/加入企业`来创建企业,类型请选择企业,企业名称可随意填写。
13+
未认证的企业有100人的服务人数上限,其他功能与认证企业没有差异。
14+
15+
本channel需安装的依赖与公众号一致,需要安装`wechatpy``web.py`,它们包含在`requirements-optional.txt`中。
16+
17+
## 使用方法
18+
19+
1.查看企业ID
20+
21+
- 扫码登陆[企业微信后台](https://work.weixin.qq.com)
22+
- 选择`我的企业`,点击`企业信息`,记住该`企业ID`
23+
24+
2.创建自建应用
25+
26+
- 选择应用管理, 在自建区选创建应用来创建企业自建应用
27+
- 上传应用logo,填写应用名称等项
28+
- 创建应用后进入应用详情页面,记住`AgentId``Secert`
29+
30+
3.配置应用
31+
32+
- 在详情页如果点击`企业可信IP`的配置(没看到可以不管),填入你服务器的公网IP
33+
- 点击`接收消息`下的启用API接收消息
34+
- `URL`填写格式为`http://url:port/wxcomapp``port`是程序监听的端口,默认是9898
35+
如果是未认证的企业,url可直接使用服务器的IP。如果是认证企业,需要使用备案的域名,可使用二级域名。
36+
- `Token`可随意填写,停留在这个页面
37+
- 在程序根目录`config.json`中增加配置(**去掉注释**),`wechatcomapp_aes_key`是当前页面的`wechatcomapp_aes_key`
38+
39+
```python
40+
"channel_type": "wechatcom_app",
41+
"wechatcom_corp_id": "", # 企业微信公司的corpID
42+
"wechatcomapp_token": "", # 企业微信app的token
43+
"wechatcomapp_port": 9898, # 企业微信app的服务端口, 不需要端口转发
44+
"wechatcomapp_secret": "", # 企业微信app的secret
45+
"wechatcomapp_agent_id": "", # 企业微信app的agent_id
46+
"wechatcomapp_aes_key": "", # 企业微信app的aes_key
47+
```
48+
49+
- 运行程序,在页面中点击保存,保存成功说明验证成功
50+
51+
4.连接个人微信
52+
53+
选择`我的企业`,点击`微信插件`,下面有个邀请关注的二维码。微信扫码后,即可在微信中看到对应企业,在这里你便可以和机器人沟通。
54+
55+
## 测试体验
56+
57+
AIGC开放社区中已经部署了多个可免费使用的Bot,扫描下方的二维码会自动邀请你来体验。
58+
59+
<img width="200" src="../../docs/images/aigcopen.png">
+168
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# -*- coding=utf-8 -*-
2+
import io
3+
import os
4+
import time
5+
6+
import requests
7+
import web
8+
from wechatpy.enterprise import create_reply, parse_message
9+
from wechatpy.enterprise.crypto import WeChatCrypto
10+
from wechatpy.enterprise.exceptions import InvalidCorpIdException
11+
from wechatpy.exceptions import InvalidSignatureException, WeChatClientException
12+
13+
from bridge.context import Context
14+
from bridge.reply import Reply, ReplyType
15+
from channel.chat_channel import ChatChannel
16+
from channel.wechatcom.wechatcomapp_client import WechatComAppClient
17+
from channel.wechatcom.wechatcomapp_message import WechatComAppMessage
18+
from common.log import logger
19+
from common.singleton import singleton
20+
from common.utils import compress_imgfile, fsize, split_string_by_utf8_length
21+
from config import conf, subscribe_msg
22+
from voice.audio_convert import any_to_amr
23+
24+
MAX_UTF8_LEN = 2048
25+
26+
27+
@singleton
28+
class WechatComAppChannel(ChatChannel):
29+
NOT_SUPPORT_REPLYTYPE = []
30+
31+
def __init__(self):
32+
super().__init__()
33+
self.corp_id = conf().get("wechatcom_corp_id")
34+
self.secret = conf().get("wechatcomapp_secret")
35+
self.agent_id = conf().get("wechatcomapp_agent_id")
36+
self.token = conf().get("wechatcomapp_token")
37+
self.aes_key = conf().get("wechatcomapp_aes_key")
38+
print(self.corp_id, self.secret, self.agent_id, self.token, self.aes_key)
39+
logger.info(
40+
"[wechatcom] init: corp_id: {}, secret: {}, agent_id: {}, token: {}, aes_key: {}".format(self.corp_id, self.secret, self.agent_id, self.token, self.aes_key)
41+
)
42+
self.crypto = WeChatCrypto(self.token, self.aes_key, self.corp_id)
43+
self.client = WechatComAppClient(self.corp_id, self.secret)
44+
45+
def startup(self):
46+
# start message listener
47+
urls = ("/wxcomapp", "channel.wechatcom.wechatcomapp_channel.Query")
48+
app = web.application(urls, globals(), autoreload=False)
49+
port = conf().get("wechatcomapp_port", 9898)
50+
web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port))
51+
52+
def send(self, reply: Reply, context: Context):
53+
receiver = context["receiver"]
54+
if reply.type in [ReplyType.TEXT, ReplyType.ERROR, ReplyType.INFO]:
55+
reply_text = reply.content
56+
texts = split_string_by_utf8_length(reply_text, MAX_UTF8_LEN)
57+
if len(texts) > 1:
58+
logger.info("[wechatcom] text too long, split into {} parts".format(len(texts)))
59+
for i, text in enumerate(texts):
60+
self.client.message.send_text(self.agent_id, receiver, text)
61+
if i != len(texts) - 1:
62+
time.sleep(0.5) # 休眠0.5秒,防止发送过快乱序
63+
logger.info("[wechatcom] Do send text to {}: {}".format(receiver, reply_text))
64+
elif reply.type == ReplyType.VOICE:
65+
try:
66+
file_path = reply.content
67+
amr_file = os.path.splitext(file_path)[0] + ".amr"
68+
any_to_amr(file_path, amr_file)
69+
response = self.client.media.upload("voice", open(amr_file, "rb"))
70+
logger.debug("[wechatcom] upload voice response: {}".format(response))
71+
except WeChatClientException as e:
72+
logger.error("[wechatcom] upload voice failed: {}".format(e))
73+
return
74+
try:
75+
os.remove(file_path)
76+
if amr_file != file_path:
77+
os.remove(amr_file)
78+
except Exception:
79+
pass
80+
self.client.message.send_voice(self.agent_id, receiver, response["media_id"])
81+
logger.info("[wechatcom] sendVoice={}, receiver={}".format(reply.content, receiver))
82+
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
83+
img_url = reply.content
84+
pic_res = requests.get(img_url, stream=True)
85+
image_storage = io.BytesIO()
86+
for block in pic_res.iter_content(1024):
87+
image_storage.write(block)
88+
if (sz := fsize(image_storage)) >= 10 * 1024 * 1024:
89+
logger.info("[wechatcom] image too large, ready to compress, sz={}".format(sz))
90+
image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1)
91+
logger.info("[wechatcom] image compressed, sz={}".format(fsize(image_storage)))
92+
image_storage.seek(0)
93+
try:
94+
response = self.client.media.upload("image", image_storage)
95+
logger.debug("[wechatcom] upload image response: {}".format(response))
96+
except WeChatClientException as e:
97+
logger.error("[wechatcom] upload image failed: {}".format(e))
98+
return
99+
100+
self.client.message.send_image(self.agent_id, receiver, response["media_id"])
101+
logger.info("[wechatcom] sendImage url={}, receiver={}".format(img_url, receiver))
102+
elif reply.type == ReplyType.IMAGE: # 从文件读取图片
103+
image_storage = reply.content
104+
if (sz := fsize(image_storage)) >= 10 * 1024 * 1024:
105+
logger.info("[wechatcom] image too large, ready to compress, sz={}".format(sz))
106+
image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1)
107+
logger.info("[wechatcom] image compressed, sz={}".format(fsize(image_storage)))
108+
image_storage.seek(0)
109+
try:
110+
response = self.client.media.upload("image", image_storage)
111+
logger.debug("[wechatcom] upload image response: {}".format(response))
112+
except WeChatClientException as e:
113+
logger.error("[wechatcom] upload image failed: {}".format(e))
114+
return
115+
self.client.message.send_image(self.agent_id, receiver, response["media_id"])
116+
logger.info("[wechatcom] sendImage, receiver={}".format(receiver))
117+
118+
119+
class Query:
120+
def GET(self):
121+
channel = WechatComAppChannel()
122+
params = web.input()
123+
logger.info("[wechatcom] receive params: {}".format(params))
124+
try:
125+
signature = params.msg_signature
126+
timestamp = params.timestamp
127+
nonce = params.nonce
128+
echostr = params.echostr
129+
echostr = channel.crypto.check_signature(signature, timestamp, nonce, echostr)
130+
except InvalidSignatureException:
131+
raise web.Forbidden()
132+
return echostr
133+
134+
def POST(self):
135+
channel = WechatComAppChannel()
136+
params = web.input()
137+
logger.info("[wechatcom] receive params: {}".format(params))
138+
try:
139+
signature = params.msg_signature
140+
timestamp = params.timestamp
141+
nonce = params.nonce
142+
message = channel.crypto.decrypt_message(web.data(), signature, timestamp, nonce)
143+
except (InvalidSignatureException, InvalidCorpIdException):
144+
raise web.Forbidden()
145+
msg = parse_message(message)
146+
logger.debug("[wechatcom] receive message: {}, msg= {}".format(message, msg))
147+
if msg.type == "event":
148+
if msg.event == "subscribe":
149+
reply_content = subscribe_msg()
150+
if reply_content:
151+
reply = create_reply(reply_content, msg).render()
152+
res = channel.crypto.encrypt_message(reply, nonce, timestamp)
153+
return res
154+
else:
155+
try:
156+
wechatcom_msg = WechatComAppMessage(msg, client=channel.client)
157+
except NotImplementedError as e:
158+
logger.debug("[wechatcom] " + str(e))
159+
return "success"
160+
context = channel._compose_context(
161+
wechatcom_msg.ctype,
162+
wechatcom_msg.content,
163+
isgroup=False,
164+
msg=wechatcom_msg,
165+
)
166+
if context:
167+
channel.produce(context)
168+
return "success"
+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import threading
2+
import time
3+
4+
from wechatpy.enterprise import WeChatClient
5+
6+
7+
class WechatComAppClient(WeChatClient):
8+
def __init__(self, corp_id, secret, access_token=None, session=None, timeout=None, auto_retry=True):
9+
super(WechatComAppClient, self).__init__(corp_id, secret, access_token, session, timeout, auto_retry)
10+
self.fetch_access_token_lock = threading.Lock()
11+
12+
def fetch_access_token(self): # 重载父类方法,加锁避免多线程重复获取access_token
13+
with self.fetch_access_token_lock:
14+
access_token = self.session.get(self.access_token_key)
15+
if access_token:
16+
if not self.expires_at:
17+
return access_token
18+
timestamp = time.time()
19+
if self.expires_at - timestamp > 60:
20+
return access_token
21+
return super().fetch_access_token()
+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from wechatpy.enterprise import WeChatClient
2+
3+
from bridge.context import ContextType
4+
from channel.chat_message import ChatMessage
5+
from common.log import logger
6+
from common.tmp_dir import TmpDir
7+
8+
9+
class WechatComAppMessage(ChatMessage):
10+
def __init__(self, msg, client: WeChatClient, is_group=False):
11+
super().__init__(msg)
12+
self.msg_id = msg.id
13+
self.create_time = msg.time
14+
self.is_group = is_group
15+
16+
if msg.type == "text":
17+
self.ctype = ContextType.TEXT
18+
self.content = msg.content
19+
elif msg.type == "voice":
20+
self.ctype = ContextType.VOICE
21+
self.content = TmpDir().path() + msg.media_id + "." + msg.format # content直接存临时目录路径
22+
23+
def download_voice():
24+
# 如果响应状态码是200,则将响应内容写入本地文件
25+
response = client.media.download(msg.media_id)
26+
if response.status_code == 200:
27+
with open(self.content, "wb") as f:
28+
f.write(response.content)
29+
else:
30+
logger.info(f"[wechatcom] Failed to download voice file, {response.content}")
31+
32+
self._prepare_fn = download_voice
33+
elif msg.type == "image":
34+
self.ctype = ContextType.IMAGE
35+
self.content = TmpDir().path() + msg.media_id + ".png" # content直接存临时目录路径
36+
37+
def download_image():
38+
# 如果响应状态码是200,则将响应内容写入本地文件
39+
response = client.media.download(msg.media_id)
40+
if response.status_code == 200:
41+
with open(self.content, "wb") as f:
42+
f.write(response.content)
43+
else:
44+
logger.info(f"[wechatcom] Failed to download image file, {response.content}")
45+
46+
self._prepare_fn = download_image
47+
else:
48+
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg.type))
49+
50+
self.from_user_id = msg.source
51+
self.to_user_id = msg.target
52+
self.other_user_id = msg.source

0 commit comments

Comments
 (0)