添加企业微信应用号部署方式,支持插件,支持语音图片交互develop
@@ -97,7 +97,7 @@ pip3 install azure-cognitiveservices-speech | |||||
cp config-template.json config.json | cp config-template.json config.json | ||||
``` | ``` | ||||
然后在`config.json`中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改: | |||||
然后在`config.json`中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改(请去掉注释): | |||||
```bash | ```bash | ||||
# config.json文件内容示例 | # config.json文件内容示例 | ||||
@@ -115,7 +115,9 @@ pip3 install azure-cognitiveservices-speech | |||||
"speech_recognition": false, # 是否开启语音识别 | "speech_recognition": false, # 是否开启语音识别 | ||||
"group_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/ | "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`: 对话内指令,主动清空前文记忆,字符串数组可自定义指令别名。 | + `clear_memory_commands`: 对话内指令,主动清空前文记忆,字符串数组可自定义指令别名。 | ||||
+ `hot_reload`: 程序退出后,暂存微信扫码状态,默认关闭。 | + `hot_reload`: 程序退出后,暂存微信扫码状态,默认关闭。 | ||||
+ `character_desc` 配置中保存着你对机器人说的一段话,他会记住这段话并作为他的设定,你可以为他定制任何人格 (关于会话上下文的更多内容参考该 [issue](https://github.com/zhayujie/chatgpt-on-wechat/issues/43)) | + `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)中列出。** | **所有可选的配置项均在该[文件](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py)中列出。** | ||||
@@ -43,7 +43,7 @@ def run(): | |||||
# os.environ['WECHATY_PUPPET_SERVICE_ENDPOINT'] = '127.0.0.1:9001' | # os.environ['WECHATY_PUPPET_SERVICE_ENDPOINT'] = '127.0.0.1:9001' | ||||
channel = channel_factory.create_channel(channel_name) | 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() | PluginManager().load_plugins() | ||||
# startup channel | # startup channel | ||||
@@ -29,4 +29,8 @@ def create_channel(channel_type): | |||||
from channel.wechatmp.wechatmp_channel import WechatMPChannel | from channel.wechatmp.wechatmp_channel import WechatMPChannel | ||||
return WechatMPChannel(passive_reply=False) | return WechatMPChannel(passive_reply=False) | ||||
elif channel_type == "wechatcom_app": | |||||
from channel.wechatcom.wechatcomapp_channel import WechatComAppChannel | |||||
return WechatComAppChannel() | |||||
raise RuntimeError | raise RuntimeError |
@@ -29,7 +29,7 @@ from plugins import * | |||||
@itchat.msg_register([TEXT, VOICE, PICTURE, NOTE]) | @itchat.msg_register([TEXT, VOICE, PICTURE, NOTE]) | ||||
def handler_single_msg(msg): | def handler_single_msg(msg): | ||||
try: | try: | ||||
cmsg = WeChatMessage(msg, False) | |||||
cmsg = WechatMessage(msg, False) | |||||
except NotImplementedError as e: | except NotImplementedError as e: | ||||
logger.debug("[WX]single message {} skipped: {}".format(msg["MsgId"], e)) | logger.debug("[WX]single message {} skipped: {}".format(msg["MsgId"], e)) | ||||
return None | return None | ||||
@@ -40,7 +40,7 @@ def handler_single_msg(msg): | |||||
@itchat.msg_register([TEXT, VOICE, PICTURE, NOTE], isGroupChat=True) | @itchat.msg_register([TEXT, VOICE, PICTURE, NOTE], isGroupChat=True) | ||||
def handler_group_msg(msg): | def handler_group_msg(msg): | ||||
try: | try: | ||||
cmsg = WeChatMessage(msg, True) | |||||
cmsg = WechatMessage(msg, True) | |||||
except NotImplementedError as e: | except NotImplementedError as e: | ||||
logger.debug("[WX]group message {} skipped: {}".format(msg["MsgId"], e)) | logger.debug("[WX]group message {} skipped: {}".format(msg["MsgId"], e)) | ||||
return None | return None | ||||
@@ -8,7 +8,7 @@ from lib import itchat | |||||
from lib.itchat.content import * | from lib.itchat.content import * | ||||
class WeChatMessage(ChatMessage): | |||||
class WechatMessage(ChatMessage): | |||||
def __init__(self, itchat_msg, is_group=False): | def __init__(self, itchat_msg, is_group=False): | ||||
super().__init__(itchat_msg) | super().__init__(itchat_msg) | ||||
self.msg_id = itchat_msg["MsgId"] | self.msg_id = itchat_msg["MsgId"] | ||||
@@ -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"> |
@@ -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" |
@@ -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() |
@@ -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 |
@@ -10,7 +10,7 @@ from channel.wechatmp.common import * | |||||
from channel.wechatmp.wechatmp_channel import WechatMPChannel | from channel.wechatmp.wechatmp_channel import WechatMPChannel | ||||
from channel.wechatmp.wechatmp_message import WeChatMPMessage | from channel.wechatmp.wechatmp_message import WeChatMPMessage | ||||
from common.log import logger | from common.log import logger | ||||
from config import conf | |||||
from config import conf, subscribe_msg | |||||
# This class is instantiated once per query | # This class is instantiated once per query | ||||
@@ -66,13 +66,14 @@ class Query: | |||||
logger.info("[wechatmp] Event {} from {}".format(msg.event, msg.source)) | logger.info("[wechatmp] Event {} from {}".format(msg.event, msg.source)) | ||||
if msg.event in ["subscribe", "subscribe_scan"]: | if msg.event in ["subscribe", "subscribe_scan"]: | ||||
reply_text = subscribe_msg() | 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: | else: | ||||
return "success" | return "success" | ||||
else: | else: | ||||
logger.info("暂且不处理") | logger.info("暂且不处理") | ||||
return "success" | |||||
return "success" | |||||
except Exception as exc: | except Exception as exc: | ||||
logger.exception(exc) | logger.exception(exc) | ||||
return exc | return exc |
@@ -1,5 +1,3 @@ | |||||
import textwrap | |||||
import web | import web | ||||
from wechatpy.crypto import WeChatCrypto | from wechatpy.crypto import WeChatCrypto | ||||
from wechatpy.exceptions import InvalidSignatureException | from wechatpy.exceptions import InvalidSignatureException | ||||
@@ -27,36 +25,3 @@ def verify_server(data): | |||||
raise web.Forbidden("Invalid signature") | raise web.Forbidden("Invalid signature") | ||||
except Exception as e: | except Exception as e: | ||||
raise web.Forbidden(str(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 |
@@ -11,7 +11,8 @@ from channel.wechatmp.common import * | |||||
from channel.wechatmp.wechatmp_channel import WechatMPChannel | from channel.wechatmp.wechatmp_channel import WechatMPChannel | ||||
from channel.wechatmp.wechatmp_message import WeChatMPMessage | from channel.wechatmp.wechatmp_message import WeChatMPMessage | ||||
from common.log import logger | 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 | # This class is instantiated once per query | ||||
@@ -199,14 +200,14 @@ class Query: | |||||
logger.info("[wechatmp] Event {} from {}".format(msg.event, msg.source)) | logger.info("[wechatmp] Event {} from {}".format(msg.event, msg.source)) | ||||
if msg.event in ["subscribe", "subscribe_scan"]: | if msg.event in ["subscribe", "subscribe_scan"]: | ||||
reply_text = subscribe_msg() | 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: | else: | ||||
return "success" | return "success" | ||||
else: | else: | ||||
logger.info("暂且不处理") | logger.info("暂且不处理") | ||||
return "success" | |||||
return "success" | |||||
except Exception as exc: | except Exception as exc: | ||||
logger.exception(exc) | logger.exception(exc) | ||||
return exc | return exc |
@@ -18,6 +18,7 @@ from channel.wechatmp.common import * | |||||
from channel.wechatmp.wechatmp_client import WechatMPClient | from channel.wechatmp.wechatmp_client import WechatMPClient | ||||
from common.log import logger | from common.log import logger | ||||
from common.singleton import singleton | from common.singleton import singleton | ||||
from common.utils import split_string_by_utf8_length | |||||
from config import conf | from config import conf | ||||
from voice.audio_convert import any_to_mp3 | 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) | texts = split_string_by_utf8_length(reply_text, MAX_UTF8_LEN) | ||||
if len(texts) > 1: | if len(texts) > 1: | ||||
logger.info("[wechatmp] text too long, split into {} parts".format(len(texts))) | 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) | 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)) | logger.info("[wechatmp] Do send text to {}: {}".format(receiver, reply_text)) | ||||
elif reply.type == ReplyType.VOICE: | elif reply.type == ReplyType.VOICE: | ||||
try: | try: | ||||
@@ -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 |
@@ -27,5 +27,6 @@ | |||||
"voice_reply_voice": false, | "voice_reply_voice": false, | ||||
"conversation_max_tokens": 1000, | "conversation_max_tokens": 1000, | ||||
"expires_in_seconds": 3600, | "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 查看详细指令。" | |||||
} | } |
@@ -8,6 +8,7 @@ import pickle | |||||
from common.log import logger | from common.log import logger | ||||
# 将所有可用的配置项写在字典里, 请使用小写字母 | # 将所有可用的配置项写在字典里, 请使用小写字母 | ||||
# 此处的配置值无实际意义,程序不会读取此处的配置,仅用于提示格式,请将配置加入到config.json中 | |||||
available_setting = { | available_setting = { | ||||
# openai api配置 | # openai api配置 | ||||
"open_ai_api_key": "", # openai api key | "open_ai_api_key": "", # openai api key | ||||
@@ -81,10 +82,19 @@ available_setting = { | |||||
"wechatmp_app_id": "", # 微信公众平台的appID | "wechatmp_app_id": "", # 微信公众平台的appID | ||||
"wechatmp_app_secret": "", # 微信公众平台的appsecret | "wechatmp_app_secret": "", # 微信公众平台的appsecret | ||||
"wechatmp_aes_key": "", # 微信公众平台的EncodingAESKey,加密模式需要 | "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指令自定义触发词 | # chatgpt指令自定义触发词 | ||||
"clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 | "clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 | ||||
# channel配置 | # 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模式,开启后会打印更多日志 | "debug": False, # 是否开启debug模式,开启后会打印更多日志 | ||||
"appdata_dir": "", # 数据目录 | "appdata_dir": "", # 数据目录 | ||||
# 插件配置 | # 插件配置 | ||||
@@ -93,8 +103,12 @@ available_setting = { | |||||
class Config(dict): | 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 | # user_datas: 用户数据,key为用户名,value为用户数据,也是dict | ||||
self.user_datas = {} | self.user_datas = {} | ||||
@@ -202,3 +216,9 @@ def get_appdata_dir(): | |||||
logger.info("[INIT] data path not exists, create it: {}".format(data_path)) | logger.info("[INIT] data path not exists, create it: {}".format(data_path)) | ||||
os.makedirs(data_path) | os.makedirs(data_path) | ||||
return 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) |
@@ -18,7 +18,7 @@ wechaty>=0.10.7 | |||||
wechaty_puppet>=0.4.23 | wechaty_puppet>=0.4.23 | ||||
pysilk_mod>=1.6.0 # needed by send voice | pysilk_mod>=1.6.0 # needed by send voice | ||||
# wechatmp | |||||
# wechatmp wechatcom | |||||
web.py | web.py | ||||
wechatpy | wechatpy | ||||
@@ -80,6 +80,20 @@ def any_to_sil(any_path, sil_path): | |||||
return audio.duration_seconds * 1000 | 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): | def sil_to_wav(silk_path, wav_path, rate: int = 24000): | ||||
""" | """ | ||||
silk 文件转 wav | silk 文件转 wav | ||||