@@ -43,7 +43,7 @@ def run(): | |||
# os.environ['WECHATY_PUPPET_SERVICE_ENDPOINT'] = '127.0.0.1:9001' | |||
channel = channel_factory.create_channel(channel_name) | |||
if channel_name in ["wx", "wxy", "terminal", "wechatmp", "wechatmp_service"]: | |||
if channel_name in ["wx", "wxy", "terminal", "wechatmp", "wechatmp_service", "wechatcom_app"]: | |||
PluginManager().load_plugins() | |||
# startup channel | |||
@@ -29,4 +29,8 @@ def create_channel(channel_type): | |||
from channel.wechatmp.wechatmp_channel import WechatMPChannel | |||
return WechatMPChannel(passive_reply=False) | |||
elif channel_type == "wechatcom_app": | |||
from channel.wechatcom.wechatcomapp_channel import WechatComAppChannel | |||
return WechatComAppChannel() | |||
raise RuntimeError |
@@ -29,7 +29,7 @@ from plugins import * | |||
@itchat.msg_register([TEXT, VOICE, PICTURE, NOTE]) | |||
def handler_single_msg(msg): | |||
try: | |||
cmsg = WeChatMessage(msg, False) | |||
cmsg = WechatMessage(msg, False) | |||
except NotImplementedError as e: | |||
logger.debug("[WX]single message {} skipped: {}".format(msg["MsgId"], e)) | |||
return None | |||
@@ -40,7 +40,7 @@ def handler_single_msg(msg): | |||
@itchat.msg_register([TEXT, VOICE, PICTURE, NOTE], isGroupChat=True) | |||
def handler_group_msg(msg): | |||
try: | |||
cmsg = WeChatMessage(msg, True) | |||
cmsg = WechatMessage(msg, True) | |||
except NotImplementedError as e: | |||
logger.debug("[WX]group message {} skipped: {}".format(msg["MsgId"], e)) | |||
return None | |||
@@ -8,7 +8,7 @@ from lib import itchat | |||
from lib.itchat.content import * | |||
class WeChatMessage(ChatMessage): | |||
class WechatMessage(ChatMessage): | |||
def __init__(self, itchat_msg, is_group=False): | |||
super().__init__(itchat_msg) | |||
self.msg_id = itchat_msg["MsgId"] | |||
@@ -0,0 +1,33 @@ | |||
> 详细文档暂无 | |||
## 自建应用 | |||
- 在企业微信工作台自建应用 | |||
建立应用后点击通过API接收消息,设置服务器地址,服务器地址是`http://url:port/wxcomapp`的形式,也可以不用域名,比如 `http://ip:port/wxcomapp` | |||
- 修改配置 | |||
在主目录下的`config.json`中填写以下配置项 | |||
```python | |||
# wechatcom的通用配置 | |||
"wechatcom_corp_id": "", # 企业微信公司的corpID | |||
# wechatcomapp的配置 | |||
"wechatcomapp_token": "", # 企业微信app的token | |||
"wechatcomapp_port": 9898, # 企业微信app的服务端口,不需要端口转发 | |||
"wechatcomapp_secret": "", # 企业微信app的secret | |||
"wechatcomapp_agent_id": "", # 企业微信app的agent_id | |||
"wechatcomapp_aes_key": "", # 企业微信app的aes_key | |||
``` | |||
- 运行程序 | |||
```python app.py``` | |||
在设置服务器页面点击保存 | |||
- 添加可信IP | |||
在自建应用管理页下方,将服务器的IP添加到可信IP |
@@ -0,0 +1,192 @@ | |||
#!/usr/bin/env python | |||
# -*- coding=utf-8 -*- | |||
import io | |||
import os | |||
import textwrap | |||
import requests | |||
import web | |||
from wechatpy.enterprise import WeChatClient, 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_message import WechatComAppMessage | |||
from common.log import logger | |||
from common.singleton import singleton | |||
from common.utils import compress_imgfile, fsize | |||
from config import conf | |||
from voice.audio_convert import any_to_amr | |||
@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 = WeChatClient(self.corp_id, self.secret) # todo: 这里可能有线程安全问题 | |||
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", 8080) | |||
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]: | |||
self.client.message.send_text(self.agent_id, receiver, reply.content) | |||
logger.info("[wechatcom] sendMsg={}, receiver={}".format(reply, receiver)) | |||
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": | |||
trigger_prefix = conf().get("single_chat_prefix", [""])[0] | |||
reply_content = textwrap.dedent( | |||
f"""\ | |||
感谢您的关注! | |||
这里是ChatGPT,可以自由对话。 | |||
支持语音对话。 | |||
支持通用表情输入。 | |||
支持图片输入输出。 | |||
支持角色扮演和文字冒险两种定制模式对话。 | |||
输入'{trigger_prefix}#help' 查看详细指令。""" | |||
) | |||
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" |
@@ -0,0 +1,65 @@ | |||
import re | |||
import requests | |||
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 | |||
from lib import itchat | |||
from lib.itchat.content import * | |||
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 |
@@ -0,0 +1,34 @@ | |||
import io | |||
import os | |||
from PIL import Image | |||
def fsize(file): | |||
if isinstance(file, io.BytesIO): | |||
return file.getbuffer().nbytes | |||
elif isinstance(file, str): | |||
return os.path.getsize(file) | |||
elif hasattr(file, "seek") and hasattr(file, "tell"): | |||
pos = file.tell() | |||
file.seek(0, os.SEEK_END) | |||
size = file.tell() | |||
file.seek(pos) | |||
return size | |||
else: | |||
raise TypeError("Unsupported type") | |||
def compress_imgfile(file, max_size): | |||
if fsize(file) <= max_size: | |||
return file | |||
file.seek(0) | |||
img = Image.open(file) | |||
rgb_image = img.convert("RGB") | |||
quality = 95 | |||
while True: | |||
out_buf = io.BytesIO() | |||
rgb_image.save(out_buf, "JPEG", quality=quality) | |||
if fsize(out_buf) <= max_size: | |||
return out_buf | |||
quality -= 5 |
@@ -81,10 +81,18 @@ available_setting = { | |||
"wechatmp_app_id": "", # 微信公众平台的appID | |||
"wechatmp_app_secret": "", # 微信公众平台的appsecret | |||
"wechatmp_aes_key": "", # 微信公众平台的EncodingAESKey,加密模式需要 | |||
# wechatcom的通用配置 | |||
"wechatcom_corp_id": "", # 企业微信公司的corpID | |||
# wechatcomapp的配置 | |||
"wechatcomapp_token": "", # 企业微信app的token | |||
"wechatcomapp_port": 9898, # 企业微信app的服务端口,不需要端口转发 | |||
"wechatcomapp_secret": "", # 企业微信app的secret | |||
"wechatcomapp_agent_id": "", # 企业微信app的agent_id | |||
"wechatcomapp_aes_key": "", # 企业微信app的aes_key | |||
# chatgpt指令自定义触发词 | |||
"clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 | |||
# channel配置 | |||
"channel_type": "wx", # 通道类型,支持:{wx,wxy,terminal,wechatmp,wechatmp_service} | |||
"channel_type": "wx", # 通道类型,支持:{wx,wxy,terminal,wechatmp,wechatmp_service,wechatcom_app} | |||
"debug": False, # 是否开启debug模式,开启后会打印更多日志 | |||
"appdata_dir": "", # 数据目录 | |||
# 插件配置 | |||
@@ -18,7 +18,7 @@ wechaty>=0.10.7 | |||
wechaty_puppet>=0.4.23 | |||
pysilk_mod>=1.6.0 # needed by send voice | |||
# wechatmp | |||
# wechatmp wechatcom | |||
web.py | |||
wechatpy | |||
@@ -80,6 +80,20 @@ def any_to_sil(any_path, sil_path): | |||
return audio.duration_seconds * 1000 | |||
def any_to_amr(any_path, amr_path): | |||
""" | |||
把任意格式转成amr文件 | |||
""" | |||
if any_path.endswith(".amr"): | |||
shutil.copy2(any_path, amr_path) | |||
return | |||
if any_path.endswith(".sil") or any_path.endswith(".silk") or any_path.endswith(".slk"): | |||
raise NotImplementedError("Not support file type: {}".format(any_path)) | |||
audio = AudioSegment.from_file(any_path) | |||
audio = audio.set_frame_rate(8000) # only support 8000 | |||
audio.export(amr_path, format="amr") | |||
def sil_to_wav(silk_path, wav_path, rate: int = 24000): | |||
""" | |||
silk 文件转 wav | |||