Compare commits

..

56 Commits
1.3.7 ... 1.3.9

Author SHA1 Message Date
zhayujie
7c8fb7eacc Merge pull request #1428 from scut-chenzk/chenzk
修复收到从微信发出的图片消息保存到本地失败的问题
2023-09-26 15:59:23 +08:00
zhayujie
b45eea5908 Merge pull request #1427 from befantasy/master
itchat通道增加ReplyType.FILE/ReplyType.VIDEO/ReplyType.VIDEO_URL,以方便插件的开发。keyword插件增加文件和视频匹配回复
2023-09-26 01:27:35 +08:00
zhayujie
6babf4ee6c Merge pull request #1445 from befantasy/patch-3
Update godcmd.py 增加debug模式的关闭
2023-09-26 00:37:17 +08:00
zhayujie
576526d4ee Merge pull request #1446 from 6vision/master
个人订阅号消息存储优化
2023-09-26 00:36:36 +08:00
zhayujie
c03e31b7be fix: linkai instruction bug 2023-09-25 23:15:59 +08:00
zhayujie
a1aa925019 fix: no summary config bug 2023-09-25 18:30:19 +08:00
zhayujie
a5a234ed97 fix: remove file after summary 2023-09-25 16:42:36 +08:00
zhayujie
5b5dbcd78b feat: remove file word calc and support url link 2023-09-24 14:33:39 +08:00
zhayujie
bd1c6361d3 Update README.md 2023-09-24 12:54:34 +08:00
zhayujie
1fc1febf03 Merge pull request #1450 from zhayujie/feat-doc-chat
feat: 文档总结和与内容对话
2023-09-24 12:30:45 +08:00
zhayujie
55cc35efa9 feat: document summary and chat with content 2023-09-24 12:27:09 +08:00
vision
5ba8fdc5e7 fix 2023-09-23 14:31:54 +08:00
vision
6ea295e227 Merge pull request #1 from 6vision/feat
个人订阅号长语音支持
2023-09-23 13:46:25 +08:00
befantasy
5010c76ef7 Update godcmd.py 增加debug模式的关闭 2023-09-23 13:37:01 +08:00
6vision
79c7f0c29f 个人订阅号长语音支持 2023-09-23 13:27:36 +08:00
6vision
2b3e643786 适配一次请求多条回复 2023-09-23 11:59:01 +08:00
chenzhenkun
90cdff327c 修复收到从微信发出的图片消息保存到本地失败的问题 2023-09-15 19:07:52 +08:00
zhayujie
55c116e727 Update README.md 2023-09-15 18:42:56 +08:00
befantasy
3dd83aa6b7 Update chat_channel.py 2023-09-15 18:38:31 +08:00
befantasy
a74aa12641 Update wechat_channel.py 2023-09-15 18:37:05 +08:00
befantasy
151e8c69f9 Update keyword.py 2023-09-15 18:22:10 +08:00
befantasy
d8bfa77705 Update keyword.py 2023-09-15 16:56:51 +08:00
befantasy
6bd286e8d5 Update wechat_channel.py to support ReplyType.FILE 2023-09-15 16:22:46 +08:00
befantasy
905532b681 Update chat_channel.py to support ReplyType.FILE 2023-09-15 16:21:27 +08:00
zhayujie
04d5c1ab01 Delete .github/ISSUE_TEMPLATE/config.yml 2023-09-15 15:45:23 +08:00
zhayujie
28be141dc7 Merge pull request #1422 from scut-chenzk/chenzk
修复接语音回复失效的问题
2023-09-15 15:14:00 +08:00
chenzk
652b786baf Merge branch 'zhayujie:master' into chenzk 2023-09-14 23:42:00 +08:00
chenzhenkun
ba6c671051 修复收到图片消息保存到本地失败的问题 2023-09-14 23:39:07 +08:00
chenzhenkun
ca25d0433f 修复接语音回复失效的问题 2023-09-14 17:52:11 +08:00
zhayujie
5338106dfa Merge pull request #1308 from leesonchen/master
企业服务号的语音输出进行切割
2023-09-12 18:18:17 +08:00
zhayujie
b6b76be4f6 fix: add summary plugin bot type 2023-09-06 16:50:23 +08:00
zhayujie
03d94fcfa0 fix: not enable user_image_create_prefix by default 2023-09-06 12:02:13 +08:00
zhayujie
b2c5f0d455 feat: mj use default config 2023-09-06 11:53:33 +08:00
zhayujie
54f60dd38c chore: remove dependencies that can only be used under windows 2023-09-04 11:14:48 +08:00
zhayujie
42f181aca2 Merge pull request #1394 from resphinas/claude_bot
Update claude_ai_bot.py
2023-09-04 10:47:02 +08:00
resphina
9c3a27894f Update claude_ai_bot.py 2023-09-03 19:12:27 +08:00
resphina
f7cd348912 Update claude_ai_bot.py 2023-09-03 19:04:43 +08:00
zhayujie
aeaeb75d3b Merge pull request #1396 from 6vision/master
Optimize image download and storage logic
2023-09-03 17:32:30 +08:00
vision
96542b532e Update requirements-optional.txt 2023-09-03 17:14:28 +08:00
vision
139295fe0d Update requirements-optional.txt
增加企微个人号channel所需依赖
2023-09-03 16:47:25 +08:00
vision
13217b2ce2 Merge pull request #1 from 6vision/patch-1
Optimize image download and storage logic
2023-09-03 16:35:01 +08:00
vision
5cc8b56a7c Optimize image download and storage logic
- Implement new compression logic for files larger than 10MB to improve storage efficiency.
- Switch from JPEG to PNG to enhance image quality and compatibility.
2023-09-03 16:29:19 +08:00
resphina
e23e01c95e Update claude_ai_bot.py 2023-09-03 15:40:08 +08:00
resphina
bca8ba12c7 Update claude_ai_bot.py 2023-09-03 15:22:25 +08:00
vision
3c44bdbe1c Update requirements-optional.txt 2023-09-03 15:10:05 +08:00
zhayujie
db93ed025b Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2023-09-02 21:50:28 +08:00
zhayujie
4209e108d0 fix: wework single chat no prefix circle reply 2023-09-02 21:49:43 +08:00
zhayujie
14cbf011af Merge pull request #1391 from resphinas/claude_bot
Rename claude_ai_session to claude_ai_session.py
2023-09-02 10:42:29 +08:00
resphina
03a41ec199 Rename claude_ai_session to claude_ai_session.py 2023-09-02 02:40:57 +08:00
zhayujie
125fe2a026 Merge pull request #1390 from scut-chenzk/chenzk
Chenzk
2023-09-01 19:42:21 +08:00
chenzhenkun
ac4adac29e 兼容微信艾特的情况 2023-09-01 19:37:19 +08:00
chenzhenkun
ac449d078e Merge remote-tracking branch 'origin/chenzk' into chenzk
# Conflicts:
#	channel/chat_channel.py
2023-09-01 19:22:02 +08:00
chenzhenkun
79be4530d4 防止命中前缀导致死循环的情况 2023-09-01 19:18:53 +08:00
chenzk
85ce52d70c Merge branch 'zhayujie:master' into chenzk 2023-09-01 18:57:52 +08:00
chenzhenkun
7ab56b9076 添加日志以方便定位问题 2023-09-01 18:56:24 +08:00
leeson
8224c2fc16 企业服务号的语音输出进行切割 2023-07-08 23:58:07 +08:00
23 changed files with 602 additions and 158 deletions

View File

@@ -1,6 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: 知识星球
url: https://public.zsxq.com/groups/88885848842852.html
about: 如果你想了解更多项目细节并与开发者们交流更多关于AI技术的实践欢迎加入星球

View File

