From ca219143d0e1210b14ab1dd91f4ddb72ccc69af0 Mon Sep 17 00:00:00 2001 From: Alone <675061370@qq.com> Date: Tue, 22 Oct 2024 15:03:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=BF=E7=94=A8docker=E6=90=AD=E5=BB=BAcow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chatgpt-on-wechat/chat_channel.py | 548 +++++++++++++++++++++++++++ chatgpt-on-wechat/config.json | 36 ++ chatgpt-on-wechat/config.py | 357 +++++++++++++++++ chatgpt-on-wechat/docker-compose.yml | 11 + chatgpt-on-wechat/教程 | 8 + 5 files changed, 960 insertions(+) create mode 100644 chatgpt-on-wechat/chat_channel.py create mode 100644 chatgpt-on-wechat/config.json create mode 100644 chatgpt-on-wechat/config.py create mode 100644 chatgpt-on-wechat/docker-compose.yml create mode 100644 chatgpt-on-wechat/教程 diff --git a/chatgpt-on-wechat/chat_channel.py b/chatgpt-on-wechat/chat_channel.py new file mode 100644 index 0000000..ddc3b6d --- /dev/null +++ b/chatgpt-on-wechat/chat_channel.py @@ -0,0 +1,548 @@ +import os +import re +import threading +import time +import requests +from asyncio import CancelledError +from concurrent.futures import Future, ThreadPoolExecutor + +from bridge.context import * +from bridge.reply import * +from channel.channel import Channel +from common.dequeue import Dequeue +from common import memory +from plugins import * + +try: + from voice.audio_convert import any_to_wav +except Exception as e: + pass + +handler_pool = ThreadPoolExecutor(max_workers=8) # 处理消息的线程池 + + +# 抽象类, 它包含了与消息通道无关的通用处理逻辑 +class ChatChannel(Channel): + name = None # 登录的用户名 + user_id = None # 登录的用户id + futures = {} # 记录每个session_id提交到线程池的future对象, 用于重置会话时把没执行的future取消掉,正在执行的不会被取消 + sessions = {} # 用于控制并发,每个session_id同时只能有一个context在处理 + lock = threading.Lock() # 用于控制对sessions的访问 + + def __init__(self): + _thread = threading.Thread(target=self.consume) + _thread.setDaemon(True) + _thread.start() + + # 根据消息构造context,消息内容相关的触发项写在这里 + def _compose_context(self, ctype: ContextType, content, **kwargs): + context = Context(ctype, content) + context.kwargs = kwargs + # context首次传入时,origin_ctype是None, + # 引入的起因是:当输入语音时,会嵌套生成两个context,第一步语音转文本,第二步通过文本生成文字回复。 + # origin_ctype用于第二步文本回复时,判断是否需要匹配前缀,如果是私聊的语音,就不需要匹配前缀 + if "origin_ctype" not in context: + context["origin_ctype"] = ctype + # context首次传入时,receiver是None,根据类型设置receiver + first_in = "receiver" not in context + # 群名匹配过程,设置session_id和receiver + if first_in: # context首次传入时,receiver是None,根据类型设置receiver + config = conf() + cmsg = context["msg"] + user_data = conf().get_user_data(cmsg.from_user_id) + context["openai_api_key"] = user_data.get("openai_api_key") + context["gpt_model"] = user_data.get("gpt_model") + if context.get("isgroup", False): + group_name = cmsg.other_user_nickname + group_id = cmsg.other_user_id + + group_name_white_list = config.get("group_name_white_list", []) + group_name_keyword_white_list = config.get("group_name_keyword_white_list", []) + if any( + [ + group_name in group_name_white_list, + "ALL_GROUP" in group_name_white_list, + check_contain(group_name, group_name_keyword_white_list), + ] + ): + group_chat_in_one_session = conf().get("group_chat_in_one_session", []) + session_id = cmsg.actual_user_id + if any( + [ + group_name in group_chat_in_one_session, + "ALL_GROUP" in group_chat_in_one_session, + ] + ): + session_id = group_id + else: + logger.debug(f"No need reply, groupName not in whitelist, group_name={group_name}") + return None + context["session_id"] = session_id + context["receiver"] = group_id + else: + context["session_id"] = cmsg.other_user_id + context["receiver"] = cmsg.other_user_id + e_context = PluginManager().emit_event(EventContext(Event.ON_RECEIVE_MESSAGE, {"channel": self, "context": context})) + context = e_context["context"] + if e_context.is_pass() or context is None: + return context + if cmsg.from_user_id == self.user_id and not config.get("trigger_by_self", True): + logger.debug("[chat_channel]self message skipped") + return None + + # 消息内容匹配过程,并处理content + if ctype == ContextType.TEXT: + if first_in and "」\n- - - - - - -" in content: # 初次匹配 过滤引用消息 + logger.debug(content) + logger.debug("[chat_channel]reference query skipped") + return None + + pattern = f"@{re.escape(self.name)}(\u2005|\u0020)" + content_search = 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)" + content_search = re.sub(pattern, r"", content_search) + if content_search == content and context["msg"].self_display_name: + # 前缀移除后没有变化,使用群昵称再次移除 + pattern = f"@{re.escape(context['msg'].self_display_name)}(\u2005|\u0020)" + content_search = re.sub(pattern, r"", content) + + + # 去除字符串开头和结尾的所有空格字符 + content_search = content_search.strip() + # logger.info("[来消息了] content={}, content_search={}".format(content, content_search)) + content_search = process_string(content_search) + + + nick_name_black_list = conf().get("nick_name_black_list", []) + if context.get("isgroup", False): # 群聊 + + if any(content_search.startswith(prefix) for prefix in ["搜剧", "搜", "全网搜"]) and not content_search.startswith("搜索"): + content_search = process_string2(content_search) + user_nickname = context['msg'].actual_user_nickname + reply_text = f"@{user_nickname}" + + + contentSearch = remove_prefix(content_search, ["搜剧", "搜", "全网搜"]).strip() + + def perform_search(): + # 初次搜索 + response_data = search_question(contentSearch) if not content_search.startswith("全网搜") else [] + if not response_data: + # 通知用户深入搜索 + reply_text2 = f"@{user_nickname}\n正在深入搜索,请稍等..." + self._send_reply(context, Reply(ReplyType.TEXT, reply_text2)) + + # 启动线程进行第二次搜索 + def perform_second_search(): + response_data = search_alone(contentSearch) + send_final_reply(response_data, reply_text, context) + + second_search_thread = threading.Thread(target=perform_second_search) + second_search_thread.start() + else: + # 如果第一次搜索找到结果,发送最终回复 + send_final_reply(response_data, reply_text, context) + + def send_final_reply(response_data, reply_text, context): + is_times = 0 + if not response_data: + reply_text_final = f"{reply_text}\n未找到,可换个关键词尝试哦~" + reply_text_final += "\n⚠️宁少写,不多写、错写~" + # reply_text_final += "\n--------------------" + # reply_text_final += "\n可访问以下链接提交资源需求" + # reply_text_final += "\nhttps://pan.xinyuedh.com" + # reply_text_final += "\n--------------------" + # reply_text_final += "\nGPT小助手分享" + # reply_text_final += "\n--------------------" + # reply_text_final += "\nhttps://chat.xinyuedh.com" + else: + reply_text_final = f"{reply_text}\n--------------------" + for item in response_data: + if item.get('is_time') == 1: + reply_text_final += f"\n 🌐️ {item.get('title', '未知标题')}" + is_times += 1 + else: + reply_text_final += f"\n{item.get('title', '未知标题')}" + reply_text_final += f"\n{item.get('url', '未知URL')}" + reply_text_final += "\n--------------------" + + if is_times > 0: + reply_text_final += "\n 🌐️资源来源网络,30分钟后删除" + reply_text_final += "\n--------------------" + else: + reply_text_final += "\n 不是短剧?请尝试:全网搜XX" + reply_text_final += "\n--------------------" + + reply_text_final += "\n欢迎观看!如果喜欢可以喊你的朋友一起来哦" + + reply = Reply(ReplyType.TEXT, reply_text_final) + self._send_reply(context, reply) + + + # 启动线程执行第一次搜索 + first_search_thread = threading.Thread(target=perform_search) + first_search_thread.start() + return None + + # 校验关键字 + match_prefix = check_prefix(content, conf().get("group_chat_prefix")) + match_contain = check_contain(content, conf().get("group_chat_keyword")) + flag = 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 + if match_prefix: + content = content.replace(match_prefix, "", 1).strip() + if context["msg"].is_at: + nick_name = context["msg"].actual_user_nickname + if nick_name and nick_name in nick_name_black_list: + # 黑名单过滤 + logger.warning(f"[chat_channel] Nickname {nick_name} in In BlackList, ignore") + return None + + logger.info("[chat_channel]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) + if subtract_res.startswith("画"): + subtract_res = "生成图片要求如下:\n" + subtract_res[1:] + + 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("[chat_channel]receive group voice, but checkprefix didn't match") + return None + else: # 单聊 + nick_name = context["msg"].from_user_nickname + if nick_name and nick_name in nick_name_black_list: + # 黑名单过滤 + logger.warning(f"[chat_channel] Nickname '{nick_name}' in In BlackList, ignore") + return None + + match_prefix = check_prefix(content, conf().get("single_chat_prefix", [""])) + if match_prefix is not None: # 判断如果匹配到自定义前缀,则返回过滤掉前缀+空格后的内容 + content = content.replace(match_prefix, "", 1).strip() + elif context["origin_ctype"] == ContextType.VOICE: # 如果源消息是私聊的语音消息,允许不匹配前缀,放宽条件 + pass + else: + return None + content = content.strip() + img_match_prefix = check_prefix(content, conf().get("image_create_prefix",[""])) + if img_match_prefix: + content = content.replace(img_match_prefix, "", 1) + context.type = ContextType.IMAGE_CREATE + else: + context.type = ContextType.TEXT + context.content = content.strip() + if "desire_rtype" not in context and conf().get("always_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE: + context["desire_rtype"] = ReplyType.VOICE + elif context.type == ContextType.VOICE: + if "desire_rtype" not in context and conf().get("voice_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE: + context["desire_rtype"] = ReplyType.VOICE + return context + + def _handle(self, context: Context): + if context is None or not context.content: + return + logger.debug("[chat_channel] ready to handle context: {}".format(context)) + # reply的构建步骤 + reply = self._generate_reply(context) + + logger.debug("[chat_channel] ready to decorate reply: {}".format(reply)) + + # reply的包装步骤 + if reply and reply.content: + reply = self._decorate_reply(context, reply) + + # reply的发送步骤 + self._send_reply(context, reply) + + def _generate_reply(self, context: Context, reply: Reply = Reply()) -> Reply: + e_context = PluginManager().emit_event( + EventContext( + Event.ON_HANDLE_CONTEXT, + {"channel": self, "context": context, "reply": reply}, + ) + ) + reply = e_context["reply"] + if not e_context.is_pass(): + logger.debug("[chat_channel] ready to handle context: type={}, content={}".format(context.type, context.content)) + if context.type == ContextType.TEXT or context.type == ContextType.IMAGE_CREATE: # 文字和图片消息 + context["channel"] = e_context["channel"] + reply = super().build_reply_content(context.content, context) + elif context.type == ContextType.VOICE: # 语音消息 + cmsg = context["msg"] + cmsg.prepare() + file_path = context.content + wav_path = os.path.splitext(file_path)[0] + ".wav" + try: + any_to_wav(file_path, wav_path) + except Exception as e: # 转换失败,直接使用mp3,对于某些api,mp3也可以识别 + logger.warning("[chat_channel]any to wav error, use raw path. " + str(e)) + wav_path = file_path + # 语音识别 + reply = super().build_voice_to_text(wav_path) + # 删除临时文件 + try: + os.remove(file_path) + if wav_path != file_path: + os.remove(wav_path) + except Exception as e: + pass + # logger.warning("[chat_channel]delete temp file error: " + str(e)) + + if reply.type == ReplyType.TEXT: + new_context = self._compose_context(ContextType.TEXT, reply.content, **context.kwargs) + if new_context: + reply = self._generate_reply(new_context) + else: + return + elif context.type == ContextType.IMAGE: # 图片消息,当前仅做下载保存到本地的逻辑 + memory.USER_IMAGE_CACHE[context["session_id"]] = { + "path": context.content, + "msg": context.get("msg") + } + elif context.type == ContextType.SHARING: # 分享信息,当前无默认逻辑 + pass + elif context.type == ContextType.FUNCTION or context.type == ContextType.FILE: # 文件消息及函数调用等,当前无默认逻辑 + pass + else: + logger.warning("[chat_channel] unknown context type: {}".format(context.type)) + return + return reply + + def _decorate_reply(self, context: Context, reply: Reply) -> Reply: + if reply and reply.type: + e_context = PluginManager().emit_event( + EventContext( + Event.ON_DECORATE_REPLY, + {"channel": self, "context": context, "reply": reply}, + ) + ) + reply = e_context["reply"] + desire_rtype = context.get("desire_rtype") + if not e_context.is_pass() and reply and reply.type: + if reply.type in self.NOT_SUPPORT_REPLYTYPE: + logger.error("[chat_channel]reply type not support: " + str(reply.type)) + reply.type = ReplyType.ERROR + reply.content = "不支持发送的消息类型: " + str(reply.type) + + if reply.type == ReplyType.TEXT: + reply_text = reply.content + if desire_rtype == ReplyType.VOICE and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE: + reply = super().build_text_to_voice(reply.content) + return self._decorate_reply(context, reply) + if context.get("isgroup", False): + if not context.get("no_need_at", False): + reply_text = "@" + context["msg"].actual_user_nickname + "\n" + reply_text.strip() + reply_text = conf().get("group_chat_reply_prefix", "") + reply_text + conf().get("group_chat_reply_suffix", "") + else: + reply_text = conf().get("single_chat_reply_prefix", "") + reply_text + conf().get("single_chat_reply_suffix", "") + 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 or reply.type == ReplyType.FILE or reply.type == ReplyType.VIDEO or reply.type == ReplyType.VIDEO_URL: + pass + else: + logger.error("[chat_channel] unknown reply type: {}".format(reply.type)) + return + if desire_rtype and desire_rtype != reply.type and reply.type not in [ReplyType.ERROR, ReplyType.INFO]: + logger.warning("[chat_channel] desire_rtype: {}, but reply type: {}".format(context.get("desire_rtype"), reply.type)) + return reply + + def _send_reply(self, context: Context, reply: Reply): + if reply and reply.type: + e_context = PluginManager().emit_event( + EventContext( + Event.ON_SEND_REPLY, + {"channel": self, "context": context, "reply": reply}, + ) + ) + reply = e_context["reply"] + if not e_context.is_pass() and reply and reply.type: + logger.debug("[chat_channel] ready to send reply: {}, context: {}".format(reply, context)) + self._send(reply, context) + + def _send(self, reply: Reply, context: Context, retry_cnt=0): + try: + self.send(reply, context) + except Exception as e: + logger.error("[chat_channel] sendMsg error: {}".format(str(e))) + if isinstance(e, NotImplementedError): + return + logger.exception(e) + if retry_cnt < 2: + time.sleep(3 + 3 * retry_cnt) + self._send(reply, context, retry_cnt + 1) + + def _success_callback(self, session_id, **kwargs): # 线程正常结束时的回调函数 + logger.debug("Worker return success, session_id = {}".format(session_id)) + + def _fail_callback(self, session_id, exception, **kwargs): # 线程异常结束时的回调函数 + logger.exception("Worker return exception: {}".format(exception)) + + def _thread_pool_callback(self, session_id, **kwargs): + def func(worker: Future): + try: + worker_exception = worker.exception() + if worker_exception: + self._fail_callback(session_id, exception=worker_exception, **kwargs) + else: + self._success_callback(session_id, **kwargs) + except CancelledError as e: + logger.info("Worker cancelled, session_id = {}".format(session_id)) + except Exception as e: + logger.exception("Worker raise exception: {}".format(e)) + with self.lock: + self.sessions[session_id][1].release() + + return func + + def produce(self, context: Context): + session_id = context["session_id"] + with self.lock: + if session_id not in self.sessions: + self.sessions[session_id] = [ + Dequeue(), + threading.BoundedSemaphore(conf().get("concurrency_in_session", 4)), + ] + if context.type == ContextType.TEXT and context.content.startswith("#"): + self.sessions[session_id][0].putleft(context) # 优先处理管理命令 + else: + self.sessions[session_id][0].put(context) + + # 消费者函数,单独线程,用于从消息队列中取出消息并处理 + def consume(self): + while True: + with self.lock: + session_ids = list(self.sessions.keys()) + for session_id in session_ids: + context_queue, semaphore = self.sessions[session_id] + if semaphore.acquire(blocking=False): # 等线程处理完毕才能删除 + if not context_queue.empty(): + context = context_queue.get() + logger.debug("[chat_channel] consume context: {}".format(context)) + future: Future = handler_pool.submit(self._handle, context) + future.add_done_callback(self._thread_pool_callback(session_id, context=context)) + if session_id not in self.futures: + self.futures[session_id] = [] + self.futures[session_id].append(future) + elif semaphore._initial_value == semaphore._value + 1: # 除了当前,没有任务再申请到信号量,说明所有任务都处理完毕 + self.futures[session_id] = [t for t in self.futures[session_id] if not t.done()] + assert len(self.futures[session_id]) == 0, "thread pool error" + del self.sessions[session_id] + else: + semaphore.release() + time.sleep(0.1) + + # 取消session_id对应的所有任务,只能取消排队的消息和已提交线程池但未执行的任务 + def cancel_session(self, session_id): + with self.lock: + if session_id in self.sessions: + for future in self.futures[session_id]: + future.cancel() + cnt = self.sessions[session_id][0].qsize() + if cnt > 0: + logger.info("Cancel {} messages in session {}".format(cnt, session_id)) + self.sessions[session_id][0] = Dequeue() + + def cancel_all_session(self): + with self.lock: + for session_id in self.sessions: + for future in self.futures[session_id]: + future.cancel() + cnt = self.sessions[session_id][0].qsize() + if cnt > 0: + logger.info("Cancel {} messages in session {}".format(cnt, session_id)) + self.sessions[session_id][0] = Dequeue() + + +def check_prefix(content, prefix_list): + if not prefix_list: + return None + for prefix in prefix_list: + if content.startswith(prefix): + return prefix + return None + + +def check_contain(content, keyword_list): + if not keyword_list: + return None + for ky in keyword_list: + if content.find(ky) != -1: + return True + return None + + +def remove_prefix(content, prefixes): + for prefix in prefixes: + if content.startswith(prefix): + return content[len(prefix):].strip() + return content.strip() + + + +def process_string(s): + # 判断是否以@开头并且包含"搜"字 + if s.startswith('@') and '搜' in s: + # 找到"搜"字的位置 + index = s.index('搜') + # 去除"搜"字前面的内容 + return s[index:] + else: + return s + +def process_string2(s): + # 判断是否包含@ + if '@' in s: + # 找到@字符的位置 + index = s.index('@') + # 删除包含@在内后面的所有字符 + return s[:index] + else: + return s + + + +def search_question(question): + url = conf().get("duanju_url", "") + '/api/search' + params = { + 'is_time': '1', + 'page_no': '1', + 'page_size': '5', + 'title': question + } + try: + response = requests.get(url, params=params) + response.raise_for_status() # 检查请求是否成功 + responseData = response.json().get('data', {}).get('items', []) + return responseData + except requests.exceptions.RequestException as e: + print(f"Error fetching data: {e}") + return [] + +def search_alone(question): + url = conf().get("duanju_url", "") + '/api/other/all_search' + payload = { + 'title': question + } + try: + response = requests.post(url, json=payload) + response.raise_for_status() + responseData = response.json().get('data', []) + return responseData + except requests.exceptions.RequestException as e: + print(f"Error fetching data: {e}") + return [] \ No newline at end of file diff --git a/chatgpt-on-wechat/config.json b/chatgpt-on-wechat/config.json new file mode 100644 index 0000000..b9ab4a5 --- /dev/null +++ b/chatgpt-on-wechat/config.json @@ -0,0 +1,36 @@ +{ + "debug": true, + "duanju_url": "https://pan.xinyuedh.com", + "channel_type": "wx", + "model": "gpt-4", + "open_ai_api_key": "", + "open_ai_api_base": "", + "text_to_image": "dall-e-3", + "claude_api_key": "YOUR API KEY", + "voice_to_text": "openai", + "text_to_voice": "openai", + "proxy": "", + "hot_reload": true, + "single_chat_prefix": [""], + "single_chat_reply_prefix": "[bot]", + "group_chat_prefix": [ + "@bot" + ], + "group_name_white_list": [ + "测试群群群", + "测试群群群2" + ], + "image_create_prefix": [], + "speech_recognition": true, + "group_speech_recognition": false, + "voice_reply_voice": false, + "conversation_max_tokens": 2500, + "expires_in_seconds": 3600, + "character_desc": "你是基于大语言模型的AI智能助手,旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", + "temperature": 0.7, + "subscribe_msg": "感谢您的关注!\n这里是AI智能助手,可以自由对话。\n支持语音对话。\n支持图片输入。\n支持图片输出,画字开头的消息将按要求创作图片。\n支持tool、角色扮演和文字冒险等丰富的插件。", + "use_linkai": false, + "linkai_api_key": "", + "linkai_app_code": "", + "linkai_api_base": "" +} diff --git a/chatgpt-on-wechat/config.py b/chatgpt-on-wechat/config.py new file mode 100644 index 0000000..8ae6477 --- /dev/null +++ b/chatgpt-on-wechat/config.py @@ -0,0 +1,357 @@ +# encoding:utf-8 + +import json +import logging +import os +import pickle +import copy + +from common.log import logger + +# 将所有可用的配置项写在字典里, 请使用小写字母 +# 此处的配置值无实际意义,程序不会读取此处的配置,仅用于提示格式,请将配置加入到config.json中 +available_setting = { + # 你的心悦搜索网址 + "duanju_url": "", + # openai api配置 + "open_ai_api_key": "", # openai api key + # openai apibase,当use_azure_chatgpt为true时,需要设置对应的api base + "open_ai_api_base": "https://api.openai.com/v1", + "proxy": "", # openai使用的代理 + # chatgpt模型, 当use_azure_chatgpt为true时,其名称为Azure上model deployment名称 + "model": "gpt-3.5-turbo", # 可选择: gpt-4o, pt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini等模型,全部可选模型详见common/const.py文件 + "bot_type": "", # 可选配置,使用兼容openai格式的三方服务时候,需填"chatGPT"。bot具体名称详见common/const.py文件列出的bot_type,如不填根据model名称判断, + "use_azure_chatgpt": False, # 是否使用azure的chatgpt + "azure_deployment_id": "", # azure 模型部署名称 + "azure_api_version": "", # azure api版本 + # Bot触发配置 + "single_chat_prefix": ["bot", "@bot"], # 私聊时文本需要包含该前缀才能触发机器人回复 + "single_chat_reply_prefix": "[bot] ", # 私聊时自动回复的前缀,用于区分真人 + "single_chat_reply_suffix": "", # 私聊时自动回复的后缀,\n 可以换行 + "group_chat_prefix": ["@bot"], # 群聊时包含该前缀则会触发机器人回复 + "no_need_at": False, # 群聊回复时是否不需要艾特 + "group_chat_reply_prefix": "", # 群聊时自动回复的前缀 + "group_chat_reply_suffix": "", # 群聊时自动回复的后缀,\n 可以换行 + "group_chat_keyword": [], # 群聊时包含该关键词则会触发机器人回复 + "group_at_off": False, # 是否关闭群聊时@bot的触发 + "group_name_white_list": ["ChatGPT测试群", "ChatGPT测试群2"], # 开启自动回复的群名称列表 + "group_name_keyword_white_list": [], # 开启自动回复的群名称关键词列表 + "group_chat_in_one_session": ["ChatGPT测试群"], # 支持会话上下文共享的群名称 + "nick_name_black_list": [], # 用户昵称黑名单 + "group_welcome_msg": "", # 配置新人进群固定欢迎语,不配置则使用随机风格欢迎 + "trigger_by_self": False, # 是否允许机器人触发 + "text_to_image": "dall-e-2", # 图片生成模型,可选 dall-e-2, dall-e-3 + # Azure OpenAI dall-e-3 配置 + "dalle3_image_style": "vivid", # 图片生成dalle3的风格,可选有 vivid, natural + "dalle3_image_quality": "hd", # 图片生成dalle3的质量,可选有 standard, hd + # Azure OpenAI DALL-E API 配置, 当use_azure_chatgpt为true时,用于将文字回复的资源和Dall-E的资源分开. + "azure_openai_dalle_api_base": "", # [可选] azure openai 用于回复图片的资源 endpoint,默认使用 open_ai_api_base + "azure_openai_dalle_api_key": "", # [可选] azure openai 用于回复图片的资源 key,默认使用 open_ai_api_key + "azure_openai_dalle_deployment_id":"", # [可选] azure openai 用于回复图片的资源 deployment id,默认使用 text_to_image + "image_proxy": True, # 是否需要图片代理,国内访问LinkAI时需要 + "image_create_prefix": ["画", "看", "找"], # 开启图片回复的前缀 + "concurrency_in_session": 1, # 同一会话最多有多少条消息在处理中,大于1可能乱序 + "image_create_size": "256x256", # 图片大小,可选有 256x256, 512x512, 1024x1024 (dall-e-3默认为1024x1024) + "group_chat_exit_group": False, + # chatgpt会话参数 + "expires_in_seconds": 3600, # 无操作会话的过期时间 + # 人格描述 + "character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", + "conversation_max_tokens": 1000, # 支持上下文记忆的最多字符数 + # chatgpt限流配置 + "rate_limit_chatgpt": 20, # chatgpt的调用频率限制 + "rate_limit_dalle": 50, # openai dalle的调用频率限制 + # chatgpt api参数 参考https://platform.openai.com/docs/api-reference/chat/create + "temperature": 0.9, + "top_p": 1, + "frequency_penalty": 0, + "presence_penalty": 0, + "request_timeout": 180, # chatgpt请求超时时间,openai接口默认设置为600,对于难问题一般需要较长时间 + "timeout": 120, # chatgpt重试超时时间,在这个时间内,将会自动重试 + # Baidu 文心一言参数 + "baidu_wenxin_model": "eb-instant", # 默认使用ERNIE-Bot-turbo模型 + "baidu_wenxin_api_key": "", # Baidu api key + "baidu_wenxin_secret_key": "", # Baidu secret key + "baidu_wenxin_prompt_enabled": False, # Enable prompt if you are using ernie character model + # 讯飞星火API + "xunfei_app_id": "", # 讯飞应用ID + "xunfei_api_key": "", # 讯飞 API key + "xunfei_api_secret": "", # 讯飞 API secret + "xunfei_domain": "", # 讯飞模型对应的domain参数,Spark4.0 Ultra为 4.0Ultra,其他模型详见: https://www.xfyun.cn/doc/spark/Web.html + "xunfei_spark_url": "", # 讯飞模型对应的请求地址,Spark4.0 Ultra为 wss://spark-api.xf-yun.com/v4.0/chat,其他模型参考详见: https://www.xfyun.cn/doc/spark/Web.html + # claude 配置 + "claude_api_cookie": "", + "claude_uuid": "", + # claude api key + "claude_api_key": "", + # 通义千问API, 获取方式查看文档 https://help.aliyun.com/document_detail/2587494.html + "qwen_access_key_id": "", + "qwen_access_key_secret": "", + "qwen_agent_key": "", + "qwen_app_id": "", + "qwen_node_id": "", # 流程编排模型用到的id,如果没有用到qwen_node_id,请务必保持为空字符串 + # 阿里灵积(通义新版sdk)模型api key + "dashscope_api_key": "", + # Google Gemini Api Key + "gemini_api_key": "", + # wework的通用配置 + "wework_smart": True, # 配置wework是否使用已登录的企业微信,False为多开 + # 语音设置 + "speech_recognition": True, # 是否开启语音识别 + "group_speech_recognition": False, # 是否开启群组语音识别 + "voice_reply_voice": False, # 是否使用语音回复语音,需要设置对应语音合成引擎的api key + "always_reply_voice": False, # 是否一直使用语音回复 + "voice_to_text": "openai", # 语音识别引擎,支持openai,baidu,google,azure,xunfei,ali + "text_to_voice": "openai", # 语音合成引擎,支持openai,baidu,google,azure,xunfei,ali,pytts(offline),elevenlabs,edge(online) + "text_to_voice_model": "tts-1", + "tts_voice_id": "alloy", + # baidu 语音api配置, 使用百度语音识别和语音合成时需要 + "baidu_app_id": "", + "baidu_api_key": "", + "baidu_secret_key": "", + # 1536普通话(支持简单的英文识别) 1737英语 1637粤语 1837四川话 1936普通话远场 + "baidu_dev_pid": 1536, + # azure 语音api配置, 使用azure语音识别和语音合成时需要 + "azure_voice_api_key": "", + "azure_voice_region": "japaneast", + # elevenlabs 语音api配置 + "xi_api_key": "", # 获取ap的方法可以参考https://docs.elevenlabs.io/api-reference/quick-start/authentication + "xi_voice_id": "", # ElevenLabs提供了9种英式、美式等英语发音id,分别是“Adam/Antoni/Arnold/Bella/Domi/Elli/Josh/Rachel/Sam” + # 服务时间限制,目前支持itchat + "chat_time_module": False, # 是否开启服务时间限制 + "chat_start_time": "00:00", # 服务开始时间 + "chat_stop_time": "24:00", # 服务结束时间 + # 翻译api + "translate": "baidu", # 翻译api,支持baidu + # baidu翻译api的配置 + "baidu_translate_app_id": "", # 百度翻译api的appid + "baidu_translate_app_key": "", # 百度翻译api的秘钥 + # itchat的配置 + "hot_reload": False, # 是否开启热重载 + # wechaty的配置 + "wechaty_puppet_service_token": "", # wechaty的token + # wechatmp的配置 + "wechatmp_token": "", # 微信公众平台的Token + "wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443 + "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 + # 飞书配置 + "feishu_port": 80, # 飞书bot监听端口 + "feishu_app_id": "", # 飞书机器人应用APP Id + "feishu_app_secret": "", # 飞书机器人APP secret + "feishu_token": "", # 飞书 verification token + "feishu_bot_name": "", # 飞书机器人的名字 + # 钉钉配置 + "dingtalk_client_id": "", # 钉钉机器人Client ID + "dingtalk_client_secret": "", # 钉钉机器人Client Secret + "dingtalk_card_enabled": False, + + # chatgpt指令自定义触发词 + "clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 + # channel配置 + "channel_type": "", # 通道类型,支持:{wx,wxy,terminal,wechatmp,wechatmp_service,wechatcom_app,dingtalk} + "subscribe_msg": "", # 订阅消息, 支持: wechatmp, wechatmp_service, wechatcom_app + "debug": False, # 是否开启debug模式,开启后会打印更多日志 + "appdata_dir": "", # 数据目录 + # 插件配置 + "plugin_trigger_prefix": "$", # 规范插件提供聊天相关指令的前缀,建议不要和管理员指令前缀"#"冲突 + # 是否使用全局插件配置 + "use_global_plugin_config": False, + "max_media_send_count": 3, # 单次最大发送媒体资源的个数 + "media_send_interval": 1, # 发送图片的事件间隔,单位秒 + # 智谱AI 平台配置 + "zhipu_ai_api_key": "", + "zhipu_ai_api_base": "https://open.bigmodel.cn/api/paas/v4", + "moonshot_api_key": "", + "moonshot_base_url": "https://api.moonshot.cn/v1/chat/completions", + # LinkAI平台配置 + "use_linkai": False, + "linkai_api_key": "", + "linkai_app_code": "", + "linkai_api_base": "https://api.link-ai.tech", # linkAI服务地址 + "Minimax_api_key": "", + "Minimax_group_id": "", + "Minimax_base_url": "", +} + + +class Config(dict): + 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 = {} + + def __getitem__(self, key): + if key not in available_setting: + raise Exception("key {} not in available_setting".format(key)) + return super().__getitem__(key) + + def __setitem__(self, key, value): + if key not in available_setting: + raise Exception("key {} not in available_setting".format(key)) + return super().__setitem__(key, value) + + def get(self, key, default=None): + try: + return self[key] + except KeyError as e: + return default + except Exception as e: + raise e + + # Make sure to return a dictionary to ensure atomic + def get_user_data(self, user) -> dict: + if self.user_datas.get(user) is None: + self.user_datas[user] = {} + return self.user_datas[user] + + def load_user_datas(self): + try: + with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "rb") as f: + self.user_datas = pickle.load(f) + logger.info("[Config] User datas loaded.") + except FileNotFoundError as e: + logger.info("[Config] User datas file not found, ignore.") + except Exception as e: + logger.info("[Config] User datas error: {}".format(e)) + self.user_datas = {} + + def save_user_datas(self): + try: + with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "wb") as f: + pickle.dump(self.user_datas, f) + logger.info("[Config] User datas saved.") + except Exception as e: + logger.info("[Config] User datas error: {}".format(e)) + + +config = Config() + + +def drag_sensitive(config): + try: + if isinstance(config, str): + conf_dict: dict = json.loads(config) + conf_dict_copy = copy.deepcopy(conf_dict) + for key in conf_dict_copy: + if "key" in key or "secret" in key: + if isinstance(conf_dict_copy[key], str): + conf_dict_copy[key] = conf_dict_copy[key][0:3] + "*" * 5 + conf_dict_copy[key][-3:] + return json.dumps(conf_dict_copy, indent=4) + + elif isinstance(config, dict): + config_copy = copy.deepcopy(config) + for key in config: + if "key" in key or "secret" in key: + if isinstance(config_copy[key], str): + config_copy[key] = config_copy[key][0:3] + "*" * 5 + config_copy[key][-3:] + return config_copy + except Exception as e: + logger.exception(e) + return config + return config + + +def load_config(): + global config + config_path = "./config.json" + if not os.path.exists(config_path): + logger.info("配置文件不存在,将使用config-template.json模板") + config_path = "./config-template.json" + + config_str = read_file(config_path) + logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str))) + + # 将json字符串反序列化为dict类型 + config = Config(json.loads(config_str)) + + # override config with environment variables. + # Some online deployment platforms (e.g. Railway) deploy project from github directly. So you shouldn't put your secrets like api key in a config file, instead use environment variables to override the default config. + for name, value in os.environ.items(): + name = name.lower() + if name in available_setting: + logger.info("[INIT] override config by environ args: {}={}".format(name, value)) + try: + config[name] = eval(value) + except: + if value == "false": + config[name] = False + elif value == "true": + config[name] = True + else: + config[name] = value + + if config.get("debug", False): + logger.setLevel(logging.DEBUG) + logger.debug("[INIT] set log level to DEBUG") + + logger.info("[INIT] load config: {}".format(drag_sensitive(config))) + + config.load_user_datas() + + +def get_root(): + return os.path.dirname(os.path.abspath(__file__)) + + +def read_file(path): + with open(path, mode="r", encoding="utf-8") as f: + return f.read() + + +def conf(): + return config + + +def get_appdata_dir(): + data_path = os.path.join(get_root(), conf().get("appdata_dir", "")) + if not os.path.exists(data_path): + 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) + + +# global plugin config +plugin_config = {} + + +def write_plugin_config(pconf: dict): + """ + 写入插件全局配置 + :param pconf: 全量插件配置 + """ + global plugin_config + for k in pconf: + plugin_config[k.lower()] = pconf[k] + + +def pconf(plugin_name: str) -> dict: + """ + 根据插件名称获取配置 + :param plugin_name: 插件名称 + :return: 该插件的配置项 + """ + return plugin_config.get(plugin_name.lower()) + + +# 全局配置,用于存放全局生效的状态 +global_config = {"admin_users": []} diff --git a/chatgpt-on-wechat/docker-compose.yml b/chatgpt-on-wechat/docker-compose.yml new file mode 100644 index 0000000..1b6579b --- /dev/null +++ b/chatgpt-on-wechat/docker-compose.yml @@ -0,0 +1,11 @@ +version: '2.0' +services: + chatgpt-on-wechat: + image: zhayujie/chatgpt-on-wechat + container_name: chatgpt-on-wechat + security_opt: + - seccomp:unconfined + volumes: + - ./config.json:/app/config.json + - ./config.py:/app/config.py + - ./chat_channel.py:/app/channel/chat_channel.py diff --git a/chatgpt-on-wechat/教程 b/chatgpt-on-wechat/教程 new file mode 100644 index 0000000..653fa7c --- /dev/null +++ b/chatgpt-on-wechat/教程 @@ -0,0 +1,8 @@ +打开config.json 修改第3行网址即可,其它功能看官方教程 + +在chatgpt-on-wechat所在目录下执行以下命令启动容器: +sudo docker compose up -d + + + +用python搭建的不用换,一段时间后还是会出现无响应断开的情况 \ No newline at end of file