Просмотр исходного кода

Merge pull request #944 from zhayujie/wechatcom-app

添加企业微信应用号部署方式,支持插件,支持语音图片交互
Jianglang 2 лет назад
Родитель
Сommit
dc52ab8aa9

+ 5 - 2
README.md

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

+ 1 - 1
app.py

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

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

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

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

+ 59 - 0
channel/wechatcom/README.md

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

+ 168 - 0
channel/wechatcom/wechatcomapp_channel.py

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

+ 21 - 0
channel/wechatcom/wechatcomapp_client.py

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

+ 52 - 0
channel/wechatcom/wechatcomapp_message.py

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

+ 5 - 4
channel/wechatmp/active_reply.py

@@ -10,7 +10,7 @@ from channel.wechatmp.common import *
 from channel.wechatmp.wechatmp_channel import WechatMPChannel
 from channel.wechatmp.wechatmp_message import WeChatMPMessage
 from common.log import logger
-from config import conf
+from config import conf, subscribe_msg
 
 
 # This class is instantiated once per query
@@ -66,13 +66,14 @@ class Query:
                 logger.info("[wechatmp] Event {} from {}".format(msg.event, msg.source))
                 if msg.event in ["subscribe", "subscribe_scan"]:
                     reply_text = subscribe_msg()
-                    replyPost = create_reply(reply_text, msg)
-                    return encrypt_func(replyPost.render())
+                    if reply_text:
+                        replyPost = create_reply(reply_text, msg)
+                        return encrypt_func(replyPost.render())
                 else:
                     return "success"
             else:
                 logger.info("暂且不处理")
-                return "success"
+            return "success"
         except Exception as exc:
             logger.exception(exc)
             return exc

+ 0 - 35
channel/wechatmp/common.py

@@ -1,5 +1,3 @@
-import textwrap
-
 import web
 from wechatpy.crypto import WeChatCrypto
 from wechatpy.exceptions import InvalidSignatureException
@@ -27,36 +25,3 @@ def verify_server(data):
         raise web.Forbidden("Invalid signature")
     except Exception as e:
         raise web.Forbidden(str(e))
-
-
-def subscribe_msg():
-    trigger_prefix = conf().get("single_chat_prefix", [""])[0]
-    msg = textwrap.dedent(
-        f"""\
-                    感谢您的关注!
-                    这里是ChatGPT,可以自由对话。
-                    资源有限,回复较慢,请勿着急。
-                    支持语音对话。
-                    支持图片输入。
-                    支持图片输出,画字开头的消息将按要求创作图片。
-                    支持tool、角色扮演和文字冒险等丰富的插件。
-                    输入'{trigger_prefix}#帮助' 查看详细指令。"""
-    )
-    return msg
-
-
-def split_string_by_utf8_length(string, max_length, max_split=0):
-    encoded = string.encode("utf-8")
-    start, end = 0, 0
-    result = []
-    while end < len(encoded):
-        if max_split > 0 and len(result) >= max_split:
-            result.append(encoded[start:].decode("utf-8"))
-            break
-        end = min(start + max_length, len(encoded))
-        # 如果当前字节不是 UTF-8 编码的开始字节,则向前查找直到找到开始字节为止
-        while end < len(encoded) and (encoded[end] & 0b11000000) == 0b10000000:
-            end -= 1
-        result.append(encoded[start:end].decode("utf-8"))
-        start = end
-    return result

+ 6 - 5
channel/wechatmp/passive_reply.py

@@ -11,7 +11,8 @@ from channel.wechatmp.common import *
 from channel.wechatmp.wechatmp_channel import WechatMPChannel
 from channel.wechatmp.wechatmp_message import WeChatMPMessage
 from common.log import logger
-from config import conf
+from common.utils import split_string_by_utf8_length
+from config import conf, subscribe_msg
 
 
 # This class is instantiated once per query
@@ -199,14 +200,14 @@ class Query:
                 logger.info("[wechatmp] Event {} from {}".format(msg.event, msg.source))
                 if msg.event in ["subscribe", "subscribe_scan"]:
                     reply_text = subscribe_msg()
-                    replyPost = create_reply(reply_text, msg)
-                    return encrypt_func(replyPost.render())
+                    if reply_text:
+                        replyPost = create_reply(reply_text, msg)
+                        return encrypt_func(replyPost.render())
                 else:
                     return "success"