@@ -5,7 +5,7 @@
最新版本支持的功能如下:
- [x] **多端部署:** 有多种部署方式可选择且功能完备,目前已支持个人微信,微信公众号和企业微信应用等部署方式
- [x] **基础对话:** 私聊及群聊的消息智能回复,支持多轮会话上下文记忆,支持 GPT-3, GPT-3.5, GPT-4, claude, 文心一言, 讯飞星火
- [x] **基础对话:** 私聊及群聊的消息智能回复,支持多轮会话上下文记忆,支持 GPT-3.5, GPT-4, claude, 文心一言, 讯飞星火
- [x] **语音识别:** 可识别语音消息,通过文字或语音回复,支持 azure, baidu, google, openai等多种语音模型
- [x] **图片生成:** 支持图片生成 和 图生图(如照片修复),可选择 Dell-E, stable diffusion, replicate, midjourney模型
- [x] **丰富插件:** 支持个性化插件扩展,已实现多角色切换、文字冒险、敏感词过滤、聊天记录总结等插件
@@ -27,11 +27,12 @@ Demo made by [Visionn](https://www.wangpc.cc/)
<img width="240" src="./docs/images/contact.jpg">
# 更新日志
>**2023.09.01** 接入讯飞星火claude机器人
>**2023.09.01** 增加 [企微个人号](https://github.com/zhayujie/chatgpt-on-wechat/pull/1385) 通道,[claude](https://github.com/zhayujie/chatgpt-on-wechat/pull/1382) 模型
>**2023.08.08** 接入百度文心一言模型,通过 [插件](https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/linkai) 支持 Midjourney 绘图
>**2023.06.12** 接入 [LinkAI](https://chat.link-ai.tech/console) 平台,可在线创建个人知识库,并接入微信、公众号及企业微信中,打造专属客服机器人。使用参考 [接入文档](https://link-ai.tech/platform/link-app/wechat)。
>**2023.06.12** 接入 [LinkAI](https://chat.link-ai.tech/console) 平台,可在线创建领域知识库,并接入微信、公众号及企业微信中,打造专属客服机器人。使用参考 [接入文档](https://link-ai.tech/platform/link-app/wechat)。
>**2023.04.26** 支持企业微信应用号部署,兼容插件,并支持语音图片交互,私人助理理想选择,[使用文档](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/wechatcom/README.md)。(contributed by [@lanvent](https://github.com/lanvent) in [#944](https://github.com/zhayujie/chatgpt-on-wechat/pull/944))
@@ -176,26 +177,6 @@ pip3 install azure-cognitiveservices-speech
+ `linkai_api_key`: LinkAI Api Key可在 [控制台](https://chat.link-ai.tech/console/interface) 创建
+ `linkai_app_code`: LinkAI 应用code选填
**6.wenxin配置 (可选 model 为 wenxin 时生效)**
+ `baidu_wenxin_api_key`: 文心一言官网api key。
+ `baidu_wenxin_secret_key`: 文心一言官网secret key。
**6.Claude配置 (可选 model 为 claude 时生效)**
+ `claude_api_cookie`: claude官网聊天界面复制完整 cookie 字符串。
+ `claude_uuid`: 可以指定对话id默认新建对话实体。
**7.xunfei配置 (可选 model 为 xunfei 时生效)**
+ `xunfei_app_id`: 讯飞星火app id。
+ `xunfei_api_key`: 讯飞星火 api key。
+ `xunfei_api_secret`: 讯飞星火 secret。
**本说明文档可能会未及时更新,当前所有可选的配置项均在该[`config.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py)中列出。**
## 运行

View File

@@ -27,6 +27,7 @@ class ClaudeAIBot(Bot, OpenAIImage):
}
else:
self.proxies = None
self.error = ""
self.org_uuid = self.get_organization_id()
def generate_uuid(self):
@@ -34,32 +35,6 @@ class ClaudeAIBot(Bot, OpenAIImage):
random_uuid_str = str(random_uuid)
formatted_uuid = f"{random_uuid_str[0:8]}-{random_uuid_str[9:13]}-{random_uuid_str[14:18]}-{random_uuid_str[19:23]}-{random_uuid_str[24:]}"
return formatted_uuid
def get_uuid(self):
if conf().get("claude_uuid") != None:
self.con_uuid = conf().get("claude_uuid")
else:
con_uuid = self.generate_uuid()
self.create_new_chat(con_uuid)
def get_organization_id(self):
url = "https://claude.ai/api/organizations"
headers = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
'Accept-Language': 'en-US,en;q=0.5',
'Referer': 'https://claude.ai/chats',
'Content-Type': 'application/json',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
'Connection': 'keep-alive',
'Cookie': f'{self.claude_api_cookie}'
}
response = requests.get(url, headers=headers,impersonate="chrome110",proxies=self.proxies)
res = json.loads(response.text)
uuid = res[0]['uuid']
return uuid
def reply(self, query, context: Context = None) -> Reply:
if context.type == ContextType.TEXT:
@@ -90,20 +65,38 @@ class ClaudeAIBot(Bot, OpenAIImage):
'Cookie': f'{self.claude_api_cookie}'
}
try:
response = requests.get(url, headers=headers,impersonate="chrome110",proxies =self.proxies )
response = requests.get(url, headers=headers, impersonate="chrome110", proxies =self.proxies, timeout=400)
res = json.loads(response.text)
uuid = res[0]['uuid']
except:
print(response.text)
if "App unavailable" in response.text:
logger.error("IP error: The IP is not allowed to be used on Claude")
self.error = "ip所在地区不被claude支持"
elif "Invalid authorization" in response.text:
logger.error("Cookie error: Invalid authorization of claude, check cookie please.")
self.error = "无法通过claude身份验证请检查cookie"
return None
return uuid
def conversation_share_check(self,session_id):
if conf().get("claude_uuid") is not None and conf().get("claude_uuid") != "":
con_uuid = conf().get("claude_uuid")
return con_uuid
if session_id not in self.con_uuid_dic:
self.con_uuid_dic[session_id] = self.generate_uuid()
self.create_new_chat(self.con_uuid_dic[session_id])
return self.con_uuid_dic[session_id]
def check_cookie(self):
flag = self.get_organization_id()
return flag
def create_new_chat(self, con_uuid):
"""
新建claude对话实体
:param con_uuid: 对话id
:return:
"""
url = f"https://claude.ai/api/organizations/{self.org_uuid}/chat_conversations"
payload = json.dumps({"uuid": con_uuid, "name": ""})
headers = {
@@ -121,7 +114,7 @@ class ClaudeAIBot(Bot, OpenAIImage):
'Sec-Fetch-Site': 'same-origin',
'TE': 'trailers'
}
response = requests.post(url, headers=headers, data=payload,impersonate="chrome110", proxies= self.proxies)
response = requests.post(url, headers=headers, data=payload, impersonate="chrome110", proxies=self.proxies, timeout=400)
# Returns JSON of the newly created conversation information
return response.json()
@@ -140,8 +133,12 @@ class ClaudeAIBot(Bot, OpenAIImage):
try:
session_id = context["session_id"]
if self.org_uuid is None:
return Reply(ReplyType.ERROR, self.error)
session = self.sessions.session_query(query, session_id)
con_uuid = self.conversation_share_check(session_id)
model = conf().get("model") or "gpt-3.5-turbo"
# remove system message
if session.messages[0].get("role") == "system":
@@ -193,11 +190,18 @@ class ClaudeAIBot(Bot, OpenAIImage):
completions.append(data['completion'])
reply_content = ''.join(completions)
logger.info(f"[CLAUDE] reply={reply_content}, total_tokens=invisible")
if "rate limi" in reply_content:
logger.error("rate limit error: The conversation has reached the system speed limit and is synchronized with Cladue. Please go to the official website to check the lifting time")
return Reply(ReplyType.ERROR, "对话达到系统速率限制与cladue同步请进入官网查看解除限制时间")
logger.info(f"[CLAUDE] reply={reply_content}, total_tokens=invisible")
self.sessions.session_reply(reply_content, session_id, 100)
return Reply(ReplyType.TEXT, reply_content)
else:
flag = self.check_cookie()
if flag == None:
return Reply(ReplyType.ERROR, self.error)
response = res.json()
error = response.get("error")
logger.error(f"[CLAUDE] chat failed, status_code={res.status_code}, "

View File

@@ -23,6 +23,7 @@ class LinkAIBot(Bot, OpenAIImage):
def __init__(self):
super().__init__()
self.sessions = SessionManager(ChatGPTSession, model=conf().get("model") or "gpt-3.5-turbo")
self.args = {}
def reply(self, query, context: Context = None) -> Reply:
if context.type == ContextType.TEXT:
@@ -72,13 +73,16 @@ class LinkAIBot(Bot, OpenAIImage):
body = {
"app_code": app_code,
"messages": session.messages,
"model": model, # 对话模型的名称, 支持 gpt-3.5-turbo, gpt-3.5-turbo-16k, gpt-4, wenxin
"model": model, # 对话模型的名称, 支持 gpt-3.5-turbo, gpt-3.5-turbo-16k, gpt-4, wenxin, xunfei
"temperature": conf().get("temperature"),
"top_p": conf().get("top_p", 1),
"frequency_penalty": conf().get("frequency_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
"presence_penalty": conf().get("presence_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
}
logger.info(f"[LINKAI] query={query}, app_code={app_code}, mode={body.get('model')}")
file_id = context.kwargs.get("file_id")
if file_id:
body["file_id"] = file_id
logger.info(f"[LINKAI] query={query}, app_code={app_code}, mode={body.get('model')}, file_id={file_id}")
headers = {"Authorization": "Bearer " + linkai_api_key}
# do http request
@@ -114,3 +118,68 @@ class LinkAIBot(Bot, OpenAIImage):
time.sleep(2)
logger.warn(f"[LINKAI] do retry, times={retry_count}")
return self._chat(query, context, retry_count + 1)
def reply_text(self, session: ChatGPTSession, app_code="", retry_count=0) -> dict:
if retry_count >= 2:
# exit from retry 2 times
logger.warn("[LINKAI] failed after maximum number of retry times")
return {
"total_tokens": 0,
"completion_tokens": 0,
"content": "请再问我一次吧"
}
try:
body = {
"app_code": app_code,
"messages": session.messages,
"model": conf().get("model") or "gpt-3.5-turbo", # 对话模型的名称, 支持 gpt-3.5-turbo, gpt-3.5-turbo-16k, gpt-4, wenxin, xunfei
"temperature": conf().get("temperature"),
"top_p": conf().get("top_p", 1),
"frequency_penalty": conf().get("frequency_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
"presence_penalty": conf().get("presence_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
}
if self.args.get("max_tokens"):
body["max_tokens"] = self.args.get("max_tokens")
headers = {"Authorization": "Bearer " + conf().get("linkai_api_key")}
# do http request
base_url = conf().get("linkai_api_base", "https://api.link-ai.chat")
res = requests.post(url=base_url + "/v1/chat/completions", json=body, headers=headers,
timeout=conf().get("request_timeout", 180))
if res.status_code == 200:
# execute success
response = res.json()
reply_content = response["choices"][0]["message"]["content"]
total_tokens = response["usage"]["total_tokens"]
logger.info(f"[LINKAI] reply={reply_content}, total_tokens={total_tokens}")
return {
"total_tokens": total_tokens,
"completion_tokens": response["usage"]["completion_tokens"],
"content": reply_content,
}
else:
response = res.json()
error = response.get("error")
logger.error(f"[LINKAI] chat failed, status_code={res.status_code}, "
f"msg={error.get('message')}, type={error.get('type')}")
if res.status_code >= 500:
# server error, need retry
time.sleep(2)
logger.warn(f"[LINKAI] do retry, times={retry_count}")
return self.reply_text(session, app_code, retry_count + 1)
return {
"total_tokens": 0,
"completion_tokens": 0,
"content": "提问太快啦,请休息一下再问我吧"
}
except Exception as e:
logger.exception(e)
# retry
time.sleep(2)
logger.warn(f"[LINKAI] do retry, times={retry_count}")
return self.reply_text(session, app_code, retry_count + 1)

View File

@@ -32,6 +32,7 @@ class Bridge(object):
if model_type in ["claude"]:
self.btype["chat"] = const.CLAUDEAI
self.bots = {}
self.chat_bots = {}
def get_bot(self, typename):
if self.bots.get(typename) is None:
@@ -61,6 +62,11 @@ class Bridge(object):
def fetch_translate(self, text, from_lang="", to_lang="en") -> Reply:
return self.get_bot("translate").translate(text, from_lang, to_lang)
def find_chat_bot(self, bot_type: str):
if self.chat_bots.get(bot_type) is None:
self.chat_bots[bot_type] = create_bot(bot_type)
return self.chat_bots.get(bot_type)
def reset_bot(self):
"""
重置bot路由

View File

@@ -9,6 +9,7 @@ class ContextType(Enum):
IMAGE = 3 # 图片消息
FILE = 4 # 文件信息
VIDEO = 5 # 视频信息
SHARING = 6 # 分享信息
IMAGE_CREATE = 10 # 创建图片命令
JOIN_GROUP = 20 # 加入群聊

View File

@@ -99,25 +99,26 @@ class ChatChannel(Channel):
match_prefix = check_prefix(content, conf().get("group_chat_prefix"))
match_contain = check_contain(content, conf().get("group_chat_keyword"))
flag = False
if match_prefix is not None or match_contain is not None:
flag = True
if match_prefix:
content = content.replace(match_prefix, "", 1).strip()
if context["msg"].is_at and context["msg"].to_user_id != context["msg"].actual_user_id:
logger.info("[WX]receive group at")
if not conf().get("group_at_off", False):
if context["msg"].to_user_id != context["msg"].actual_user_id:
if match_prefix is not None or match_contain is not None:
flag = True
pattern = f"@{re.escape(self.name)}(\u2005|\u0020)"
subtract_res = re.sub(pattern, r"", content)
if isinstance(context["msg"].at_list, list):
for at in context["msg"].at_list:
pattern = f"@{re.escape(at)}(\u2005|\u0020)"
subtract_res = re.sub(pattern, r"", subtract_res)
if subtract_res == content and context["msg"].self_display_name:
# 前缀移除后没有变化,使用群昵称再次移除
pattern = f"@{re.escape(context['msg'].self_display_name)}(\u2005|\u0020)"
if match_prefix:
content = content.replace(match_prefix, "", 1).strip()
if context["msg"].is_at:
logger.info("[WX]receive group at")
if not conf().get("group_at_off", False):
flag = True
pattern = f"@{re.escape(self.name)}(\u2005|\u0020)"
subtract_res = re.sub(pattern, r"", content)
content = subtract_res
if isinstance(context["msg"].at_list, list):
for at in context["msg"].at_list:
pattern = f"@{re.escape(at)}(\u2005|\u0020)"
subtract_res = re.sub(pattern, r"", subtract_res)
if subtract_res == content and context["msg"].self_display_name:
# 前缀移除后没有变化,使用群昵称再次移除
pattern = f"@{re.escape(context['msg'].self_display_name)}(\u2005|\u0020)"
subtract_res = re.sub(pattern, r"", content)
content = subtract_res
if not flag:
if context["origin_ctype"] == ContextType.VOICE:
logger.info("[WX]receive group voice, but checkprefix didn't match")
@@ -201,8 +202,10 @@ class ChatChannel(Channel):
reply = self._generate_reply(new_context)
else:
return
elif context.type == ContextType.IMAGE or context.type == ContextType.FUNCTION \
or context.type == ContextType.FILE: # 图片/文件消息及函数调用等,当前无默认逻辑
elif context.type == ContextType.IMAGE: # 图片消息,当前仅做下载保存到本地的逻辑
cmsg = context["msg"]
cmsg.prepare()
elif context.type == ContextType.FUNCTION or context.type == ContextType.FILE: # 文件消息及函数调用等,当前无默认逻辑
pass
else:
logger.error("[WX] unknown context type: {}".format(context.type))
@@ -238,7 +241,7 @@ class ChatChannel(Channel):
reply.content = reply_text
elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
reply.content = "[" + str(reply.type) + "]\n" + reply.content
elif reply.type == ReplyType.IMAGE_URL or reply.type == ReplyType.VOICE or reply.type == ReplyType.IMAGE:
elif reply.type == ReplyType.IMAGE_URL or reply.type == ReplyType.VOICE or reply.type == ReplyType.IMAGE or reply.type == ReplyType.FILE or reply.type == ReplyType.VIDEO or reply.type == ReplyType.VIDEO_URL:
pass
else:
logger.error("[WX] unknown reply type: {}".format(reply.type))

View File

@@ -25,7 +25,7 @@ from lib import itchat
from lib.itchat.content import *
@itchat.msg_register([TEXT, VOICE, PICTURE, NOTE])
@itchat.msg_register([TEXT, VOICE, PICTURE, NOTE, ATTACHMENT, SHARING])
def handler_single_msg(msg):
try:
cmsg = WechatMessage(msg, False)
@@ -36,7 +36,7 @@ def handler_single_msg(msg):
return None
@itchat.msg_register([TEXT, VOICE, PICTURE, NOTE], isGroupChat=True)
@itchat.msg_register([TEXT, VOICE, PICTURE, NOTE, ATTACHMENT, SHARING], isGroupChat=True)
def handler_group_msg(msg):
try:
cmsg = WechatMessage(msg, True)
@@ -172,6 +172,8 @@ class WechatChannel(ChatChannel):
elif cmsg.ctype == ContextType.TEXT:
# logger.debug("[WX]receive group msg: {}, cmsg={}".format(json.dumps(cmsg._rawmsg, ensure_ascii=False), cmsg))
pass
elif cmsg.ctype == ContextType.FILE:
logger.debug(f"[WX]receive attachment msg, file_name={cmsg.content}")
else:
logger.debug("[WX]receive group msg: {}".format(cmsg.content))
context = self._compose_context(cmsg.ctype, cmsg.content, isgroup=True, msg=cmsg)
@@ -208,3 +210,24 @@ class WechatChannel(ChatChannel):
image_storage.seek(0)
itchat.send_image(image_storage, toUserName=receiver)
logger.info("[WX] sendImage, receiver={}".format(receiver))
elif reply.type == ReplyType.FILE: # 新增文件回复类型
file_storage = reply.content
itchat.send_file(file_storage, toUserName=receiver)
logger.info("[WX] sendFile, receiver={}".format(receiver))
elif reply.type == ReplyType.VIDEO: # 新增视频回复类型
video_storage = reply.content
itchat.send_video(video_storage, toUserName=receiver)
logger.info("[WX] sendFile, receiver={}".format(receiver))
elif reply.type == ReplyType.VIDEO_URL: # 新增视频URL回复类型
video_url = reply.content
logger.debug(f"[WX] start download video, video_url={video_url}")
video_res = requests.get(video_url, stream=True)
video_storage = io.BytesIO()
size = 0
for block in video_res.iter_content(1024):
size += len(block)
video_storage.write(block)
logger.info(f"[WX] download video success, size={size}, video_url={video_url}")
video_storage.seek(0)
itchat.send_video(video_storage, toUserName=receiver)
logger.info("[WX] sendVideo url={}, receiver={}".format(video_url, receiver))

View File

@@ -7,7 +7,6 @@ from common.tmp_dir import TmpDir
from lib import itchat
from lib.itchat.content import *
class WechatMessage(ChatMessage):
def __init__(self, itchat_msg, is_group=False):
super().__init__(itchat_msg)
@@ -42,6 +41,14 @@ class WechatMessage(ChatMessage):
self.actual_user_nickname = re.findall(r"\"(.*?)\"", itchat_msg["Content"])[0]
else:
raise NotImplementedError("Unsupported note message: " + itchat_msg["Content"])
elif itchat_msg["Type"] == ATTACHMENT:
self.ctype = ContextType.FILE
self.content = TmpDir().path() + itchat_msg["FileName"]
self._prepare_fn = lambda: itchat_msg.download(self.content)
elif itchat_msg["Type"] == SHARING:
self.ctype = ContextType.SHARING
self.content = itchat_msg.get("Url")
else:
raise NotImplementedError("Unsupported message type: Type:{} MsgType:{}".format(itchat_msg["Type"], itchat_msg["MsgType"]))

View File

@@ -49,7 +49,7 @@ class Query:
# New request
if (
from_user not in channel.cache_dict
channel.cache_dict.get(from_user) is None
and from_user not in channel.running
or content.startswith("#")
and message_id not in channel.request_cnt # insert the godcmd
@@ -131,8 +131,10 @@ class Query:
# Only one request can access to the cached data
try:
(reply_type, reply_content) = channel.cache_dict.pop(from_user)
except KeyError:
(reply_type, reply_content) = channel.cache_dict[from_user].pop(0)
if not channel.cache_dict[from_user]: # If popping the message makes the list empty, delete the user entry from cache
del channel.cache_dict[from_user]
except IndexError:
return "success"
if reply_type == "text":
@@ -146,7 +148,7 @@ class Query:
max_split=1,
)
reply_text = splits[0] + continue_text
channel.cache_dict[from_user] = ("text", splits[1])
channel.cache_dict[from_user].append(("text", splits[1]))
logger.info(
"[wechatmp] Request {} do send to {} {}: {}\n{}".format(

View File

@@ -10,6 +10,7 @@ import requests
import web
from wechatpy.crypto import WeChatCrypto
from wechatpy.exceptions import WeChatClientException
from collections import defaultdict
from bridge.context import *
from bridge.reply import *
@@ -20,7 +21,7 @@ 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
from voice.audio_convert import any_to_mp3, split_audio
# If using SSL, uncomment the following lines, and modify the certificate path.
# from cheroot.server import HTTPServer
@@ -46,7 +47,7 @@ class WechatMPChannel(ChatChannel):
self.crypto = WeChatCrypto(token, aes_key, appid)
if self.passive_reply:
# Cache the reply to the user's first message
self.cache_dict = dict()
self.cache_dict = defaultdict(list)
# Record whether the current message is being processed
self.running = set()
# Count the request from wechat official server by message_id
@@ -82,24 +83,28 @@ class WechatMPChannel(ChatChannel):
if reply.type == ReplyType.TEXT or reply.type == ReplyType.INFO or reply.type == ReplyType.ERROR:
reply_text = reply.content
logger.info("[wechatmp] text cached, receiver {}\n{}".format(receiver, reply_text))
self.cache_dict[receiver] = ("text", reply_text)
self.cache_dict[receiver].append(("text", reply_text))
elif reply.type == ReplyType.VOICE:
try:
voice_file_path = reply.content
with open(voice_file_path, "rb") as f:
# support: <2M, <60s, mp3/wma/wav/amr
response = self.client.material.add("voice", f)
logger.debug("[wechatmp] upload voice response: {}".format(response))
# 根据文件大小估计一个微信自动审核的时间,审核结束前返回将会导致语音无法播放,这个估计有待验证
f_size = os.fstat(f.fileno()).st_size
time.sleep(1.0 + 2 * f_size / 1024 / 1024)
# todo check media_id
except WeChatClientException as e:
logger.error("[wechatmp] upload voice failed: {}".format(e))
return
media_id = response["media_id"]
logger.info("[wechatmp] voice uploaded, receiver {}, media_id {}".format(receiver, media_id))
self.cache_dict[receiver] = ("voice", media_id)
voice_file_path = reply.content
duration, files = split_audio(voice_file_path, 60 * 1000)
if len(files) > 1:
logger.info("[wechatmp] voice too long {}s > 60s , split into {} parts".format(duration / 1000.0, len(files)))
for path in files:
# support: <2M, <60s, mp3/wma/wav/amr
try:
with open(path, "rb") as f:
response = self.client.material.add("voice", f)
logger.debug("[wechatmp] upload voice response: {}".format(response))
f_size = os.fstat(f.fileno()).st_size
time.sleep(1.0 + 2 * f_size / 1024 / 1024)
# todo check media_id
except WeChatClientException as e:
logger.error("[wechatmp] upload voice failed: {}".format(e))
return
media_id = response["media_id"]
logger.info("[wechatmp] voice uploaded, receiver {}, media_id {}".format(receiver, media_id))
self.cache_dict[receiver].append(("voice", media_id))
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
img_url = reply.content
@@ -119,7 +124,7 @@ class WechatMPChannel(ChatChannel):
return
media_id = response["media_id"]
logger.info("[wechatmp] image uploaded, receiver {}, media_id {}".format(receiver, media_id))
self.cache_dict[receiver] = ("image", media_id)
self.cache_dict[receiver].append(("image", media_id))
elif reply.type == ReplyType.IMAGE: # 从文件读取图片
image_storage = reply.content
image_storage.seek(0)
@@ -134,7 +139,7 @@ class WechatMPChannel(ChatChannel):
return
media_id = response["media_id"]
logger.info("[wechatmp] image uploaded, receiver {}, media_id {}".format(receiver, media_id))
self.cache_dict[receiver] = ("image", media_id)
self.cache_dict[receiver].append(("image", media_id))
else:
if reply.type == ReplyType.TEXT or reply.type == ReplyType.INFO or reply.type == ReplyType.ERROR:
reply_text = reply.content
@@ -162,13 +167,28 @@ class WechatMPChannel(ChatChannel):
file_name = os.path.basename(file_path)
file_type = "audio/mpeg"
logger.info("[wechatmp] file_name: {}, file_type: {} ".format(file_name, file_type))
# support: <2M, <60s, AMR\MP3
response = self.client.media.upload("voice", (file_name, open(file_path, "rb"), file_type))
logger.debug("[wechatmp] upload voice response: {}".format(response))
media_ids = []
duration, files = split_audio(file_path, 60 * 1000)
if len(files) > 1:
logger.info("[wechatmp] voice too long {}s > 60s , split into {} parts".format(duration / 1000.0, len(files)))
for path in files:
# support: <2M, <60s, AMR\MP3
response = self.client.media.upload("voice", (os.path.basename(path), open(path, "rb"), file_type))
logger.debug("[wechatcom] upload voice response: {}".format(response))
media_ids.append(response["media_id"])
os.remove(path)
except WeChatClientException as e:
logger.error("[wechatmp] upload voice failed: {}".format(e))
return
self.client.message.send_voice(receiver, response["media_id"])
try:
os.remove(file_path)
except Exception:
pass
for media_id in media_ids:
self.client.message.send_voice(receiver, media_id)
time.sleep(1)
logger.info("[wechatmp] Do send voice to {}".format(receiver))
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
img_url = reply.content

View File

@@ -16,6 +16,7 @@ from channel.wework.wework_message import WeworkMessage
from common.singleton import singleton
from common.log import logger
from common.time_check import time_checker
from common.utils import compress_imgfile, fsize
from config import conf
from channel.wework.run import wework
from channel.wework import run
@@ -38,12 +39,25 @@ def download_and_compress_image(url, filename, quality=30):
os.makedirs(directory)
# 下载图片
response = requests.get(url)
image = Image.open(io.BytesIO(response.content))
pic_res = requests.get(url, stream=True)
image_storage = io.BytesIO()
for block in pic_res.iter_content(1024):
image_storage.write(block)
# 压缩图片
image_path = os.path.join(directory, f"{filename}.jpg")
image.save(image_path, "JPEG", quality=quality)
# 检查图片大小并可能进行压缩
sz = fsize(image_storage)
if sz >= 10 * 1024 * 1024: # 如果图片大于 10 MB
logger.info("[wework] image too large, ready to compress, sz={}".format(sz))
image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1)
logger.info("[wework] image compressed, sz={}".format(fsize(image_storage)))
# 将内存缓冲区的指针重置到起始位置
image_storage.seek(0)
# 读取并保存图片
image = Image.open(image_storage)
image_path = os.path.join(directory, f"{filename}.png")
image.save(image_path, "png")
return image_path
@@ -213,6 +227,9 @@ class WeworkChannel(ChatChannel):
@time_checker
@_check
def handle_single(self, cmsg: ChatMessage):
if cmsg.from_user_id == cmsg.to_user_id:
# ignore self reply
return
if cmsg.ctype == ContextType.VOICE:
if not conf().get("speech_recognition"):
return
@@ -302,5 +319,8 @@ class WeworkChannel(ChatChannel):
wework.send_video(receiver, video_path)
logger.info("[WX] sendVideo, receiver={}".format(receiver))
elif reply.type == ReplyType.VOICE:
current_dir = os.getcwd()
voice_file = reply.content.split("/")[-1]
reply.content = os.path.join(current_dir, "tmp", voice_file)
wework.send_file(receiver, reply.content)
logger.info("[WX] sendFile={}, receiver={}".format(reply.content, receiver))

View File

@@ -8,6 +8,7 @@ import pilk
from bridge.context import ContextType
from channel.chat_message import ChatMessage
from common.log import logger
from ntwork.const import send_type
def get_with_retry(get_func, max_retries=5, delay=5):
@@ -39,8 +40,6 @@ def get_room_info(wework, conversation_id):
def cdn_download(wework, message, file_name):
data = message["data"]
url = data["cdn"]["url"]
auth_key = data["cdn"]["auth_key"]
aes_key = data["cdn"]["aes_key"]
file_size = data["cdn"]["size"]
@@ -48,8 +47,32 @@ def cdn_download(wework, message, file_name):
current_dir = os.getcwd()
save_path = os.path.join(current_dir, "tmp", file_name)
result = wework.wx_cdn_download(url, auth_key, aes_key, file_size, save_path)
logger.debug(result)
# 下载保存图片到本地
if "url" in data["cdn"].keys() and "auth_key" in data["cdn"].keys():
url = data["cdn"]["url"]
auth_key = data["cdn"]["auth_key"]
# result = wework.wx_cdn_download(url, auth_key, aes_key, file_size, save_path) # ntwork库本身接口有问题缺失了aes_key这个参数
"""
下载wx类型的cdn文件以https开头
"""
data = {
'url': url,
'auth_key': auth_key,
'aes_key': aes_key,
'size': file_size,
'save_path': save_path
}
result = wework._WeWork__send_sync(send_type.MT_WXCDN_DOWNLOAD_MSG, data) # 直接用wx_cdn_download的接口内部实现来调用
elif "file_id" in data["cdn"].keys():
file_type = 2
file_id = data["cdn"]["file_id"]
result = wework.c2c_cdn_download(file_id, aes_key, file_size, file_type, save_path)
else:
logger.error(f"something is wrong, data: {data}")
return
# 输出下载结果
logger.debug(f"result: {result}")
def c2c_download_and_convert(wework, message, file_name):
@@ -69,6 +92,12 @@ def c2c_download_and_convert(wework, message, file_name):
wav_file = base_name + ".wav"
pilk.silk_to_wav(save_path, wav_file, rate=24000)
# 删除SILK文件
try:
os.remove(save_path)
except Exception as e:
pass
class WeworkMessage(ChatMessage):
def __init__(self, wework_msg, wework, is_group=False):
@@ -157,7 +186,9 @@ class WeworkMessage(ChatMessage):
at_list = tmp_list
logger.debug(f"at_list: {at_list}")
logger.debug(f"nickname: {nickname}")
self.is_at = nickname in at_list
self.is_at = False
if nickname in at_list or login_info['nickname'] in at_list or login_info['username'] in at_list:
self.is_at = True
self.at_list = at_list
# 检查消息内容是否包含@用户名。处理复制粘贴的消息,这类消息可能不会触发@通知,但内容中可能包含 "@用户名"。

View File

@@ -4,8 +4,10 @@ import json
import os
import random
import string
import logging
from typing import Tuple
import bridge.bridge
import plugins
from bridge.bridge import Bridge
from bridge.context import ContextType
@@ -310,6 +312,8 @@ class Godcmd(Plugin):
elif cmd == "reset":
if bottype in [const.OPEN_AI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI, const.BAIDU, const.XUNFEI]:
bot.sessions.clear_session(session_id)
if Bridge().chat_bots.get(bottype):
Bridge().chat_bots.get(bottype).sessions.clear_session(session_id)
channel.cancel_session(session_id)
ok, result = True, "会话已重置"
else:
@@ -339,8 +343,12 @@ class Godcmd(Plugin):
else:
ok, result = False, "当前对话机器人不支持重置会话"
elif cmd == "debug":
logger.setLevel("DEBUG")
ok, result = True, "DEBUG模式已开启"
if logger.getEffectiveLevel() == logging.DEBUG: # 判断当前日志模式是否DEBUG
logger.setLevel(logging.INFO)
ok, result = True, "DEBUG模式已关闭"
else:
logger.setLevel(logging.DEBUG)
ok, result = True, "DEBUG模式已开启"
elif cmd == "plist":
plugins = PluginManager().list_plugins()
ok = True

View File

@@ -2,7 +2,7 @@
import json
import os
import requests
import plugins
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
@@ -51,15 +51,37 @@ class Keyword(Plugin):
content = e_context["context"].content.strip()
logger.debug("[keyword] on_handle_context. content: %s" % content)
if content in self.keyword:
logger.debug(f"[keyword] 匹配到关键字【{content}")
logger.info(f"[keyword] 匹配到关键字【{content}")
reply_text = self.keyword[content]
# 判断匹配内容的类型
if (reply_text.startswith("http://") or reply_text.startswith("https://")) and any(reply_text.endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp"]):
# 如果是以 http:// 或 https:// 开头,且.jpg/.jpeg/.png/.gif结尾,则认为是图片 URL
if (reply_text.startswith("http://") or reply_text.startswith("https://")) and any(reply_text.endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".gif", ".img"]):
# 如果是以 http:// 或 https:// 开头,且".jpg", ".jpeg", ".png", ".gif", ".img"结尾,则认为是图片 URL
reply = Reply()
reply.type = ReplyType.IMAGE_URL
reply.content = reply_text
elif (reply_text.startswith("http://") or reply_text.startswith("https://")) and any(reply_text.endswith(ext) for ext in [".pdf", ".doc", ".docx", ".xls", "xlsx",".zip", ".rar"]):
# 如果是以 http:// 或 https:// 开头,且".pdf", ".doc", ".docx", ".xls", "xlsx",".zip", ".rar"结尾则下载文件到tmp目录并发送给用户
file_path = "tmp"
if not os.path.exists(file_path):
os.makedirs(file_path)
file_name = reply_text.split("/")[-1] # 获取文件名
file_path = os.path.join(file_path, file_name)
response = requests.get(reply_text)
with open(file_path, "wb") as f:
f.write(response.content)
#channel/wechat/wechat_channel.py和channel/wechat_channel.py中缺少ReplyType.FILE类型。
reply = Reply()
reply.type = ReplyType.FILE
reply.content = file_path
elif (reply_text.startswith("http://") or reply_text.startswith("https://")) and any(reply_text.endswith(ext) for ext in [".mp4"]):
# 如果是以 http:// 或 https:// 开头,且".mp4"结尾则下载视频到tmp目录并发送给用户
reply = Reply()
reply.type = ReplyType.VIDEO_URL
reply.content = reply_text
else:
# 否则认为是普通文本
reply = Reply()
@@ -68,7 +90,7 @@ class Keyword(Plugin):
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束并跳过处理context的默认逻辑
def get_help_text(self, **kwargs):
help_text = "关键词过滤"
return help_text

View File

@@ -1,18 +1,18 @@
## 插件说明
基于 LinkAI 提供的知识库、Midjourney绘画等能力对机器人的功能进行增强。平台地址: https://chat.link-ai.tech/console
基于 LinkAI 提供的知识库、Midjourney绘画、文档对话等能力对机器人的功能进行增强。平台地址: https://chat.link-ai.tech/console
## 插件配置
`plugins/linkai` 目录下的 `config.json.template` 配置模板复制为最终生效的 `config.json`:
`plugins/linkai` 目录下的 `config.json.template` 配置模板复制为最终生效的 `config.json`。 (如果未配置则会默认使用`config.json.template`模板中配置,但功能默认关闭,需要可通过指令进行开启)。
以下是配置项说明:
以下是插件配置项说明:
```bash
{
"group_app_map": { # 群聊 和 应用编码 的映射关系
"测试群1": "default", # 表示在名称为 "测试群1" 的群聊中将使用app_code 为 default 的应用
"测试群2": "Kv2fXJcH"
"group_app_map": { # 群聊 和 应用编码 的映射关系
"测试群名称1": "default", # 表示在名称为 "测试群名称1" 的群聊中将使用app_code 为 default 的应用
"测试群名称2": "Kv2fXJcH"
},
"midjourney": {
"enabled": true, # midjourney 绘画开关
@@ -21,19 +21,30 @@
"max_tasks": 3, # 支持同时提交的总任务个数
"max_tasks_per_user": 1, # 支持单个用户同时提交的任务个数
"use_image_create_prefix": true # 是否使用全局的绘画触发词,如果开启将同时支持由`config.json`中的 image_create_prefix 配置触发
},
"summary": {
"enabled": true, # 文档总结和对话功能开关
"group_enabled": true, # 是否支持群聊开启
"max_file_size": 10000 # 文件的大小限制单位KB默认为10M超过该大小直接忽略
}
}
```
根目录 `config.json` 中配置,`API_KEY` 在 [控制台](https://chat.link-ai.tech/console/interface) 中创建并复制过来:
```bash
"linkai_api_key": "Link_xxxxxxxxx"
```
注意:
- 配置项中 `group_app_map` 部分是用于映射群聊与LinkAI平台上的应用 `midjourney` 部分是 mj 画图的配置,可根据需要进行填写,未填写配置时默认不开启相应功能
- 配置项中 `group_app_map` 部分是用于映射群聊与LinkAI平台上的应用 `midjourney` 部分是 mj 画图的配置,`summary` 部分是文档总结及对话功能的配置。三部分的配置相互独立,可按需开启
- 实际 `config.json` 配置中应保证json格式不应携带 '#' 及后面的注释
- 如果是`docker`部署,可通过映射 `plugins/config.json` 到容器中来完成插件配置,参考[文档](https://github.com/zhayujie/chatgpt-on-wechat#3-%E6%8F%92%E4%BB%B6%E4%BD%BF%E7%94%A8)
## 插件使用
> 使用插件中的知识库管理功能需要首先开启`linkai`对话,依赖全局 `config.json` 中的 `use_linkai` 和 `linkai_api_key` 配置而midjourney绘画功能则只需填写 `linkai_api_key` 配置,`use_linkai` 无论是否关闭均可使用。具体可参考 [详细文档](https://link-ai.tech/platform/link-app/wechat)。
> 使用插件中的知识库管理功能需要首先开启`linkai`对话,依赖全局 `config.json` 中的 `use_linkai` 和 `linkai_api_key` 配置而midjourney绘画 和 summary文档总结对话功能则只需填写 `linkai_api_key` 配置,`use_linkai` 无论是否关闭均可使用。具体可参考 [详细文档](https://link-ai.tech/platform/link-app/wechat)。
完成配置后运行项目,会自动运行插件,输入 `#help linkai` 可查看插件功能。
@@ -51,6 +62,8 @@
### 2.Midjourney绘画功能
若未配置 `plugins/linkai/config.json`,默认会关闭画图功能,直接使用 `$mj open` 可基于默认配置直接使用mj画图。
指令格式:
```
@@ -69,7 +82,27 @@
"$mjr 11055927171882"
```
注:
1. 开启 `use_image_create_prefix` 配置后可直接复用全局画图触发词,以"画"开头便可以生成图片。
2. 提示词内容中包含敏感词或者参数格式错误可能导致绘画失败,生成失败不消耗积分
3. 使用 `$mj open``$mj close` 指令可以快速打开和关闭绘图功能
意事项
1. 使用 `$mj open``$mj close` 指令可以快速打开和关闭绘图功能
2. 海外环境部署请将 `img_proxy` 设置为 `false`
3. 开启 `use_image_create_prefix` 配置后可直接复用全局画图触发词,以"画"开头便可以生成图片。
4. 提示词内容中包含敏感词或者参数格式错误可能导致绘画失败,生成失败不消耗积分
5. 若未收到图片可能有两种可能一种是收到了图片但微信发送失败可以在后台日志查看有没有获取到图片url一般原因是受到了wx限制可以稍后重试或更换账号尝试另一种情况是图片提示词存在疑似违规mj不会直接提示错误但会在画图后删掉原图导致程序无法获取这种情况不消耗积分。
### 3.文档总结对话功能
#### 配置
该功能依赖 LinkAI的知识库及对话功能需要在项目根目录的config.json中设置 `linkai_api_key` 同时根据上述插件配置说明在插件config.json添加 `summary` 部分的配置,设置 `enabled` 为 true。
如果不想创建 `plugins/linkai/config.json` 配置,可以直接通过 `$linkai sum open` 指令开启该功能。
#### 使用
功能开启后,向机器人发送 **文件****分享链接卡片** 即可生成摘要,进一步可以与文件或链接的内容进行多轮对话。
#### 限制
1. 文件目前 支持 `txt`, `docx`, `pdf`, `md`, `csv`格式,文件大小由 `max_file_size` 限制最大不超过15M文件字数最多可支持百万字的文件。但不建议上传字数过多的文件一是token消耗过大二是摘要很难覆盖到全部内容只能通过多轮对话来了解细节。
2. 分享链接 目前仅支持 公众号文章,后续会支持更多文章类型及视频链接等
3. 总结及对话的 费用与 LinkAI 3.5-4K 模型的计费方式相同按文档内容的tokens进行计算

View File

@@ -1,7 +1,7 @@
{
"group_app_map": {
"测试群1": "default",
"测试群2": "Kv2fXJcH"
"测试群1": "default",
"测试群2": "Kv2fXJcH"
},
"midjourney": {
"enabled": true,
@@ -10,5 +10,10 @@
"max_tasks": 3,
"max_tasks_per_user": 1,
"use_image_create_prefix": true
},
"summary": {
"enabled": true,
"group_enabled": true,
"max_file_size": 15000
}
}

View File

@@ -4,7 +4,11 @@ from bridge.reply import Reply, ReplyType
from config import global_config
from plugins import *
from .midjourney import MJBot
from .summary import LinkSummary
from bridge import bridge
from common.expired_dict import ExpiredDict
from common import const
import os
@plugins.register(
@@ -12,16 +16,24 @@ from bridge import bridge
desc="A plugin that supports knowledge base and midjourney drawing.",
version="0.1.0",
author="https://link-ai.tech",
desire_priority=99
)
class LinkAI(Plugin):
def __init__(self):
super().__init__()
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
self.config = super().load_config()
if not self.config:
# 未加载到配置,使用模板中的配置
self.config = self._load_config_template()
if self.config:
self.mj_bot = MJBot(self.config.get("midjourney"))
self.sum_config = {}
if self.config:
self.sum_config = self.config.get("summary")
logger.info("[LinkAI] inited")
def on_handle_context(self, e_context: EventContext):
"""
消息处理逻辑
@@ -31,10 +43,39 @@ class LinkAI(Plugin):
return
context = e_context['context']
if context.type not in [ContextType.TEXT, ContextType.IMAGE, ContextType.IMAGE_CREATE]:
if context.type not in [ContextType.TEXT, ContextType.IMAGE, ContextType.IMAGE_CREATE, ContextType.FILE, ContextType.SHARING]:
# filter content no need solve
return
if context.type == ContextType.FILE and self._is_summary_open(context):
# 文件处理
context.get("msg").prepare()
file_path = context.content
if not LinkSummary().check_file(file_path, self.sum_config):
return
_send_info(e_context, "正在为你加速生成摘要,请稍后")
res = LinkSummary().summary_file(file_path)
if not res:
_set_reply_text("总结出现异常,请稍后再试吧", e_context)
return
USER_FILE_MAP[_find_user_id(context) + "-sum_id"] = res.get("summary_id")
_set_reply_text(res.get("summary") + "\n\n💬 发送 \"开启对话\" 可以开启与文件内容的对话", e_context, level=ReplyType.TEXT)
os.remove(file_path)
return
if (context.type == ContextType.SHARING and self._is_summary_open(context)) or \
(context.type == ContextType.TEXT and LinkSummary().check_url(context.content)):
if not LinkSummary().check_url(context.content):
return
_send_info(e_context, "正在为你加速生成摘要,请稍后")
res = LinkSummary().summary_url(context.content)
if not res:
_set_reply_text("总结出现异常,请稍后再试吧", e_context)
return
_set_reply_text(res.get("summary") + "\n\n💬 发送 \"开启对话\" 可以开启与文章内容的对话", e_context, level=ReplyType.TEXT)
USER_FILE_MAP[_find_user_id(context) + "-sum_id"] = res.get("summary_id")
return
mj_type = self.mj_bot.judge_mj_task_type(e_context)
if mj_type:
# MJ作图任务处理
@@ -46,10 +87,38 @@ class LinkAI(Plugin):
self._process_admin_cmd(e_context)
return
if context.type == ContextType.TEXT and context.content == "开启对话" and _find_sum_id(context):
# 文本对话
_send_info(e_context, "正在为你开启对话,请稍后")
res = LinkSummary().summary_chat(_find_sum_id(context))
if not res:
_set_reply_text("开启对话失败,请稍后再试吧", e_context)
return
USER_FILE_MAP[_find_user_id(context) + "-file_id"] = res.get("file_id")
_set_reply_text("💡你可以问我关于这篇文章的任何问题,例如:\n\n" + res.get("questions") + "\n\n发送 \"退出对话\" 可以关闭与文章的对话", e_context, level=ReplyType.TEXT)
return
if context.type == ContextType.TEXT and context.content == "退出对话" and _find_file_id(context):
del USER_FILE_MAP[_find_user_id(context) + "-file_id"]
bot = bridge.Bridge().find_chat_bot(const.LINKAI)
bot.sessions.clear_session(context["session_id"])
_set_reply_text("对话已退出", e_context, level=ReplyType.TEXT)
return
if context.type == ContextType.TEXT and _find_file_id(context):
bot = bridge.Bridge().find_chat_bot(const.LINKAI)
context.kwargs["file_id"] = _find_file_id(context)
reply = bot.reply(context.content, context)
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
return
if self._is_chat_task(e_context):
# 文本对话任务处理
self._process_chat_task(e_context)
# 插件管理功能
def _process_admin_cmd(self, e_context: EventContext):
context = e_context['context']
@@ -70,7 +139,7 @@ class LinkAI(Plugin):
is_open = False
conf()["use_linkai"] = is_open
bridge.Bridge().reset_bot()
_set_reply_text(f"知识库功能{tips_text}", e_context, level=ReplyType.INFO)
_set_reply_text(f"LinkAI对话功能{tips_text}", e_context, level=ReplyType.INFO)
return
if len(cmd) == 3 and cmd[1] == "app":
@@ -91,11 +160,36 @@ class LinkAI(Plugin):
# 保存插件配置
super().save_config(self.config)
_set_reply_text(f"应用设置成功: {app_code}", e_context, level=ReplyType.INFO)
else:
_set_reply_text(f"指令错误,请输入{_get_trigger_prefix()}linkai help 获取帮助", e_context,
level=ReplyType.INFO)
return
if len(cmd) == 3 and cmd[1] == "sum" and (cmd[2] == "open" or cmd[2] == "close"):
# 知识库开关指令
if not _is_admin(e_context):
_set_reply_text("需要管理员权限执行", e_context, level=ReplyType.ERROR)
return
is_open = True
tips_text = "开启"
if cmd[2] == "close":
tips_text = "关闭"
is_open = False
if not self.sum_config:
_set_reply_text(f"插件未启用summary功能请参考以下链添加插件配置\n\nhttps://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/linkai/README.md", e_context, level=ReplyType.INFO)
else:
self.sum_config["enabled"] = is_open
_set_reply_text(f"文章总结功能{tips_text}", e_context, level=ReplyType.INFO)
return
_set_reply_text(f"指令错误,请输入{_get_trigger_prefix()}linkai help 获取帮助", e_context,
level=ReplyType.INFO)
return
def _is_summary_open(self, context) -> bool:
if not self.sum_config or not self.sum_config.get("enabled"):
return False
if not context.kwargs.get("isgroup") and not self.sum_config.get("group_enabled"):
return False
return True
# LinkAI 对话任务处理
def _is_chat_task(self, e_context: EventContext):
context = e_context['context']
@@ -109,7 +203,7 @@ class LinkAI(Plugin):
"""
context = e_context['context']
# 群聊应用管理
group_name = context.kwargs.get("msg").from_user_nickname
group_name = context.get("msg").from_user_nickname
app_code = self._fetch_group_app_code(group_name)
if app_code:
context.kwargs['app_code'] = app_code
@@ -127,7 +221,7 @@ class LinkAI(Plugin):
def get_help_text(self, verbose=False, **kwargs):
trigger_prefix = _get_trigger_prefix()
help_text = "用于集成 LinkAI 提供的知识库、Midjourney绘画等能力。\n\n"
help_text = "用于集成 LinkAI 提供的知识库、Midjourney绘画、文档总结对话等能力。\n\n"
if not verbose:
return help_text
help_text += f'📖 知识库\n - 群聊中指定应用: {trigger_prefix}linkai app 应用编码\n'
@@ -137,8 +231,27 @@ class LinkAI(Plugin):
help_text += f"🎨 绘画\n - 生成: {trigger_prefix}mj 描述词1, 描述词2.. \n - 放大: {trigger_prefix}mju 图片ID 图片序号\n - 变换: {trigger_prefix}mjv 图片ID 图片序号\n - 重置: {trigger_prefix}mjr 图片ID"
help_text += f"\n\n例如:\n\"{trigger_prefix}mj a little cat, white --ar 9:16\"\n\"{trigger_prefix}mju 11055927171882 2\""
help_text += f"\n\"{trigger_prefix}mjv 11055927171882 2\"\n\"{trigger_prefix}mjr 11055927171882\""
help_text += f"\n\n💡 文档总结和对话\n - 开启: {trigger_prefix}linkai sum open\n - 使用: 发送文件、公众号文章等可生成摘要,并与内容对话"
return help_text
def _load_config_template(self):
logger.debug("No LinkAI plugin config.json, use plugins/linkai/config.json.template")
try:
plugin_config_path = os.path.join(self.path, "config.json.template")
if os.path.exists(plugin_config_path):
with open(plugin_config_path, "r", encoding="utf-8") as f:
plugin_conf = json.load(f)
plugin_conf["midjourney"]["enabled"] = False
plugin_conf["summary"]["enabled"] = False
return plugin_conf
except Exception as e:
logger.exception(e)
def _send_info(e_context: EventContext, content: str):
reply = Reply(ReplyType.TEXT, content)
channel = e_context["channel"]
channel.send(reply, e_context["context"])
# 静态方法
def _is_admin(e_context: EventContext) -> bool:
@@ -154,11 +267,26 @@ def _is_admin(e_context: EventContext) -> bool:
return context["receiver"] in global_config["admin_users"]
def _find_user_id(context):
if context["isgroup"]:
return context.kwargs.get("msg").actual_user_id
else:
return context["receiver"]
def _set_reply_text(content: str, e_context: EventContext, level: ReplyType = ReplyType.ERROR):
reply = Reply(level, content)
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
def _get_trigger_prefix():
return conf().get("plugin_trigger_prefix", "$")
def _find_sum_id(context):
return USER_FILE_MAP.get(_find_user_id(context) + "-sum_id")
def _find_file_id(context):
return USER_FILE_MAP.get(_find_user_id(context) + "-file_id")
USER_FILE_MAP = ExpiredDict(conf().get("expires_in_seconds") or 60 * 60)

View File

@@ -5,7 +5,6 @@ import requests
import threading
import time
from bridge.reply import Reply, ReplyType
import aiohttp
import asyncio
from bridge.context import ContextType
from plugins import EventContext, EventAction
@@ -96,7 +95,7 @@ class MJBot:
return TaskType.VARIATION
elif cmd_list[0].lower() == f"{trigger_prefix}mjr":
return TaskType.RESET
elif context.type == ContextType.IMAGE_CREATE and self.config.get("use_image_create_prefix"):
elif context.type == ContextType.IMAGE_CREATE and self.config.get("use_image_create_prefix") and self.config.get("enabled"):
return TaskType.GENERATE
def process_mj_task(self, mj_type: TaskType, e_context: EventContext):

89
plugins/linkai/summary.py Normal file
View File

@@ -0,0 +1,89 @@
import requests
from config import conf
from common.log import logger
import os
class LinkSummary:
def __init__(self):
pass
def summary_file(self, file_path: str):
file_body = {
"file": open(file_path, "rb"),
"name": file_path.split("/")[-1],
}
res = requests.post(url=self.base_url() + "/v1/summary/file", headers=self.headers(), files=file_body, timeout=(5, 180))
return self._parse_summary_res(res)
def summary_url(self, url: str):
body = {
"url": url
}
res = requests.post(url=self.base_url() + "/v1/summary/url", headers=self.headers(), json=body, timeout=(5, 180))
return self._parse_summary_res(res)
def summary_chat(self, summary_id: str):
body = {
"summary_id": summary_id
}
res = requests.post(url=self.base_url() + "/v1/summary/chat", headers=self.headers(), json=body, timeout=(5, 180))
if res.status_code == 200:
res = res.json()
logger.debug(f"[LinkSum] chat open, res={res}")
if res.get("code") == 200:
data = res.get("data")
return {
"questions": data.get("questions"),
"file_id": data.get("file_id")
}
else:
res_json = res.json()
logger.error(f"[LinkSum] summary error, status_code={res.status_code}, msg={res_json.get('message')}")
return None
def _parse_summary_res(self, res):
if res.status_code == 200:
res = res.json()
logger.debug(f"[LinkSum] url summary, res={res}")
if res.get("code") == 200:
data = res.get("data")
return {
"summary": data.get("summary"),
"summary_id": data.get("summary_id")
}
else:
res_json = res.json()
logger.error(f"[LinkSum] summary error, status_code={res.status_code}, msg={res_json.get('message')}")
return None
def base_url(self):
return conf().get("linkai_api_base", "https://api.link-ai.chat")
def headers(self):
return {"Authorization": "Bearer " + conf().get("linkai_api_key")}
def check_file(self, file_path: str, sum_config: dict) -> bool:
file_size = os.path.getsize(file_path) // 1000
if (sum_config.get("max_file_size") and file_size > sum_config.get("max_file_size")) or file_size > 15000:
logger.warn(f"[LinkSum] file size exceeds limit, No processing, file_size={file_size}KB")
return True
suffix = file_path.split(".")[-1]
support_list = ["txt", "csv", "docx", "pdf", "md"]
if suffix not in support_list:
logger.warn(f"[LinkSum] unsupported file, suffix={suffix}, support_list={support_list}")
return False
return True
def check_url(self, url: str):
if not url:
return False
support_list = ["http://mp.weixin.qq.com", "https://mp.weixin.qq.com"]
for support_url in support_list:
if url.strip().startswith(support_url):
return True
logger.debug("[LinkSum] unsupported url, no need to process")
return False

View File

@@ -15,8 +15,8 @@ class Plugin:
"""
# 优先获取 plugins/config.json 中的全局配置
plugin_conf = pconf(self.name)
if not plugin_conf or not conf().get("use_global_plugin_config"):
# 全局配置不存在 或者 未开启全局配置开关,则获取插件目录下的配置
if not plugin_conf:
# 全局配置不存在,则获取插件目录下的配置
plugin_config_path = os.path.join(self.path, "config.json")
if os.path.exists(plugin_config_path):
with open(plugin_config_path, "r", encoding="utf-8") as f:

View File

@@ -23,7 +23,6 @@ web.py
wechatpy
# chatgpt-tool-hub plugin
--extra-index-url https://pypi.python.org/simple
chatgpt_tool_hub==0.4.6
# xunfei spark