Bladeren bron

Merge Pull Request #936 into wechatcom-app

master
lanvent 1 jaar geleden
bovenliggende
commit
be5a62f1b8
11 gewijzigde bestanden met toevoegingen van 356 en 6 verwijderingen
  1. +1
    -1
      app.py
  2. +4
    -0
      channel/channel_factory.py
  3. +2
    -2
      channel/wechat/wechat_channel.py
  4. +1
    -1
      channel/wechat/wechat_message.py
  5. +33
    -0
      channel/wechatcom/README.md
  6. +192
    -0
      channel/wechatcom/wechatcomapp_channel.py
  7. +65
    -0
      channel/wechatcom/wechatcomapp_message.py
  8. +34
    -0
      common/utils.py
  9. +9
    -1
      config.py
  10. +1
    -1
      requirements-optional.txt
  11. +14
    -0
      voice/audio_convert.py

+ 1
- 1
app.py Bestand weergeven

@@ -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


+ 4
- 0
channel/channel_factory.py Bestand weergeven

@@ -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

+ 2
- 2
channel/wechat/wechat_channel.py Bestand weergeven

@@ -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


+ 1
- 1
channel/wechat/wechat_message.py Bestand weergeven

@@ -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"]


+ 33
- 0
channel/wechatcom/README.md Bestand weergeven

@@ -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

+ 192
- 0
channel/wechatcom/wechatcomapp_channel.py Bestand weergeven

@@ -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"

+ 65
- 0
channel/wechatcom/wechatcomapp_message.py Bestand weergeven

@@ -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

+ 34
- 0
common/utils.py Bestand weergeven

@@ -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

+ 9
- 1
config.py Bestand weergeven

@@ -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": "", # 数据目录
# 插件配置


+ 1
- 1
requirements-optional.txt Bestand weergeven

@@ -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



+ 14
- 0
voice/audio_convert.py Bestand weergeven

@@ -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


Laden…
Annuleren
Opslaan