-
             else:
                 logger.info("暂且不处理")
-                return "success"
+            return "success"
         except Exception as exc:
             logger.exception(exc)
             return exc

+ 4 - 1
channel/wechatmp/wechatmp_channel.py

@@ -18,6 +18,7 @@ from channel.wechatmp.common import *
 from channel.wechatmp.wechatmp_client import WechatMPClient
 from common.log import logger
 from common.singleton import singleton
+from common.utils import split_string_by_utf8_length
 from config import conf
 from voice.audio_convert import any_to_mp3
 
@@ -140,8 +141,10 @@ class WechatMPChannel(ChatChannel):
                 texts = split_string_by_utf8_length(reply_text, MAX_UTF8_LEN)
                 if len(texts) > 1:
                     logger.info("[wechatmp] text too long, split into {} parts".format(len(texts)))
-                for text in texts:
+                for i, text in enumerate(texts):
                     self.client.message.send_text(receiver, text)
+                    if i != len(texts) - 1:
+                        time.sleep(0.5)  # 休眠0.5秒,防止发送过快乱序
                 logger.info("[wechatmp] Do send text to {}: {}".format(receiver, reply_text))
             elif reply.type == ReplyType.VOICE:
                 try:

+ 51 - 0
common/utils.py

@@ -0,0 +1,51 @@
+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
+
+
+def split_string_by_utf8_length(string, max_length, max_split=0):
+    encoded = string.encode("utf-8")
+    start, end = 0, 0
+    result = []
+    while end < len(encoded):
+        if max_split > 0 and len(result) >= max_split:
+            result.append(encoded[start:].decode("utf-8"))
+            break
+        end = min(start + max_length, len(encoded))
+        # 如果当前字节不是 UTF-8 编码的开始字节,则向前查找直到找到开始字节为止
+        while end < len(encoded) and (encoded[end] & 0b11000000) == 0b10000000:
+            end -= 1
+        result.append(encoded[start:end].decode("utf-8"))
+        start = end
+    return result

+ 2 - 1
config-template.json

@@ -27,5 +27,6 @@
   "voice_reply_voice": false,
   "conversation_max_tokens": 1000,
   "expires_in_seconds": 3600,
-  "character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。"
+  "character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。",
+  "subcribe_msg": "感谢您的关注!\n这里是ChatGPT,可以自由对话。\n支持语音对话。\n支持图片输入。\n支持图片输出,画字开头的消息将按要求创作图片。\n支持tool、角色扮演和文字冒险等丰富的插件。\n输入{trigger_prefix}#help 查看详细指令。"
 }

+ 23 - 3
config.py

@@ -8,6 +8,7 @@ import pickle
 from common.log import logger
 
 # 将所有可用的配置项写在字典里, 请使用小写字母
+# 此处的配置值无实际意义,程序不会读取此处的配置,仅用于提示格式,请将配置加入到config.json中
 available_setting = {
     # openai api配置
     "open_ai_api_key": "",  # openai api key
@@ -81,10 +82,19 @@ 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}
+    "subscribe_msg": "",  # 订阅消息, 支持: wechatmp, wechatmp_service, wechatcom_app
     "debug": False,  # 是否开启debug模式,开启后会打印更多日志
     "appdata_dir": "",  # 数据目录
     # 插件配置
@@ -93,8 +103,12 @@ available_setting = {
 
 
 class Config(dict):
-    def __init__(self, d: dict = {}):
-        super().__init__(d)
+    def __init__(self, d=None):
+        super().__init__()
+        if d is None:
+            d = {}
+        for k, v in d.items():
+            self[k] = v
         # user_datas: 用户数据,key为用户名,value为用户数据,也是dict
         self.user_datas = {}
 
@@ -202,3 +216,9 @@ def get_appdata_dir():
         logger.info("[INIT] data path not exists, create it: {}".format(data_path))
         os.makedirs(data_path)
     return data_path
+
+
+def subscribe_msg():
+    trigger_prefix = conf().get("single_chat_prefix", [""])[0]
+    msg = conf().get("subscribe_msg", "")
+    return msg.format(trigger_prefix=trigger_prefix)

BIN
docs/images/aigcopen.png


+ 1 - 1
requirements-optional.txt

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

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