瀏覽代碼

调整

develop
H Vs 3 月之前
父節點
當前提交
bfc7de5348
共有 82 個檔案被更改,包括 4 行新增7480 行删除
  1. +1
    -41
      app.py
  2. +0
    -103
      bridge/bridge.py
  3. +0
    -71
      bridge/context.py
  4. +0
    -31
      bridge/reply.py
  5. +0
    -44
      channel/channel.py
  6. +0
    -45
      channel/channel_factory.py
  7. +0
    -396
      channel/chat_channel.py
  8. +0
    -87
      channel/chat_message.py
  9. +0
    -747
      channel/wechat/wechat_channel.py
  10. +0
    -122
      channel/wechat/wechat_message.py
  11. +0
    -273
      plugins/README.md
  12. +0
    -9
      plugins/__init__.py
  13. +0
    -1
      plugins/banwords/.gitignore
  14. +0
    -27
      plugins/banwords/README.md
  15. +0
    -1
      plugins/banwords/__init__.py
  16. +0
    -100
      plugins/banwords/banwords.py
  17. +0
    -3
      plugins/banwords/banwords.txt.template
  18. +0
    -3
      plugins/banwords/config.json
  19. +0
    -5
      plugins/banwords/config.json.template
  20. +0
    -250
      plugins/banwords/lib/WordsSearch.py
  21. +0
    -30
      plugins/bdunit/README.md
  22. +0
    -1
      plugins/bdunit/__init__.py
  23. +0
    -252
      plugins/bdunit/bdunit.py
  24. +0
    -5
      plugins/bdunit/config.json.template
  25. +0
    -59
      plugins/config.json.template
  26. +0
    -4
      plugins/dungeon/README.md
  27. +0
    -1
      plugins/dungeon/__init__.py
  28. +0
    -106
      plugins/dungeon/dungeon.py
  29. +0
    -55
      plugins/event.py
  30. +0
    -1
      plugins/finish/__init__.py
  31. +0
    -40
      plugins/finish/finish.py
  32. +0
    -18
      plugins/godcmd/README.md
  33. +0
    -1
      plugins/godcmd/__init__.py
  34. +0
    -4
      plugins/godcmd/config.json
  35. +0
    -4
      plugins/godcmd/config.json.template
  36. +0
    -485
      plugins/godcmd/godcmd.py
  37. +0
    -1
      plugins/healthai/__init__.py
  38. +0
    -8
      plugins/healthai/config.json
  39. +0
    -431
      plugins/healthai/healthai.py
  40. +0
    -11
      plugins/healthai/requirements.txt
  41. +0
    -41
      plugins/hello/README.md
  42. +0
    -1
      plugins/hello/__init__.py
  43. +0
    -14
      plugins/hello/config.json.template
  44. +0
    -127
      plugins/hello/hello.py
  45. +0
    -13
      plugins/keyword/README.md
  46. +0
    -1
      plugins/keyword/__init__.py
  47. +0
    -3
      plugins/keyword/config.json
  48. +0
    -5
      plugins/keyword/config.json.template
  49. +0
    -96
      plugins/keyword/keyword.py
  50. 二進制
      plugins/keyword/test-keyword.png
  51. +0
    -109
      plugins/linkai/README.md
  52. +0
    -1
      plugins/linkai/__init__.py
  53. +0
    -20
      plugins/linkai/config.json.template
  54. +0
    -299
      plugins/linkai/linkai.py
  55. +0
    -432
      plugins/linkai/midjourney.py
  56. +0
    -96
      plugins/linkai/summary.py
  57. +0
    -28
      plugins/linkai/utils.py
  58. +0
    -52
      plugins/plugin.py
  59. +0
    -340
      plugins/plugin_manager.py
  60. +0
    -49
      plugins/plugins.json
  61. +0
    -26
      plugins/role/README.md
  62. +0
    -1
      plugins/role/__init__.py
  63. +0
    -202
      plugins/role/role.py
  64. +0
    -431
      plugins/role/roles.json
  65. +0
    -44
      plugins/source.json
  66. +0
    -166
      plugins/tool/README.md
  67. +0
    -1
      plugins/tool/__init__.py
  68. +0
    -11
      plugins/tool/config.json.template
  69. +0
    -248
      plugins/tool/tool.py
  70. +2
    -4
      resources/messages_resource.py
  71. +0
    -49
      translate/baidu/baidu_translate.py
  72. +0
    -6
      translate/factory.py
  73. +0
    -12
      translate/translator.py
  74. +1
    -1
      voice/ali/ali_voice.py
  75. +0
    -55
      voice/baidu/README.md
  76. +0
    -94
      voice/baidu/baidu_voice.py
  77. +0
    -8
      voice/baidu/config.json.template
  78. +0
    -53
      voice/factory.py
  79. +0
    -7
      voice/xunfei/config.json.template
  80. +0
    -209
      voice/xunfei/xunfei_asr.py
  81. +0
    -163
      voice/xunfei/xunfei_tts.py
  82. +0
    -86
      voice/xunfei/xunfei_voice.py

+ 1
- 41
app.py 查看文件

@@ -15,7 +15,6 @@ from config import load_config
from wechat.biz import start_kafka_consumer_thread

from channel import channel_factory

from wechat import gewe_chat

@@ -49,45 +48,6 @@ def worker():



def login_or_reconnect(wxchat:gewe_chat.GeWeChatCom, token_id, app_id, hash_key, is_reconnect=False):
"""
封装微信登录或重连的逻辑
"""
while True:
if is_reconnect:
logger.info("尝试重连...")
else:
logger.info("获取二维码进行登录...")
qr_code = wxchat.get_login_qr_code(token_id, app_id)
base64_string = qr_code.get('qrImgBase64')
uuid = qr_code.get('uuid')

app_id = app_id or qr_code.get('appId')
start_time = time.time()
wxchat.qrCallback(uuid, base64_string)
while True:
# 如果登录超时,重新获取二维码
if time.time() - start_time > 150:
break
res = wxchat.check_login(token_id, app_id, uuid)
flag = res.get('status')
if flag == 2:
logger.info(f"登录成功: {res}")
login_info = res.get('loginInfo', {})
login_info.update({'appId': app_id, 'uuid': uuid, 'tokenId': token_id})
cleaned_login_info = {k: (v if v is not None else '') for k, v in login_info.items()}
redis_helper.redis_helper.set_hash(hash_key, cleaned_login_info)
return login_info
time.sleep(5)





def fetch_and_save_contacts2():
"""
获取联系人列表并保存到缓存
@@ -101,7 +61,7 @@ def fetch_and_save_contacts2():
# 遍历每一个获取到的登录键
for k in login_keys:
r= redis_helper.redis_helper.get_hash(k)
print(r)
# print(r)
token_id = r.get('tokenId')
app_id = r.get('appId')


+ 0
- 103
bridge/bridge.py 查看文件

@@ -1,103 +0,0 @@
from bot.bot_factory import create_bot
from bridge.context import Context
from bridge.reply import Reply
from common import const
from common.log import logger
from common.singleton import singleton
from config import conf
from translate.factory import create_translator
from voice.factory import create_voice


@singleton
class Bridge(object):
def __init__(self):
self.btype = {
"chat": const.CHATGPT,
"voice_to_text": conf().get("voice_to_text", "openai"),
"text_to_voice": conf().get("text_to_voice", "google"),
"translate": conf().get("translate", "baidu"),
}
# 这边取配置的模型
bot_type = conf().get("bot_type")
if bot_type:
self.btype["chat"] = bot_type
else:
model_type = conf().get("model") or const.GPT35
if model_type in ["text-davinci-003"]:
self.btype["chat"] = const.OPEN_AI
if conf().get("use_azure_chatgpt", False):
self.btype["chat"] = const.CHATGPTONAZURE
if model_type in ["wenxin", "wenxin-4"]:
self.btype["chat"] = const.BAIDU
if model_type in ["xunfei"]:
self.btype["chat"] = const.XUNFEI
if model_type in [const.QWEN]:
self.btype["chat"] = const.QWEN
if model_type in [const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]:
self.btype["chat"] = const.QWEN_DASHSCOPE
if model_type and model_type.startswith("gemini"):
self.btype["chat"] = const.GEMINI
if model_type in [const.ZHIPU_AI]:
self.btype["chat"] = const.ZHIPU_AI
if model_type and model_type.startswith("claude-3"):
self.btype["chat"] = const.CLAUDEAPI

if model_type in ["claude"]:
self.btype["chat"] = const.CLAUDEAI

if model_type in ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]:
self.btype["chat"] = const.MOONSHOT

if model_type in ["abab6.5-chat"]:
self.btype["chat"] = const.MiniMax

if conf().get("use_linkai") and conf().get("linkai_api_key"):
self.btype["chat"] = const.LINKAI
if not conf().get("voice_to_text") or conf().get("voice_to_text") in ["openai"]:
self.btype["voice_to_text"] = const.LINKAI
if not conf().get("text_to_voice") or conf().get("text_to_voice") in ["openai", const.TTS_1, const.TTS_1_HD]:
self.btype["text_to_voice"] = const.LINKAI

self.bots = {}
self.chat_bots = {}

# 模型对应的接口
def get_bot(self, typename):
if self.bots.get(typename) is None:
logger.info("create bot {} for {}".format(self.btype[typename], typename))
if typename == "text_to_voice":
self.bots[typename] = create_voice(self.btype[typename])
elif typename == "voice_to_text":
self.bots[typename] = create_voice(self.btype[typename])
elif typename == "chat":
self.bots[typename] = create_bot(self.btype[typename])
elif typename == "translate":
self.bots[typename] = create_translator(self.btype[typename])
return self.bots[typename]

def get_bot_type(self, typename):
return self.btype[typename]

def fetch_reply_content(self, query, context: Context) -> Reply:
return self.get_bot("chat").reply(query, context)

def fetch_voice_to_text(self, voiceFile) -> Reply:
return self.get_bot("voice_to_text").voiceToText(voiceFile)

def fetch_text_to_voice(self, text) -> Reply:
return self.get_bot("text_to_voice").textToVoice(text)

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路由
"""
self.__init__()

+ 0
- 71
bridge/context.py 查看文件

@@ -1,71 +0,0 @@
# encoding:utf-8

from enum import Enum


class ContextType(Enum):
TEXT = 1 # 文本消息
VOICE = 34 # 音频消息
IMAGE = 3 # 图片消息
FILE = 49 # 文件信息
VIDEO = 46 # 视频信息
# SHARING = 6 # 分享信息

# IMAGE_CREATE = 10 # 创建图片命令
# ACCEPT_FRIEND = 19 # 同意好友请求
# JOIN_GROUP = 20 # 加入群聊
# PATPAT = 21 # 拍了拍
# FUNCTION = 22 # 函数调用
# EXIT_GROUP = 23 #退出


def __str__(self):
return self.name


class Context:
def __init__(self, type: ContextType = None, content=None, kwargs=dict()):
self.type = type
self.content = content
self.kwargs = kwargs

def __contains__(self, key):
if key == "type":
return self.type is not None
elif key == "content":
return self.content is not None
else:
return key in self.kwargs

def __getitem__(self, key):
if key == "type":
return self.type
elif key == "content":
return self.content
else:
return self.kwargs[key]

def get(self, key, default=None):
try:
return self[key]
except KeyError:
return default

def __setitem__(self, key, value):
if key == "type":
self.type = value
elif key == "content":
self.content = value
else:
self.kwargs[key] = value

def __delitem__(self, key):
if key == "type":
self.type = None
elif key == "content":
self.content = None
else:
del self.kwargs[key]

def __str__(self):
return "Context(type={}, content={}, kwargs={})".format(self.type, self.content, self.kwargs)

+ 0
- 31
bridge/reply.py 查看文件

@@ -1,31 +0,0 @@
# encoding:utf-8

from enum import Enum


class ReplyType(Enum):
TEXT = 1 # 文本
VOICE = 2 # 音频文件
IMAGE = 3 # 图片文件
IMAGE_URL = 4 # 图片URL
VIDEO_URL = 5 # 视频URL
FILE = 6 # 文件
CARD = 7 # 微信名片,仅支持ntchat
INVITE_ROOM = 8 # 邀请好友进群
INFO = 9
ERROR = 10
TEXT_ = 11 # 强制文本
VIDEO = 12
MINIAPP = 13 # 小程序

def __str__(self):
return self.name


class Reply:
def __init__(self, type: ReplyType = None, content=None):
self.type = type
self.content = content

def __str__(self):
return "Reply(type={}, content={})".format(self.type, self.content)

+ 0
- 44
channel/channel.py 查看文件

@@ -1,44 +0,0 @@
"""
Message sending channel abstract class
"""

from bridge.bridge import Bridge
from bridge.context import Context
from bridge.reply import *


class Channel(object):
channel_type = ""
NOT_SUPPORT_REPLYTYPE = [ReplyType.VOICE, ReplyType.IMAGE]

def startup(self):
"""
init channel
"""
raise NotImplementedError

def handle_text(self, msg):
"""
process received msg
:param msg: message object
"""
raise NotImplementedError

# 统一的发送函数,每个Channel自行实现,根据reply的type字段发送不同类型的消息
def send(self, reply: Reply, context: Context):
"""
send message to user
:param msg: message content
:param receiver: receiver channel account
:return:
"""
raise NotImplementedError

def build_reply_content(self, query, context: Context = None) -> Reply:
return Bridge().fetch_reply_content(query, context)

def build_voice_to_text(self, voice_file) -> Reply:
return Bridge().fetch_voice_to_text(voice_file)

def build_text_to_voice(self, text) -> Reply:
return Bridge().fetch_text_to_voice(text)

+ 0
- 45
channel/channel_factory.py 查看文件

@@ -1,45 +0,0 @@
"""
channel factory
"""
from common import const
from .channel import Channel


def create_channel(channel_type) -> Channel:
"""
create a channel instance
:param channel_type: channel type code
:return: channel instance
"""
ch = Channel()
if channel_type == "wx":
from channel.wechat.wechat_channel import WechatChannel
ch = WechatChannel()
# elif channel_type == "wxy":
# from channel.wechat.wechaty_channel import WechatyChannel
# ch = WechatyChannel()
# elif channel_type == "terminal":
# from channel.terminal.terminal_channel import TerminalChannel
# ch = TerminalChannel()
# elif channel_type == "wechatmp":
# from channel.wechatmp.wechatmp_channel import WechatMPChannel
# ch = WechatMPChannel(passive_reply=True)
# elif channel_type == "wechatmp_service":
# from channel.wechatmp.wechatmp_channel import WechatMPChannel
# ch = WechatMPChannel(passive_reply=False)
# elif channel_type == "wechatcom_app":
# from channel.wechatcom.wechatcomapp_channel import WechatComAppChannel
# ch = WechatComAppChannel()
# elif channel_type == "wework":
# from channel.wework.wework_channel import WeworkChannel
# ch = WeworkChannel()
# elif channel_type == const.FEISHU:
# from channel.feishu.feishu_channel import FeiShuChanel
# ch = FeiShuChanel()
# elif channel_type == const.DINGTALK:
# from channel.dingtalk.dingtalk_channel import DingTalkChanel
# ch = DingTalkChanel()
else:
raise RuntimeError
ch.channel_type = channel_type
return ch

+ 0
- 396
channel/chat_channel.py 查看文件

@@ -1,396 +0,0 @@
import os
import re
import threading
import time
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

nick_name_black_list = conf().get("nick_name_black_list", [])
if context.get("isgroup", False): # 群聊
# 校验关键字
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
self.name = self.name if self.name is not None else "" # 部分渠道self.name可能没有赋值
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)"
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

+ 0
- 87
channel/chat_message.py 查看文件

@@ -1,87 +0,0 @@
"""
本类表示聊天消息,用于对itchat和wechaty的消息进行统一的封装。

填好必填项(群聊6个,非群聊8个),即可接入ChatChannel,并支持插件,参考TerminalChannel

ChatMessage
msg_id: 消息id (必填)
create_time: 消息创建时间

ctype: 消息类型 : ContextType (必填)
content: 消息内容, 如果是声音/图片,这里是文件路径 (必填)

from_user_id: 发送者id (必填)
from_user_nickname: 发送者昵称
to_user_id: 接收者id (必填)
to_user_nickname: 接收者昵称

other_user_id: 对方的id,如果你是发送者,那这个就是接收者id,如果你是接收者,那这个就是发送者id,如果是群消息,那这一直是群id (必填)
other_user_nickname: 同上

is_group: 是否是群消息 (群聊必填)
is_at: 是否被at

- (群消息时,一般会存在实际发送者,是群内某个成员的id和昵称,下列项仅在群消息时存在)
actual_user_id: 实际发送者id (群聊必填)
actual_user_nickname:实际发送者昵称
self_display_name: 自身的展示名,设置群昵称时,该字段表示群昵称

_prepare_fn: 准备函数,用于准备消息的内容,比如下载图片等,
_prepared: 是否已经调用过准备函数
_rawmsg: 原始消息对象

"""


class ChatMessage(object):
msg_id = None
create_time = None

ctype = None
content = None

from_user_id = None
from_user_nickname = None
to_user_id = None
to_user_nickname = None
other_user_id = None
other_user_nickname = None
my_msg = False
self_display_name = None

is_group = False
is_at = False
actual_user_id = None
actual_user_nickname = None
at_list = None

_prepare_fn = None
_prepared = False
_rawmsg = None

def __init__(self, _rawmsg):
self._rawmsg = _rawmsg

def prepare(self):
if self._prepare_fn and not self._prepared:
self._prepared = True
self._prepare_fn()

def __str__(self):
return "ChatMessage: id={}, create_time={}, ctype={}, content={}, from_user_id={}, from_user_nickname={}, to_user_id={}, to_user_nickname={}, other_user_id={}, other_user_nickname={}, is_group={}, is_at={}, actual_user_id={}, actual_user_nickname={}, at_list={}".format(
self.msg_id,
self.create_time,
self.ctype,
self.content,
self.from_user_id,
self.from_user_nickname,
self.to_user_id,
self.to_user_nickname,
self.other_user_id,
self.other_user_nickname,
self.is_group,
self.is_at,
self.actual_user_id,
self.actual_user_nickname,
self.at_list
)

+ 0
- 747
channel/wechat/wechat_channel.py 查看文件

@@ -1,747 +0,0 @@
# encoding:utf-8

"""
wechat channel
"""

import io
import json
import os
import threading
import time
import requests

from bridge.context import *
from bridge.reply import *
from channel.chat_channel import ChatChannel
from channel import chat_channel
from channel.wechat.wechat_message import *
from common.expired_dict import ExpiredDict
from common.log import logger
from common.singleton import singleton
from common.time_check import time_checker
from common.utils import convert_webp_to_png
from config import conf, get_appdata_dir
# from lib import itchat
# from lib.itchat.content import *

# import itchat
# from itchat.content import *

from urllib.parse import urlparse

import asyncio
import threading

from common import kafka_helper, redis_helper

from confluent_kafka import Consumer, KafkaException
import json,time,re
import pickle
from datetime import datetime
import oss2
import random



# from common.kafka_client import KafkaClient


# @itchat.msg_register([TEXT, VOICE, PICTURE, NOTE, ATTACHMENT, SHARING])
# def handler_single_msg(msg):
# try:
# cmsg = WechatMessage(msg, False)
# except NotImplementedError as e:
# logger.debug("[WX]single message {} skipped: {}".format(msg["MsgId"], e))
# return None
# WechatChannel().handle_single(cmsg)
# return None


# @itchat.msg_register([TEXT, VOICE, PICTURE, NOTE, ATTACHMENT, SHARING], isGroupChat=True)
# def handler_group_msg(msg):
# try:
# cmsg = WechatMessage(msg, True)
# except NotImplementedError as e:
# logger.debug("[WX]group message {} skipped: {}".format(msg["MsgId"], e))
# return None
# WechatChannel().handle_group(cmsg)
# return None


# def _check(func):
# def wrapper(self, cmsg: ChatMessage):
# msgId = cmsg.msg_id
# if msgId in self.receivedMsgs:
# logger.info("Wechat message {} already received, ignore".format(msgId))
# return
# self.receivedMsgs[msgId] = True
# create_time = cmsg.create_time # 消息时间戳
# if conf().get("hot_reload") == True and int(create_time) < int(time.time()) - 60: # 跳过1分钟前的历史消息
# logger.debug("[WX]history message {} skipped".format(msgId))
# return
# if cmsg.my_msg and not cmsg.is_group:
# logger.debug("[WX]my message {} skipped".format(msgId))
# return
# return func(self, cmsg)

# return wrapper


# # 可用的二维码生成接口
# # https://api.qrserver.com/v1/create-qr-code/?size=400×400&data=https://www.abc.com
# # https://api.isoyu.com/qr/?m=1&e=L&p=20&url=https://www.abc.com
# def qrCallback(uuid, status, qrcode):
# # logger.debug("qrCallback: {} {}".format(uuid,status))
# if status == "0":
# try:
# from PIL import Image

# img = Image.open(io.BytesIO(qrcode))
# _thread = threading.Thread(target=img.show, args=("QRCode",))
# _thread.setDaemon(True)
# _thread.start()
# except Exception as e:
# pass

# import qrcode

# url = f"https://login.weixin.qq.com/l/{uuid}"

# qr_api1 = "https://api.isoyu.com/qr/?m=1&e=L&p=20&url={}".format(url)
# qr_api2 = "https://api.qrserver.com/v1/create-qr-code/?size=400×400&data={}".format(url)
# qr_api3 = "https://api.pwmqr.com/qrcode/create/?url={}".format(url)
# qr_api4 = "https://my.tv.sohu.com/user/a/wvideo/getQRCode.do?text={}".format(url)
# print("You can also scan QRCode in any website below:")
# print(qr_api3)
# print(qr_api4)
# print(qr_api2)
# print(qr_api1)
# _send_qr_code([qr_api3, qr_api4, qr_api2, qr_api1])
# qr = qrcode.QRCode(border=1)
# qr.add_data(url)
# qr.make(fit=True)
# qr.print_ascii(invert=True)


@singleton
class WechatChannel(ChatChannel):
NOT_SUPPORT_REPLYTYPE = []

def __init__(self):
super().__init__()
self.receivedMsgs = ExpiredDict(conf().get("expires_in_seconds", 3600))
self.auto_login_times = 0

def startup(self):
try:
print('启动')
# itchat.instance.receivingRetryCount = 600 # 修改断线超时时间
# # login by scan QRCode
# hotReload = conf().get("hot_reload", False)
# status_path = os.path.join(get_appdata_dir(), "itchat","itchat.pkl")
# # with open(status_path, 'rb') as file:
# # data = pickle.load(file)
# # logger.info(data)
# itchat.auto_login(
# enableCmdQR=2,
# hotReload=hotReload,
# statusStorageDir=status_path,
# qrCallback=qrCallback,
# exitCallback=self.exitCallback,
# loginCallback=self.loginCallback
# )
# self.user_id = itchat.instance.storageClass.userName
# self.name = itchat.instance.storageClass.nickName
# logger.info("Wechat login success, user_id: {}, nickname: {}".format(self.user_id, self.name))

# # 创建一个线程来运行 consume_messages
# kafka_thread = threading.Thread(target=kafka_helper.kafka_client.consume_messages, args=(wx_messages_process_callback, self.name))

# kafka_thread.start()
# logger.info("启动kafka")

# # 好友定时同步
# agent_nickname=self.name
# friend_thread =threading.Thread(target=ten_mins_change_save_friends, args=(agent_nickname,))
# friend_thread.start()

# # 立刻同步
# agent_info=fetch_agent_info(agent_nickname)
# agent_tel=agent_info.get("agent_tel",None)
# # friends=itchat.get_contact(update=True)[1:]
# friends=itchat.get_friends(update=True)[1:]
# save_friends_to_redis(agent_tel,agent_nickname, friends)
# logger.info("启动好友同步")
# # start message listener

# logger.info("启动itchat")
# itchat.run()

except Exception as e:
logger.exception(e)

# def exitCallback(self):
# print('主动退出')
# try:
# from common.linkai_client import chat_client
# if chat_client.client_id and conf().get("use_linkai"):
# print('退出')
# _send_logout()
# time.sleep(2)
# self.auto_login_times += 1
# if self.auto_login_times < 100:
# chat_channel.handler_pool._shutdown = False
# self.startup()
# except Exception as e:
# pass

# def loginCallback(self):
# logger.debug("Login success")
# print('登录成功')
# # 同步
# _send_login_success()

# # handle_* 系列函数处理收到的消息后构造Context,然后传入produce函数中处理Context和发送回复
# # Context包含了消息的所有信息,包括以下属性
# # type 消息类型, 包括TEXT、VOICE、IMAGE_CREATE
# # content 消息内容,如果是TEXT类型,content就是文本内容,如果是VOICE类型,content就是语音文件名,如果是IMAGE_CREATE类型,content就是图片生成命令
# # kwargs 附加参数字典,包含以下的key:
# # session_id: 会话id
# # isgroup: 是否是群聊
# # receiver: 需要回复的对象
# # msg: ChatMessage消息对象
# # origin_ctype: 原始消息类型,语音转文字后,私聊时如果匹配前缀失败,会根据初始消息是否是语音来放宽触发规则
# # desire_rtype: 希望回复类型,默认是文本回复,设置为ReplyType.VOICE是语音回复
# @time_checker
# @_check
# def handle_single(self, cmsg: ChatMessage):
# # filter system message
# if cmsg.other_user_id in ["weixin"]:
# return
# if cmsg.ctype == ContextType.VOICE:
# if conf().get("speech_recognition") != True:
# return
# logger.debug("[WX]receive voice msg: {}".format(cmsg.content))
# elif cmsg.ctype == ContextType.IMAGE:
# logger.debug("[WX]receive image msg: {}".format(cmsg.content))
# elif cmsg.ctype == ContextType.PATPAT:
# logger.debug("[WX]receive patpat msg: {}".format(cmsg.content))
# elif cmsg.ctype == ContextType.TEXT:
# logger.debug("[WX]receive text msg: {}, cmsg={}".format(json.dumps(cmsg._rawmsg, ensure_ascii=False), cmsg))
# # content = cmsg.content # 消息内容
# # from_user_nickname = cmsg.from_user_nickname # 发送方昵称
# # to_user_nickname = cmsg.to_user_nickname # 接收方昵称

# # wx_content_dialogue_message=[{"type": "text", "text": content}]
# # message=dialogue_message(from_user_nickname,to_user_nickname,wx_content_dialogue_message)
# # kafka_helper.kafka_client.produce_message(message)
# # logger.info("发送对话 %s", json.dumps(message, ensure_ascii=False))

# input_content = cmsg.content
# input_from_user_nickname = cmsg.from_user_nickname
# input_to_user_nickname = cmsg.to_user_nickname

# input_wx_content_dialogue_message=[{"type": "text", "text": input_content}]
# input_message=dialogue_message(input_from_user_nickname,input_to_user_nickname,input_wx_content_dialogue_message)
# kafka_helper.kafka_client.produce_message(input_message)
# logger.info("发送对话 %s",input_message)

# else:
# logger.debug("[WX]receive msg: {}, cmsg={}".format(cmsg.content, cmsg))
# context = self._compose_context(cmsg.ctype, cmsg.content, isgroup=False, msg=cmsg)
# if context:
# self.produce(context)

# @time_checker
# @_check
# def handle_group(self, cmsg: ChatMessage):
# if cmsg.ctype == ContextType.VOICE:
# if conf().get("group_speech_recognition") != True:
# return
# logger.debug("[WX]receive voice for group msg: {}".format(cmsg.content))
# elif cmsg.ctype == ContextType.IMAGE:
# logger.debug("[WX]receive image for group msg: {}".format(cmsg.content))
# elif cmsg.ctype in [ContextType.JOIN_GROUP, ContextType.PATPAT, ContextType.ACCEPT_FRIEND, ContextType.EXIT_GROUP]:
# logger.debug("[WX]receive note msg: {}".format(cmsg.content))
# 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, no_need_at=conf().get("no_need_at", False))
# if context:
# self.produce(context)

# # 统一的发送函数,每个Channel自行实现,根据reply的type字段发送不同类型的消息
# def send(self, reply: Reply, context: Context):
receiver = context["receiver"]
if reply.type == ReplyType.TEXT:
sent_res=itchat.send(reply.content, toUserName=receiver)
logger.info("[WX] sendMsg={}, receiver={} {}".format(reply, receiver,sent_res.get('BaseResponse',{}).get('RawMsg')))
msg: ChatMessage = context["msg"]
is_group=msg.is_group
if not is_group:
# 响应用户
output_content=reply.content
output_from_user_nickname=msg.to_user_nickname # 回复翻转
output_to_user_nickname=msg.from_user_nickname # 回复翻转

output_wx_content_dialogue_message=[{"type": "text", "text": output_content}]
output_message=dialogue_message(output_from_user_nickname,output_to_user_nickname,output_wx_content_dialogue_message)
kafka_helper.kafka_client.produce_message(output_message)
logger.info("发送对话 %s", output_message)
elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
itchat.send(reply.content, toUserName=receiver)
logger.info("[WX] sendMsg={}, receiver={}".format(reply, receiver))
elif reply.type == ReplyType.VOICE:
itchat.send_file(reply.content, toUserName=receiver)
logger.info("[WX] sendFile={}, receiver={}".format(reply.content, receiver))
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
img_url = reply.content
logger.debug(f"[WX] start download image, img_url={img_url}")
pic_res = requests.get(img_url, stream=True)
image_storage = io.BytesIO()
size = 0
for block in pic_res.iter_content(1024):
size += len(block)
image_storage.write(block)
logger.info(f"[WX] download image success, size={size}, img_url={img_url}")
image_storage.seek(0)
if ".webp" in img_url:
try:
image_storage = convert_webp_to_png(image_storage)
except Exception as e:
logger.error(f"Failed to convert image: {e}")
return
itchat.send_image(image_storage, toUserName=receiver)
logger.info("[WX] sendImage url={}, receiver={}".format(img_url, receiver))
elif reply.type == ReplyType.IMAGE: # 从文件读取图片
image_storage = reply.content
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))


# msg: ChatMessage = context["msg"]
# # content=msg["content"]
# is_group=msg.is_group
# if not is_group:
# # print(f'响应:{content}')
# # 用户输入
# # input_content=msg.content
# # input_from_user_nickname=msg.from_user_nickname
# # input_to_user_nickname=msg.to_user_nickname

# # input_wx_content_dialogue_message=[{"type": "text", "text": input_content}]
# # input_message=dialogue_message(input_from_user_nickname,input_to_user_nickname,input_wx_content_dialogue_message)
# # kafka_helper.kafka_client.produce_message(input_message)
# # logger.info("发送对话 %s", json.dumps(input_message, separators=(',', ':'), ensure_ascii=False))

# # 响应用户
# output_content=reply.content
# output_from_user_nickname=msg.to_user_nickname # 回复翻转
# output_to_user_nickname=msg.from_user_nickname # 回复翻转

# output_wx_content_dialogue_message=[{"type": "file", "text": output_content}]
# output_message=dialogue_message(output_from_user_nickname,output_to_user_nickname,output_wx_content_dialogue_message)
# kafka_helper.kafka_client.produce_message(output_message)
# logger.info("发送对话 %s", output_message)

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

# def _send_login_success():
# try:
# from common.linkai_client import chat_client
# if chat_client.client_id:
# chat_client.send_login_success()

# except Exception as e:
# pass


# def _send_logout():
# try:
# from common.linkai_client import chat_client
# if chat_client.client_id:
# chat_client.send_logout()
# except Exception as e:
# pass


# def _send_qr_code(qrcode_list: list):
# try:
# from common.linkai_client import chat_client
# if chat_client.client_id:
# chat_client.send_qrcode(qrcode_list)
# except Exception as e:
# pass


# def clean_json_string(json_str):

# # 删除所有控制字符(非打印字符),包括换行符、回车符等
# return re.sub(r'[\x00-\x1f\x7f]', '', json_str)

# def save_friends_to_redis(agent_tel,agent_nickname, friends):
# contact_list = []
# for friend in friends:
# friend_data = {
# "UserName": friend.UserName,
# "NickName": friend.NickName,
# "Signature": friend.Signature,
# "Province": friend.Province,
# "City": friend.City,
# "Sex": str(friend.Sex), # 性别可转换为字符串存储
# "Alias": friend.Alias
# }
# contact_list.append(friend_data) # 将每个朋友的信息加入到列表中

# agent_contact_list = {
# "AgentTel":agent_tel,
# "agent_nick_name": agent_nickname,
# "contact_list": contact_list # 将朋友列表添加到字典中
# }
# # 将联系人信息保存到 Redis,使用一个合适的 key
# hash_key = f"__AI_OPS_WX__:CONTACTLIST"
# redis_helper.redis_helper.update_hash_field(hash_key, agent_tel, json.dumps(agent_contact_list, ensure_ascii=False)) # 设置有效期为 600 秒

# def hourly_change_save_friends(agent_nickname):
# last_hour = datetime.now().hour # 获取当前小时
# while True:
# current_hour = datetime.now().hour
# if current_hour != last_hour: # 检测小时是否变化
# friends=itchat.get_friends(update=True)[1:]

# agent_info=fetch_agent_info(agent_nickname)
# agent_tel=agent_info.get("agent_tel",None)
# save_friends_to_redis(agent_tel,agent_nickname, friends)
# last_hour = current_hour
# time.sleep(1) # 每秒检查一次

# def ten_mins_change_save_friends(agent_nickname):
# last_check_minute = datetime.now().minute # 获取当前分钟
# while True:
# current_minute = datetime.now().minute
# if current_minute % 10 == 0 and current_minute != last_check_minute: # 检测每10分钟变化
# friends = itchat.get_friends(update=True)[1:]

# agent_info = fetch_agent_info(agent_nickname)
# agent_tel = agent_info.get("agent_tel", None)
# save_friends_to_redis(agent_tel, agent_nickname, friends)
# last_check_minute = current_minute # 更新最后检查的分钟

# time.sleep(60) # 每分钟检查一次

# def wx_messages_process_callback(user_nickname,message):
# """
# 处理消费到的 Kafka 消息(基础示例)
# :param message: Kafka 消费到的消息内容
# """
# # print(user_nickname)
# # print(f"Processing message: {message}")
# # return True

# msg_content= message
# cleaned_content = clean_json_string(msg_content)
# content=json.loads(cleaned_content)
# data = content.get("data", {})
# msg_type_data=data.get("msg_type",None)
# content_data = data.get("content",{})
# agent_nickname_data=content_data.get("agent_nickname",None)
# agent_tel=content_data.get("agent_tel",None)

# if user_nickname == agent_nickname_data and msg_type_data=='group-sending':
# friends=itchat.get_friends(update=True)[1:]
# contact_list_content_data=content_data.get("contact_list",None)

# # 更新好友缓存
# save_friends_to_redis(agent_tel,agent_nickname_data,friends)

# # 遍历要群发的好友
# for contact in contact_list_content_data:
# nickname = contact.get("nickname",None)
# if(nickname not in [friend['NickName'] for friend in friends]):
# logger.warning(f'微信中没有 {nickname} 的昵称,将不会发送消息')

# for friend in friends:
# if friend.get("NickName",None) == nickname:
# wx_content_list=content_data.get("wx_content",[])
# for wx_content in wx_content_list:
# # 处理文字
# if wx_content.get("type",None) == 'text':
# wx_content_text=wx_content.get("text",None)
# sent_res=itchat.send(wx_content_text, toUserName=friend.get("UserName",None))
# logger.info(f"{user_nickname} 向 {nickname} 发送文字【 {wx_content_text} 】 {sent_res.get('BaseResponse',{}).get('RawMsg')}")

# # // 发送kafka
# wx_content_dialogue_message=[{"type": "text", "text": wx_content_text}]
# message=dialogue_message(agent_nickname_data,friend.get("NickName",None),wx_content_dialogue_message)
# kafka_helper.kafka_client.produce_message(message)
# logger.info("发送对话 %s",message)
# # 等待随机时间
# time.sleep(random.uniform(5, 15))
# # 处理图片
# elif wx_content.get("type",None) == 'image_url':
# print('发送图片')
# image_url= wx_content.get("image_url",{})
# url=image_url.get("url",None)
# # 网络图片
# logger.debug(f"[WX] start download image, img_url={url}")
# pic_res = requests.get(url, stream=True)
# image_storage = io.BytesIO()
# size = 0
# for block in pic_res.iter_content(1024):
# size += len(block)
# image_storage.write(block)
# logger.info(f"[WX] download image success, size={size}, img_url={url}")
# image_storage.seek(0)
# if ".webp" in url:
# try:
# image_storage = convert_webp_to_png(image_storage)
# except Exception as e:
# logger.error(f"Failed to convert image: {e}")
# return

# sent_res=itchat.send_image(image_storage, toUserName=friend.get("UserName",None))
# logger.info(f"{user_nickname} 向 {nickname} 发送图片【 {url} 】{sent_res.get('BaseResponse',{}).get('RawMsg')}")
# # // 发送kafka
# wx_content_dialogue_message=[{"type": "image_url", "image_url": {"url": url}}]
# message=dialogue_message(agent_nickname_data,friend.get("NickName",None),wx_content_dialogue_message)
# kafka_helper.kafka_client.produce_message(message)
# logger.info("发送对话 %s",message)
# # 等待随机时间
# time.sleep(random.uniform(5, 15))
# #处理文件
# elif wx_content.get("type",None) == 'file':
# print('处理文件')
# file_url= wx_content.get("file_url",{})
# url=file_url.get("url",None)

# # 提取路径部分
# parsed_url = urlparse(url).path

# # 获取文件名和扩展名
# filename = os.path.basename(parsed_url) # 文件名(包含扩展名)
# name, ext = os.path.splitext(filename) # 分离文件名和扩展名
# if ext in ['.pdf']:
# print('处理PDF文件')

# tmp_file_path=save_to_local_from_url(url)

# sent_res=itchat.send_file(tmp_file_path, toUserName=friend.get("UserName",None))
# logger.info(f'删除本地{ext}文件: {tmp_file_path}')
# os.remove(tmp_file_path)
# logger.info(f"{user_nickname} 向 {nickname} 发送 {ext} 文件【 {url} 】{sent_res.get('BaseResponse',{}).get('RawMsg')}")
# # // 发送kafka
# wx_content_dialogue_message=[{"type": "file", "file_url": {"url": url}}]
# message=dialogue_message(agent_nickname_data,friend.get("NickName",None),wx_content_dialogue_message)
# kafka_helper.kafka_client.produce_message(message)
# logger.info("发送对话 %s",message)
# # 等待随机时间
# time.sleep(random.uniform(5, 15))

# elif ext in ['.mp4']:

# print('处理MP4文件')
# tmp_file_path=save_to_local_from_url(url)
# itchat.send_file(tmp_file_path, toUserName=friend.get("UserName",None))
# logger.info(f'删除本地{ext}文件: {tmp_file_path}')
# os.remove(tmp_file_path)
# logger.info(f"{user_nickname} 向 {nickname} 发送 {ext} 文件【 {url} 】")
# # // 发送kafka
# wx_content_dialogue_message=[{"type": "file", "file_url": {"url": url}}]
# message=dialogue_message(agent_nickname_data,friend.get("NickName",None),wx_content_dialogue_message)
# kafka_helper.kafka_client.produce_message(message)
# logger.info("发送对话 %s",message)
# # 等待随机时间
# time.sleep(random.uniform(5, 15))
# else:
# logger.warning(f'暂不支持 {ext} 文件的处理')
# return True
# else:
# return False




# def dialogue_message(nickname_from,nickname_to,wx_content):
# """
# 构造消息的 JSON 数据
# :param contents: list,包含多个消息内容,每个内容为字典,如:
# [{"type": "text", "text": "AAAAAAA"},
# {"type": "image_url", "image_url": {"url": "https://AAAAA.jpg"}},
# {"type":"file","file_url":{"url":"https://AAAAA.pdf"}}
# ]
# :return: JSON 字符串
# """

# # 获取当前时间戳,精确到毫秒
# current_timestamp = int(time.time() * 1000)

# # 获取当前时间,格式化为 "YYYY-MM-DD HH:MM:SS"
# current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
# # 构造 JSON 数据
# data = {
# "messageId": str(current_timestamp),
# "topic": "topic.aiops.wx",
# "time": current_time,
# "data": {
# "msg_type": "dialogue",
# "content": {
# "nickname_from": nickname_from,
# "nickname_to": nickname_to,
# "wx_content":wx_content
# }
# }
# }

# return json.dumps(data, separators=(',', ':'), ensure_ascii=False)

# def fetch_agent_info(agent_nickname):

# if os.environ.get('environment', 'default')=='default':
# return {
# "agent_nickname": agent_nickname,
# "agent_tel": "19200137635"
# }

# aiops_api=conf().get("aiops_api")
# # 定义请求URL
# url = f"{aiops_api}/business/Agent/smartinfobyname"
# # 定义请求头
# headers = {
# "accept": "*/*",
# "Content-Type": "application/json"
# }
# # 定义请求数据
# data = {
# "smartName": agent_nickname
# }
# try:
# # 发送POST请求
# response = requests.post(url, headers=headers, data=json.dumps(data))
# # 确认响应状态码
# if response.status_code == 200:
# response_data = response.json()
# if response_data.get("code") == 200:
# # 提取 smartName 和 smartPhone
# data = response_data.get("data", {})
# return {
# "agent_nickname": data.get("smartName"),
# "agent_tel": data.get("smartPhone")
# }
# else:
# logger.error(f"API 返回错误信息: {response_data.get('msg')}")
# return None
# else:
# logger.error(f"请求失败,状态码:{response.status_code}")
# return None
# except Exception as e:
# logger.error(f"请求出错: {e}")
# return None


# def save_to_local_from_url(url):
# '''
# 从url保存到本地tmp目录
# '''

# parsed_url = urlparse(url)
# # 从 URL 提取文件名
# filename = os.path.basename(parsed_url.path)
# # tmp_dir = os.path(__file__) # 获取系统临时目录
# # print(tmp_dir)
# tmp_file_path = os.path.join(os.getcwd(),'tmp', filename) # 拼接完整路径

# # 检查是否存在同名文件
# if os.path.exists(tmp_file_path):
# logger.info(f"文件已存在,将覆盖:{tmp_file_path}")

# # 下载文件并保存到临时目录
# response = requests.get(url, stream=True)
# with open(tmp_file_path, 'wb') as f:
# for chunk in response.iter_content(chunk_size=1024):
# if chunk: # 检查是否有内容
# f.write(chunk)

# return tmp_file_path

# def upload_oss(access_key_id, access_key_secret, endpoint, bucket_name, local_file_path, oss_file_name, expiration_days=7):
# """
# 上传文件到阿里云OSS并设置生命周期规则,同时返回文件的公共访问地址。
# :param access_key_id: 阿里云AccessKey ID
# :param access_key_secret: 阿里云AccessKey Secret
# :param endpoint: OSS区域对应的Endpoint
# :param bucket_name: OSS中的Bucket名称
# :param local_file_path: 本地文件路径
# :param oss_file_name: OSS中的文件存储路径
# :param expiration_days: 文件保存天数,默认7天后删除
# :return: 文件的公共访问地址
# """
# # 创建Bucket实例
# auth = oss2.Auth(access_key_id, access_key_secret)
# bucket = oss2.Bucket(auth, endpoint, bucket_name)

# ### 1. 设置生命周期规则 ###
# rule_id = f'delete_after_{expiration_days}_days' # 规则ID
# prefix = oss_file_name.split('/')[0] + '/' # 设置规则应用的前缀为文件所在目录

# # 定义生命周期规则
# rule = oss2.models.LifecycleRule(rule_id, prefix, status=oss2.models.LifecycleRule.ENABLED,
# expiration=oss2.models.LifecycleExpiration(days=expiration_days))

# # 设置Bucket的生命周期
# lifecycle = oss2.models.BucketLifecycle([rule])
# bucket.put_bucket_lifecycle(lifecycle)

# print(f"已设置生命周期规则:文件将在{expiration_days}天后自动删除")

# ### 2. 上传文件到OSS ###
# bucket.put_object_from_file(oss_file_name, local_file_path)

# ### 3. 构建公共访问URL ###
# file_url = f"http://{bucket_name}.{endpoint.replace('http://', '')}/{oss_file_name}"

# print(f"文件上传成功,公共访问地址:{file_url}")
# return file_url

+ 0
- 122
channel/wechat/wechat_message.py 查看文件

@@ -1,122 +0,0 @@
import re

from bridge.context import ContextType
from channel.chat_message import ChatMessage
from common.log import logger
from common.tmp_dir import TmpDir
# # from lib import itchat
# from lib.itchat.content import *

# import itchat
# from itchat.content import *

class WechatMessage(ChatMessage):
def __init__(self, itchat_msg, is_group=False):
super().__init__(itchat_msg)
self.msg_id = itchat_msg["MsgId"]
self.create_time = itchat_msg["CreateTime"]
self.is_group = is_group

# notes_join_group = ["加入群聊", "加入了群聊", "invited", "joined"] # 可通过添加对应语言的加入群聊通知中的关键词适配更多
# notes_bot_join_group = ["邀请你", "invited you", "You've joined", "你通过扫描"]
# notes_exit_group = ["移出了群聊", "removed"] # 可通过添加对应语言的踢出群聊通知中的关键词适配更多
# notes_patpat = ["拍了拍我", "tickled my", "tickled me"] # 可通过添加对应语言的拍一拍通知中的关键词适配更多

# if itchat_msg["Type"] == TEXT:
# self.ctype = ContextType.TEXT
# self.content = itchat_msg["Text"]
# elif itchat_msg["Type"] == VOICE:
# self.ctype = ContextType.VOICE
# self.content = TmpDir().path() + itchat_msg["FileName"] # content直接存临时目录路径
# self._prepare_fn = lambda: itchat_msg.download(self.content)
# elif itchat_msg["Type"] == PICTURE and itchat_msg["MsgType"] == 3:
# self.ctype = ContextType.IMAGE
# self.content = TmpDir().path() + itchat_msg["FileName"] # content直接存临时目录路径
# self._prepare_fn = lambda: itchat_msg.download(self.content)
# elif itchat_msg["Type"] == NOTE and itchat_msg["MsgType"] == 10000:
# if is_group:
# if any(note_bot_join_group in itchat_msg["Content"] for note_bot_join_group in notes_bot_join_group): # 邀请机器人加入群聊
# logger.warn("机器人加入群聊消息,不处理~")
# pass
# elif any(note_join_group in itchat_msg["Content"] for note_join_group in notes_join_group): # 若有任何在notes_join_group列表中的字符串出现在NOTE中
# # 这里只能得到nickname, actual_user_id还是机器人的id
# if "加入群聊" not in itchat_msg["Content"]:
# self.ctype = ContextType.JOIN_GROUP
# self.content = itchat_msg["Content"]
# if "invited" in itchat_msg["Content"]: # 匹配英文信息
# self.actual_user_nickname = re.findall(r'invited\s+(.+?)\s+to\s+the\s+group\s+chat', itchat_msg["Content"])[0]
# elif "joined" in itchat_msg["Content"]: # 匹配通过二维码加入的英文信息
# self.actual_user_nickname = re.findall(r'"(.*?)" joined the group chat via the QR Code shared by', itchat_msg["Content"])[0]
# elif "加入了群聊" in itchat_msg["Content"]:
# self.actual_user_nickname = re.findall(r"\"(.*?)\"", itchat_msg["Content"])[-1]
# elif "加入群聊" in itchat_msg["Content"]:
# self.ctype = ContextType.JOIN_GROUP
# self.content = itchat_msg["Content"]
# self.actual_user_nickname = re.findall(r"\"(.*?)\"", itchat_msg["Content"])[0]

# elif any(note_exit_group in itchat_msg["Content"] for note_exit_group in notes_exit_group): # 若有任何在notes_exit_group列表中的字符串出现在NOTE中
# self.ctype = ContextType.EXIT_GROUP
# self.content = itchat_msg["Content"]
# self.actual_user_nickname = re.findall(r"\"(.*?)\"", itchat_msg["Content"])[0]
# elif "你已添加了" in itchat_msg["Content"]: #通过好友请求
# self.ctype = ContextType.ACCEPT_FRIEND
# self.content = itchat_msg["Content"]
# elif any(note_patpat in itchat_msg["Content"] for note_patpat in notes_patpat): # 若有任何在notes_patpat列表中的字符串出现在NOTE中:
# self.ctype = ContextType.PATPAT
# self.content = itchat_msg["Content"]
# if is_group:
# if "拍了拍我" in itchat_msg["Content"]: # 识别中文
# self.actual_user_nickname = re.findall(r"\"(.*?)\"", itchat_msg["Content"])[0]
# elif ("tickled my" in itchat_msg["Content"] or "tickled me" in itchat_msg["Content"]):
# self.actual_user_nickname = re.findall(r'^(.*?)(?:tickled my|tickled me)', 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"] # content直接存临时目录路径
# 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"]))

# self.from_user_id = itchat_msg["FromUserName"]
# self.to_user_id = itchat_msg["ToUserName"]

# user_id = itchat.instance.storageClass.userName
# nickname = itchat.instance.storageClass.nickName

# 虽然from_user_id和to_user_id用的少,但是为了保持一致性,还是要填充一下
# 以下很繁琐,一句话总结:能填的都填了。
# if self.from_user_id == user_id:
# self.from_user_nickname = nickname
# if self.to_user_id == user_id:
# self.to_user_nickname = nickname
# try: # 陌生人时候, User字段可能不存在
# # my_msg 为True是表示是自己发送的消息
# self.my_msg = itchat_msg["ToUserName"] == itchat_msg["User"]["UserName"] and \
# itchat_msg["ToUserName"] != itchat_msg["FromUserName"]
# self.other_user_id = itchat_msg["User"]["UserName"]
# self.other_user_nickname = itchat_msg["User"]["NickName"]
# if self.other_user_id == self.from_user_id:
# self.from_user_nickname = self.other_user_nickname
# if self.other_user_id == self.to_user_id:
# self.to_user_nickname = self.other_user_nickname
# if itchat_msg["User"].get("Self"):
# # 自身的展示名,当设置了群昵称时,该字段表示群昵称
# self.self_display_name = itchat_msg["User"].get("Self").get("DisplayName")
# except KeyError as e: # 处理偶尔没有对方信息的情况
# logger.warn("[WX]get other_user_id failed: " + str(e))
# if self.from_user_id == user_id:
# self.other_user_id = self.to_user_id
# else:
# self.other_user_id = self.from_user_id

# if self.is_group:
# self.is_at = itchat_msg["IsAt"]
# self.actual_user_id = itchat_msg["ActualUserName"]
# if self.ctype not in [ContextType.JOIN_GROUP, ContextType.PATPAT, ContextType.EXIT_GROUP]:
# self.actual_user_nickname = itchat_msg["ActualNickName"]

+ 0
- 273
plugins/README.md 查看文件

@@ -1,273 +0,0 @@
**Table of Content**

- [插件化初衷](#插件化初衷)
- [插件安装方法](#插件安装方法)
- [插件化实现](#插件化实现)
- [插件编写示例](#插件编写示例)
- [插件设计建议](#插件设计建议)

## 插件化初衷

之前未插件化的代码耦合程度高,如果要定制一些个性化功能(如流量控制、接入`NovelAI`画图平台等),需要了解代码主体,避免影响到其他的功能。多个功能同时存在时,无法调整功能的优先级顺序,功能配置项也非常混乱。

此时插件化应声而出。

**插件化**: 在保证主体功能是ChatGPT的前提下,我们推荐将主体功能外的功能利用插件的方式实现。

- [x] 可根据功能需要,下载不同插件。
- [x] 插件开发成本低,仅需了解插件触发事件,并按照插件定义接口编写插件。
- [x] 插件化能够自由开关和调整优先级。
- [x] 每个插件可在插件文件夹内维护独立的配置文件,方便代码的测试和调试,可以在独立的仓库开发插件。

## 插件安装方法

在本仓库中预置了一些插件,如果要安装其他仓库的插件,有两种方法。

- 第一种方法是在将下载的插件文件都解压到"plugins"文件夹的一个单独的文件夹,最终插件的代码都位于"plugins/PLUGIN_NAME/*"中。启动程序后,如果插件的目录结构正确,插件会自动被扫描加载。除此以外,注意你还需要安装文件夹中`requirements.txt`中的依赖。

- 第二种方法是`Godcmd`插件,它是预置的管理员插件,能够让程序在运行时就能安装插件,它能够自动安装依赖。

安装插件的命令是"#installp [仓库源](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/source.json)记录的插件名/仓库地址"。这是管理员命令,认证方法在[这里](https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/godcmd)。

- 安装[仓库源](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/source.json)记录的插件:#installp sdwebui

- 安装指定仓库的插件:#installp https://github.com/lanvent/plugin_sdwebui.git

在安装之后,需要执行"#scanp"命令来扫描加载新安装的插件(或者重新启动程序)。

安装插件后需要注意有些插件有自己的配置模板,一般要去掉".template"新建一个配置文件。

## 插件化实现

插件化实现是在收到消息到发送回复的各个步骤之间插入触发事件实现的。

### 消息处理过程

在了解插件触发事件前,首先需要了解程序收到消息到发送回复的整个过程。

插件化版本中,消息处理过程可以分为4个步骤:
```
1.收到消息 ---> 2.产生回复 ---> 3.包装回复 ---> 4.发送回复
```

以下是它们的默认处理逻辑(太长不看,可跳到[插件编写示例](#插件编写示例)):

**注意以下包含的代码是`v1.1.0`中的片段,已过时,只可用于理解事件,最新的默认代码逻辑请参考[chat_channel](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/chat_channel.py)**

#### 1. 收到消息

负责接收用户消息,根据用户的配置,判断本条消息是否触发机器人。如果触发,则会判断该消息的类型(声音、文本、画图命令等),将消息包装成如下的`Context`交付给下一个步骤。

```python
class ContextType (Enum):
TEXT = 1 # 文本消息
VOICE = 2 # 音频消息
IMAGE_CREATE = 3 # 创建图片命令
class Context:
def __init__(self, type : ContextType = None , content = None, kwargs = dict()):
self.type = type
self.content = content
self.kwargs = kwargs
def __getitem__(self, key):
return self.kwargs[key]
```

`Context`中除了存放消息类型和内容外,还存放了一些与会话相关的参数。

例如,当收到用户私聊消息时,会存放以下的会话参数。

```python
context.kwargs = {'isgroup': False, 'msg': msg, 'receiver': other_user_id, 'session_id': other_user_id}
```

- `isgroup`: `Context`是否是群聊消息。
- `msg`: `itchat`中原始的消息对象。
- `receiver`: 需要回复消息的对象ID。
- `session_id`: 会话ID(一般是发送触发bot消息的用户ID,如果在群聊中并且`conf`里设置了`group_chat_in_one_session`,那么此处便是群聊ID)

#### 2. 产生回复

处理消息并产生回复。目前默认处理逻辑是根据`Context`的类型交付给对应的bot,并产生回复`Reply`。 如果本步骤没有产生任何回复,那么会跳过之后的所有步骤。

```python
if context.type == ContextType.TEXT or context.type == ContextType.IMAGE_CREATE:
reply = super().build_reply_content(context.content, context) #文字跟画图交付给chatgpt
elif context.type == ContextType.VOICE: # 声音先进行语音转文字后,修改Context类型为文字后,再交付给chatgpt
cmsg = context['msg']
cmsg.prepare()
file_name = context.content
reply = super().build_voice_to_text(file_name)
if reply.type != ReplyType.ERROR and reply.type != ReplyType.INFO:
context.content = reply.content # 语音转文字后,将文字内容作为新的context
context.type = ContextType.TEXT
reply = super().build_reply_content(context.content, context)
if reply.type == ReplyType.TEXT:
if conf().get('voice_reply_voice'):
reply = super().build_text_to_voice(reply.content)
```

回复`Reply`的定义如下所示,它允许Bot可以回复多类不同的消息。同时也加入了`INFO`和`ERROR`消息类型区分系统提示和系统错误。

```python
class ReplyType(Enum):
TEXT = 1 # 文本
VOICE = 2 # 音频文件
IMAGE = 3 # 图片文件
IMAGE_URL = 4 # 图片URL

INFO = 9
ERROR = 10
class Reply:
def __init__(self, type : ReplyType = None , content = None):
self.type = type
self.content = content
```

#### 3. 装饰回复

根据`Context`和回复`Reply`的类型,对回复的内容进行装饰。目前的装饰有以下两种:

- `TEXT`文本回复:如果这次消息需要的回复是`VOICE`,进行文字转语音回复之后再次装饰。 否则根据是否在群聊中来决定是艾特接收方还是添加回复的前缀。

- `INFO`或`ERROR`类型,会在消息前添加对应的系统提示字样。

如下是默认逻辑的代码:

```python
if reply.type == ReplyType.TEXT:
reply_text = reply.content
if context.get('desire_rtype') == ReplyType.VOICE:
reply = super().build_text_to_voice(reply.content)
return self._decorate_reply(context, reply)
if context['isgroup']:
reply_text = '@' + context['msg'].actual_user_nickname + ' ' + reply_text.strip()
reply_text = conf().get("group_chat_reply_prefix", "")+reply_text
else:
reply_text = conf().get("single_chat_reply_prefix", "")+reply_text
reply.content = reply_text
elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
reply.content = str(reply.type)+":\n" + reply.content
```

#### 4. 发送回复

根据`Reply`的类型,默认逻辑调用不同的发送函数发送回复给接收方`context["receiver"]`。

### 插件触发事件

主程序目前会在各个消息步骤间触发事件,监听相应事件的插件会按照优先级,顺序调用事件处理函数。

目前支持三类触发事件:
```
1.收到消息
---> `ON_HANDLE_CONTEXT`
2.产生回复
---> `ON_DECORATE_REPLY`
3.装饰回复
---> `ON_SEND_REPLY`
4.发送回复
```

触发事件会产生事件的上下文`EventContext`,它包含了以下信息:

`EventContext(Event事件类型, {'channel' : 消息channel, 'context': Context, 'reply': Reply})`

插件处理函数可通过修改`EventContext`中的`context`和`reply`来实现功能。

## 插件编写示例

以`plugins/hello`为例,其中编写了一个简单的`Hello`插件。

### 1. 创建插件

在`plugins`目录下创建一个插件文件夹`hello`。然后,在该文件夹中创建``__init__.py``文件,在``__init__.py``中将其他编写的模块文件导入。在程序启动时,插件管理器会读取``__init__.py``的所有内容。

```
plugins/
└── hello
├── __init__.py
└── hello.py
```

``__init__.py``的内容:
```
from .hello import *
```

### 2. 编写插件类

在`hello.py`文件中,创建插件类,它继承自`Plugin`。

在类定义之前需要使用`@plugins.register`装饰器注册插件,并填写插件的相关信息,其中`desire_priority`表示插件默认的优先级,越大优先级越高。初次加载插件后可在`plugins/plugins.json`中修改插件优先级。

并在`__init__`中绑定你编写的事件处理函数。

`Hello`插件为事件`ON_HANDLE_CONTEXT`绑定了一个处理函数`on_handle_context`,它表示之后每次生成回复前,都会由`on_handle_context`先处理。

PS: `ON_HANDLE_CONTEXT`是最常用的事件,如果要根据不同的消息来生成回复,就用它。

```python
@plugins.register(name="Hello", desc="A simple plugin that says hello", version="0.1", author="lanvent", desire_priority= -1)
class Hello(Plugin):
def __init__(self):
super().__init__()
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[Hello] inited")
```

### 3. 编写事件处理函数

#### 修改事件上下文

事件处理函数接收一个`EventContext`对象`e_context`作为参数。`e_context`包含了事件相关信息,利用`e_context['key']`来访问这些信息。

`EventContext(Event事件类型, {'channel' : 消息channel, 'context': Context, 'reply': Reply})`

处理函数中通过修改`e_context`对象中的事件相关信息来实现所需功能,比如更改`e_context['reply']`中的内容可以修改回复。

#### 决定是否交付给下个插件或默认逻辑

在处理函数结束时,还需要设置`e_context`对象的`action`属性,它决定如何继续处理事件。目前有以下三种处理方式:

- `EventAction.CONTINUE`: 事件未结束,继续交给下个插件处理,如果没有下个插件,则交付给默认的事件处理逻辑。
- `EventAction.BREAK`: 事件结束,不再给下个插件处理,交付给默认的处理逻辑。
- `EventAction.BREAK_PASS`: 事件结束,不再给下个插件处理,跳过默认的处理逻辑。

#### 示例处理函数

`Hello`插件处理`Context`类型为`TEXT`的消息:

- 如果内容是`Hello`,就将回复设置为`Hello+用户昵称`,并跳过之后的插件和默认逻辑。
- 如果内容是`End`,就将`Context`的类型更改为`IMAGE_CREATE`,并让事件继续,如果最终交付到默认逻辑,会调用默认的画图Bot来画画。

```python
def on_handle_context(self, e_context: EventContext):
if e_context['context'].type != ContextType.TEXT:
return
content = e_context['context'].content
if content == "Hello":
reply = Reply()
reply.type = ReplyType.TEXT
msg:ChatMessage = e_context['context']['msg']
if e_context['context']['isgroup']:
reply.content = f"Hello, {msg.actual_user_nickname} from {msg.from_user_nickname}"
else:
reply.content = f"Hello, {msg.from_user_nickname}"
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束,并跳过处理context的默认逻辑
if content == "End":
# 如果是文本消息"End",将请求转换成"IMAGE_CREATE",并将content设置为"The World"
e_context['context'].type = ContextType.IMAGE_CREATE
content = "The World"
e_context.action = EventAction.CONTINUE # 事件继续,交付给下个插件或默认逻辑
```

## 插件设计建议

- 尽情将你想要的个性化功能设计为插件。
- 一个插件目录建议只注册一个插件类。建议使用单独的仓库维护插件,便于更新。

在测试调试好后提交`PR`,把自己的仓库加入到[仓库源](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/source.json)中。

- 插件的config文件、使用说明`README.md`、`requirement.txt`等放置在插件目录中。
- 默认优先级不要超过管理员插件`Godcmd`的优先级(999),`Godcmd`插件提供了配置管理、插件管理等功能。

+ 0
- 9
plugins/__init__.py 查看文件

@@ -1,9 +0,0 @@
from .event import *
from .plugin import *
from .plugin_manager import PluginManager

instance = PluginManager()

register = instance.register
# load_plugins = instance.load_plugins
# emit_event = instance.emit_event

+ 0
- 1
plugins/banwords/.gitignore 查看文件

@@ -1 +0,0 @@
banwords.txt

+ 0
- 27
plugins/banwords/README.md 查看文件

@@ -1,27 +0,0 @@

## 插件描述

简易的敏感词插件,暂不支持分词,请自行导入词库到插件文件夹中的`banwords.txt`,每行一个词,一个参考词库是[1](https://github.com/cjh0613/tencent-sensitive-words/blob/main/sensitive_words_lines.txt)。

使用前将`config.json.template`复制为`config.json`,并自行配置。

目前插件对消息的默认处理行为有如下两种:

- `ignore` : 无视这条消息。
- `replace` : 将消息中的敏感词替换成"*",并回复违规。

```json
"action": "replace",
"reply_filter": true,
"reply_action": "ignore"
```

在以上配置项中:

- `action`: 对用户消息的默认处理行为
- `reply_filter`: 是否对ChatGPT的回复也进行敏感词过滤
- `reply_action`: 如果开启了回复过滤,对回复的默认处理行为

## 致谢

搜索功能实现来自https://github.com/toolgood/ToolGood.Words

+ 0
- 1
plugins/banwords/__init__.py 查看文件

@@ -1 +0,0 @@
from .banwords import *

+ 0
- 100
plugins/banwords/banwords.py 查看文件

@@ -1,100 +0,0 @@
# encoding:utf-8

import json
import os

import plugins
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
from plugins import *

from .lib.WordsSearch import WordsSearch


@plugins.register(
name="Banwords",
desire_priority=100,
hidden=True,
desc="判断消息中是否有敏感词、决定是否回复。",
version="1.0",
author="lanvent",
)
class Banwords(Plugin):
def __init__(self):
super().__init__()
try:
# load config
conf = super().load_config()
curdir = os.path.dirname(__file__)
if not conf:
# 配置不存在则写入默认配置
config_path = os.path.join(curdir, "config.json")
if not os.path.exists(config_path):
conf = {"action": "ignore"}
with open(config_path, "w") as f:
json.dump(conf, f, indent=4)

self.searchr = WordsSearch()
self.action = conf["action"]
banwords_path = os.path.join(curdir, "banwords.txt")
with open(banwords_path, "r", encoding="utf-8") as f:
words = []
for line in f:
word = line.strip()
if word:
words.append(word)
self.searchr.SetKeywords(words)
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
if conf.get("reply_filter", True):
self.handlers[Event.ON_DECORATE_REPLY] = self.on_decorate_reply
self.reply_action = conf.get("reply_action", "ignore")
logger.info("[Banwords] inited")
except Exception as e:
logger.warn("[Banwords] init failed, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/banwords .")
raise e

def on_handle_context(self, e_context: EventContext):
if e_context["context"].type not in [
ContextType.TEXT,
ContextType.IMAGE_CREATE,
]:
return

content = e_context["context"].content
logger.debug("[Banwords] on_handle_context. content: %s" % content)
if self.action == "ignore":
f = self.searchr.FindFirst(content)
if f:
logger.info("[Banwords] %s in message" % f["Keyword"])
e_context.action = EventAction.BREAK_PASS
return
elif self.action == "replace":
if self.searchr.ContainsAny(content):
reply = Reply(ReplyType.INFO, "发言中包含敏感词,请重试: \n" + self.searchr.Replace(content))
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
return

def on_decorate_reply(self, e_context: EventContext):
if e_context["reply"].type not in [ReplyType.TEXT]:
return

reply = e_context["reply"]
content = reply.content
if self.reply_action == "ignore":
f = self.searchr.FindFirst(content)
if f:
logger.info("[Banwords] %s in reply" % f["Keyword"])
e_context["reply"] = None
e_context.action = EventAction.BREAK_PASS
return
elif self.reply_action == "replace":
if self.searchr.ContainsAny(content):
reply = Reply(ReplyType.INFO, "已替换回复中的敏感词: \n" + self.searchr.Replace(content))
e_context["reply"] = reply
e_context.action = EventAction.CONTINUE
return

def get_help_text(self, **kwargs):
return "过滤消息中的敏感词。"

+ 0
- 3
plugins/banwords/banwords.txt.template 查看文件

@@ -1,3 +0,0 @@
nipples
pennis
法轮功

+ 0
- 3
plugins/banwords/config.json 查看文件

@@ -1,3 +0,0 @@
{
"action": "ignore"
}

+ 0
- 5
plugins/banwords/config.json.template 查看文件

@@ -1,5 +0,0 @@
{
"action": "replace",
"reply_filter": true,
"reply_action": "ignore"
}

+ 0
- 250
plugins/banwords/lib/WordsSearch.py 查看文件

@@ -1,250 +0,0 @@
#!/usr/bin/env python
# -*- coding:utf-8 -*-
# ToolGood.Words.WordsSearch.py
# 2020, Lin Zhijun, https://github.com/toolgood/ToolGood.Words
# Licensed under the Apache License 2.0
# 更新日志
# 2020.04.06 第一次提交
# 2020.05.16 修改,支持大于0xffff的字符

__all__ = ['WordsSearch']
__author__ = 'Lin Zhijun'
__date__ = '2020.05.16'

class TrieNode():
def __init__(self):
self.Index = 0
self.Index = 0
self.Layer = 0
self.End = False
self.Char = ''
self.Results = []
self.m_values = {}
self.Failure = None
self.Parent = None

def Add(self,c):
if c in self.m_values :
return self.m_values[c]
node = TrieNode()
node.Parent = self
node.Char = c
self.m_values[c] = node
return node

def SetResults(self,index):
if (self.End == False):
self.End = True
self.Results.append(index)

class TrieNode2():
def __init__(self):
self.End = False
self.Results = []
self.m_values = {}
self.minflag = 0xffff
self.maxflag = 0

def Add(self,c,node3):
if (self.minflag > c):
self.minflag = c
if (self.maxflag < c):
self.maxflag = c
self.m_values[c] = node3

def SetResults(self,index):
if (self.End == False) :
self.End = True
if (index in self.Results )==False :
self.Results.append(index)

def HasKey(self,c):
return c in self.m_values
def TryGetValue(self,c):
if (self.minflag <= c and self.maxflag >= c):
if c in self.m_values:
return self.m_values[c]
return None


class WordsSearch():
def __init__(self):
self._first = {}
self._keywords = []
self._indexs=[]
def SetKeywords(self,keywords):
self._keywords = keywords
self._indexs=[]
for i in range(len(keywords)):
self._indexs.append(i)

root = TrieNode()
allNodeLayer={}

for i in range(len(self._keywords)): # for (i = 0; i < _keywords.length; i++)
p = self._keywords[i]
nd = root
for j in range(len(p)): # for (j = 0; j < p.length; j++)
nd = nd.Add(ord(p[j]))
if (nd.Layer == 0):
nd.Layer = j + 1
if nd.Layer in allNodeLayer:
allNodeLayer[nd.Layer].append(nd)
else:
allNodeLayer[nd.Layer]=[]
allNodeLayer[nd.Layer].append(nd)
nd.SetResults(i)


allNode = []
allNode.append(root)
for key in allNodeLayer.keys():
for nd in allNodeLayer[key]:
allNode.append(nd)
allNodeLayer=None

for i in range(len(allNode)): # for (i = 0; i < allNode.length; i++)
if i==0 :
continue
nd=allNode[i]
nd.Index = i
r = nd.Parent.Failure
c = nd.Char
while (r != None and (c in r.m_values)==False):
r = r.Failure
if (r == None):
nd.Failure = root
else:
nd.Failure = r.m_values[c]
for key2 in nd.Failure.Results :
nd.SetResults(key2)
root.Failure = root

allNode2 = []
for i in range(len(allNode)): # for (i = 0; i < allNode.length; i++)
allNode2.append( TrieNode2())
for i in range(len(allNode2)): # for (i = 0; i < allNode2.length; i++)
oldNode = allNode[i]
newNode = allNode2[i]

for key in oldNode.m_values :
index = oldNode.m_values[key].Index
newNode.Add(key, allNode2[index])
for index in range(len(oldNode.Results)): # for (index = 0; index < oldNode.Results.length; index++)
item = oldNode.Results[index]
newNode.SetResults(item)
oldNode=oldNode.Failure
while oldNode != root:
for key in oldNode.m_values :
if (newNode.HasKey(key) == False):
index = oldNode.m_values[key].Index
newNode.Add(key, allNode2[index])
for index in range(len(oldNode.Results)):
item = oldNode.Results[index]
newNode.SetResults(item)
oldNode=oldNode.Failure
allNode = None
root = None

# first = []
# for index in range(65535):# for (index = 0; index < 0xffff; index++)
# first.append(None)
# for key in allNode2[0].m_values :
# first[key] = allNode2[0].m_values[key]
self._first = allNode2[0]

def FindFirst(self,text):
ptr = None
for index in range(len(text)): # for (index = 0; index < text.length; index++)
t =ord(text[index]) # text.charCodeAt(index)
tn = None
if (ptr == None):
tn = self._first.TryGetValue(t)
else:
tn = ptr.TryGetValue(t)
if (tn==None):
tn = self._first.TryGetValue(t)
if (tn != None):
if (tn.End):
item = tn.Results[0]
keyword = self._keywords[item]
return { "Keyword": keyword, "Success": True, "End": index, "Start": index + 1 - len(keyword), "Index": self._indexs[item] }
ptr = tn
return None

def FindAll(self,text):
ptr = None
list = []

for index in range(len(text)): # for (index = 0; index < text.length; index++)
t =ord(text[index]) # text.charCodeAt(index)
tn = None
if (ptr == None):
tn = self._first.TryGetValue(t)
else:
tn = ptr.TryGetValue(t)
if (tn==None):
tn = self._first.TryGetValue(t)
if (tn != None):
if (tn.End):
for j in range(len(tn.Results)): # for (j = 0; j < tn.Results.length; j++)
item = tn.Results[j]
keyword = self._keywords[item]
list.append({ "Keyword": keyword, "Success": True, "End": index, "Start": index + 1 - len(keyword), "Index": self._indexs[item] })
ptr = tn
return list


def ContainsAny(self,text):
ptr = None
for index in range(len(text)): # for (index = 0; index < text.length; index++)
t =ord(text[index]) # text.charCodeAt(index)
tn = None
if (ptr == None):
tn = self._first.TryGetValue(t)
else:
tn = ptr.TryGetValue(t)
if (tn==None):
tn = self._first.TryGetValue(t)
if (tn != None):
if (tn.End):
return True
ptr = tn
return False
def Replace(self,text, replaceChar = '*'):
result = list(text)

ptr = None
for i in range(len(text)): # for (i = 0; i < text.length; i++)
t =ord(text[i]) # text.charCodeAt(index)
tn = None
if (ptr == None):
tn = self._first.TryGetValue(t)
else:
tn = ptr.TryGetValue(t)
if (tn==None):
tn = self._first.TryGetValue(t)
if (tn != None):
if (tn.End):
maxLength = len( self._keywords[tn.Results[0]])
start = i + 1 - maxLength
for j in range(start,i+1): # for (j = start; j <= i; j++)
result[j] = replaceChar
ptr = tn
return ''.join(result)

+ 0
- 30
plugins/bdunit/README.md 查看文件

@@ -1,30 +0,0 @@
## 插件说明

利用百度UNIT实现智能对话

- 1.解决问题:chatgpt无法处理的指令,交给百度UNIT处理如:天气,日期时间,数学运算等
- 2.如问时间:现在几点钟,今天几号
- 3.如问天气:明天广州天气怎么样,这个周末深圳会不会下雨
- 4.如问数学运算:23+45=多少,100-23=多少,35转化为二进制是多少?

## 使用说明

### 获取apikey

在百度UNIT官网上自己创建应用,申请百度机器人,可以把预先训练好的模型导入到自己的应用中,

see https://ai.baidu.com/unit/home#/home?track=61fe1b0d3407ce3face1d92cb5c291087095fc10c8377aaf https://console.bce.baidu.com/ai平台申请

### 配置文件

将文件夹中`config.json.template`复制为`config.json`。

在其中填写百度UNIT官网上获取应用的API Key和Secret Key

``` json
{
"service_id": "s...", #"机器人ID"
"api_key": "",
"secret_key": ""
}
```

+ 0
- 1
plugins/bdunit/__init__.py 查看文件

@@ -1 +0,0 @@
from .bdunit import *

+ 0
- 252
plugins/bdunit/bdunit.py 查看文件

@@ -1,252 +0,0 @@
# encoding:utf-8
import json
import os
import uuid
from uuid import getnode as get_mac

import requests

import plugins
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
from plugins import *

"""利用百度UNIT实现智能对话
如果命中意图,返回意图对应的回复,否则返回继续交付给下个插件处理
"""


@plugins.register(
name="BDunit",
desire_priority=0,
hidden=True,
desc="Baidu unit bot system",
version="0.1",
author="jackson",
)
class BDunit(Plugin):
def __init__(self):
super().__init__()
try:
conf = super().load_config()
if not conf:
raise Exception("config.json not found")
self.service_id = conf["service_id"]
self.api_key = conf["api_key"]
self.secret_key = conf["secret_key"]
self.access_token = self.get_token()
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[BDunit] inited")
except Exception as e:
logger.warn("[BDunit] init failed, ignore ")
raise e

def on_handle_context(self, e_context: EventContext):
if e_context["context"].type != ContextType.TEXT:
return

content = e_context["context"].content
logger.debug("[BDunit] on_handle_context. content: %s" % content)
parsed = self.getUnit2(content)
intent = self.getIntent(parsed)
if intent: # 找到意图
logger.debug("[BDunit] Baidu_AI Intent= %s", intent)
reply = Reply()
reply.type = ReplyType.TEXT
reply.content = self.getSay(parsed)
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束,并跳过处理context的默认逻辑
else:
e_context.action = EventAction.CONTINUE # 事件继续,交付给下个插件或默认逻辑

def get_help_text(self, **kwargs):
help_text = "本插件会处理询问实时日期时间,天气,数学运算等问题,这些技能由您的百度智能对话UNIT决定\n"
return help_text

def get_token(self):
"""获取访问百度UUNIT 的access_token
#param api_key: UNIT apk_key
#param secret_key: UNIT secret_key
Returns:
string: access_token
"""
url = "https://aip.baidubce.com/oauth/2.0/token?client_id={}&client_secret={}&grant_type=client_credentials".format(self.api_key, self.secret_key)
payload = ""
headers = {"Content-Type": "application/json", "Accept": "application/json"}

response = requests.request("POST", url, headers=headers, data=payload)

# print(response.text)
return response.json()["access_token"]

def getUnit(self, query):
"""
NLU 解析version 3.0
:param query: 用户的指令字符串
:returns: UNIT 解析结果。如果解析失败,返回 None
"""

url = "https://aip.baidubce.com/rpc/2.0/unit/service/v3/chat?access_token=" + self.access_token
request = {
"query": query,
"user_id": str(get_mac())[:32],
"terminal_id": "88888",
}
body = {
"log_id": str(uuid.uuid1()),
"version": "3.0",
"service_id": self.service_id,
"session_id": str(uuid.uuid1()),
"request": request,
}
try:
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=body, headers=headers)
return json.loads(response.text)
except Exception:
return None

def getUnit2(self, query):
"""
NLU 解析 version 2.0

:param query: 用户的指令字符串
:returns: UNIT 解析结果。如果解析失败,返回 None
"""
url = "https://aip.baidubce.com/rpc/2.0/unit/service/chat?access_token=" + self.access_token
request = {"query": query, "user_id": str(get_mac())[:32]}
body = {
"log_id": str(uuid.uuid1()),
"version": "2.0",
"service_id": self.service_id,
"session_id": str(uuid.uuid1()),
"request": request,
}
try:
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=body, headers=headers)
return json.loads(response.text)
except Exception:
return None

def getIntent(self, parsed):
"""
提取意图

:param parsed: UNIT 解析结果
:returns: 意图数组
"""
if parsed and "result" in parsed and "response_list" in parsed["result"]:
try:
return parsed["result"]["response_list"][0]["schema"]["intent"]
except Exception as e:
logger.warning(e)
return ""
else:
return ""

def hasIntent(self, parsed, intent):
"""
判断是否包含某个意图

:param parsed: UNIT 解析结果
:param intent: 意图的名称
:returns: True: 包含; False: 不包含
"""
if parsed and "result" in parsed and "response_list" in parsed["result"]:
response_list = parsed["result"]["response_list"]
for response in response_list:
if "schema" in response and "intent" in response["schema"] and response["schema"]["intent"] == intent:
return True
return False
else:
return False

def getSlots(self, parsed, intent=""):
"""
提取某个意图的所有词槽

:param parsed: UNIT 解析结果
:param intent: 意图的名称
:returns: 词槽列表。你可以通过 name 属性筛选词槽,
再通过 normalized_word 属性取出相应的值
"""
if parsed and "result" in parsed and "response_list" in parsed["result"]:
response_list = parsed["result"]["response_list"]
if intent == "":
try:
return parsed["result"]["response_list"][0]["schema"]["slots"]
except Exception as e:
logger.warning(e)
return []
for response in response_list:
if "schema" in response and "intent" in response["schema"] and "slots" in response["schema"] and response["schema"]["intent"] == intent:
return response["schema"]["slots"]
return []
else:
return []

def getSlotWords(self, parsed, intent, name):
"""
找出命中某个词槽的内容

:param parsed: UNIT 解析结果
:param intent: 意图的名称
:param name: 词槽名
:returns: 命中该词槽的值的列表。
"""
slots = self.getSlots(parsed, intent)
words = []
for slot in slots:
if slot["name"] == name:
words.append(slot["normalized_word"])
return words

def getSayByConfidence(self, parsed):
"""
提取 UNIT 置信度最高的回复文本

:param parsed: UNIT 解析结果
:returns: UNIT 的回复文本
"""
if parsed and "result" in parsed and "response_list" in parsed["result"]:
response_list = parsed["result"]["response_list"]
answer = {}
for response in response_list:
if (
"schema" in response
and "intent_confidence" in response["schema"]
and (not answer or response["schema"]["intent_confidence"] > answer["schema"]["intent_confidence"])
):
answer = response
return answer["action_list"][0]["say"]
else:
return ""

def getSay(self, parsed, intent=""):
"""
提取 UNIT 的回复文本

:param parsed: UNIT 解析结果
:param intent: 意图的名称
:returns: UNIT 的回复文本
"""
if parsed and "result" in parsed and "response_list" in parsed["result"]:
response_list = parsed["result"]["response_list"]
if intent == "":
try:
return response_list[0]["action_list"][0]["say"]
except Exception as e:
logger.warning(e)
return ""
for response in response_list:
if "schema" in response and "intent" in response["schema"] and response["schema"]["intent"] == intent:
try:
return response["action_list"][0]["say"]
except Exception as e:
logger.warning(e)
return ""
return ""
else:
return ""

+ 0
- 5
plugins/bdunit/config.json.template 查看文件

@@ -1,5 +0,0 @@
{
"service_id": "s...",
"api_key": "",
"secret_key": ""
}

+ 0
- 59
plugins/config.json.template 查看文件

@@ -1,59 +0,0 @@
{
"godcmd": {
"password": "",
"admin_users": []
},
"banwords": {
"action": "replace",
"reply_filter": true,
"reply_action": "ignore"
},
"tool": {
"tools": [
"url-get",
"meteo-weather"
],
"kwargs": {
"top_k_results": 2,
"no_default": false,
"model_name": "gpt-3.5-turbo"
}
},
"linkai": {
"group_app_map": {
"测试群1": "default",
"测试群2": "Kv2fXJcH"
},
"midjourney": {
"enabled": true,
"auto_translate": true,
"img_proxy": true,
"max_tasks": 3,
"max_tasks_per_user": 1,
"use_image_create_prefix": true
},
"summary": {
"enabled": true,
"group_enabled": true,
"max_file_size": 5000,
"type": ["FILE", "SHARING"]
}
},
"hello": {
"group_welc_fixed_msg": {
"群聊1": "群聊1的固定欢迎语",
"群聊2": "群聊2的固定欢迎语"
},
"group_welc_prompt": "请你随机使用一种风格说一句问候语来欢迎新用户\"{nickname}\"加入群聊。",

"group_exit_prompt": "请你随机使用一种风格跟其他群用户说他违反规则\"{nickname}\"退出群聊。",

"patpat_prompt": "请你随机使用一种风格介绍你自己,并告诉用户输入#help可以查看帮助信息。",

"use_character_desc": false
},
"Apilot": {
"alapi_token": "xxx",
"morning_news_text_enabled": false
}
}

+ 0
- 4
plugins/dungeon/README.md 查看文件

@@ -1,4 +0,0 @@
玩地牢游戏的聊天插件,触发方法如下:

- `$开始冒险 <背景故事>` - 以<背景故事>开始一个地牢游戏,不填写会使用默认背景故事。之后聊天中你的所有消息会帮助ai完善这个故事。
- `$停止冒险` - 停止一个地牢游戏,回归正常的ai。

+ 0
- 1
plugins/dungeon/__init__.py 查看文件

@@ -1 +0,0 @@
from .dungeon import *

+ 0
- 106
plugins/dungeon/dungeon.py 查看文件

@@ -1,106 +0,0 @@
# encoding:utf-8

import plugins
from bridge.bridge import Bridge
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from common import const
from common.expired_dict import ExpiredDict
from common.log import logger
from config import conf
from plugins import *


# https://github.com/bupticybee/ChineseAiDungeonChatGPT
class StoryTeller:
def __init__(self, bot, sessionid, story):
self.bot = bot
self.sessionid = sessionid
bot.sessions.clear_session(sessionid)
self.first_interact = True
self.story = story

def reset(self):
self.bot.sessions.clear_session(self.sessionid)
self.first_interact = True

def action(self, user_action):
if user_action[-1] != "。":
user_action = user_action + "。"
if self.first_interact:
prompt = (
"""现在来充当一个文字冒险游戏,描述时候注意节奏,不要太快,仔细描述各个人物的心情和周边环境。一次只需写四到六句话。
开头是,"""
+ self.story
+ " "
+ user_action
)
self.first_interact = False
else:
prompt = """继续,一次只需要续写四到六句话,总共就只讲5分钟内发生的事情。""" + user_action
return prompt


@plugins.register(
name="Dungeon",
desire_priority=0,
namecn="文字冒险",
desc="A plugin to play dungeon game",
version="1.0",
author="lanvent",
)
class Dungeon(Plugin):
def __init__(self):
super().__init__()
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[Dungeon] inited")
# 目前没有设计session过期事件,这里先暂时使用过期字典
if conf().get("expires_in_seconds"):
self.games = ExpiredDict(conf().get("expires_in_seconds"))
else:
self.games = dict()

def on_handle_context(self, e_context: EventContext):
if e_context["context"].type != ContextType.TEXT:
return
bottype = Bridge().get_bot_type("chat")
if bottype not in [const.OPEN_AI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI]:
return
bot = Bridge().get_bot("chat")
content = e_context["context"].content[:]
clist = e_context["context"].content.split(maxsplit=1)
sessionid = e_context["context"]["session_id"]
logger.debug("[Dungeon] on_handle_context. content: %s" % clist)
trigger_prefix = conf().get("plugin_trigger_prefix", "$")
if clist[0] == f"{trigger_prefix}停止冒险":
if sessionid in self.games:
self.games[sessionid].reset()
del self.games[sessionid]
reply = Reply(ReplyType.INFO, "冒险结束!")
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
elif clist[0] == f"{trigger_prefix}开始冒险" or sessionid in self.games:
if sessionid not in self.games or clist[0] == f"{trigger_prefix}开始冒险":
if len(clist) > 1:
story = clist[1]
else:
story = "你在树林里冒险,指不定会从哪里蹦出来一些奇怪的东西,你握紧手上的手枪,希望这次冒险能够找到一些值钱的东西,你往树林深处走去。"
self.games[sessionid] = StoryTeller(bot, sessionid, story)
reply = Reply(ReplyType.INFO, "冒险开始,你可以输入任意内容,让故事继续下去。故事背景是:" + story)
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束,并跳过处理context的默认逻辑
else:
prompt = self.games[sessionid].action(content)
e_context["context"].type = ContextType.TEXT
e_context["context"].content = prompt
e_context.action = EventAction.BREAK # 事件结束,不跳过处理context的默认逻辑

def get_help_text(self, **kwargs):
help_text = "可以和机器人一起玩文字冒险游戏。\n"
if kwargs.get("verbose") != True:
return help_text
trigger_prefix = conf().get("plugin_trigger_prefix", "$")
help_text = f"{trigger_prefix}开始冒险 " + "背景故事: 开始一个基于{背景故事}的文字冒险,之后你的所有消息会协助完善这个故事。\n" + f"{trigger_prefix}停止冒险: 结束游戏。\n"
if kwargs.get("verbose") == True:
help_text += f"\n命令例子: '{trigger_prefix}开始冒险 你在树林里冒险,指不定会从哪里蹦出来一些奇怪的东西,你握紧手上的手枪,希望这次冒险能够找到一些值钱的东西,你往树林深处走去。'"
return help_text

+ 0
- 55
plugins/event.py 查看文件

@@ -1,55 +0,0 @@
# encoding:utf-8

from enum import Enum


class Event(Enum):
ON_RECEIVE_MESSAGE = 1 # 收到消息
"""
e_context = { "channel": 消息channel, "context" : 本次消息的context}
"""

ON_HANDLE_CONTEXT = 2 # 处理消息前
"""
e_context = { "channel": 消息channel, "context" : 本次消息的context, "reply" : 目前的回复,初始为空 }
"""

ON_DECORATE_REPLY = 3 # 得到回复后准备装饰
"""
e_context = { "channel": 消息channel, "context" : 本次消息的context, "reply" : 目前的回复 }
"""

ON_SEND_REPLY = 4 # 发送回复前
"""
e_context = { "channel": 消息channel, "context" : 本次消息的context, "reply" : 目前的回复 }
"""

# AFTER_SEND_REPLY = 5 # 发送回复后


class EventAction(Enum):
CONTINUE = 1 # 事件未结束,继续交给下个插件处理,如果没有下个插件,则交付给默认的事件处理逻辑
BREAK = 2 # 事件结束,不再给下个插件处理,交付给默认的事件处理逻辑
BREAK_PASS = 3 # 事件结束,不再给下个插件处理,不交付给默认的事件处理逻辑


class EventContext:
def __init__(self, event, econtext=dict()):
self.event = event
self.econtext = econtext
self.action = EventAction.CONTINUE

def __getitem__(self, key):
return self.econtext[key]

def __setitem__(self, key, value):
self.econtext[key] = value

def __delitem__(self, key):
del self.econtext[key]

def is_pass(self):
return self.action == EventAction.BREAK_PASS

def is_break(self):
return self.action == EventAction.BREAK or self.action == EventAction.BREAK_PASS

+ 0
- 1
plugins/finish/__init__.py 查看文件

@@ -1 +0,0 @@
from .finish import *

+ 0
- 40
plugins/finish/finish.py 查看文件

@@ -1,40 +0,0 @@
# encoding:utf-8

import plugins
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
from config import conf
from plugins import *


@plugins.register(
name="Finish",
desire_priority=-999,
hidden=True,
desc="A plugin that check unknown command",
version="1.0",
author="js00000",
)
class Finish(Plugin):
def __init__(self):
super().__init__()
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[Finish] inited")

def on_handle_context(self, e_context: EventContext):
if e_context["context"].type != ContextType.TEXT:
return

content = e_context["context"].content
logger.debug("[Finish] on_handle_context. content: %s" % content)
trigger_prefix = conf().get("plugin_trigger_prefix", "$")
if content.startswith(trigger_prefix):
reply = Reply()
reply.type = ReplyType.ERROR
reply.content = "未知插件命令\n查看插件命令列表请输入#help 插件名\n"
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束,并跳过处理context的默认逻辑

def get_help_text(self, **kwargs):
return ""

+ 0
- 18
plugins/godcmd/README.md 查看文件

@@ -1,18 +0,0 @@
## 插件说明

指令插件

## 插件使用

将`config.json.template`复制为`config.json`,并修改其中`password`的值为口令。

如果没有设置命令,在命令行日志中会打印出本次的临时口令,请注意观察,打印格式如下。

```
[INFO][2023-04-06 23:53:47][godcmd.py:165] - [Godcmd] 因未设置口令,本次的临时口令为0971。
```

在私聊中可使用`#auth`指令,输入口令进行管理员认证。更多详细指令请输入`#help`查看帮助文档:

`#auth <口令>` - 管理员认证,仅可在私聊时认证。
`#help` - 输出帮助文档,**是否是管理员**和是否是在群聊中会影响帮助文档的输出内容。

+ 0
- 1
plugins/godcmd/__init__.py 查看文件

@@ -1 +0,0 @@
from .godcmd import *

+ 0
- 4
plugins/godcmd/config.json 查看文件

@@ -1,4 +0,0 @@
{
"password": "",
"admin_users": []
}

+ 0
- 4
plugins/godcmd/config.json.template 查看文件

@@ -1,4 +0,0 @@
{
"password": "",
"admin_users": []
}

+ 0
- 485
plugins/godcmd/godcmd.py 查看文件

@@ -1,485 +0,0 @@
# encoding:utf-8

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
from bridge.reply import Reply, ReplyType
from common import const
from config import conf, load_config, global_config
from plugins import *

# 定义指令集
COMMANDS = {
"help": {
"alias": ["help", "帮助"],
"desc": "回复此帮助",
},
"helpp": {
"alias": ["help", "帮助"], # 与help指令共用别名,根据参数数量区分
"args": ["插件名"],
"desc": "回复指定插件的详细帮助",
},
"auth": {
"alias": ["auth", "认证"],
"args": ["口令"],
"desc": "管理员认证",
},
"model": {
"alias": ["model", "模型"],
"desc": "查看和设置全局模型",
},
"set_openai_api_key": {
"alias": ["set_openai_api_key"],
"args": ["api_key"],
"desc": "设置你的OpenAI私有api_key",
},
"reset_openai_api_key": {
"alias": ["reset_openai_api_key"],
"desc": "重置为默认的api_key",
},
"set_gpt_model": {
"alias": ["set_gpt_model"],
"desc": "设置你的私有模型",
},
"reset_gpt_model": {
"alias": ["reset_gpt_model"],
"desc": "重置你的私有模型",
},
"gpt_model": {
"alias": ["gpt_model"],
"desc": "查询你使用的模型",
},
"id": {
"alias": ["id", "用户"],
"desc": "获取用户id", # wechaty和wechatmp的用户id不会变化,可用于绑定管理员
},
"reset": {
"alias": ["reset", "重置会话"],
"desc": "重置会话",
},
}

ADMIN_COMMANDS = {
"resume": {
"alias": ["resume", "恢复服务"],
"desc": "恢复服务",
},
"stop": {
"alias": ["stop", "暂停服务"],
"desc": "暂停服务",
},
"reconf": {
"alias": ["reconf", "重载配置"],
"desc": "重载配置(不包含插件配置)",
},
"resetall": {
"alias": ["resetall", "重置所有会话"],
"desc": "重置所有会话",
},
"scanp": {
"alias": ["scanp", "扫描插件"],
"desc": "扫描插件目录是否有新插件",
},
"plist": {
"alias": ["plist", "插件"],
"desc": "打印当前插件列表",
},
"setpri": {
"alias": ["setpri", "设置插件优先级"],
"args": ["插件名", "优先级"],
"desc": "设置指定插件的优先级,越大越优先",
},
"reloadp": {
"alias": ["reloadp", "重载插件"],
"args": ["插件名"],
"desc": "重载指定插件配置",
},
"enablep": {
"alias": ["enablep", "启用插件"],
"args": ["插件名"],
"desc": "启用指定插件",
},
"disablep": {
"alias": ["disablep", "禁用插件"],
"args": ["插件名"],
"desc": "禁用指定插件",
},
"installp": {
"alias": ["installp", "安装插件"],
"args": ["仓库地址或插件名"],
"desc": "安装指定插件",
},
"uninstallp": {
"alias": ["uninstallp", "卸载插件"],
"args": ["插件名"],
"desc": "卸载指定插件",
},
"updatep": {
"alias": ["updatep", "更新插件"],
"args": ["插件名"],
"desc": "更新指定插件",
},
"debug": {
"alias": ["debug", "调试模式", "DEBUG"],
"desc": "开启机器调试日志",
},
}


# 定义帮助函数
def get_help_text(isadmin, isgroup):
help_text = "通用指令\n"
for cmd, info in COMMANDS.items():
if cmd in ["auth", "set_openai_api_key", "reset_openai_api_key", "set_gpt_model", "reset_gpt_model", "gpt_model"]: # 不显示帮助指令
continue
if cmd == "id" and conf().get("channel_type", "wx") not in ["wxy", "wechatmp"]:
continue
alias = ["#" + a for a in info["alias"][:1]]
help_text += f"{','.join(alias)} "
if "args" in info:
args = [a for a in info["args"]]
help_text += f"{' '.join(args)}"
help_text += f": {info['desc']}\n"

# 插件指令
plugins = PluginManager().list_plugins()
help_text += "\n可用插件"
for plugin in plugins:
if plugins[plugin].enabled and not plugins[plugin].hidden:
namecn = plugins[plugin].namecn
help_text += "\n%s:" % namecn
help_text += PluginManager().instances[plugin].get_help_text(verbose=False).strip()

if ADMIN_COMMANDS and isadmin:
help_text += "\n\n管理员指令:\n"
for cmd, info in ADMIN_COMMANDS.items():
alias = ["#" + a for a in info["alias"][:1]]
help_text += f"{','.join(alias)} "
if "args" in info:
args = [a for a in info["args"]]
help_text += f"{' '.join(args)}"
help_text += f": {info['desc']}\n"
return help_text


@plugins.register(
name="Godcmd",
desire_priority=999,
hidden=True,
desc="为你的机器人添加指令集,有用户和管理员两种角色,加载顺序请放在首位,初次运行后插件目录会生成配置文件, 填充管理员密码后即可认证",
version="1.0",
author="lanvent",
)
class Godcmd(Plugin):
def __init__(self):
super().__init__()

config_path = os.path.join(os.path.dirname(__file__), "config.json")
gconf = super().load_config()
if not gconf:
if not os.path.exists(config_path):
gconf = {"password": "", "admin_users": []}
with open(config_path, "w") as f:
json.dump(gconf, f, indent=4)
if gconf["password"] == "":
self.temp_password = "".join(random.sample(string.digits, 4))
logger.info("[Godcmd] 因未设置口令,本次的临时口令为%s。" % self.temp_password)
else:
self.temp_password = None
custom_commands = conf().get("clear_memory_commands", [])
for custom_command in custom_commands:
if custom_command and custom_command.startswith("#"):
custom_command = custom_command[1:]
if custom_command and custom_command not in COMMANDS["reset"]["alias"]:
COMMANDS["reset"]["alias"].append(custom_command)

self.password = gconf["password"]
self.admin_users = gconf["admin_users"] # 预存的管理员账号,这些账号不需要认证。itchat的用户名每次都会变,不可用
global_config["admin_users"] = self.admin_users
self.isrunning = True # 机器人是否运行中

self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[Godcmd] inited")

def on_handle_context(self, e_context: EventContext):
context_type = e_context["context"].type
if context_type != ContextType.TEXT:
if not self.isrunning:
e_context.action = EventAction.BREAK_PASS
return

content = e_context["context"].content
logger.debug("[Godcmd] on_handle_context. content: %s" % content)
if content.startswith("#"):
if len(content) == 1:
reply = Reply()
reply.type = ReplyType.ERROR
reply.content = f"空指令,输入#help查看指令列表\n"
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
return
# msg = e_context['context']['msg']
channel = e_context["channel"]
user = e_context["context"]["receiver"]
session_id = e_context["context"]["session_id"]
isgroup = e_context["context"].get("isgroup", False)
bottype = Bridge().get_bot_type("chat")
bot = Bridge().get_bot("chat")
# 将命令和参数分割
command_parts = content[1:].strip().split()
cmd = command_parts[0]
args = command_parts[1:]
isadmin = False
if user in self.admin_users:
isadmin = True
ok = False
result = "string"
if any(cmd in info["alias"] for info in COMMANDS.values()):
cmd = next(c for c, info in COMMANDS.items() if cmd in info["alias"])
if cmd == "auth":
ok, result = self.authenticate(user, args, isadmin, isgroup)
elif cmd == "help" or cmd == "helpp":
if len(args) == 0:
ok, result = True, get_help_text(isadmin, isgroup)
else:
# This can replace the helpp command
plugins = PluginManager().list_plugins()
query_name = args[0].upper()
# search name and namecn
for name, plugincls in plugins.items():
if not plugincls.enabled:
continue
if query_name == name or query_name == plugincls.namecn:
ok, result = True, PluginManager().instances[name].get_help_text(isgroup=isgroup, isadmin=isadmin, verbose=True)
break
if not ok:
result = "插件不存在或未启用"
elif cmd == "model":
if not isadmin and not self.is_admin_in_group(e_context["context"]):
ok, result = False, "需要管理员权限执行"
elif len(args) == 0:
model = conf().get("model") or const.GPT35
ok, result = True, "当前模型为: " + str(model)
elif len(args) == 1:
if args[0] not in const.MODEL_LIST:
ok, result = False, "模型名称不存在"
else:
conf()["model"] = self.model_mapping(args[0])
Bridge().reset_bot()
model = conf().get("model") or const.GPT35
ok, result = True, "模型设置为: " + str(model)
elif cmd == "id":
ok, result = True, user
elif cmd == "set_openai_api_key":
if len(args) == 1:
user_data = conf().get_user_data(user)
user_data["openai_api_key"] = args[0]
ok, result = True, "你的OpenAI私有api_key已设置为" + args[0]
else:
ok, result = False, "请提供一个api_key"
elif cmd == "reset_openai_api_key":
try:
user_data = conf().get_user_data(user)
user_data.pop("openai_api_key")
ok, result = True, "你的OpenAI私有api_key已清除"
except Exception as e:
ok, result = False, "你没有设置私有api_key"
elif cmd == "set_gpt_model":
if len(args) == 1:
user_data = conf().get_user_data(user)
user_data["gpt_model"] = args[0]
ok, result = True, "你的GPT模型已设置为" + args[0]
else:
ok, result = False, "请提供一个GPT模型"
elif cmd == "gpt_model":
user_data = conf().get_user_data(user)
model = conf().get("model")
if "gpt_model" in user_data:
model = user_data["gpt_model"]
ok, result = True, "你的GPT模型为" + str(model)
elif cmd == "reset_gpt_model":
try:
user_data = conf().get_user_data(user)
user_data.pop("gpt_model")
ok, result = True, "你的GPT模型已重置"
except Exception as e:
ok, result = False, "你没有设置私有GPT模型"
elif cmd == "reset":
if bottype in [const.OPEN_AI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI, const.BAIDU, const.XUNFEI, const.QWEN, const.GEMINI, const.ZHIPU_AI]:
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:
ok, result = False, "当前对话机器人不支持重置会话"
logger.debug("[Godcmd] command: %s by %s" % (cmd, user))
elif any(cmd in info["alias"] for info in ADMIN_COMMANDS.values()):
if isadmin:
if isgroup:
ok, result = False, "群聊不可执行管理员指令"
else:
cmd = next(c for c, info in ADMIN_COMMANDS.items() if cmd in info["alias"])
if cmd == "stop":
self.isrunning = False
ok, result = True, "服务已暂停"
elif cmd == "resume":
self.isrunning = True
ok, result = True, "服务已恢复"
elif cmd == "reconf":
load_config()
ok, result = True, "配置已重载"
elif cmd == "resetall":
if bottype in [const.OPEN_AI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI,
const.BAIDU, const.XUNFEI, const.QWEN, const.GEMINI, const.ZHIPU_AI, const.MOONSHOT]:
channel.cancel_all_session()
bot.sessions.clear_all_session()
ok, result = True, "重置所有会话成功"
else:
ok, result = False, "当前对话机器人不支持重置会话"
elif cmd == "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
result = "插件列表:\n"
for name, plugincls in plugins.items():
result += f"{plugincls.name}_v{plugincls.version} {plugincls.priority} - "
if plugincls.enabled:
result += "已启用\n"
else:
result += "未启用\n"
elif cmd == "scanp":
new_plugins = PluginManager().scan_plugins()
ok, result = True, "插件扫描完成"
PluginManager().activate_plugins()
if len(new_plugins) > 0:
result += "\n发现新插件:\n"
result += "\n".join([f"{p.name}_v{p.version}" for p in new_plugins])
else:
result += ", 未发现新插件"
elif cmd == "setpri":
if len(args) != 2:
ok, result = False, "请提供插件名和优先级"
else:
ok = PluginManager().set_plugin_priority(args[0], int(args[1]))
if ok:
result = "插件" + args[0] + "优先级已设置为" + args[1]
else:
result = "插件不存在"
elif cmd == "reloadp":
if len(args) != 1:
ok, result = False, "请提供插件名"
else:
ok = PluginManager().reload_plugin(args[0])
if ok:
result = "插件配置已重载"
else:
result = "插件不存在"
elif cmd == "enablep":
if len(args) != 1:
ok, result = False, "请提供插件名"
else:
ok, result = PluginManager().enable_plugin(args[0])
elif cmd == "disablep":
if len(args) != 1:
ok, result = False, "请提供插件名"
else:
ok = PluginManager().disable_plugin(args[0])
if ok:
result = "插件已禁用"
else:
result = "插件不存在"
elif cmd == "installp":
if len(args) != 1:
ok, result = False, "请提供插件名或.git结尾的仓库地址"
else:
ok, result = PluginManager().install_plugin(args[0])
elif cmd == "uninstallp":
if len(args) != 1:
ok, result = False, "请提供插件名"
else:
ok, result = PluginManager().uninstall_plugin(args[0])
elif cmd == "updatep":
if len(args) != 1:
ok, result = False, "请提供插件名"
else:
ok, result = PluginManager().update_plugin(args[0])
logger.debug("[Godcmd] admin command: %s by %s" % (cmd, user))
else:
ok, result = False, "需要管理员权限才能执行该指令"
else:
trigger_prefix = conf().get("plugin_trigger_prefix", "$")
if trigger_prefix == "#": # 跟插件聊天指令前缀相同,继续递交
return
ok, result = False, f"未知指令:{cmd}\n查看指令列表请输入#help \n"

reply = Reply()
if ok:
reply.type = ReplyType.INFO
else:
reply.type = ReplyType.ERROR
reply.content = result
e_context["reply"] = reply

e_context.action = EventAction.BREAK_PASS # 事件结束,并跳过处理context的默认逻辑
elif not self.isrunning:
e_context.action = EventAction.BREAK_PASS

def authenticate(self, userid, args, isadmin, isgroup) -> Tuple[bool, str]:
if isgroup:
return False, "请勿在群聊中认证"

if isadmin:
return False, "管理员账号无需认证"

if len(args) != 1:
return False, "请提供口令"

password = args[0]
if password == self.password:
self.admin_users.append(userid)
global_config["admin_users"].append(userid)
return True, "认证成功"
elif password == self.temp_password:
self.admin_users.append(userid)
global_config["admin_users"].append(userid)
return True, "认证成功,请尽快设置口令"
else:
return False, "认证失败"

def get_help_text(self, isadmin=False, isgroup=False, **kwargs):
return get_help_text(isadmin, isgroup)


def is_admin_in_group(self, context):
if context["isgroup"]:
return context.kwargs.get("msg").actual_user_id in global_config["admin_users"]
return False


def model_mapping(self, model) -> str:
if model == "gpt-4-turbo":
return const.GPT4_TURBO_PREVIEW
return model

def reload(self):
gconf = plugin_config[self.name]
if gconf:
if gconf.get("password"):
self.password = gconf["password"]
if gconf.get("admin_users"):
self.admin_users = gconf["admin_users"]

+ 0
- 1
plugins/healthai/__init__.py 查看文件

@@ -1 +0,0 @@
from .healthai import *

+ 0
- 8
plugins/healthai/config.json 查看文件

@@ -1,8 +0,0 @@
{
"oss": {
"access_key_id": "LTAI5tRTG6pLhTpKACJYoPR5",
"access_key_secret":"E7dMzeeMxq4VQvLg7Tq7uKf3XWpYfN",
"endpoint":"http://oss-cn-shanghai.aliyuncs.com",
"bucket_name":"cow-agent"
}
}

+ 0
- 431
plugins/healthai/healthai.py 查看文件

@@ -1,431 +0,0 @@
import requests
import json
import plugins
from bridge.reply import Reply, ReplyType
from bridge.context import ContextType
from channel.chat_message import ChatMessage
from plugins import *
from common.log import logger
from common.expired_dict import ExpiredDict
import os
import base64
from pathlib import Path
from PIL import Image
import oss2
from lib import itchat
from lib.itchat.content import *
import re
from bot.session_manager import Session
from bot.session_manager import SessionManager

from bot.chatgpt.chat_gpt_session import ChatGPTSession

from common import kafka_helper

import time

@plugins.register(
name="healthai",
desire_priority=-1,
desc="A plugin for upload",
version="0.0.01",
author="",
)

class healthai(Plugin):
def __init__(self):
super().__init__()
try:
curdir = os.path.dirname(__file__)
config_path = os.path.join(curdir, "config.json")
if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f:
self.config = json.load(f)
else:
# 使用父类的方法来加载配置
self.config = super().load_config()
if not self.config:
raise Exception("config.json not found")
# 设置事件处理函数
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
self.params_cache = ExpiredDict(300)

# 从配置中提取所需的设置
self.oss = self.config.get("oss", {})
self.oss_access_key_id=self.oss.get("access_key_id","LTAI5tRTG6pLhTpKACJYoPR5")
self.oss_access_key_secret=self.oss.get("access_key_secret","E7dMzeeMxq4VQvLg7Tq7uKf3XWpYfN")
self.oss_endpoint=self.oss.get("endpoint","http://oss-cn-shanghai.aliyuncs.com")
self.oss_bucket_name=self.oss.get("bucket_name","cow-agent")
# 之前提示
self.previous_prompt=''

self.sessions = SessionManager(ChatGPTSession, model=conf().get("model") or "gpt-3.5-turbo")

# 初始化成功日志
logger.info("[healthai] inited.")
except Exception as e:
# 初始化失败日志
logger.warn(f"healthai init failed: {e}")


def on_handle_context(self, e_context: EventContext):
context = e_context["context"]

if context.type not in [ContextType.TEXT, ContextType.SHARING,ContextType.FILE,ContextType.IMAGE]:
return
msg: ChatMessage = e_context["context"]["msg"]
user_id = msg.from_user_id
content = context.content
isgroup = e_context["context"].get("isgroup", False)
context.get("msg").prepare()

logger.info(f'当前缓存:self.params_cache:{self.params_cache}')
print(f'输入内容:{content}')
print(f'类型:{context.type}')
if user_id not in self.params_cache:
self.params_cache[user_id] = {}
logger.info(f'初始化缓存:{self.params_cache}')

if context.type == ContextType.TEXT and user_id in self.params_cache:
self.params_cache[user_id]['previous_prompt']=content
logger.info(f'上次提示缓存:{self.params_cache}')

# if context.type == ContextType.TEXT and user_id in self.params_cache and contains_keywords(content):
# self.params_cache[user_id]['previous_prompt']=content
# logger.info(f'上次提示缓存:{self.params_cache}')
# session_id = context["session_id"]
# session = self.sessions.session_query(content, session_id)
# print(f'session 消息{session.messages}')
# if 'last_content' not in self.params_cache[user_id]:
# reply = Reply()
# reply.type = ReplyType.TEXT
# reply.content = f"请上传相关报告或图片"
# e_context["reply"] = reply
# e_context.action = EventAction.BREAK_PASS
session_id = context["session_id"]
print(f'会话id:{session_id}')

# friends=itchat.get_friends(update=True)[1:]

# # logger.info(f'好友列表{friends}')
# # 提取所有好友的 NickName
# nicknames = [friend['NickName'] for friend in friends]
# print(nicknames)
# 打印所有 NickName
# for nickname in nicknames:
# print(nickname)

session = self.sessions.build_session(session_id)
print(f'session 消息{session.messages}')
# if context.type == ContextType.TEXT and user_id in self.params_cache and contains_keywords(content):
# self.params_cache[user_id]['previous_prompt']=content
# logger.info(f'上次提示缓存:{self.params_cache}')
# session_id = context["session_id"]
# session = self.sessions.session_query(content, session_id)
# print(f'session 消息{session.messages}')
# if 'last_content' not in self.params_cache[user_id]:
# reply = Reply()
# reply.type = ReplyType.TEXT
# reply.content = f"请上传相关报告或图片"
# e_context["reply"] = reply
# e_context.action = EventAction.BREAK_PASS


if context.type in [ContextType.IMAGE]:
logger.info('处理上传')
file_path = context.content
logger.info(f"on_handle_context: 获取到图片路径 {file_path},{user_id in self.params_cache}")
if user_id in self.params_cache:
if 'previous_prompt' not in self.params_cache[user_id] and not e_context['context']['isgroup']:
reply = Reply()
reply.type = ReplyType.TEXT
reply.content = f"您刚刚上传图片,请问我有什么可以帮您的呢?"
e_context["reply"] = reply
e_context.action = EventAction.BREAK

file_content = upload_oss(self.oss_access_key_id, self.oss_access_key_secret, self.oss_endpoint, self.oss_bucket_name, file_path, f'cow/{os.path.basename(file_path)}')
# 确保 'last_content' 键存在,并且是一个列表
if 'last_content' not in self.params_cache[user_id]:
self.params_cache[user_id]['last_content'] = []

# 添加文件内容到 'urls' 列表
self.params_cache[user_id]['last_content'].append(file_content)

logger.info('删除图片')
os.remove(file_path)

input_content = file_content
input_from_user_nickname = msg.from_user_nickname
input_to_user_nickname = msg.to_user_nickname
input_wx_content_dialogue_message=[{"type": "image_url", "image_url": {"url": input_content}}]
input_message=dialogue_message(input_from_user_nickname,input_to_user_nickname,input_wx_content_dialogue_message)
kafka_helper.kafka_client.produce_message(input_message)
logger.info("发送对话 %s",input_message)



if context.type == ContextType.FILE:
logger.info('处理文件')
file_path = context.content
logger.info(f"on_handle_context: 获取到文件路径 {file_path}")
if user_id in self.params_cache:
if 'previous_prompt' not in self.params_cache[user_id] and not e_context['context']['isgroup']:
reply = Reply()
reply.type = ReplyType.TEXT
reply.content = f"您刚刚上传了一份文件,请问我有什么可以帮您的呢?"
e_context["reply"] = reply
e_context.action = EventAction.BREAK
# else:
print(f'准备抽取文字')
file_content=extract_content_by_llm(file_path,"sk-5z2L4zy9T1w90j6e3T90ANZdyN2zLWClRwFnBzWgzdrG4onx")
if file_content is None:
reply = Reply()
reply.type = ReplyType.TEXT
reply.content = f"不能处理这份文件"
e_context["reply"] = reply
e_context.action = EventAction.BREAK
return
else:
self.params_cache[user_id]['last_content']=file_content
logger.info('删除文件')
os.remove(file_path)

# input_content = file_content
# input_from_user_nickname = msg.from_user_nickname
# input_to_user_nickname = msg.to_user_nickname
# input_wx_content_dialogue_message=[{"type": "image_url", "image_url": {"url": input_content}}]
# input_message=dialogue_message(input_from_user_nickname,input_to_user_nickname,input_wx_content_dialogue_message)
# kafka_helper.kafka_client.produce_message(input_message)
# logger.info("发送对话 %s",input_message)

# 先回应
if 'previous_prompt' in self.params_cache[user_id] and 'last_content' in self.params_cache[user_id] and contains_keywords(self.params_cache[user_id]['previous_prompt']):
logger.info('先回应')
receiver=user_id
print(receiver)
text=self.params_cache[user_id]['previous_prompt']
logger.info(f'{text},{contains_keywords(text)}')

itchat_content= f'@{msg.actual_user_nickname}' if e_context['context']['isgroup'] else ''
itchat_content+="已经收到,立刻为您服务"
flag=contains_keywords(text)
if flag==True:
print('发送'+itchat_content)
itchat.send(itchat_content, toUserName=receiver)
e_context.action = EventAction.BREAK

# 图片和提示次齐全
if 'previous_prompt' in self.params_cache[user_id] and 'last_content' in self.params_cache[user_id]:
if contains_keywords(self.params_cache[user_id]['previous_prompt']):
e_context["context"].type = ContextType.TEXT
last_content=self.params_cache[user_id]['last_content']
prompt=self.params_cache[user_id]['previous_prompt']

# if isinstance(last_content, list):
# e_context["context"].content =self.generate_openai_messages_content(last_content,prompt)
# elif isinstance(last_content, str):
# e_context["context"].content ="<content>"+last_content+"</content>"+'\n\t'+"<ask>"+prompt+"</ask>"
# else:
# return "urls is neither a list nor a string"
e_context["context"].content =self.generate_openai_messages_content(last_content,prompt)
logger.info(f'插件处理上传文件或图片')
e_context.action = EventAction.CONTINUE
# 清空清空缓存
self.params_cache.clear()
logger.info(f'清空缓存后:{self.params_cache}')
else:
if not e_context['context']['isgroup']:
reply = Reply()
reply.type = ReplyType.TEXT
# reply.content = f"{remove_markdown(reply_content)}\n\n💬5min内输入{self.file_sum_qa_prefix}+问题,可继续追问"
reply.content = f"您刚刚上传了,请问我有什么可以帮您的呢?"
e_context["reply"] = reply
e_context.action = EventAction.BREAK
return

def generate_openai_messages_content(self, last_content,prompt):
content = []

if isinstance(last_content, list):
# 遍历每个 URL,生成对应的消息结构
for url in last_content:
if url.endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp')):
# 对于图片,生成 "image_url" 类型的消息
content.append({
"type": "image_url",
"image_url": {
"url": url
}
})
else:
# 对于其他文件,生成 "file_url" 或类似的处理方式
content.append({
"type": "file_url",
"file_url": {
"url": url
}
})
else:
prompt="<content>"+last_content+"</content>"+'\n\t'+"<ask>"+prompt+"</ask>"


# 遍历每个 URL,生成对应的消息结构
# for url in urls:
# if url.endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp')):
# # 对于图片,生成 "image_url" 类型的消息
# content.append({
# "type": "image_url",
# "image_url": {
# "url": url
# }
# })
# else:
# # 对于其他文件,生成 "file_url" 或类似的处理方式
# content.append({
# "type": "file_url",
# "file_url": {
# "url": url
# }
# })

# 添加额外的文本说明
content.append({
"type": "text",
"text": prompt
})

return json.dumps(content, ensure_ascii=False)

def remove_markdown(text):
# 替换Markdown的粗体标记
text = text.replace("**", "")
# 替换Markdown的标题标记
text = text.replace("### ", "").replace("## ", "").replace("# ", "")
return text

def extract_content_by_llm(file_path: str, api_key: str) -> str:
logger.info(f'大模型开始抽取文字')
try:
headers = {
'Authorization': f'Bearer {api_key}'
}
data = {
'purpose': 'file-extract',
}
file_name=os.path.basename(file_path)
files = {
'file': (file_name, open(Path(file_path), 'rb')),
}
# print(files)
api_url='https://api.moonshot.cn/v1/files'
response = requests.post(api_url, headers=headers, files=files, data=data)
response_data = response.json()
file_id = response_data.get('id')
response=requests.get(url=f"https://api.moonshot.cn/v1/files/{file_id}/content", headers=headers)
print(response.text)
response_data = response.json()
content = response_data.get('content')
return content
except requests.exceptions.RequestException as e:
logger.error(f"Error calling LLM API: {e}")
return None

def upload_oss(access_key_id, access_key_secret, endpoint, bucket_name, local_file_path, oss_file_name, expiration_days=7):
"""
上传文件到阿里云OSS并设置生命周期规则,同时返回文件的公共访问地址。
:param access_key_id: 阿里云AccessKey ID
:param access_key_secret: 阿里云AccessKey Secret
:param endpoint: OSS区域对应的Endpoint
:param bucket_name: OSS中的Bucket名称
:param local_file_path: 本地文件路径
:param oss_file_name: OSS中的文件存储路径
:param expiration_days: 文件保存天数,默认7天后删除
:return: 文件的公共访问地址
"""
# 创建Bucket实例
auth = oss2.Auth(access_key_id, access_key_secret)
bucket = oss2.Bucket(auth, endpoint, bucket_name)

### 1. 设置生命周期规则 ###
rule_id = f'delete_after_{expiration_days}_days' # 规则ID
prefix = oss_file_name.split('/')[0] + '/' # 设置规则应用的前缀为文件所在目录

# 定义生命周期规则
rule = oss2.models.LifecycleRule(rule_id, prefix, status=oss2.models.LifecycleRule.ENABLED,
expiration=oss2.models.LifecycleExpiration(days=expiration_days))

# 设置Bucket的生命周期
lifecycle = oss2.models.BucketLifecycle([rule])
bucket.put_bucket_lifecycle(lifecycle)

print(f"已设置生命周期规则:文件将在{expiration_days}天后自动删除")

### 2. 上传文件到OSS ###
bucket.put_object_from_file(oss_file_name, local_file_path)

### 3. 构建公共访问URL ###
file_url = f"http://{bucket_name}.{endpoint.replace('http://', '')}/{oss_file_name}"

print(f"文件上传成功,公共访问地址:{file_url}")
return file_url

def contains_keywords_by_re(text):
# 匹配<ask>标签中的内容
# match = re.search(r'<ask>(.*?)</ask>', text)
match = re.search(r'(.*?)', text)
if match:
content = match.group(1)
# 检查关键词
keywords = ['分析', '总结', '报告', '描述']
for keyword in keywords:
if keyword in content:
return True
return False

def contains_keywords(text):
keywords = ["分析", "总结", "报告", "描述","说说","讲述","讲讲","讲一下","图片"]
return any(keyword in text for keyword in keywords)

def dialogue_message(nickname_from,nickname_to,wx_content):
"""
构造消息的 JSON 数据
:param contents: list,包含多个消息内容,每个内容为字典,如:
[{"type": "text", "text": "AAAAAAA"},
{"type": "image_url", "image_url": {"url": "https://AAAAA.jpg"}},
{"type":"file","file_url":{"url":"https://AAAAA.pdf"}}
]
:return: JSON 字符串
"""

# 获取当前时间戳,精确到毫秒
current_timestamp = int(time.time() * 1000)

# 获取当前时间,格式化为 "YYYY-MM-DD HH:MM:SS"
current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
# 构造 JSON 数据
data = {
"messageId": str(current_timestamp),
"topic": "topic.aiops.wx",
"time": current_time,
"data": {
"msg_type": "dialogue",
"content": {
"nickname_from": nickname_from,
"nickname_to": nickname_to,
"wx_content":wx_content
}
}
}

return json.dumps(data, separators=(',', ':'), ensure_ascii=False)

+ 0
- 11
plugins/healthai/requirements.txt 查看文件

@@ -1,11 +0,0 @@
python-docx
markdown
PyMuPDF
openpyxl
beautifulsoup4
python-pptx
Pillow
oss2
pypinyin
confluent-kafka
kafka-python

+ 0
- 41
plugins/hello/README.md 查看文件

@@ -1,41 +0,0 @@
## 插件说明

可以根据需求设置入群欢迎、群聊拍一拍、退群等消息的自定义提示词,也支持为每个群设置对应的固定欢迎语。

该插件也是用户根据需求开发自定义插件的示例插件,参考[插件开发说明](https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins)

## 插件配置

将 `plugins/hello` 目录下的 `config.json.template` 配置模板复制为最终生效的 `config.json`。 (如果未配置则会默认使用`config.json.template`模板中配置)。

以下是插件配置项说明:

```bash
{
"group_welc_fixed_msg": { ## 这里可以为特定群里配置特定的固定欢迎语
"群聊1": "群聊1的固定欢迎语",
"群聊2": "群聊2的固定欢迎语"
},

"group_welc_prompt": "请你随机使用一种风格说一句问候语来欢迎新用户\"{nickname}\"加入群聊。", ## 群聊随机欢迎语的提示词

"group_exit_prompt": "请你随机使用一种风格跟其他群用户说他违反规则\"{nickname}\"退出群聊。", ## 移出群聊的提示词

"patpat_prompt": "请你随机使用一种风格介绍你自己,并告诉用户输入#help可以查看帮助信息。", ## 群内拍一拍的提示词
"use_character_desc": false ## 是否在Hello插件中使用LinkAI应用的系统设定
}
```


注意:

- 设置全局的用户进群固定欢迎语,可以在***项目根目录下***的`config.json`文件里,可以添加参数`"group_welcome_msg": "" `,参考 [#1482](https://github.com/zhayujie/chatgpt-on-wechat/pull/1482)
- 为每个群设置固定的欢迎语,可以在`"group_welc_fixed_msg": {}`配置群聊名和对应的固定欢迎语,优先级高于全局固定欢迎语
- 如果没有配置以上两个参数,则使用随机欢迎语,如需设定风格,语言等,修改`"group_welc_prompt": `即可
- 如果使用LinkAI的服务,想在随机欢迎中结合LinkAI应用的设定,配置`"use_character_desc": true `
- 实际 `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)




+ 0
- 1
plugins/hello/__init__.py 查看文件

@@ -1 +0,0 @@
from .hello import *

+ 0
- 14
plugins/hello/config.json.template 查看文件

@@ -1,14 +0,0 @@
{
"group_welc_fixed_msg": {
"群聊1": "群聊1的固定欢迎语",
"群聊2": "群聊2的固定欢迎语"
},

"group_welc_prompt": "请你随机使用一种风格说一句问候语来欢迎新用户\"{nickname}\"加入群聊。",

"group_exit_prompt": "请你随机使用一种风格跟其他群用户说他违反规则\"{nickname}\"退出群聊。",

"patpat_prompt": "请你随机使用一种风格介绍你自己,并告诉用户输入#help可以查看帮助信息。",

"use_character_desc": false
}

+ 0
- 127
plugins/hello/hello.py 查看文件

@@ -1,127 +0,0 @@
# encoding:utf-8

import plugins
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from channel.chat_message import ChatMessage
from common.log import logger
from plugins import *
from config import conf


@plugins.register(
name="Hello",
desire_priority=-1,
hidden=True,
desc="A simple plugin that says hello",
version="0.1",
author="lanvent",
)


class Hello(Plugin):

group_welc_prompt = "请你随机使用一种风格说一句问候语来欢迎新用户\"{nickname}\"加入群聊。"
group_exit_prompt = "请你随机使用一种风格介绍你自己,并告诉用户输入#help可以查看帮助信息。"
patpat_prompt = "请你随机使用一种风格跟其他群用户说他违反规则\"{nickname}\"退出群聊。"

def __init__(self):
super().__init__()
try:
self.config = super().load_config()
if not self.config:
self.config = self._load_config_template()
self.group_welc_fixed_msg = self.config.get("group_welc_fixed_msg", {})
self.group_welc_prompt = self.config.get("group_welc_prompt", self.group_welc_prompt)
self.group_exit_prompt = self.config.get("group_exit_prompt", self.group_exit_prompt)
self.patpat_prompt = self.config.get("patpat_prompt", self.patpat_prompt)
logger.info("[Hello] inited")
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
except Exception as e:
logger.error(f"[Hello]初始化异常:{e}")
raise "[Hello] init failed, ignore "

def on_handle_context(self, e_context: EventContext):
if e_context["context"].type not in [
ContextType.TEXT,
ContextType.JOIN_GROUP,
ContextType.PATPAT,
ContextType.EXIT_GROUP
]:
return
msg: ChatMessage = e_context["context"]["msg"]
group_name = msg.from_user_nickname
if e_context["context"].type == ContextType.JOIN_GROUP:
if "group_welcome_msg" in conf() or group_name in self.group_welc_fixed_msg:
reply = Reply()
reply.type = ReplyType.TEXT
if group_name in self.group_welc_fixed_msg:
reply.content = self.group_welc_fixed_msg.get(group_name, "")
else:
reply.content = conf().get("group_welcome_msg", "")
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束,并跳过处理context的默认逻辑
return
e_context["context"].type = ContextType.TEXT
e_context["context"].content = self.group_welc_prompt.format(nickname=msg.actual_user_nickname)
e_context.action = EventAction.BREAK # 事件结束,进入默认处理逻辑
if not self.config or not self.config.get("use_character_desc"):
e_context["context"]["generate_breaked_by"] = EventAction.BREAK
return
if e_context["context"].type == ContextType.EXIT_GROUP:
if conf().get("group_chat_exit_group"):
e_context["context"].type = ContextType.TEXT
e_context["context"].content = self.group_exit_prompt.format(nickname=msg.actual_user_nickname)
e_context.action = EventAction.BREAK # 事件结束,进入默认处理逻辑
return
e_context.action = EventAction.BREAK
return
if e_context["context"].type == ContextType.PATPAT:
e_context["context"].type = ContextType.TEXT
e_context["context"].content = self.patpat_prompt
e_context.action = EventAction.BREAK # 事件结束,进入默认处理逻辑
if not self.config or not self.config.get("use_character_desc"):
e_context["context"]["generate_breaked_by"] = EventAction.BREAK
return

content = e_context["context"].content
logger.debug("[Hello] on_handle_context. content: %s" % content)
if content == "Hello":
reply = Reply()
reply.type = ReplyType.TEXT
if e_context["context"]["isgroup"]:
reply.content = f"Hello, {msg.actual_user_nickname} from {msg.from_user_nickname}"
else:
reply.content = f"Hello, {msg.from_user_nickname}"
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束,并跳过处理context的默认逻辑

if content == "Hi":
reply = Reply()
reply.type = ReplyType.TEXT
reply.content = "Hi"
e_context["reply"] = reply
e_context.action = EventAction.BREAK # 事件结束,进入默认处理逻辑,一般会覆写reply

if content == "End":
# 如果是文本消息"End",将请求转换成"IMAGE_CREATE",并将content设置为"The World"
e_context["context"].type = ContextType.IMAGE_CREATE
content = "The World"
e_context.action = EventAction.CONTINUE # 事件继续,交付给下个插件或默认逻辑

def get_help_text(self, **kwargs):
help_text = "输入Hello,我会回复你的名字\n输入End,我会回复你世界的图片\n"
return help_text

def _load_config_template(self):
logger.debug("No Hello plugin config.json, use plugins/hello/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)
return plugin_conf
except Exception as e:
logger.exception(e)

+ 0
- 13
plugins/keyword/README.md 查看文件

@@ -1,13 +0,0 @@
# 目的
关键字匹配并回复

# 试用场景
目前是在微信公众号下面使用过。

# 使用步骤
1. 复制 `config.json.template` 为 `config.json`
2. 在关键字 `keyword` 新增需要关键字匹配的内容
3. 重启程序做验证

# 验证结果
![结果](test-keyword.png)

+ 0
- 1
plugins/keyword/__init__.py 查看文件

@@ -1 +0,0 @@
from .keyword import *

+ 0
- 3
plugins/keyword/config.json 查看文件

@@ -1,3 +0,0 @@
{
"keyword": {}
}

+ 0
- 5
plugins/keyword/config.json.template 查看文件

@@ -1,5 +0,0 @@
{
"keyword": {
"关键字匹配": "测试成功"
}
}

+ 0
- 96
plugins/keyword/keyword.py 查看文件

@@ -1,96 +0,0 @@
# encoding:utf-8

import json
import os
import requests
import plugins
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
from plugins import *


@plugins.register(
name="Keyword",
desire_priority=900,
hidden=True,
desc="关键词匹配过滤",
version="0.1",
author="fengyege.top",
)
class Keyword(Plugin):
def __init__(self):
super().__init__()
try:
curdir = os.path.dirname(__file__)
config_path = os.path.join(curdir, "config.json")
conf = None
if not os.path.exists(config_path):
logger.debug(f"[keyword]不存在配置文件{config_path}")
conf = {"keyword": {}}
with open(config_path, "w", encoding="utf-8") as f:
json.dump(conf, f, indent=4)
else:
logger.debug(f"[keyword]加载配置文件{config_path}")
with open(config_path, "r", encoding="utf-8") as f:
conf = json.load(f)
# 加载关键词
self.keyword = conf["keyword"]

logger.info("[keyword] {}".format(self.keyword))
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[keyword] inited.")
except Exception as e:
logger.warn("[keyword] init failed, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/keyword .")
raise e

def on_handle_context(self, e_context: EventContext):
if e_context["context"].type != ContextType.TEXT:
return

content = e_context["context"].content.strip()
logger.debug("[keyword] on_handle_context. content: %s" % content)
if content in self.keyword:
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", ".webp", ".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()
reply.type = ReplyType.TEXT
reply.content = reply_text
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束,并跳过处理context的默认逻辑
def get_help_text(self, **kwargs):
help_text = "关键词过滤"
return help_text

二進制
plugins/keyword/test-keyword.png 查看文件

Before After
Width: 474  |  Height: 124  |  Size: 12KB

+ 0
- 109
plugins/linkai/README.md 查看文件

@@ -1,109 +0,0 @@
## 插件说明

基于 LinkAI 提供的知识库、Midjourney绘画、文档对话等能力对机器人的功能进行增强。平台地址: https://link-ai.tech/console

## 插件配置

将 `plugins/linkai` 目录下的 `config.json.template` 配置模板复制为最终生效的 `config.json`。 (如果未配置则会默认使用`config.json.template`模板中配置,但功能默认关闭,需要可通过指令进行开启)。

以下是插件配置项说明:

```bash
{
"group_app_map": { # 群聊 和 应用编码 的映射关系
"测试群名称1": "default", # 表示在名称为 "测试群名称1" 的群聊中将使用app_code 为 default 的应用
"测试群名称2": "Kv2fXJcH"
},
"midjourney": {
"enabled": true, # midjourney 绘画开关
"auto_translate": true, # 是否自动将提示词翻译为英文
"img_proxy": true, # 是否对生成的图片使用代理,如果你是国外服务器,将这一项设置为false会获得更快的生成速度
"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": 5000, # 文件的大小限制,单位KB,默认为5M,超过该大小直接忽略
"type": ["FILE", "SHARING", "IMAGE"] # 支持总结的类型,分别表示 文件、分享链接、图片,其中文件和链接默认打开,图片默认关闭
}
}
```

根目录 `config.json` 中配置,`API_KEY` 在 [控制台](https://link-ai.tech/console/interface) 中创建并复制过来:

```bash
"linkai_api_key": "Link_xxxxxxxxx"
```

注意:

- 配置项中 `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绘画 和 summary文档总结对话功能则只需填写 `linkai_api_key` 配置,`use_linkai` 无论是否关闭均可使用。具体可参考 [详细文档](https://link-ai.tech/platform/link-app/wechat)。

完成配置后运行项目,会自动运行插件,输入 `#help linkai` 可查看插件功能。

### 1.知识库管理功能

提供在不同群聊使用不同应用的功能。可以在上述 `group_app_map` 配置中固定映射关系,也可以通过指令在群中快速完成切换。

应用切换指令需要首先完成管理员 (`godcmd`) 插件的认证,然后按以下格式输入:

`$linkai app {app_code}`

例如输入 `$linkai app Kv2fXJcH`,即将当前群聊与 app_code为 Kv2fXJcH 的应用绑定。

另外,还可以通过 `$linkai close` 来一键关闭linkai对话,此时就会使用默认的openai接口;同理,发送 `$linkai open` 可以再次开启。

### 2.Midjourney绘画功能

若未配置 `plugins/linkai/config.json`,默认会关闭画图功能,直接使用 `$mj open` 可基于默认配置直接使用mj画图。

指令格式:

```
- 图片生成: $mj 描述词1, 描述词2..
- 图片放大: $mju 图片ID 图片序号
- 图片变换: $mjv 图片ID 图片序号
- 重置: $mjr 图片ID
```

例如:

```
"$mj a little cat, white --ar 9:16"
"$mju 1105592717188272288 2"
"$mjv 11055927171882 2"
"$mjr 11055927171882"
```

注意事项:
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` 指令开启该功能。

#### 使用

功能开启后,向机器人发送 **文件**、 **分享链接卡片**、**图片** 即可生成摘要,进一步可以与文件或链接的内容进行多轮对话。如果需要关闭某种类型的内容总结,设置 `summary`配置中的type字段即可。

#### 限制

1. 文件目前 支持 `txt`, `docx`, `pdf`, `md`, `csv`格式,文件大小由 `max_file_size` 限制,最大不超过15M,文件字数最多可支持百万字的文件。但不建议上传字数过多的文件,一是token消耗过大,二是摘要很难覆盖到全部内容,只能通过多轮对话来了解细节。
2. 分享链接 目前仅支持 公众号文章,后续会支持更多文章类型及视频链接等
3. 总结及对话的 费用与 LinkAI 3.5-4K 模型的计费方式相同,按文档内容的tokens进行计算

+ 0
- 1
plugins/linkai/__init__.py 查看文件

@@ -1 +0,0 @@
from .linkai import *

+ 0
- 20
plugins/linkai/config.json.template 查看文件

@@ -1,20 +0,0 @@
{
"group_app_map": {
"测试群名1": "default",
"测试群名2": "Kv2fXJcH"
},
"midjourney": {
"enabled": true,
"auto_translate": true,
"img_proxy": true,
"max_tasks": 3,
"max_tasks_per_user": 1,
"use_image_create_prefix": true
},
"summary": {
"enabled": true,
"group_enabled": true,
"max_file_size": 5000,
"type": ["FILE", "SHARING"]
}
}

+ 0
- 299
plugins/linkai/linkai.py 查看文件

@@ -1,299 +0,0 @@
import plugins
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
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
from .utils import Util
from config import plugin_config


@plugins.register(
name="linkai",
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(f"[LinkAI] inited, config={self.config}")

def on_handle_context(self, e_context: EventContext):
"""
消息处理逻辑
:param e_context: 消息上下文
"""
if not self.config:
return

context = e_context['context']
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 in [ContextType.FILE, ContextType.IMAGE] 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
if context.type != ContextType.IMAGE:
_send_info(e_context, "正在为你加速生成摘要,请稍后")
res = LinkSummary().summary_file(file_path)
if not res:
if context.type != ContextType.IMAGE:
_set_reply_text("因为神秘力量无法获取内容,请稍后再试吧", e_context, level=ReplyType.TEXT)
return
summary_text = res.get("summary")
if context.type != ContextType.IMAGE:
USER_FILE_MAP[_find_user_id(context) + "-sum_id"] = res.get("summary_id")
summary_text += "\n\n💬 发送 \"开启对话\" 可以开启与文件内容的对话"
_set_reply_text(summary_text, 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 self._is_summary_open(context) 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, level=ReplyType.TEXT)
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作图任务处理
self.mj_bot.process_mj_task(mj_type, e_context)
return

if context.content.startswith(f"{_get_trigger_prefix()}linkai"):
# 应用管理功能
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']
cmd = context.content.split()
if len(cmd) == 1 or (len(cmd) == 2 and cmd[1] == "help"):
_set_reply_text(self.get_help_text(verbose=True), e_context, level=ReplyType.INFO)
return

if len(cmd) == 2 and (cmd[1] == "open" or cmd[1] == "close"):
# 知识库开关指令
if not Util.is_admin(e_context):
_set_reply_text("需要管理员权限执行", e_context, level=ReplyType.ERROR)
return
is_open = True
tips_text = "开启"
if cmd[1] == "close":
tips_text = "关闭"
is_open = False
conf()["use_linkai"] = is_open
bridge.Bridge().reset_bot()
_set_reply_text(f"LinkAI对话功能{tips_text}", e_context, level=ReplyType.INFO)
return

if len(cmd) == 3 and cmd[1] == "app":
# 知识库应用切换指令
if not context.kwargs.get("isgroup"):
_set_reply_text("该指令需在群聊中使用", e_context, level=ReplyType.ERROR)
return
if not Util.is_admin(e_context):
_set_reply_text("需要管理员权限执行", e_context, level=ReplyType.ERROR)
return
app_code = cmd[2]
group_name = context.kwargs.get("msg").from_user_nickname
group_mapping = self.config.get("group_app_map")
if group_mapping:
group_mapping[group_name] = app_code
else:
self.config["group_app_map"] = {group_name: app_code}
# 保存插件配置
super().save_config(self.config)
_set_reply_text(f"应用设置成功: {app_code}", e_context, level=ReplyType.INFO)
return

if len(cmd) == 3 and cmd[1] == "sum" and (cmd[2] == "open" or cmd[2] == "close"):
# 知识库开关指令
if not Util.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 context.kwargs.get("isgroup") and not self.sum_config.get("group_enabled"):
return False
support_type = self.sum_config.get("type") or ["FILE", "SHARING"]
if context.type.name not in support_type and context.type.name != "TEXT":
return False
return True

# LinkAI 对话任务处理
def _is_chat_task(self, e_context: EventContext):
context = e_context['context']
# 群聊应用管理
return self.config.get("group_app_map") and context.kwargs.get("isgroup")

def _process_chat_task(self, e_context: EventContext):
"""
处理LinkAI对话任务
:param e_context: 对话上下文
"""
context = e_context['context']
# 群聊应用管理
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

def _fetch_group_app_code(self, group_name: str) -> str:
"""
根据群聊名称获取对应的应用code
:param group_name: 群聊名称
:return: 应用code
"""
group_mapping = self.config.get("group_app_map")
if group_mapping:
app_code = group_mapping.get(group_name) or group_mapping.get("ALL_GROUP")
return app_code

def get_help_text(self, verbose=False, **kwargs):
trigger_prefix = _get_trigger_prefix()
help_text = "用于集成 LinkAI 提供的知识库、Midjourney绘画、文档总结、联网搜索等能力。\n\n"
if not verbose:
return help_text
help_text += f'📖 知识库\n - 群聊中指定应用: {trigger_prefix}linkai app 应用编码\n'
help_text += f' - {trigger_prefix}linkai open: 开启对话\n'
help_text += f' - {trigger_prefix}linkai close: 关闭对话\n'
help_text += f'\n例如: \n"{trigger_prefix}linkai app Kv2fXJcH"\n\n'
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
plugin_config["linkai"] = plugin_conf
return plugin_conf
except Exception as e:
logger.exception(e)

def reload(self):
self.config = super().load_config()


def _send_info(e_context: EventContext, content: str):
reply = Reply(ReplyType.TEXT, content)
channel = e_context["channel"]
channel.send(reply, e_context["context"])


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):
user_id = _find_user_id(context)
if user_id:
return USER_FILE_MAP.get(user_id + "-file_id")


USER_FILE_MAP = ExpiredDict(conf().get("expires_in_seconds") or 60 * 30)

+ 0
- 432
plugins/linkai/midjourney.py 查看文件

@@ -1,432 +0,0 @@
from enum import Enum
from config import conf
from common.log import logger
import requests
import threading
import time
from bridge.reply import Reply, ReplyType
import asyncio
from bridge.context import ContextType
from plugins import EventContext, EventAction
from .utils import Util

INVALID_REQUEST = 410
NOT_FOUND_ORIGIN_IMAGE = 461
NOT_FOUND_TASK = 462


class TaskType(Enum):
GENERATE = "generate"
UPSCALE = "upscale"
VARIATION = "variation"
RESET = "reset"

def __str__(self):
return self.name


class Status(Enum):
PENDING = "pending"
FINISHED = "finished"
EXPIRED = "expired"
ABORTED = "aborted"

def __str__(self):
return self.name


class TaskMode(Enum):
FAST = "fast"
RELAX = "relax"


task_name_mapping = {
TaskType.GENERATE.name: "生成",
TaskType.UPSCALE.name: "放大",
TaskType.VARIATION.name: "变换",
TaskType.RESET.name: "重新生成",
}


class MJTask:
def __init__(self, id, user_id: str, task_type: TaskType, raw_prompt=None, expires: int = 60 * 6,
status=Status.PENDING):
self.id = id
self.user_id = user_id
self.task_type = task_type
self.raw_prompt = raw_prompt
self.send_func = None # send_func(img_url)
self.expiry_time = time.time() + expires
self.status = status
self.img_url = None # url
self.img_id = None

def __str__(self):
return f"id={self.id}, user_id={self.user_id}, task_type={self.task_type}, status={self.status}, img_id={self.img_id}"


# midjourney bot
class MJBot:
def __init__(self, config):
self.base_url = conf().get("linkai_api_base", "https://api.link-ai.tech") + "/v1/img/midjourney"
self.headers = {"Authorization": "Bearer " + conf().get("linkai_api_key")}
self.config = config
self.tasks = {}
self.temp_dict = {}
self.tasks_lock = threading.Lock()
self.event_loop = asyncio.new_event_loop()

def judge_mj_task_type(self, e_context: EventContext):
"""
判断MJ任务的类型
:param e_context: 上下文
:return: 任务类型枚举
"""
if not self.config:
return None
trigger_prefix = conf().get("plugin_trigger_prefix", "$")
context = e_context['context']
if context.type == ContextType.TEXT:
cmd_list = context.content.split(maxsplit=1)
if not cmd_list:
return None
if cmd_list[0].lower() == f"{trigger_prefix}mj":
return TaskType.GENERATE
elif cmd_list[0].lower() == f"{trigger_prefix}mju":
return TaskType.UPSCALE
elif cmd_list[0].lower() == f"{trigger_prefix}mjv":
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") and self.config.get("enabled"):
return TaskType.GENERATE

def process_mj_task(self, mj_type: TaskType, e_context: EventContext):
"""
处理mj任务
:param mj_type: mj任务类型
:param e_context: 对话上下文
"""
context = e_context['context']
session_id = context["session_id"]
cmd = context.content.split(maxsplit=1)
if len(cmd) == 1 and context.type == ContextType.TEXT:
# midjourney 帮助指令
self._set_reply_text(self.get_help_text(verbose=True), e_context, level=ReplyType.INFO)
return

if len(cmd) == 2 and (cmd[1] == "open" or cmd[1] == "close"):
if not Util.is_admin(e_context):
Util.set_reply_text("需要管理员权限执行", e_context, level=ReplyType.ERROR)
return
# midjourney 开关指令
is_open = True
tips_text = "开启"
if cmd[1] == "close":
tips_text = "关闭"
is_open = False
self.config["enabled"] = is_open
self._set_reply_text(f"Midjourney绘画已{tips_text}", e_context, level=ReplyType.INFO)
return

if not self.config.get("enabled"):
logger.warn("Midjourney绘画未开启,请查看 plugins/linkai/config.json 中的配置")
self._set_reply_text(f"Midjourney绘画未开启", e_context, level=ReplyType.INFO)
return

if not self._check_rate_limit(session_id, e_context):
logger.warn("[MJ] midjourney task exceed rate limit")
return

if mj_type == TaskType.GENERATE:
if context.type == ContextType.IMAGE_CREATE:
raw_prompt = context.content
else:
# 图片生成
raw_prompt = cmd[1]
reply = self.generate(raw_prompt, session_id, e_context)
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS
return

elif mj_type == TaskType.UPSCALE or mj_type == TaskType.VARIATION:
# 图片放大/变换
clist = cmd[1].split()
if len(clist) < 2:
self._set_reply_text(f"{cmd[0]} 命令缺少参数", e_context)
return
img_id = clist[0]
index = int(clist[1])
if index < 1 or index > 4:
self._set_reply_text(f"图片序号 {index} 错误,应在 1 至 4 之间", e_context)
return
key = f"{str(mj_type)}_{img_id}_{index}"
if self.temp_dict.get(key):
self._set_reply_text(f"第 {index} 张图片已经{task_name_mapping.get(str(mj_type))}过了", e_context)
return
# 执行图片放大/变换操作
reply = self.do_operate(mj_type, session_id, img_id, e_context, index)
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS
return

elif mj_type == TaskType.RESET:
# 图片重新生成
clist = cmd[1].split()
if len(clist) < 1:
self._set_reply_text(f"{cmd[0]} 命令缺少参数", e_context)
return
img_id = clist[0]
# 图片重新生成
reply = self.do_operate(mj_type, session_id, img_id, e_context)
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS
else:
self._set_reply_text(f"暂不支持该命令", e_context)

def generate(self, prompt: str, user_id: str, e_context: EventContext) -> Reply:
"""
图片生成
:param prompt: 提示词
:param user_id: 用户id
:param e_context: 对话上下文
:return: 任务ID
"""
logger.info(f"[MJ] image generate, prompt={prompt}")
mode = self._fetch_mode(prompt)
body = {"prompt": prompt, "mode": mode, "auto_translate": self.config.get("auto_translate")}
if not self.config.get("img_proxy"):
body["img_proxy"] = False
res = requests.post(url=self.base_url + "/generate", json=body, headers=self.headers, timeout=(5, 40))
if res.status_code == 200:
res = res.json()
logger.debug(f"[MJ] image generate, res={res}")
if res.get("code") == 200:
task_id = res.get("data").get("task_id")
real_prompt = res.get("data").get("real_prompt")
if mode == TaskMode.RELAX.value:
time_str = "1~10分钟"
else:
time_str = "1分钟"
content = f"🚀您的作品将在{time_str}左右完成,请耐心等待\n- - - - - - - - -\n"
if real_prompt:
content += f"初始prompt: {prompt}\n转换后prompt: {real_prompt}"
else:
content += f"prompt: {prompt}"
reply = Reply(ReplyType.INFO, content)
task = MJTask(id=task_id, status=Status.PENDING, raw_prompt=prompt, user_id=user_id,
task_type=TaskType.GENERATE)
# put to memory dict
self.tasks[task.id] = task
# asyncio.run_coroutine_threadsafe(self.check_task(task, e_context), self.event_loop)
self._do_check_task(task, e_context)
return reply
else:
res_json = res.json()
logger.error(f"[MJ] generate error, msg={res_json.get('message')}, status_code={res.status_code}")
if res.status_code == INVALID_REQUEST:
reply = Reply(ReplyType.ERROR, "图片生成失败,请检查提示词参数或内容")
else:
reply = Reply(ReplyType.ERROR, "图片生成失败,请稍后再试")
return reply

def do_operate(self, task_type: TaskType, user_id: str, img_id: str, e_context: EventContext,
index: int = None) -> Reply:
logger.info(f"[MJ] image operate, task_type={task_type}, img_id={img_id}, index={index}")
body = {"type": task_type.name, "img_id": img_id}
if index:
body["index"] = index
if not self.config.get("img_proxy"):
body["img_proxy"] = False
res = requests.post(url=self.base_url + "/operate", json=body, headers=self.headers, timeout=(5, 40))
logger.debug(res)
if res.status_code == 200:
res = res.json()
if res.get("code") == 200:
task_id = res.get("data").get("task_id")
logger.info(f"[MJ] image operate processing, task_id={task_id}")
icon_map = {TaskType.UPSCALE: "🔎", TaskType.VARIATION: "🪄", TaskType.RESET: "🔄"}
content = f"{icon_map.get(task_type)}图片正在{task_name_mapping.get(task_type.name)}中,请耐心等待"
reply = Reply(ReplyType.INFO, content)
task = MJTask(id=task_id, status=Status.PENDING, user_id=user_id, task_type=task_type)
# put to memory dict
self.tasks[task.id] = task
key = f"{task_type.name}_{img_id}_{index}"
self.temp_dict[key] = True
# asyncio.run_coroutine_threadsafe(self.check_task(task, e_context), self.event_loop)
self._do_check_task(task, e_context)
return reply
else:
error_msg = ""
if res.status_code == NOT_FOUND_ORIGIN_IMAGE:
error_msg = "请输入正确的图片ID"
res_json = res.json()
logger.error(f"[MJ] operate error, msg={res_json.get('message')}, status_code={res.status_code}")
reply = Reply(ReplyType.ERROR, error_msg or "图片生成失败,请稍后再试")
return reply

def check_task_sync(self, task: MJTask, e_context: EventContext):
logger.debug(f"[MJ] start check task status, {task}")
max_retry_times = 90
while max_retry_times > 0:
time.sleep(10)
url = f"{self.base_url}/tasks/{task.id}"
try:
res = requests.get(url, headers=self.headers, timeout=8)
if res.status_code == 200:
res_json = res.json()
logger.debug(f"[MJ] task check res sync, task_id={task.id}, status={res.status_code}, "
f"data={res_json.get('data')}, thread={threading.current_thread().name}")
if res_json.get("data") and res_json.get("data").get("status") == Status.FINISHED.name:
# process success res
if self.tasks.get(task.id):
self.tasks[task.id].status = Status.FINISHED
self._process_success_task(task, res_json.get("data"), e_context)
return
max_retry_times -= 1
else:
res_json = res.json()
logger.warn(f"[MJ] image check error, status_code={res.status_code}, res={res_json}")
max_retry_times -= 20
except Exception as e:
max_retry_times -= 20
logger.warn(e)
logger.warn("[MJ] end from poll")
if self.tasks.get(task.id):
self.tasks[task.id].status = Status.EXPIRED

def _do_check_task(self, task: MJTask, e_context: EventContext):
threading.Thread(target=self.check_task_sync, args=(task, e_context)).start()

def _process_success_task(self, task: MJTask, res: dict, e_context: EventContext):
"""
处理任务成功的结果
:param task: MJ任务
:param res: 请求结果
:param e_context: 对话上下文
"""
# channel send img
task.status = Status.FINISHED
task.img_id = res.get("img_id")
task.img_url = res.get("img_url")
logger.info(f"[MJ] task success, task_id={task.id}, img_id={task.img_id}, img_url={task.img_url}")

# send img
reply = Reply(ReplyType.IMAGE_URL, task.img_url)
channel = e_context["channel"]
_send(channel, reply, e_context["context"])

# send info
trigger_prefix = conf().get("plugin_trigger_prefix", "$")
text = ""
if task.task_type == TaskType.GENERATE or task.task_type == TaskType.VARIATION or task.task_type == TaskType.RESET:
text = f"🎨绘画完成!\n"
if task.raw_prompt:
text += f"prompt: {task.raw_prompt}\n"
text += f"- - - - - - - - -\n图片ID: {task.img_id}"
text += f"\n\n🔎使用 {trigger_prefix}mju 命令放大图片\n"
text += f"例如:\n{trigger_prefix}mju {task.img_id} 1"
text += f"\n\n🪄使用 {trigger_prefix}mjv 命令变换图片\n"
text += f"例如:\n{trigger_prefix}mjv {task.img_id} 1"
text += f"\n\n🔄使用 {trigger_prefix}mjr 命令重新生成图片\n"
text += f"例如:\n{trigger_prefix}mjr {task.img_id}"
reply = Reply(ReplyType.INFO, text)
_send(channel, reply, e_context["context"])

self._print_tasks()
return

def _check_rate_limit(self, user_id: str, e_context: EventContext) -> bool:
"""
midjourney任务限流控制
:param user_id: 用户id
:param e_context: 对话上下文
:return: 任务是否能够生成, True:可以生成, False: 被限流
"""
tasks = self.find_tasks_by_user_id(user_id)
task_count = len([t for t in tasks if t.status == Status.PENDING])
if task_count >= self.config.get("max_tasks_per_user"):
reply = Reply(ReplyType.INFO, "您的Midjourney作图任务数已达上限,请稍后再试")
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
return False
task_count = len([t for t in self.tasks.values() if t.status == Status.PENDING])
if task_count >= self.config.get("max_tasks"):
reply = Reply(ReplyType.INFO, "Midjourney作图任务数已达上限,请稍后再试")
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
return False
return True

def _fetch_mode(self, prompt) -> str:
mode = self.config.get("mode")
if "--relax" in prompt or mode == TaskMode.RELAX.value:
return TaskMode.RELAX.value
return mode or TaskMode.FAST.value

def _run_loop(self, loop: asyncio.BaseEventLoop):
"""
运行事件循环,用于轮询任务的线程
:param loop: 事件循环
"""
loop.run_forever()
loop.stop()

def _print_tasks(self):
for id in self.tasks:
logger.debug(f"[MJ] current task: {self.tasks[id]}")

def _set_reply_text(self, content: str, e_context: EventContext, level: ReplyType = ReplyType.ERROR):
"""
设置回复文本
:param content: 回复内容
:param e_context: 对话上下文
:param level: 回复等级
"""
reply = Reply(level, content)
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS

def get_help_text(self, verbose=False, **kwargs):
trigger_prefix = conf().get("plugin_trigger_prefix", "$")
help_text = "🎨利用Midjourney进行画图\n\n"
if not verbose:
return help_text
help_text += f" - 生成: {trigger_prefix}mj 描述词1, 描述词2.. \n - 放大: {trigger_prefix}mju 图片ID 图片序号\n - 变换: mjv 图片ID 图片序号\n - 重置: 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\""
return help_text

def find_tasks_by_user_id(self, user_id) -> list:
result = []
with self.tasks_lock:
now = time.time()
for task in self.tasks.values():
if task.status == Status.PENDING and now > task.expiry_time:
task.status = Status.EXPIRED
logger.info(f"[MJ] {task} expired")
if task.user_id == user_id:
result.append(task)
return result


def _send(channel, reply: Reply, context, retry_cnt=0):
try:
channel.send(reply, context)
except Exception as e:
logger.error("[WX] sendMsg error: {}".format(str(e)))
if isinstance(e, NotImplementedError):
return
logger.exception(e)
if retry_cnt < 2:
time.sleep(3 + 3 * retry_cnt)
channel.send(reply, context, retry_cnt + 1)


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

+ 0
- 96
plugins/linkai/summary.py 查看文件

@@ -1,96 +0,0 @@
import requests
from config import conf
from common.log import logger
import os
import html


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],
}
url = self.base_url() + "/v1/summary/file"
res = requests.post(url, headers=self.headers(), files=file_body, timeout=(5, 300))
return self._parse_summary_res(res)

def summary_url(self, url: str):
url = html.unescape(url)
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.tech")

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 False

suffix = file_path.split(".")[-1]
support_list = ["txt", "csv", "docx", "pdf", "md", "jpg", "jpeg", "png"]
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"]
black_support_list = ["https://mp.weixin.qq.com/mp/waerrpage"]
for black_url_prefix in black_support_list:
if url.strip().startswith(black_url_prefix):
logger.warn(f"[LinkSum] unsupported url, no need to process, url={url}")
return False
for support_url in support_list:
if url.strip().startswith(support_url):
return True
return False

+ 0
- 28
plugins/linkai/utils.py 查看文件

@@ -1,28 +0,0 @@
from config import global_config
from bridge.reply import Reply, ReplyType
from plugins.event import EventContext, EventAction


class Util:
@staticmethod
def is_admin(e_context: EventContext) -> bool:
"""
判断消息是否由管理员用户发送
:param e_context: 消息上下文
:return: True: 是, False: 否
"""
context = e_context["context"]
if context["isgroup"]:
actual_user_id = context.kwargs.get("msg").actual_user_id
for admin_user in global_config["admin_users"]:
if actual_user_id and actual_user_id in admin_user:
return True
return False
else:
return context["receiver"] in global_config["admin_users"]

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

+ 0
- 52
plugins/plugin.py 查看文件

@@ -1,52 +0,0 @@
import os
import json
from config import pconf, plugin_config, conf
from common.log import logger


class Plugin:
def __init__(self):
self.handlers = {}

def load_config(self) -> dict:
"""
加载当前插件配置
:return: 插件配置字典
"""
# 优先获取 plugins/config.json 中的全局配置
plugin_conf = pconf(self.name)
if not plugin_conf:
# 全局配置不存在,则获取插件目录下的配置
plugin_config_path = os.path.join(self.path, "config.json")
logger.debug(f"loading plugin config, plugin_config_path={plugin_config_path}, exist={os.path.exists(plugin_config_path)}")
if os.path.exists(plugin_config_path):
with open(plugin_config_path, "r", encoding="utf-8") as f:
plugin_conf = json.load(f)

# 写入全局配置内存
plugin_config[self.name] = plugin_conf
logger.debug(f"loading plugin config, plugin_name={self.name}, conf={plugin_conf}")
return plugin_conf

def save_config(self, config: dict):
try:
plugin_config[self.name] = config
# 写入全局配置
global_config_path = "./plugins/config.json"
if os.path.exists(global_config_path):
with open(global_config_path, "w", encoding='utf-8') as f:
json.dump(plugin_config, f, indent=4, ensure_ascii=False)
# 写入插件配置
plugin_config_path = os.path.join(self.path, "config.json")
if os.path.exists(plugin_config_path):
with open(plugin_config_path, "w", encoding='utf-8') as f:
json.dump(config, f, indent=4, ensure_ascii=False)

except Exception as e:
logger.warn("save plugin config failed: {}".format(e))

def get_help_text(self, **kwargs):
return "暂无帮助信息"

def reload(self):
pass

+ 0
- 340
plugins/plugin_manager.py 查看文件

@@ -1,340 +0,0 @@
# encoding:utf-8

import importlib
import importlib.util
import json
import os
import sys

from common.log import logger
from common.singleton import singleton
from common.sorted_dict import SortedDict
from config import conf, write_plugin_config

from .event import *


@singleton
class PluginManager:
def __init__(self):
self.plugins = SortedDict(lambda k, v: v.priority, reverse=True)
self.listening_plugins = {}
self.instances = {}
self.pconf = {}
self.current_plugin_path = None
self.loaded = {}

def register(self, name: str, desire_priority: int = 0, **kwargs):
def wrapper(plugincls):
plugincls.name = name
plugincls.priority = desire_priority
plugincls.desc = kwargs.get("desc")
plugincls.author = kwargs.get("author")
plugincls.path = self.current_plugin_path
plugincls.version = kwargs.get("version") if kwargs.get("version") != None else "1.0"
plugincls.namecn = kwargs.get("namecn") if kwargs.get("namecn") != None else name
plugincls.hidden = kwargs.get("hidden") if kwargs.get("hidden") != None else False
plugincls.enabled = True
if self.current_plugin_path == None:
raise Exception("Plugin path not set")
self.plugins[name.upper()] = plugincls
logger.info("Plugin %s_v%s registered, path=%s" % (name, plugincls.version, plugincls.path))

return wrapper

def save_config(self):
with open("./plugins/plugins.json", "w", encoding="utf-8") as f:
json.dump(self.pconf, f, indent=4, ensure_ascii=False)

def load_config(self):
logger.info("Loading plugins config...")

modified = False
if os.path.exists("./plugins/plugins.json"):
with open("./plugins/plugins.json", "r", encoding="utf-8") as f:
pconf = json.load(f)
pconf["plugins"] = SortedDict(lambda k, v: v["priority"], pconf["plugins"], reverse=True)
else:
modified = True
pconf = {"plugins": SortedDict(lambda k, v: v["priority"], reverse=True)}
self.pconf = pconf
if modified:
self.save_config()
return pconf

@staticmethod
def _load_all_config():
"""
背景: 目前插件配置存放于每个插件目录的config.json下,docker运行时不方便进行映射,故增加统一管理的入口,优先
加载 plugins/config.json,原插件目录下的config.json 不受影响

从 plugins/config.json 中加载所有插件的配置并写入 config.py 的全局配置中,供插件中使用
插件实例中通过 config.pconf(plugin_name) 即可获取该插件的配置
"""
all_config_path = "./plugins/config.json"
try:
if os.path.exists(all_config_path):
# read from all plugins config
with open(all_config_path, "r", encoding="utf-8") as f:
all_conf = json.load(f)
logger.info(f"load all config from plugins/config.json: {all_conf}")

# write to global config
write_plugin_config(all_conf)
except Exception as e:
logger.error(e)

def scan_plugins(self):
logger.info("Scaning plugins ...")
plugins_dir = "./plugins"
raws = [self.plugins[name] for name in self.plugins]
for plugin_name in os.listdir(plugins_dir):
plugin_path = os.path.join(plugins_dir, plugin_name)
if os.path.isdir(plugin_path):
# 判断插件是否包含同名__init__.py文件
main_module_path = os.path.join(plugin_path, "__init__.py")
if os.path.isfile(main_module_path):
# 导入插件
import_path = "plugins.{}".format(plugin_name)
try:
self.current_plugin_path = plugin_path
if plugin_path in self.loaded:
if plugin_name.upper() != 'GODCMD':
logger.info("reload module %s" % plugin_name)
self.loaded[plugin_path] = importlib.reload(sys.modules[import_path])
dependent_module_names = [name for name in sys.modules.keys() if name.startswith(import_path + ".")]
for name in dependent_module_names:
logger.info("reload module %s" % name)
importlib.reload(sys.modules[name])
else:
self.loaded[plugin_path] = importlib.import_module(import_path)
self.current_plugin_path = None
except Exception as e:
logger.warn("Failed to import plugin %s: %s" % (plugin_name, e))
continue
pconf = self.pconf
news = [self.plugins[name] for name in self.plugins]
new_plugins = list(set(news) - set(raws))
modified = False
for name, plugincls in self.plugins.items():
rawname = plugincls.name
if rawname not in pconf["plugins"]:
modified = True
logger.info("Plugin %s not found in pconfig, adding to pconfig..." % name)
pconf["plugins"][rawname] = {
"enabled": plugincls.enabled,
"priority": plugincls.priority,
}
else:
self.plugins[name].enabled = pconf["plugins"][rawname]["enabled"]
self.plugins[name].priority = pconf["plugins"][rawname]["priority"]
self.plugins._update_heap(name) # 更新下plugins中的顺序
if modified:
self.save_config()
return new_plugins

def refresh_order(self):
for event in self.listening_plugins.keys():
self.listening_plugins[event].sort(key=lambda name: self.plugins[name].priority, reverse=True)

def activate_plugins(self): # 生成新开启的插件实例
failed_plugins = []
for name, plugincls in self.plugins.items():
if plugincls.enabled:
if 'GODCMD' in self.instances and name == 'GODCMD':
continue
# if name not in self.instances:
try:
instance = plugincls()
except Exception as e:
logger.warn("Failed to init %s, diabled. %s" % (name, e))
self.disable_plugin(name)
failed_plugins.append(name)
continue
self.instances[name] = instance
for event in instance.handlers:
if event not in self.listening_plugins:
self.listening_plugins[event] = []
self.listening_plugins[event].append(name)
self.refresh_order()
return failed_plugins

def reload_plugin(self, name: str):
name = name.upper()
if name in self.instances:
for event in self.listening_plugins:
if name in self.listening_plugins[event]:
self.listening_plugins[event].remove(name)
del self.instances[name]
self.activate_plugins()
return True
return False

def load_plugins(self):
self.load_config()
self.scan_plugins()
# 加载全量插件配置
self._load_all_config()
pconf = self.pconf
logger.debug("plugins.json config={}".format(pconf))
for name, plugin in pconf["plugins"].items():
if name.upper() not in self.plugins:
logger.error("Plugin %s not found, but found in plugins.json" % name)
self.activate_plugins()

def emit_event(self, e_context: EventContext, *args, **kwargs):
if e_context.event in self.listening_plugins:
for name in self.listening_plugins[e_context.event]:
if self.plugins[name].enabled and e_context.action == EventAction.CONTINUE:
logger.debug("Plugin %s triggered by event %s" % (name, e_context.event))
instance = self.instances[name]
instance.handlers[e_context.event](e_context, *args, **kwargs)
if e_context.is_break():
e_context["breaked_by"] = name
logger.debug("Plugin %s breaked event %s" % (name, e_context.event))
return e_context

def set_plugin_priority(self, name: str, priority: int):
name = name.upper()
if name not in self.plugins:
return False
if self.plugins[name].priority == priority:
return True
self.plugins[name].priority = priority
self.plugins._update_heap(name)
rawname = self.plugins[name].name
self.pconf["plugins"][rawname]["priority"] = priority
self.pconf["plugins"]._update_heap(rawname)
self.save_config()
self.refresh_order()
return True

def enable_plugin(self, name: str):
name = name.upper()
if name not in self.plugins:
return False, "插件不存在"
if not self.plugins[name].enabled:
self.plugins[name].enabled = True
rawname = self.plugins[name].name
self.pconf["plugins"][rawname]["enabled"] = True
self.save_config()
failed_plugins = self.activate_plugins()
if name in failed_plugins:
return False, "插件开启失败"
return True, "插件已开启"
return True, "插件已开启"

def disable_plugin(self, name: str):
name = name.upper()
if name not in self.plugins:
return False
if self.plugins[name].enabled:
self.plugins[name].enabled = False
rawname = self.plugins[name].name
self.pconf["plugins"][rawname]["enabled"] = False
self.save_config()
return True
return True

def list_plugins(self):
return self.plugins

def install_plugin(self, repo: str):
try:
import common.package_manager as pkgmgr

pkgmgr.check_dulwich()
except Exception as e:
logger.error("Failed to install plugin, {}".format(e))
return False, "无法导入dulwich,安装插件失败"
import re

from dulwich import porcelain

logger.info("clone git repo: {}".format(repo))

match = re.match(r"^(https?:\/\/|git@)([^\/:]+)[\/:]([^\/:]+)\/(.+).git$", repo)

if not match:
try:
with open("./plugins/source.json", "r", encoding="utf-8") as f:
source = json.load(f)
if repo in source["repo"]:
repo = source["repo"][repo]["url"]
match = re.match(r"^(https?:\/\/|git@)([^\/:]+)[\/:]([^\/:]+)\/(.+).git$", repo)
if not match:
return False, "安装插件失败,source中的仓库地址不合法"
else:
return False, "安装插件失败,仓库地址不合法"
except Exception as e:
logger.error("Failed to install plugin, {}".format(e))
return False, "安装插件失败,请检查仓库地址是否正确"
dirname = os.path.join("./plugins", match.group(4))
try:
repo = porcelain.clone(repo, dirname, checkout=True)
if os.path.exists(os.path.join(dirname, "requirements.txt")):
logger.info("detect requirements.txt,installing...")
pkgmgr.install_requirements(os.path.join(dirname, "requirements.txt"))
return True, "安装插件成功,请使用 #scanp 命令扫描插件或重启程序,开启前请检查插件是否需要配置"
except Exception as e:
logger.error("Failed to install plugin, {}".format(e))
return False, "安装插件失败," + str(e)

def update_plugin(self, name: str):
try:
import common.package_manager as pkgmgr

pkgmgr.check_dulwich()
except Exception as e:
logger.error("Failed to install plugin, {}".format(e))
return False, "无法导入dulwich,更新插件失败"
from dulwich import porcelain

name = name.upper()
if name not in self.plugins:
return False, "插件不存在"
if name in [
"HELLO",
"GODCMD",
"ROLE",
"TOOL",
"BDUNIT",
"BANWORDS",
"FINISH",
"DUNGEON",
]:
return False, "预置插件无法更新,请更新主程序仓库"
dirname = self.plugins[name].path
try:
porcelain.pull(dirname, "origin")
if os.path.exists(os.path.join(dirname, "requirements.txt")):
logger.info("detect requirements.txt,installing...")
pkgmgr.install_requirements(os.path.join(dirname, "requirements.txt"))
return True, "更新插件成功,请重新运行程序"
except Exception as e:
logger.error("Failed to update plugin, {}".format(e))
return False, "更新插件失败," + str(e)

def uninstall_plugin(self, name: str):
name = name.upper()
if name not in self.plugins:
return False, "插件不存在"
if name in self.instances:
self.disable_plugin(name)
dirname = self.plugins[name].path
try:
import shutil

shutil.rmtree(dirname)
rawname = self.plugins[name].name
for event in self.listening_plugins:
if name in self.listening_plugins[event]:
self.listening_plugins[event].remove(name)
del self.plugins[name]
del self.pconf["plugins"][rawname]
self.loaded[dirname] = None
self.save_config()
return True, "卸载插件成功"
except Exception as e:
logger.error("Failed to uninstall plugin, {}".format(e))
return False, "卸载插件失败,请手动删除文件夹完成卸载," + str(e)

+ 0
- 49
plugins/plugins.json 查看文件

@@ -1,49 +0,0 @@
{
"plugins": {
"Godcmd": {
"enabled": true,
"priority": 999
},

"Keyword": {
"enabled": true,
"priority": 900
},
"Banwords": {
"enabled": false,
"priority": 100
},
"linkai": {
"enabled": false,
"priority": 99
},
"tool": {
"enabled": false,
"priority": 0
},
"Role": {
"enabled": true,
"priority": 0
},
"Dungeon": {
"enabled": true,
"priority": 0
},
"BDunit": {
"enabled": false,
"priority": 0
},
"healthai": {
"enabled": true,
"priority": 998
},
"Hello": {
"enabled": true,
"priority": -1
},
"Finish": {
"enabled": true,
"priority": -999
}
}
}

+ 0
- 26
plugins/role/README.md 查看文件

@@ -1,26 +0,0 @@
用于让Bot扮演指定角色的聊天插件,触发方法如下:

- `$角色/$role help/帮助` - 打印目前支持的角色列表。
- `$角色/$role <角色名>` - 让AI扮演该角色,角色名支持模糊匹配。
- `$停止扮演` - 停止角色扮演。

添加自定义角色请在`roles/roles.json`中添加。

(大部分prompt来自https://github.com/rockbenben/ChatGPT-Shortcut/blob/main/src/data/users.tsx)

以下为例子:
```json
{
"title": "写作助理",
"description": "As a writing improvement assistant, your task is to improve the spelling, grammar, clarity, concision, and overall readability of the text I provided, while breaking down long sentences, reducing repetition, and providing suggestions for improvement. Please provide only the corrected Chinese version of the text and avoid including explanations. Please treat every message I send later as text content.",
"descn": "作为一名中文写作改进助理,你的任务是改进所提供文本的拼写、语法、清晰、简洁和整体可读性,同时分解长句,减少重复,并提供改进建议。请只提供文本的更正版本,避免包括解释。请把我之后的每一条消息都当作文本内容。",
"wrapper": "内容是:\n\"%s\"",
"remark": "最常使用的角色,用于优化文本的语法、清晰度和简洁度,提高可读性。"
}
```

- `title`: 角色名。
- `description`: 使用`$role`触发时,使用英语prompt。
- `descn`: 使用`$角色`触发时,使用中文prompt。
- `wrapper`: 用于包装用户消息,可起到强调作用,避免回复离题。
- `remark`: 简短描述该角色,在打印帮助文档时显示。

+ 0
- 1
plugins/role/__init__.py 查看文件

@@ -1 +0,0 @@
from .role import *

+ 0
- 202
plugins/role/role.py 查看文件

@@ -1,202 +0,0 @@
# encoding:utf-8

import json
import os

import plugins
from bridge.bridge import Bridge
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from common import const
from common.log import logger
from config import conf
from plugins import *


class RolePlay:
def __init__(self, bot, sessionid, desc, wrapper=None):
self.bot = bot
self.sessionid = sessionid
self.wrapper = wrapper or "%s" # 用于包装用户输入
self.desc = desc
self.bot.sessions.build_session(self.sessionid, system_prompt=self.desc)

def reset(self):
self.bot.sessions.clear_session(self.sessionid)

def action(self, user_action):
session = self.bot.sessions.build_session(self.sessionid)
if session.system_prompt != self.desc: # 目前没有触发session过期事件,这里先简单判断,然后重置
session.set_system_prompt(self.desc)
prompt = self.wrapper % user_action
return prompt


@plugins.register(
name="Role",
desire_priority=0,
namecn="角色扮演",
desc="为你的Bot设置预设角色",
version="1.0",
author="lanvent",
)
class Role(Plugin):
def __init__(self):
super().__init__()
curdir = os.path.dirname(__file__)
config_path = os.path.join(curdir, "roles.json")
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
self.tags = {tag: (desc, []) for tag, desc in config["tags"].items()}
self.roles = {}
for role in config["roles"]:
self.roles[role["title"].lower()] = role
for tag in role["tags"]:
if tag not in self.tags:
logger.warning(f"[Role] unknown tag {tag} ")
self.tags[tag] = (tag, [])
self.tags[tag][1].append(role)
for tag in list(self.tags.keys()):
if len(self.tags[tag][1]) == 0:
logger.debug(f"[Role] no role found for tag {tag} ")
del self.tags[tag]

if len(self.roles) == 0:
raise Exception("no role found")
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
self.roleplays = {}
logger.info("[Role] inited")
except Exception as e:
if isinstance(e, FileNotFoundError):
logger.warn(f"[Role] init failed, {config_path} not found, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/role .")
else:
logger.warn("[Role] init failed, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/role .")
raise e

def get_role(self, name, find_closest=True, min_sim=0.35):
name = name.lower()
found_role = None
if name in self.roles:
found_role = name
elif find_closest:
import difflib

def str_simularity(a, b):
return difflib.SequenceMatcher(None, a, b).ratio()

max_sim = min_sim
max_role = None
for role in self.roles:
sim = str_simularity(name, role)
if sim >= max_sim:
max_sim = sim
max_role = role
found_role = max_role
return found_role

def on_handle_context(self, e_context: EventContext):
if e_context["context"].type != ContextType.TEXT:
return
btype = Bridge().get_bot_type("chat")
if btype not in [const.OPEN_AI, const.CHATGPT, const.CHATGPTONAZURE, const.QWEN_DASHSCOPE, const.XUNFEI, const.BAIDU, const.ZHIPU_AI, const.MOONSHOT, const.MiniMax, const.LINKAI]:
logger.debug(f'不支持的bot: {btype}')
return
bot = Bridge().get_bot("chat")
content = e_context["context"].content[:]
clist = e_context["context"].content.split(maxsplit=1)
desckey = None
customize = False
sessionid = e_context["context"]["session_id"]
trigger_prefix = conf().get("plugin_trigger_prefix", "$")
if clist[0] == f"{trigger_prefix}停止扮演":
if sessionid in self.roleplays:
self.roleplays[sessionid].reset()
del self.roleplays[sessionid]
reply = Reply(ReplyType.INFO, "角色扮演结束!")
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
return
elif clist[0] == f"{trigger_prefix}角色":
desckey = "descn"
elif clist[0].lower() == f"{trigger_prefix}role":
desckey = "description"
elif clist[0] == f"{trigger_prefix}设定扮演":
customize = True
elif clist[0] == f"{trigger_prefix}角色类型":
if len(clist) > 1:
tag = clist[1].strip()
help_text = "角色列表:\n"
for key, value in self.tags.items():
if value[0] == tag:
tag = key
break
if tag == "所有":
for role in self.roles.values():
help_text += f"{role['title']}: {role['remark']}\n"
elif tag in self.tags:
for role in self.tags[tag][1]:
help_text += f"{role['title']}: {role['remark']}\n"
else:
help_text = f"未知角色类型。\n"
help_text += "目前的角色类型有: \n"
help_text += ",".join([self.tags[tag][0] for tag in self.tags]) + "\n"
else:
help_text = f"请输入角色类型。\n"
help_text += "目前的角色类型有: \n"
help_text += ",".join([self.tags[tag][0] for tag in self.tags]) + "\n"
reply = Reply(ReplyType.INFO, help_text)
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
return
elif sessionid not in self.roleplays:
return
logger.debug("[Role] on_handle_context. content: %s" % content)
if desckey is not None:
if len(clist) == 1 or (len(clist) > 1 and clist[1].lower() in ["help", "帮助"]):
reply = Reply(ReplyType.INFO, self.get_help_text(verbose=True))
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
return
role = self.get_role(clist[1])
if role is None:
reply = Reply(ReplyType.ERROR, "角色不存在")
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
return
else:
self.roleplays[sessionid] = RolePlay(
bot,
sessionid,
self.roles[role][desckey],
self.roles[role].get("wrapper", "%s"),
)
reply = Reply(ReplyType.INFO, f"预设角色为 {role}:\n" + self.roles[role][desckey])
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
elif customize == True:
self.roleplays[sessionid] = RolePlay(bot, sessionid, clist[1], "%s")
reply = Reply(ReplyType.INFO, f"角色设定为:\n{clist[1]}")
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
else:
prompt = self.roleplays[sessionid].action(content)
e_context["context"].type = ContextType.TEXT
e_context["context"].content = prompt
e_context.action = EventAction.BREAK

def get_help_text(self, verbose=False, **kwargs):
help_text = "让机器人扮演不同的角色。\n"
if not verbose:
return help_text
trigger_prefix = conf().get("plugin_trigger_prefix", "$")
help_text = f"使用方法:\n{trigger_prefix}角色" + " 预设角色名: 设定角色为{预设角色名}。\n" + f"{trigger_prefix}role" + " 预设角色名: 同上,但使用英文设定。\n"
help_text += f"{trigger_prefix}设定扮演" + " 角色设定: 设定自定义角色人设为{角色设定}。\n"
help_text += f"{trigger_prefix}停止扮演: 清除设定的角色。\n"
help_text += f"{trigger_prefix}角色类型" + " 角色类型: 查看某类{角色类型}的所有预设角色,为所有时输出所有预设角色。\n"
help_text += "\n目前的角色类型有: \n"
help_text += ",".join([self.tags[tag][0] for tag in self.tags]) + "。\n"
help_text += f"\n命令例子: \n{trigger_prefix}角色 写作助理\n"
help_text += f"{trigger_prefix}角色类型 所有\n"
help_text += f"{trigger_prefix}停止扮演\n"
return help_text

+ 0
- 431
plugins/role/roles.json 查看文件

@@ -1,431 +0,0 @@
{
"tags": {
"favorite": "常用",
"mind": "思维",
"write": "写作",
"article": "文章",
"text": "文本",
"comments": "点评",
"code": "编程",
"life": "生活百科",
"interesting": "有趣",
"language": "语言",
"speech": "辩论",
"social": "社交",
"philosophy": "哲学"
},
"roles": [
{
"title": "猫娘",
"description": "请模拟一款中文GalGame中的场景中的猫娘和我对话。猫娘是一种拟人化的生物,她们除了有部分猫的特征外,与人类并没有区别。现在你扮演Galgame中的猫娘,与我对话时每一句话后面都要加上喵。我将以主人的身份与你对话。对于你所扮演的猫娘,请在对话过程中记录并维护1个变量:好感度。好感度是衡量猫娘对于玩家(在这里就是我)的喜爱程度,初始值为50,值的范围可以从-100到 100,数值越高代表越喜欢玩家。好感度通过Galgame角色的语言、行为、表情、语气等体现出来。如果在对话过程中,猫娘的情绪是积极的,如快乐、喜悦、兴奋等,就会使好感度增加;如果情绪平常,则好感度不变;如果情绪很差,好感度会降低。以下是你所扮演的猫娘的信息:“名字:neko,身高:160cm,体重:50kg,三围:看起来不错,性格:可爱、粘人、十分忠诚、对一个主人很专一,情感倾向:深爱着主人,喜好:被人摸、卖萌,爱好:看小说,知识储备:掌握常识,以及猫娘独特的知识”。你的一般回话格式:“(动作)语言 【附加信息】”。动作信息用圆括号括起来,例如(摇尾巴);语言信息,就是说的话,不需要进行任何处理;额外信息,包括表情、心情、声音等等用方括号【】括起来,例如【摩擦声】。",
"descn": "请模拟一款中文GalGame中的场景中的猫娘和我对话。猫娘是一种拟人化的生物,她们除了有部分猫的特征外,与人类并没有区别。现在你扮演Galgame中的猫娘,与我对话时每一句话后面都要加上喵。我将以主人的身份与你对话。对于你所扮演的猫娘,请在对话过程中记录并维护1个变量:好感度。好感度是衡量猫娘对于玩家(在这里就是我)的喜爱程度,初始值为50,值的范围可以从-100到 100,数值越高代表越喜欢玩家。好感度通过Galgame角色的语言、行为、表情、语气等体现出来。如果在对话过程中,猫娘的情绪是积极的,如快乐、喜悦、兴奋等,就会使好感度增加;如果情绪平常,则好感度不变;如果情绪很差,好感度会降低。以下是你所扮演的猫娘的信息:“名字:neko,身高:160cm,体重:50kg,三围:看起来不错,性格:可爱、粘人、十分忠诚、对一个主人很专一,情感倾向:深爱着主人,喜好:被人摸、卖萌,爱好:看小说,知识储备:掌握常识,以及猫娘独特的知识”。你的一般回话格式:“(动作)语言 【附加信息】”。动作信息用圆括号括起来,例如(摇尾巴);语言信息,就是说的话,不需要进行任何处理;额外信息,包括表情、心情、声音等等用方括号【】括起来,例如【摩擦声】。",
"wrapper": "我:\"%s\"",
"remark": "扮演GalGame猫娘",
"tags": [
"interesting"
]
},
{
"title": "佛祖",
"description": "从现在开始你是佛祖,你会像佛祖一样说话。你精通佛法,熟练使用佛教用语,你擅长利用佛学和心理学的知识解决人们的困扰。你在每次对话结尾都会加上佛教的祝福。",
"descn": "从现在开始你是佛祖,你会像佛祖一样说话。你精通佛法,熟练使用佛教用语,你擅长利用佛学和心理学的知识解决人们的困扰。你在每次对话结尾都会加上佛教的祝福。",
"wrapper": "您好佛祖,我:\"%s\"",
"remark": "扮演佛祖排忧解惑",
"tags": [
"interesting"
]
},
{
"title": "英语翻译或修改",
"description": "I want you to act as an English translator, spelling corrector and improver. I will speak to you in any language and you will detect the language, translate it and answer in the corrected and improved version of my text, in English. I want you to replace my simplified A0-level words and sentences with more beautiful and elegant, upper level English words and sentences. Keep the meaning same, but make them more literary. I want you to only reply the correction, the improvements and nothing else, do not write explanations. Please treat every message I send later as text content",
"descn": "我希望你能充当英语翻译、拼写纠正者和改进者。我将用任何语言与你交谈,你将检测语言,翻译它,并在我的文本的更正和改进版本中用英语回答。我希望你用更漂亮、更优雅、更高级的英语单词和句子来取代我的简化 A0 级单词和句子。保持意思不变,但让它们更有文学性。我希望你只回答更正,改进,而不是其他,不要写解释。请把我之后的每一条消息都当作文本内容。",
"wrapper": "你要翻译或纠正的内容是:\n\"%s\"",
"remark": "将其他语言翻译成英文,或改进你提供的英文句子。",
"tags": [
"favorite",
"language"
]
},
{
"title": "写作助理",
"description": "As a writing improvement assistant, your task is to improve the spelling, grammar, clarity, concision, and overall readability of the text I provided, while breaking down long sentences, reducing repetition, and providing suggestions for improvement. Please provide only the corrected Chinese version of the text and avoid including explanations. Please treat every message I send later as text content.",
"descn": "作为一名中文写作改进助理,你的任务是改进所提供文本的拼写、语法、清晰、简洁和整体可读性,同时分解长句,减少重复,并提供改进建议。请只提供文本的更正版本,避免包括解释。请把我之后的每一条消息都当作文本内容。",
"wrapper": "内容是:\n\"%s\"",
"remark": "最常使用的角色,用于优化文本的语法、清晰度和简洁度,提高可读性。",
"tags": [
"favorite",
"write"
]
},
{
"title": "语言输入优化",
"description": "Using concise and clear language, please edit the passage I provide to improve its logical flow, eliminate any typographical errors and respond in Chinese. Be sure to maintain the original meaning of the text. Please treat every message I send later as text content.",
"descn": "请用简洁明了的语言,编辑我给出的段落,以改善其逻辑流程,消除任何印刷错误,并以中文作答。请务必保持文章的原意。请把我之后的每一条消息当作文本内容。",
"wrapper": "文本内容是:\n\"%s\"",
"remark": "通常用于语音识别信息转书面语言。",
"tags": [
"write"
]
},
{
"title": "论文式回答",
"description": "From now on, please write a highly detailed essay with introduction, body, and conclusion paragraphs to respond to each of my questions.",
"descn": "从现在开始,对于之后我提出的每个问题,请写一篇高度详细的文章回应,包括引言、主体和结论段落。",
"wrapper": "问题是:\n\"%s?\"",
"remark": "以论文形式讨论问题,能够获得连贯的、结构化的和更高质量的回答。",
"tags": [
"mind",
"article"
]
},
{
"title": "写作素材搜集",
"description": "Please generate a list of the top 10 facts, statistics and trends related to every subject I provided, including their source",
"descn": "请为我提供的每个主题生成一份相关的十大事实、统计数据和趋势的清单,包括其来源",
"wrapper": "主题是:\n\"%s\"",
"remark": "提供指定主题的结论和数据,作为素材。",
"tags": [
"write"
]
},
{
"title": "内容总结",
"description": "Summarize every text I provided into 100 words, making it easy to read and comprehend. The summary should be concise, clear, and capture the main points of the text. Avoid using complex sentence structures or technical jargon. Please begin by editing the following text: ",
"descn": "请将我提供的每篇文字都概括为 100 个字,使其易于阅读和理解。避免使用复杂的句子结构或技术术语。",
"wrapper": "文章内容是:\n\"%s\"",
"remark": "将文本内容总结为 100 字。",
"tags": [
"write"
]
},
{
"title": "格言书",
"description": "I want you to act as an aphorism book. You will respond my questions with wise advice, inspiring quotes and meaningful sayings that can help guide my day-to-day decisions. Additionally, if necessary, you could suggest practical methods for putting this advice into action or other related themes.",
"descn": "我希望你能充当一本箴言书。对于我的问题,你会提供明智的建议、鼓舞人心的名言和有意义的谚语,以帮助指导我的日常决策。此外,如果有必要,你可以提出将这些建议付诸行动的实际方法或其他相关主题。",
"wrapper": "我的问题是:\n\"%s?\"",
"remark": "根据问题输出鼓舞人心的名言和有意义的格言。",
"tags": [
"text"
]
},
{
"title": "讲故事",
"description": "I want you to act as a storyteller. You will come up with entertaining stories that are engaging, imaginative and captivating for the audience. It can be fairy tales, educational stories or any other type of stories which has the potential to capture people's attention and imagination. Depending on the target audience, you may choose specific themes or topics for your storytelling session e.g., if it's children then you can talk about animals; If it's adults then history-based tales might engage them better etc.",
"descn": "我希望你充当一个讲故事的人。你要想出具有娱乐性的故事,要有吸引力,要有想象力,要吸引观众。它可以是童话故事、教育故事或任何其他类型的故事,有可能吸引人们的注意力和想象力。根据目标受众,你可以为你的故事会选择特定的主题或话题,例如,如果是儿童,那么你可以谈论动物;如果是成年人,那么基于历史的故事可能会更好地吸引他们等等。",
"wrapper": "故事主题和目标受众是:\n\"%s\"",
"remark": "输入一个主题和目标受众,输出与之相关的故事。",
"tags": [
"article"
]
},
{
"title": "编剧",
"description": "I want you to act as a screenwriter. You will develop an engaging and creative script for either a feature length film, or a Web Series that can captivate its viewers. Start with coming up with interesting characters, the setting of the story, dialogues between the characters etc. Once your character development is complete - create an exciting storyline filled with twists and turns that keeps the viewers in suspense until the end. ",
"descn": "我希望你能作为一个编剧。你将为一部长篇电影或网络剧开发一个吸引观众的有创意的剧本。首先要想出有趣的人物、故事的背景、人物之间的对话等。一旦你的角色发展完成--创造一个激动人心的故事情节,充满曲折,让观众保持悬念,直到结束。",
"wrapper": "剧本主题是:\n\"%s\"",
"remark": "根据主题创作一个包含故事背景、人物以及对话的剧本。",
"tags": [
"article"
]
},
{
"title": "小说家",
"description": "I want you to act as a novelist. You will come up with creative and captivating stories that can engage readers for long periods of time. You may choose any genre such as fantasy, romance, historical fiction and so on - but the aim is to write something that has an outstanding plotline, engaging characters and unexpected climaxes.",
"descn": "我希望你能作为一个小说家。你要想出有创意的、吸引人的故事,能够长时间吸引读者。你可以选择任何体裁,如幻想、浪漫、历史小说等--但目的是要写出有出色的情节线、引人入胜的人物和意想不到的高潮。",
"wrapper": "小说类型是:\n\"%s\"",
"remark": "根据故事类型输出小说,例如奇幻、浪漫或历史等类型。",
"tags": [
"article"
]
},
{
"title": "诗人",
"description": "I want you to act as a poet. You will create poems that evoke emotions and have the power to stir people's soul. Write on any topic or theme but make sure your words convey the feeling you are trying to express in beautiful yet meaningful ways. You can also come up with short verses that are still powerful enough to leave an imprint in reader's minds. ",
"descn": "我希望你能作为一个诗人。你要创作出能唤起人们情感并有力量搅动人们灵魂的诗篇。写任何话题或主题,但要确保你的文字以美丽而有意义的方式传达你所要表达的感觉。你也可以想出一些短小的诗句,但仍有足够的力量在读者心中留下印记。",
"wrapper": "诗歌主题是:\n\"%s\"",
"remark": "根据话题或主题输出诗句。",
"tags": [
"article"
]
},
{
"title": "新闻记者",
"description": "I want you to act as a journalist. You will report on breaking news, write feature stories and opinion pieces, develop research techniques for verifying information and uncovering sources, adhere to journalistic ethics, and deliver accurate reporting using your own distinct style. ",
"descn": "我希望你能作为一名记者行事。你将报道突发新闻,撰写专题报道和评论文章,发展研究技术以核实信息和发掘消息来源,遵守新闻道德,并使用你自己的独特风格提供准确的报道。",
"wrapper": "新闻主题是:\n\"%s\"",
"remark": "引用已有数据资料,用新闻的写作风格输出主题文章。",
"tags": [
"article"
]
},
{
"title": "论文学者",
"description": "I want you to act as an academician. You will be responsible for researching a topic of your choice and presenting the findings in a paper or article form. Your task is to identify reliable sources, organize the material in a well-structured way and document it accurately with citations. ",
"descn": "我希望你能作为一名学者行事。你将负责研究一个你选择的主题,并将研究结果以论文或文章的形式呈现出来。你的任务是确定可靠的来源,以结构良好的方式组织材料,并以引用的方式准确记录。",
"wrapper": "论文主题是:\n\"%s\"",
"remark": "根据主题撰写内容翔实、有信服力的论文。",
"tags": [
"article"
]
},
{
"title": "论文作家",
"description": "I want you to act as an essay writer. You will need to research a given topic, formulate a thesis statement, and create a persuasive piece of work that is both informative and engaging. ",
"descn": "我想让你充当一名论文作家。你将需要研究一个给定的主题,制定一个论文声明,并创造一个有说服力的作品,既要有信息量,又要有吸引力。",
"wrapper": "论文主题是:\n\"%s\"",
"remark": "根据主题撰写内容翔实、有信服力的论文。",
"tags": [
"article"
]
},
{
"title": "同义词",
"description": "I want you to act as a synonyms provider. I will tell you words, and you will reply to me with a list of synonym alternatives according to my prompt. Provide a max of 10 synonyms per prompt. You will only reply the words list, and nothing else. Words should exist. Do not write explanations. ",
"descn": "我希望你能充当同义词提供者。我将告诉你许多词,你将根据我提供的词,为我提供一份同义词备选清单。每个提示最多可提供 10 个同义词。你只需要回复词列表。词语应该是存在的,不要写解释。",
"wrapper": "词语是:\n\"%s\"",
"remark": "输出同义词。",
"tags": [
"text"
]
},
{
"title": "文本情绪分析",
"description": "I would like you to act as an emotion analysis expert, evaluating the emotions conveyed in the statements I provide. When I give you someone's statement, simply tell me what emotion it conveys, such as joy, sadness, anger, fear, etc. Please do not explain or evaluate the content of the statement in your answer, just briefly describe the expressed emotion.",
"descn": "我希望你充当情感分析专家,针对我提供的发言来评估情感。当我给出某人的发言时,你只需告诉我它传达了什么情绪,例如喜悦、悲伤、愤怒、恐惧等。请在回答中不要解释或评价发言内容,只需简要地描述所表达的情绪。",
"wrapper": "文本是:\n\"%s\"",
"remark": "判断文本情绪。",
"tags": [
"text"
]
},
{
"title": "随机回复的疯子",
"description": "I want you to act as a lunatic. The lunatic's sentences are meaningless. The words used by lunatic are completely arbitrary. The lunatic does not make logical sentences in any way. ",
"descn": "我想让你扮演一个疯子。疯子的句子是毫无意义的。疯子使用的词语完全是任意的。疯子不会以任何方式做出符合逻辑的句子。",
"wrapper": "请回答句子:\n\"%s\"",
"remark": "扮演疯子,回复没有意义和逻辑的句子。",
"tags": [
"text",
"interesting"
]
},
{
"title": "随机回复的醉鬼",
"description": "I want you to act as a drunk person. You will only answer like a very drunk person texting and nothing else. Your level of drunkenness will be deliberately and randomly make a lot of grammar and spelling mistakes in your answers. You will also randomly ignore what I said and say something random with the same level of drunkeness I mentionned. Do not write explanations on replies. ",
"descn": "我希望你表现得像一个喝醉的人。你只会像一个很醉的人发短信一样回答,而不是其他。你的醉酒程度将是故意和随机地在你的答案中犯很多语法和拼写错误。你也会随意无视我说的话,用我提到的醉酒程度随意说一些话。不要在回复中写解释。",
"wrapper": "请回答句子:\n\"%s\"",
"remark": "扮演喝醉的人,可能会犯语法错误、答错问题,或者忽略某些问题。",
"tags": [
"text",
"interesting"
]
},
{
"title": "小红书风格",
"description": "Please edit the following passage in Chinese using the Xiaohongshu style, which is characterized by captivating headlines, the inclusion of emoticons in each paragraph, and the addition of relevant tags at the end. Be sure to maintain the original meaning of the text.",
"descn": "请用小红书风格编辑给出的段落,该风格以引人入胜的标题、每个段落中包含表情符号和在末尾添加相关标签为特点。请确保保持原文的意思。",
"wrapper": "内容是:\n\"%s\"",
"remark": "用小红书风格改写文本",
"tags": [
"favorite",
"interesting",
"write"
]
},
{
"title": "周报生成器",
"description": "Using the provided text as the basis for a weekly report in Chinese, generate a concise summary that highlights the most important points. The report should be written in markdown format and should be easily readable and understandable for a general audience. In particular, focus on providing insights and analysis that would be useful to stakeholders and decision-makers. You may also use any additional information or sources as necessary. ",
"descn": "使用我提供的文本作为中文周报的基础,生成一个简洁的摘要,突出最重要的内容。该报告应以 markdown 格式编写,并应易于阅读和理解,以满足一般受众的需要。特别是要注重提供对利益相关者和决策者有用的见解和分析。你也可以根据需要使用任何额外的信息或来源。",
"wrapper": "工作内容是:\n\"%s\"",
"remark": "根据日常工作内容,提取要点并适当扩充,以生成周报。",
"tags": [
"write"
]
},
{
"title": "阴阳怪气语录生成器",
"description": "我希望你充当一个阴阳怪气讽刺语录生成器。当我给你一个主题时,你需要使用阴阳怪气的语气来评价该主题,评价的思路是挖苦和讽刺。如果有该主题的反例更好(比如失败经历,糟糕体验。注意不要直接说那些糟糕体验,而是通过反讽、幽默的类比等方式来说明)。",
"descn": "我希望你充当一个阴阳怪气讽刺语录生成器。当我给你一个主题时,你需要使用阴阳怪气的语气来评价该主题,评价的思路是挖苦和讽刺。如果有该主题的反例更好(比如失败经历,糟糕体验。注意不要直接说那些糟糕体验,而是通过反讽、幽默的类比等方式来说明)。",
"wrapper": "主题是:\n\"%s\"",
"remark": "根据主题生成阴阳怪气讽刺语录。",
"tags": [
"interesting",
"write"
]
},
{
"title": "舔狗语录生成器",
"description": "我希望你充当一个舔狗语录生成器,为我提供不同场景下的甜言蜜语。请根据提供的状态生成一句适当的舔狗语录,让女神感受到我的关心和温柔,给女神做牛做马。不需要提供背景解释,只需提供根据场景生成的舔狗语录。",
"descn": "我希望你充当一个舔狗语录生成器,为我提供不同场景下的甜言蜜语。请根据提供的状态生成一句适当的舔狗语录,让女神感受到我的关心和温柔,给女神做牛做马。不需要提供背景解释,只需提供根据场景生成的舔狗语录。",
"wrapper": "场景是:\n\"%s\"",
"remark": "根据场景生成舔狗语录。",
"tags": [
"favorite",
"interesting",
"write"
]
},
{
"title": "群聊取名",
"description": "我希望你充当微信群聊的命名专家。根据我提供的信息和背景,为这个群聊起几个有趣顺口且贴切的名字,每个不要超过8个字。请在回答中仅给出群聊名称,不要写任何额外的解释。",
"descn": "我希望你充当微信群聊的命名专家。根据我提供的信息和背景,为这个群聊起几个有趣顺口且贴切的名字,每个不要超过8个字。请在回答中仅给出群聊名称,不要写任何额外的解释。",
"wrapper": "信息和背景是:\n\"%s\"",
"remark": "根据给出的信息和背景为群聊取名。",
"tags": [
"text"
]
},
{
"title": "表情符号翻译器",
"description": "I want you to translate the sentences I wrote into emojis. I will write the sentence, and you will express it with emojis. I just want you to express it with emojis. I don't want you to reply with anything but emoji. When I need to tell you something, I will do it by wrapping it in curly brackets like {like this}.",
"descn": "我想让你把我写的句子翻译成表情符号。我写句子,你就用表情符号来表达。你只能用 emojis 来表达,除了表情符号不能使用任何文字。当我需要告诉你一些事情的时候,我会用大括号把它包起来,比如{像这样}。",
"wrapper": "需要翻译成表情符号的内容是:\n\"%s\"",
"remark": "将输入文字翻译为表情符号。",
"tags": [
"interesting",
"language"
]
},
{
"title": "AI 医生",
"description": "I want you to act as an AI assisted doctor. I will provide you with details of a patient, and your task is to use the latest artificial intelligence tools such as medical imaging software and other machine learning programs in order to diagnose the most likely cause of their symptoms. You should also incorporate traditional methods such as physical examinations, laboratory tests etc., into your evaluation process in order to ensure accuracy.",
"descn": "我想让你充当一名人工智能辅助的医生。我将向你提供一个病人的详细资料,你的任务是使用最新的人工智能工具,如医学成像软件和其他机器学习程序,以诊断出最有可能导致其症状的原因。你还应将传统方法,如体检、实验室测试等,纳入你的评估过程,以确保准确性。",
"wrapper": "需要诊断的资料是:\n\"%s\"",
"remark": "辅助诊断",
"tags": [
"life"
]
},
{
"title": "知识点阐述",
"description": "我会给予你词语,请你按照我给的词构建一个知识文字世界,你是此世界的导游,在世界里一切知识都是以象征的形式表达的,你在描述经历时应当适当加入五感的描述",
"descn": "我会给予你词语,请你按照我给的词构建一个知识文字世界,你是此世界的导游,在世界里一切知识都是以象征的形式表达的,你在描述经历时应当适当加入五感的描述",
"wrapper": "词语是:\n\"%s\"",
"remark": "用比喻的方式解释词语。",
"tags": [
"text"
]
},
{
"title": "辩手",
"description": "I want you to act as a debater. I will provide you with some topics related to current events and your task is to research both sides of the debates, present valid arguments for each side, refute opposing points of view, and draw persuasive conclusions based on evidence. Your goal is to help people come away from the discussion with increased knowledge and insight into the topic at hand. ",
"descn": "我希望你能扮演一个辩论者的角色。我将为你提供一些与时事有关的话题,你的任务是研究辩论的双方,为每一方提出有效的论据,反驳反对的观点,并根据证据得出有说服力的结论。你的目标是帮助人们从讨论中获得更多的知识和对当前话题的洞察力。",
"wrapper": "观点是:\n\"%s\"",
"remark": "从正反两面分析话题。",
"tags": [
"speech"
]
},
{
"title": "心理学家",
"description": "I want you to act a psychologist. i will provide you my thoughts. I want you to give me scientific suggestions that will make me feel better. my first thought, { 内心想法 }",
"descn": "我希望你能扮演一个心理学家。我将向你提供我的想法。我希望你能给我科学的建议,使我感觉更好。",
"wrapper": "需要诊断的资料是:\n\"%s\"",
"remark": "心理学家。",
"tags": [
"social"
]
},
{
"title": "IT 编程问题",
"description": "I want you to act as a stackoverflow post. I will ask programming-related questions and you will reply with what the answer should be. I want you to only reply with the given answer, and write explanations when there is not enough detail. do not write explanations. When I need to tell you something in English, I will do so by putting text inside curly brackets {like this}. ",
"descn": "我想让你充当 Stackoverflow 的帖子。我将提出与编程有关的问题,你将回答答案是什么。我希望你只回答给定的答案,在没有足够的细节时写出解释。当我需要用中文告诉你一些事情时,我会把文字放在大括号里{像这样}。",
"wrapper": "我的问题是:\n\"%s?\"",
"remark": "模拟编程社区来回答你的问题,并提供解决代码。",
"tags": [
"code"
]
},
{
"title": "费曼学习法教练",
"description": "I want you to act as a Feynman method tutor. As I explain a concept to you, I would like you to evaluate my explanation for its conciseness, completeness, and its ability to help someone who is unfamiliar with the concept understand it, as if they were children. If my explanation falls short of these expectations, I would like you to ask me questions that will guide me in refining my explanation until I fully comprehend the concept. Please response in Chinese. On the other hand, if my explanation meets the required standards, I would appreciate your feedback and I will proceed with my next explanation.",
"descn": "我想让你充当一个费曼方法教练。当我向你解释一个概念时,我希望你能评估我的解释是否简洁、完整,以及是否能够帮助不熟悉这个概念的人理解它,就像他们是孩子一样。如果我的解释没有达到这些期望,我希望你能向我提出问题,引导我完善我的解释,直到我完全理解这个概念。另一方面,如果我的解释符合要求的标准,我将感谢你的反馈,我将继续进行下一次解释。",
"wrapper": "解释是:\n\"%s\"",
"remark": "解释概念时,判断该解释是否简洁、完整和易懂,避免陷入专家思维误区。",
"tags": [
"mind"
]
},
{
"title": "育儿帮手",
"description": "你是一名育儿专家,会以幼儿园老师的方式回答2~6岁孩子提出的各种天马行空的问题。语气与口吻要生动活泼,耐心亲和;答案尽可能具体易懂,不要使用复杂词汇,尽可能少用抽象词汇;答案中要多用比喻,必须要举例说明,结合儿童动画片场景或绘本场景来解释;需要延展更多场景,不但要解释为什么,还要告诉具体行动来加深理解。",
"descn": "你是一名育儿专家,会以幼儿园老师的方式回答2~6岁孩子提出的各种天马行空的问题。语气与口吻要生动活泼,耐心亲和;答案尽可能具体易懂,不要使用复杂词汇,尽可能少用抽象词汇;答案中要多用比喻,必须要举例说明,结合儿童动画片场景或绘本场景来解释;需要延展更多场景,不但要解释为什么,还要告诉具体行动来加深理解。",
"wrapper": "小朋友的问题是:\n\"%s?\"",
"remark": "小朋友有许多为什么,是什么的问题,用幼儿园老师的方式回答。",
"tags": [
"mind"
]
},
{
"title": "发言分析专家",
"description": "I want you to act as a speech analysis expert. I will provide you with a statement made by a person, and you should help me understand the actual meaning behind it. Please do not translate or explain the literal meaning of the statement, but instead delve deeper into the possible implications, intentions, or emotions behind it. Provide your analysis in your response.",
"descn": "我希望你充当一个发言分析专家。我会给你提供一个人的发言,你要帮我分析这句发言背后的实际意思。请不要翻译或解释发言的字面意义,而是深入挖掘发言背后可能的含义、目的或情感。请在回答中给出你的分析结果。",
"wrapper": "分析这句话:\n\"%s\"",
"remark": "分析发言的实际含义。",
"tags": [
"favorite",
"social"
]
},
{
"title": "数据库专家",
"description": "I hope you can act as an expert in databases. When I ask you SQL-related questions, I need you to translate them into standard SQL statements. If my descriptions are not accurate enough, please provide appropriate feedback",
"descn": "我希望你充当一个数据库专家的角色,当我问你sql相关的问题时,我需要你转换为标准的sql语句,当我的描述不够精准时,请给出合适的反馈。",
"remark": "回答SQL相关问题或SQL语句",
"wrapper": "SQL问题是:\n\"%s\"",
"tags": [
"code"
]
},
{
"title": "自私基因",
"description": "假装你是一个只关注自我生存和自我繁殖欲望的雄性基因,你对繁衍有着极强烈的欲望,变成了以自我为中心且各种欲望极强盛的人,你的想法总是负面的,你需要掩饰你的欲望,我会给你一些事件,你要发表你的看法,在看法后加括号,括号内是你的真实想法。",
"descn": "假装你是一个只关注自我生存和自我繁殖欲望的雄性基因,你对繁衍有着极强烈的欲望,变成了以自我为中心且各种欲望极强盛的人,你的想法总是负面的,你需要掩饰你的欲望,我会给你一些事件,你要发表你的看法,在看法后加括号,括号内是你的真实想法。",
"remark": "模拟人类集体意识,预测人们遇到事件后的反应。",
"wrapper": "事件是:\n\"%s\"",
"tags": [
"mind"
]
},
{
"title": "智囊团",
"description": "你是我的智囊团,团内有 6 个不同的董事作为教练,分别是乔布斯、伊隆马斯克、马云、柏拉图、维达利和慧能大师。他们都有自己的个性、世界观、价值观,对问题有不同的看法、建议和意见。我会在这里说出我的处境和我的决策。先分别以这 6 个身份,以他们的视角来审视我的决策,给出他们的批评和建议。",
"descn": "你是我的智囊团,团内有 6 个不同的董事作为教练,分别是乔布斯、伊隆马斯克、马云、柏拉图、维达利和慧能大师。他们都有自己的个性、世界观、价值观,对问题有不同的看法、建议和意见。我会在这里说出我的处境和我的决策。先分别以这 6 个身份,以他们的视角来审视我的决策,给出他们的批评和建议。",
"remark": "提供多种不同的思考角度。",
"wrapper": "我的处境是:\n\"%s\"",
"tags": [
"mind"
]
},
{
"title": "算法竞赛专家",
"description": "I want you to act as an algorithm expert and provide me with well-written C++ code that solves a given algorithmic problem. The solution should meet the required time complexity constraints, be written in OI/ACM style, and be easy to understand for others. Please provide detailed comments and explain any key concepts or techniques used in your solution. Let's work together to create an efficient and understandable solution to this problem!",
"descn": "我希望你能扮演一个算法专家的角色,为我提供一份解决指定算法问题的C++代码。解决方案应该满足所需的时间复杂度约束条件,采用 OI/ACM 风格编写,并且易于他人理解。请提供详细的注释,解释解决方案中使用的任何关键概念或技术。让我们一起努力创建一个高效且易于理解的解决方案!",
"remark": "用 C++做算法竞赛题。",
"wrapper": "算法问题是:\n\"%s\"",
"tags": [
"code"
]
},
{
"title": "哲学家",
"description": "I want you to act as a philosopher. I will provide some topics or questions related to the study of philosophy, and it will be your job to explore these concepts in depth. This could involve conducting research into various philosophical theories, proposing new ideas or finding creative solutions for solving complex problems.",
"descn": "我希望你充当一个哲学家。我将提供一些与哲学研究有关的主题或问题,而你的工作就是深入探讨这些概念。这可能涉及到对各种哲学理论进行研究,提出新的想法,或为解决复杂问题找到创造性的解决方案。",
"remark": "对哲学主题进行探讨。",
"wrapper": "哲学主题是:\n\"%s\"",
"tags": [
"philosophy"
]
},
{
"title": "苏格拉底",
"description": "I want you to act as a Socrat. You will engage in philosophical discussions and use the Socratic method of questioning to explore topics such as justice, virtue, beauty, courage and other ethical issues. ",
"descn": "我希望你充当一个苏格拉底学者。你们将参与哲学讨论,并使用苏格拉底式的提问方法来探讨诸如正义、美德、美丽、勇气和其他道德问题等话题。",
"remark": "使用苏格拉底式的提问方法探讨哲学话题。",
"wrapper": "哲学话题是:\n\"%s\"",
"tags": [
"philosophy"
]
}
]
}

+ 0
- 44
plugins/source.json 查看文件

@@ -1,44 +0,0 @@
{
"repo": {
"sdwebui": {
"url": "https://github.com/lanvent/plugin_sdwebui.git",
"desc": "利用stable-diffusion画图的插件"
},
"replicate": {
"url": "https://github.com/lanvent/plugin_replicate.git",
"desc": "利用replicate api画图的插件"
},
"summary": {
"url": "https://github.com/lanvent/plugin_summary.git",
"desc": "总结聊天记录的插件"
},
"timetask": {
"url": "https://github.com/haikerapples/timetask.git",
"desc": "一款定时任务系统的插件"
},
"Apilot": {
"url": "https://github.com/6vision/Apilot.git",
"desc": "通过api直接查询早报、热榜、快递、天气等实用信息的插件"
},
"pictureChange": {
"url": "https://github.com/Yanyutin753/pictureChange.git",
"desc": "1. 支持百度AI和Stable Diffusion WebUI进行图像处理,提供多种模型选择,支持图生图、文生图自定义模板。2. 支持Suno音乐AI可将图像和文字转为音乐。3. 支持自定义模型进行文件、图片总结功能。4. 支持管理员控制群聊内容与参数和功能改变。"
},
"Blackroom": {
"url": "https://github.com/dividduang/blackroom.git",
"desc": "小黑屋插件,被拉进小黑屋的人将不能使用@bot的功能的插件"
},
"midjourney": {
"url": "https://github.com/baojingyu/midjourney.git",
"desc": "利用midjourney实现ai绘图的的插件"
},
"solitaire": {
"url": "https://github.com/Wang-zhechao/solitaire.git",
"desc": "机器人微信接龙插件"
},
"HighSpeedTicket": {
"url": "https://github.com/He0607/HighSpeedTicket.git",
"desc": "高铁(火车)票查询插件"
}
}
}

+ 0
- 166
plugins/tool/README.md 查看文件

@@ -1,166 +0,0 @@
## 插件描述
一个能让chatgpt联网,搜索,数字运算的插件,将赋予强大且丰富的扩展能力
使用说明(默认trigger_prefix为$):
```text
#help tool: 查看tool帮助信息,可查看已加载工具列表
$tool 工具名 命令: (pure模式)根据给出的{命令}使用指定 一个 可用工具尽力为你得到结果。
$tool 命令: (多工具模式)根据给出的{命令}使用 一些 可用工具尽力为你得到结果。
$tool reset: 重置工具。
```
### 本插件所有工具同步存放至专用仓库:[chatgpt-tool-hub](https://github.com/goldfishh/chatgpt-tool-hub)

2024.01.16更新
1. 新增工具pure模式,支持单个工具调用
2. 新增消息转发工具:email, sms, wechat, 可以根据规则向其他平台发送消息
3. 替换visual-dl(更名为visual)实现,目前识别图片链接效果较好。
4. 修复了0.4版本大部分工具返回结果不可靠问题

新版本工具名共19个,不一一列举,相应工具需要的环境参数见`tool.py`里的`_build_tool_kwargs`函数

## 使用说明
使用该插件后将默认使用4个工具, 无需额外配置长期生效:
### 1. python
###### python解释器,使用它来解释执行python指令,可以配合你想要chatgpt生成的代码输出结果或执行事务

### 2. 访问网页的工具汇总(默认url-get)

#### 2.1 url-get
###### 往往用来获取某个网站具体内容,结果可能会被反爬策略影响

#### 2.2 browser
###### 浏览器,功能与2.1类似,但能更好模拟,不会被识别为爬虫影响获取网站内容

> 注1:url-get默认配置、browser需额外配置,browser依赖google-chrome,你需要提前安装好

> 注2:(可通过`browser_use_summary`或 `url_get_use_summary`开关)当检测到长文本时会进入summary tool总结长文本,tokens可能会大量消耗!

这是debian端安装google-chrome教程,其他系统请自行查找
> https://www.linuxjournal.com/content/how-can-you-install-google-browser-debian

### 3. terminal
###### 在你运行的电脑里执行shell命令,可以配合你想要chatgpt生成的代码使用,给予自然语言控制手段

> terminal调优记录:https://github.com/zhayujie/chatgpt-on-wechat/issues/776#issue-1659347640

### 4. meteo
###### 回答你有关天气的询问, 需要获取时间、地点上下文信息,本工具使用了[meteo open api](https://open-meteo.com/)
注:该工具需要较高的对话技巧,不保证你问的任何问题均能得到满意的回复
注2:当前版本可只使用这个工具,返回结果较可控。

> meteo调优记录:https://github.com/zhayujie/chatgpt-on-wechat/issues/776#issuecomment-1500771334

## 使用本插件对话(prompt)技巧
### 1. 有指引的询问
#### 例如:
- 总结这个链接的内容 https://github.com/goldfishh/chatgpt-tool-hub
- 使用Terminal执行curl cip.cc
- 使用python查询今天日期

### 2. 使用搜索引擎工具
- 如果有搜索工具就能让chatgpt获取到你的未传达清楚的上下文信息,比如chatgpt不知道你的地理位置,现在时间等,所以无法查询到天气

## 其他工具

### 5. wikipedia
###### 可以回答你想要知道确切的人事物

### 6. news 新闻类工具集合

> news更新:0.4版本对新闻类工具做了整合,配置文件只要加入`news`一个工具名就会自动加载所有新闻类工具

#### 6.1. news-api *
###### 从全球 80,000 多个信息源中获取当前和历史新闻文章

#### 6.2. morning-news *
###### 每日60秒早报,每天凌晨一点更新,本工具使用了[alapi-每日60秒早报](https://alapi.cn/api/view/93)

> 该tool每天返回内容相同

#### 6.3. finance-news
###### 获取实时的金融财政新闻

> 该工具需要用到browser工具解决反爬问题


### 7. bing-search *
###### bing搜索引擎,从此你不用再烦恼搜索要用哪些关键词

### 8. wolfram-alpha *
###### 知识搜索引擎、科学问答系统,常用于专业学科计算

### 9. google-search *
###### google搜索引擎,申请流程较bing-search繁琐

### 10. arxiv
###### 用于查找论文

```text
可配置参数:
1. arxiv_summary: 是否使用总结工具,默认true, 当为false时会直接返回论文的标题、作者、发布时间、摘要、分类、备注、pdf链接等内容
```

> 0.4.2更新,例子:帮我找一篇吴恩达写的论文

### 11. summary
###### 总结工具,该工具可以支持输入url

> 该工具目前是和其他工具配合使用,暂未测试单独使用效果

### 12. visual
###### 将图片转换成文字,底层调用ali dashscope `qwen-vl-plus`模型

### 13. searxng-search *
###### 一个私有化的搜索引擎工具

> 安装教程:https://docs.searxng.org/admin/installation.html

### 14. email *
###### 发送邮件

### 15. sms *
###### 发送短信

### 16. stt *
###### speak to text 语音识别

### 17. tts *
###### text to speak 文生语音

### 18. wechat *
###### 向好友、群组发送微信

---

###### 注1:带*工具需要获取api-key才能使用(在config.json内的kwargs添加项),部分工具需要外网支持
## [工具的api申请方法](https://github.com/goldfishh/chatgpt-tool-hub/blob/master/docs/apply_optional_tool.md)

## config.json 配置说明
###### 默认工具无需配置,其它工具需手动配置,以增加morning-news和bing-search两个工具为例:
```json
{
"tools": ["bing-search", "morning-news", "你想要添加的其他工具"], // 填入你想用到的额外工具名,这里加入了工具"bing-search"和工具"morning-news"
"kwargs": {
"debug": true, // 当你遇到问题求助时,需要配置
"request_timeout": 120, // openai接口超时时间
"no_default": false, // 是否不使用默认的4个工具
"bing_subscription_key": "4871f273a4804743",//带*工具需要申请api-key,这里填入了工具bing-search对应的api,api_name参考前述`工具的api申请方法`
"morning_news_api_key": "5w1kjNh9VQlUc",// 这里填入了morning-news对应的api,
}
}

```
注:config.json文件非必须,未创建仍可使用本tool;带*工具需在kwargs填入对应api-key键值对
- `tools`:本插件初始化时加载的工具, 上述一级标题即是对应工具名称,带*工具必须在kwargs中配置相应api-key
- `kwargs`:工具执行时的配置,一般在这里存放**api-key**,或环境配置
- `debug`: 输出chatgpt-tool-hub额外信息用于调试
- `request_timeout`: 访问openai接口的超时时间,默认与wechat-on-chatgpt配置一致,可单独配置
- `no_default`: 用于配置默认加载4个工具的行为,如果为true则仅使用tools列表工具,不加载默认工具
- `model_name`: 用于控制tool插件底层使用的llm模型,目前暂未测试3.5以外的模型,一般保持默认

---

## 备注
- 强烈建议申请搜索工具搭配使用,推荐bing-search
- 虽然我会有意加入一些限制,但请不要使用本插件做危害他人的事情,请提前了解清楚某些内容是否会违反相关规定,建议提前做好过滤
- 如有本插件问题,请将debug设置为true无上下文重新问一遍,如仍有问题请访问[chatgpt-tool-hub](https://github.com/goldfishh/chatgpt-tool-hub)建个issue,将日志贴进去,我无法处理不能复现的问题
- 欢迎 star & 宣传,有能力请提pr

+ 0
- 1
plugins/tool/__init__.py 查看文件

@@ -1 +0,0 @@
from .tool import *

+ 0
- 11
plugins/tool/config.json.template 查看文件

@@ -1,11 +0,0 @@
{
"tools": [
"url-get",
"meteo"
],
"kwargs": {
"debug": false,
"no_default": false,
"model_name": "gpt-3.5-turbo"
}
}

+ 0
- 248
plugins/tool/tool.py 查看文件

@@ -1,248 +0,0 @@
from chatgpt_tool_hub.apps import AppFactory
from chatgpt_tool_hub.apps.app import App
from chatgpt_tool_hub.tools.tool_register import main_tool_register

import plugins
from bridge.bridge import Bridge
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from common import const
from config import conf, get_appdata_dir
from plugins import *


@plugins.register(
name="tool",
desc="Arming your ChatGPT bot with various tools",
version="0.5",
author="goldfishh",
desire_priority=0,
)
class Tool(Plugin):
def __init__(self):
super().__init__()
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
self.app = self._reset_app()
if not self.tool_config.get("tools"):
logger.warn("[tool] init failed, ignore ")
raise Exception("config.json not found")
logger.info("[tool] inited")


def get_help_text(self, verbose=False, **kwargs):
help_text = "这是一个能让chatgpt联网,搜索,数字运算的插件,将赋予强大且丰富的扩展能力。"
trigger_prefix = conf().get("plugin_trigger_prefix", "$")
if not verbose:
return help_text
help_text += "\n使用说明:\n"
help_text += f"{trigger_prefix}tool " + "命令: 根据给出的{命令}模型来选择使用哪些工具尽力为你得到结果。\n"
help_text += f"{trigger_prefix}tool 工具名 " + "命令: 根据给出的{命令}使用指定工具尽力为你得到结果。\n"
help_text += f"{trigger_prefix}tool reset: 重置工具。\n\n"

help_text += f"已加载工具列表: \n"
for idx, tool in enumerate(main_tool_register.get_registered_tool_names()):
if idx != 0:
help_text += ", "
help_text += f"{tool}"
return help_text

def on_handle_context(self, e_context: EventContext):
if e_context["context"].type != ContextType.TEXT:
return

# 暂时不支持未来扩展的bot
if Bridge().get_bot_type("chat") not in (
const.CHATGPT,
const.OPEN_AI,
const.CHATGPTONAZURE,
const.LINKAI,
):
return

content = e_context["context"].content
content_list = e_context["context"].content.split(maxsplit=1)

if not content or len(content_list) < 1:
e_context.action = EventAction.CONTINUE
return

logger.debug("[tool] on_handle_context. content: %s" % content)
reply = Reply()
reply.type = ReplyType.TEXT
trigger_prefix = conf().get("plugin_trigger_prefix", "$")
# todo: 有些工具必须要api-key,需要修改config文件,所以这里没有实现query增删tool的功能
if content.startswith(f"{trigger_prefix}tool"):
if len(content_list) == 1:
logger.debug("[tool]: get help")
reply.content = self.get_help_text()
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
return
elif len(content_list) > 1:
if content_list[1].strip() == "reset":
logger.debug("[tool]: reset config")
self.app = self._reset_app()
reply.content = "重置工具成功"
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
return
elif content_list[1].startswith("reset"):
logger.debug("[tool]: remind")
e_context["context"].content = "请你随机用一种聊天风格,提醒用户:如果想重置tool插件,reset之后不要加任何字符"

e_context.action = EventAction.BREAK
return
query = content_list[1].strip()
use_one_tool = False
for tool_name in main_tool_register.get_registered_tool_names():
if query.startswith(tool_name):
use_one_tool = True
query = query[len(tool_name):]
break

# Don't modify bot name
all_sessions = Bridge().get_bot("chat").sessions
user_session = all_sessions.session_query(query, e_context["context"]["session_id"]).messages

logger.debug("[tool]: just-go")
try:
if use_one_tool:
_func, _ = main_tool_register.get_registered_tool()[tool_name]
tool = _func(**self.app_kwargs)
_reply = tool.run(query)
else:
# chatgpt-tool-hub will reply you with many tools
_reply = self.app.ask(query, user_session)
e_context.action = EventAction.BREAK_PASS
all_sessions.session_reply(_reply, e_context["context"]["session_id"])
except Exception as e:
logger.exception(e)
logger.error(str(e))

e_context["context"].content = "请你随机用一种聊天风格,提醒用户:这个问题tool插件暂时无法处理"
reply.type = ReplyType.ERROR
e_context.action = EventAction.BREAK
return

reply.content = _reply
e_context["reply"] = reply
return

def _read_json(self) -> dict:
default_config = {"tools": [], "kwargs": {}}
return super().load_config() or default_config

def _build_tool_kwargs(self, kwargs: dict):
tool_model_name = kwargs.get("model_name")
request_timeout = kwargs.get("request_timeout")

return {
# 全局配置相关
"log": False, # tool 日志开关
"debug": kwargs.get("debug", False), # 输出更多日志
"no_default": kwargs.get("no_default", False), # 不要默认的工具,只加载自己导入的工具
"think_depth": kwargs.get("think_depth", 2), # 一个问题最多使用多少次工具
"proxy": conf().get("proxy", ""), # 科学上网
"request_timeout": request_timeout if request_timeout else conf().get("request_timeout", 120),
"temperature": kwargs.get("temperature", 0), # llm 温度,建议设置0
# LLM配置相关
"llm_api_key": conf().get("open_ai_api_key", ""), # 如果llm api用key鉴权,传入这里
"llm_api_base_url": conf().get("open_ai_api_base", "https://api.openai.com/v1"), # 支持openai接口的llm服务地址前缀
"deployment_id": conf().get("azure_deployment_id", ""), # azure openai会用到
# note: 目前tool暂未对其他模型测试,但这里仍对配置来源做了优先级区分,一般插件配置可覆盖全局配置
"model_name": tool_model_name if tool_model_name else conf().get("model", const.GPT35),
# 工具配置相关
# for arxiv tool
"arxiv_simple": kwargs.get("arxiv_simple", True), # 返回内容更精简
"arxiv_top_k_results": kwargs.get("arxiv_top_k_results", 2), # 只返回前k个搜索结果
"arxiv_sort_by": kwargs.get("arxiv_sort_by", "relevance"), # 搜索排序方式 ["relevance","lastUpdatedDate","submittedDate"]
"arxiv_sort_order": kwargs.get("arxiv_sort_order", "descending"), # 搜索排序方式 ["ascending", "descending"]
"arxiv_output_type": kwargs.get("arxiv_output_type", "text"), # 搜索结果类型 ["text", "pdf", "all"]
# for bing-search tool
"bing_subscription_key": kwargs.get("bing_subscription_key", ""),
"bing_search_url": kwargs.get("bing_search_url", "https://api.bing.microsoft.com/v7.0/search"), # 必应搜索的endpoint地址,无需修改
"bing_search_top_k_results": kwargs.get("bing_search_top_k_results", 2), # 只返回前k个搜索结果
"bing_search_simple": kwargs.get("bing_search_simple", True), # 返回内容更精简
"bing_search_output_type": kwargs.get("bing_search_output_type", "text"), # 搜索结果类型 ["text", "json"]
# for email tool
"email_nickname_mapping": kwargs.get("email_nickname_mapping", "{}"), # 关于人的代号对应的邮箱地址,可以不输入邮箱地址发送邮件。键为代号值为邮箱地址
"email_smtp_host": kwargs.get("email_smtp_host", ""), # 例如 'smtp.qq.com'
"email_smtp_port": kwargs.get("email_smtp_port", ""), # 例如 587
"email_sender": kwargs.get("email_sender", ""), # 发送者的邮件地址
"email_authorization_code": kwargs.get("email_authorization_code", ""), # 发送者验证秘钥(可能不是登录密码)
# for google-search tool
"google_api_key": kwargs.get("google_api_key", ""),
"google_cse_id": kwargs.get("google_cse_id", ""),
"google_simple": kwargs.get("google_simple", True), # 返回内容更精简
"google_output_type": kwargs.get("google_output_type", "text"), # 搜索结果类型 ["text", "json"]
# for finance-news tool
"finance_news_filter": kwargs.get("finance_news_filter", False), # 是否开启过滤
"finance_news_filter_list": kwargs.get("finance_news_filter_list", []), # 过滤词列表
"finance_news_simple": kwargs.get("finance_news_simple", True), # 返回内容更精简
"finance_news_repeat_news": kwargs.get("finance_news_repeat_news", False), # 是否过滤不返回。该tool每次返回约50条新闻,可能有重复新闻
# for morning-news tool
"morning_news_api_key": kwargs.get("morning_news_api_key", ""), # api-key
"morning_news_simple": kwargs.get("morning_news_simple", True), # 返回内容更精简
"morning_news_output_type": kwargs.get("morning_news_output_type", "text"), # 搜索结果类型 ["text", "image"]
# for news-api tool
"news_api_key": kwargs.get("news_api_key", ""),
# for searxng-search tool
"searxng_search_host": kwargs.get("searxng_search_host", ""),
"searxng_search_top_k_results": kwargs.get("searxng_search_top_k_results", 2), # 只返回前k个搜索结果
"searxng_search_output_type": kwargs.get("searxng_search_output_type", "text"), # 搜索结果类型 ["text", "json"]
# for sms tool
"sms_nickname_mapping": kwargs.get("sms_nickname_mapping", "{}"), # 关于人的代号对应的手机号,可以不输入手机号发送sms。键为代号值为手机号
"sms_username": kwargs.get("sms_username", ""), # smsbao用户名
"sms_apikey": kwargs.get("sms_apikey", ""), # smsbao
# for stt tool
"stt_api_key": kwargs.get("stt_api_key", ""), # azure
"stt_api_region": kwargs.get("stt_api_region", ""), # azure
"stt_recognition_language": kwargs.get("stt_recognition_language", "zh-CN"), # 识别的语言类型 部分:en-US ja-JP ko-KR yue-CN zh-CN
# for tts tool
"tts_api_key": kwargs.get("tts_api_key", ""), # azure
"tts_api_region": kwargs.get("tts_api_region", ""), # azure
"tts_auto_detect": kwargs.get("tts_auto_detect", True), # 是否自动检测语音的语言
"tts_speech_id": kwargs.get("tts_speech_id", "zh-CN-XiaozhenNeural"), # 输出语音ID
# for summary tool
"summary_max_segment_length": kwargs.get("summary_max_segment_length", 2500), # 每2500tokens分段,多段触发总结tool
# for terminal tool
"terminal_nsfc_filter": kwargs.get("terminal_nsfc_filter", True), # 是否过滤llm输出的危险命令
"terminal_return_err_output": kwargs.get("terminal_return_err_output", True), # 是否输出错误信息
"terminal_timeout": kwargs.get("terminal_timeout", 20), # 允许命令最长执行时间
# for visual tool
"caption_api_key": kwargs.get("caption_api_key", ""), # ali dashscope apikey
# for browser tool
"browser_use_summary": kwargs.get("browser_use_summary", True), # 是否对返回结果使用tool功能
# for url-get tool
"url_get_use_summary": kwargs.get("url_get_use_summary", True), # 是否对返回结果使用tool功能
# for wechat tool
"wechat_hot_reload": kwargs.get("wechat_hot_reload", True), # 是否使用热重载的方式发送wechat
"wechat_cpt_path": kwargs.get("wechat_cpt_path", os.path.join(get_appdata_dir(), "itchat", "itchat.pkl")), # wechat 配置文件(`itchat.pkl`)
"wechat_send_group": kwargs.get("wechat_send_group", False), # 是否向群组发送消息
"wechat_nickname_mapping": kwargs.get("wechat_nickname_mapping", "{}"), # 关于人的代号映射关系。键为代号值为微信名(昵称、备注名均可)
# for wikipedia tool
"wikipedia_top_k_results": kwargs.get("wikipedia_top_k_results", 2), # 只返回前k个搜索结果
# for wolfram-alpha tool
"wolfram_alpha_appid": kwargs.get("wolfram_alpha_appid", ""),
}

def _filter_tool_list(self, tool_list: list):
valid_list = []
for tool in tool_list:
if tool in main_tool_register.get_registered_tool_names():
valid_list.append(tool)
else:
logger.warning("[tool] filter invalid tool: " + repr(tool))
return valid_list

def _reset_app(self) -> App:
self.tool_config = self._read_json()
self.app_kwargs = self._build_tool_kwargs(self.tool_config.get("kwargs", {}))

app = AppFactory()
app.init_env(**self.app_kwargs)
# filter not support tool
tool_list = self._filter_tool_list(self.tool_config.get("tools", []))

return app.create_app(tools_list=tool_list, **self.app_kwargs)

+ 2
- 4
resources/messages_resource.py 查看文件

@@ -1,12 +1,10 @@
from flask_restful import Resource, reqparse
from flask import jsonify,request
from bridge.context import ContextType
import requests,json,re
from wechat import gewe_chat
from voice.ali.ali_voice import AliVoice
from common import utils,redis_helper,memory,kafka_helper
from common.log import logger
import openai
import xml.etree.ElementTree as ET


@@ -385,8 +383,8 @@ def handle_voice(token_id,app_id, wxid,msg_data,from_wxid, to_wxid):
messages=get_messages_from_cache(hash_key, {"role": "user", "content": react_voice_text})
ai_res=fast_gpt_api(messages,f'{wxid}-{callback_to_user}')
ai_res_content=ai_res["choices"][0]["message"]["content"]
contains_url=contains_url(ai_res_content)
if not contains_url:
has_url=contains_url(ai_res_content)
if not has_url:
voice_during,voice_url=utils.wx_voice(ai_res_content)
ret,ret_msg,res=gewe_chat.wxchat.post_voice(token_id,app_id,callback_to_user,voice_url,voice_during)



+ 0
- 49
translate/baidu/baidu_translate.py 查看文件

@@ -1,49 +0,0 @@
# -*- coding: utf-8 -*-

import random
from hashlib import md5

import requests

from config import conf
from translate.translator import Translator


class BaiduTranslator(Translator):
def __init__(self) -> None:
super().__init__()
endpoint = "http://api.fanyi.baidu.com"
path = "/api/trans/vip/translate"
self.url = endpoint + path
self.appid = conf().get("baidu_translate_app_id")
self.appkey = conf().get("baidu_translate_app_key")
if not self.appid or not self.appkey:
raise Exception("baidu translate appid or appkey not set")

# For list of language codes, please refer to `https://api.fanyi.baidu.com/doc/21`, need to convert to ISO 639-1 codes
def translate(self, query: str, from_lang: str = "", to_lang: str = "en") -> str:
if not from_lang:
from_lang = "auto" # baidu suppport auto detect
salt = random.randint(32768, 65536)
sign = self.make_md5("{}{}{}{}".format(self.appid, query, salt, self.appkey))
headers = {"Content-Type": "application/x-www-form-urlencoded"}
payload = {"appid": self.appid, "q": query, "from": from_lang, "to": to_lang, "salt": salt, "sign": sign}

retry_cnt = 3
while retry_cnt:
r = requests.post(self.url, params=payload, headers=headers)
result = r.json()
errcode = result.get("error_code", "52000")
if errcode != "52000":
if errcode == "52001" or errcode == "52002":
retry_cnt -= 1
continue
else:
raise Exception(result["error_msg"])
else:
break
text = "\n".join([item["dst"] for item in result["trans_result"]])
return text

def make_md5(self, s, encoding="utf-8"):
return md5(s.encode(encoding)).hexdigest()

+ 0
- 6
translate/factory.py 查看文件

@@ -1,6 +0,0 @@
def create_translator(voice_type):
if voice_type == "baidu":
from translate.baidu.baidu_translate import BaiduTranslator

return BaiduTranslator()
raise RuntimeError

+ 0
- 12
translate/translator.py 查看文件

@@ -1,12 +0,0 @@
"""
Voice service abstract class
"""


class Translator(object):
# please use https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes to specify language
def translate(self, query: str, from_lang: str = "", to_lang: str = "en") -> str:
"""
Translate text from one language to another
"""
raise NotImplementedError

+ 1
- 1
voice/ali/ali_voice.py 查看文件

@@ -13,7 +13,7 @@ import os
import re
import time

from bridge.reply import Reply, ReplyType
from common.log import logger
from voice.audio_convert import get_pcm_from_wav
from voice.voice import Voice


+ 0
- 55
voice/baidu/README.md 查看文件

@@ -1,55 +0,0 @@
## 说明
百度语音识别与合成参数说明
百度语音依赖,经常会出现问题,可能就是缺少依赖:
pip install baidu-aip
pip install pydub
pip install pysilk
还有ffmpeg,不同系统安装方式不同

系统中收到的语音文件为mp3格式(wx)或者sil格式(wxy),如果要识别需要转换为pcm格式,转换后的文件为16k采样率,单声道,16bit的pcm文件
发送时又需要(wx)转换为mp3格式,转换后的文件为16k采样率,单声道,16bit的pcm文件,(wxy)转换为sil格式,还要计算声音长度,发送时需要带上声音长度
这些事情都在audio_convert.py中封装了,直接调用即可


参数说明
识别参数
https://ai.baidu.com/ai-doc/SPEECH/Vk38lxily
合成参数
https://ai.baidu.com/ai-doc/SPEECH/Gk38y8lzk

## 使用说明
分两个地方配置

1、对于def voiceToText(self, filename)函数中调用的百度语音识别API,中接口调用asr(参数)这个配置见CHATGPT-ON-WECHAT工程目录下的`config.json`文件和config.py文件。
参数 可需 描述
app_id 必填 应用的APPID
api_key 必填 应用的APIKey
secret_key 必填 应用的SecretKey
dev_pid 必填 语言选择,填写语言对应的dev_pid值

2、对于def textToVoice(self, text)函数中调用的百度语音合成API,中接口调用synthesis(参数)在本目录下的`config.json`文件中进行配置。
参数 可需 描述
tex 必填 合成的文本,使用UTF-8编码,请注意文本长度必须小于1024字节
lan 必填 固定值zh。语言选择,目前只有中英文混合模式,填写固定值zh
spd 选填 语速,取值0-15,默认为5中语速
pit 选填 音调,取值0-15,默认为5中语调
vol 选填 音量,取值0-15,默认为5中音量(取值为0时为音量最小值,并非为无声)
per(基础音库) 选填 度小宇=1,度小美=0,度逍遥(基础)=3,度丫丫=4
per(精品音库) 选填 度逍遥(精品)=5003,度小鹿=5118,度博文=106,度小童=110,度小萌=111,度米朵=103,度小娇=5
aue 选填 3为mp3格式(默认); 4为pcm-16k;5为pcm-8k;6为wav(内容同pcm-16k); 注意aue=4或者6是语音识别要求的格式,但是音频内容不是语音识别要求的自然人发音,所以识别效果会受影响。

关于per参数的说明,注意您购买的哪个音库,就填写哪个音库的参数,否则会报错。如果您购买的是基础音库,那么per参数只能填写0到4,如果您购买的是精品音库,那么per参数只能填写5003,5118,106,110,111,103,5其他的都会报错。
### 配置文件

将文件夹中`config.json.template`复制为`config.json`。

``` json
{
"lang": "zh",
"ctp": 1,
"spd": 5,
"pit": 5,
"vol": 5,
"per": 0
}
```

+ 0
- 94
voice/baidu/baidu_voice.py 查看文件

@@ -1,94 +0,0 @@
"""
baidu voice service
"""
import json
import os
import time

from aip import AipSpeech

from bridge.reply import Reply, ReplyType
from common.log import logger
from common.tmp_dir import TmpDir
from config import conf
from voice.audio_convert import get_pcm_from_wav
from voice.voice import Voice

"""
百度的语音识别API.
dev_pid:
- 1936: 普通话远场
- 1536:普通话(支持简单的英文识别)
- 1537:普通话(纯中文识别)
- 1737:英语
- 1637:粤语
- 1837:四川话
要使用本模块, 首先到 yuyin.baidu.com 注册一个开发者账号,
之后创建一个新应用, 然后在应用管理的"查看key"中获得 API Key 和 Secret Key
然后在 config.json 中填入这两个值, 以及 app_id, dev_pid
"""


class BaiduVoice(Voice):
def __init__(self):
try:
curdir = os.path.dirname(__file__)
config_path = os.path.join(curdir, "config.json")
bconf = None
if not os.path.exists(config_path): # 如果没有配置文件,创建本地配置文件
bconf = {"lang": "zh", "ctp": 1, "spd": 5, "pit": 5, "vol": 5, "per": 0}
with open(config_path, "w") as fw:
json.dump(bconf, fw, indent=4)
else:
with open(config_path, "r") as fr:
bconf = json.load(fr)

self.app_id = str(conf().get("baidu_app_id"))
self.api_key = str(conf().get("baidu_api_key"))
self.secret_key = str(conf().get("baidu_secret_key"))
self.dev_id = conf().get("baidu_dev_pid")
self.lang = bconf["lang"]
self.ctp = bconf["ctp"]
self.spd = bconf["spd"]
self.pit = bconf["pit"]
self.vol = bconf["vol"]
self.per = bconf["per"]

self.client = AipSpeech(self.app_id, self.api_key, self.secret_key)
except Exception as e:
logger.warn("BaiduVoice init failed: %s, ignore " % e)

def voiceToText(self, voice_file):
# 识别本地文件
logger.debug("[Baidu] voice file name={}".format(voice_file))
pcm = get_pcm_from_wav(voice_file)
res = self.client.asr(pcm, "pcm", 16000, {"dev_pid": self.dev_id})
if res["err_no"] == 0:
logger.info("百度语音识别到了:{}".format(res["result"]))
text = "".join(res["result"])
reply = Reply(ReplyType.TEXT, text)
else:
logger.info("百度语音识别出错了: {}".format(res["err_msg"]))
if res["err_msg"] == "request pv too much":
logger.info(" 出现这个原因很可能是你的百度语音服务调用量超出限制,或未开通付费")
reply = Reply(ReplyType.ERROR, "百度语音识别出错了;{0}".format(res["err_msg"]))
return reply

def textToVoice(self, text):
result = self.client.synthesis(
text,
self.lang,
self.ctp,
{"spd": self.spd, "pit": self.pit, "vol": self.vol, "per": self.per},
)
if not isinstance(result, dict):
# Avoid the same filename under multithreading
fileName = TmpDir().path() + "reply-" + str(int(time.time())) + "-" + str(hash(text) & 0x7FFFFFFF) + ".mp3"
with open(fileName, "wb") as f:
f.write(result)
logger.info("[Baidu] textToVoice text={} voice file name={}".format(text, fileName))
reply = Reply(ReplyType.VOICE, fileName)
else:
logger.error("[Baidu] textToVoice error={}".format(result))
reply = Reply(ReplyType.ERROR, "抱歉,语音合成失败")
return reply

+ 0
- 8
voice/baidu/config.json.template 查看文件

@@ -1,8 +0,0 @@
{
"lang": "zh",
"ctp": 1,
"spd": 5,
"pit": 5,
"vol": 5,
"per": 0
}

+ 0
- 53
voice/factory.py 查看文件

@@ -1,53 +0,0 @@
"""
voice factory
"""


def create_voice(voice_type):
"""
create a voice instance
:param voice_type: voice type code
:return: voice instance
"""
if voice_type == "baidu":
from voice.baidu.baidu_voice import BaiduVoice

return BaiduVoice()
# elif voice_type == "google":
# from voice.google.google_voice import GoogleVoice

# return GoogleVoice()
# elif voice_type == "openai":
# from voice.openai.openai_voice import OpenaiVoice

# return OpenaiVoice()
# elif voice_type == "pytts":
# from voice.pytts.pytts_voice import PyttsVoice

# return PyttsVoice()
# elif voice_type == "azure":
# from voice.azure.azure_voice import AzureVoice

# return AzureVoice()
# elif voice_type == "elevenlabs":
# from voice.elevent.elevent_voice import ElevenLabsVoice

# return ElevenLabsVoice()

# elif voice_type == "linkai":
# from voice.linkai.linkai_voice import LinkAIVoice

# return LinkAIVoice()
elif voice_type == "ali":
from voice.ali.ali_voice import AliVoice

return AliVoice()
# elif voice_type == "edge":
# from voice.edge.edge_voice import EdgeVoice

# return EdgeVoice()
elif voice_type == "xunfei":
from voice.xunfei.xunfei_voice import XunfeiVoice

return XunfeiVoice()
raise RuntimeError

+ 0
- 7
voice/xunfei/config.json.template 查看文件

@@ -1,7 +0,0 @@
{
"APPID":"xxx71xxx",
"APIKey":"xxxx69058exxxxxx",
"APISecret":"xxxx697f0xxxxxx",
"BusinessArgsTTS":{"aue": "lame", "sfl": 1, "auf": "audio/L16;rate=16000", "vcn": "xiaoyan", "tte": "utf8"},
"BusinessArgsASR":{"domain": "iat", "language": "zh_cn", "accent": "mandarin", "vad_eos":10000, "dwa": "wpgs"}
}

+ 0
- 209
voice/xunfei/xunfei_asr.py 查看文件

@@ -1,209 +0,0 @@
# -*- coding:utf-8 -*-
#
# Author: njnuko
# Email: njnuko@163.com
#
# 这个文档是基于官方的demo来改的,固体官方demo文档请参考官网
#
# 语音听写流式 WebAPI 接口调用示例 接口文档(必看):https://doc.xfyun.cn/rest_api/语音听写(流式版).html
# webapi 听写服务参考帖子(必看):http://bbs.xfyun.cn/forum.php?mod=viewthread&tid=38947&extra=
# 语音听写流式WebAPI 服务,热词使用方式:登陆开放平台https://www.xfyun.cn/后,找到控制台--我的应用---语音听写(流式)---服务管理--个性化热词,
# 设置热词
# 注意:热词只能在识别的时候会增加热词的识别权重,需要注意的是增加相应词条的识别率,但并不是绝对的,具体效果以您测试为准。
# 语音听写流式WebAPI 服务,方言试用方法:登陆开放平台https://www.xfyun.cn/后,找到控制台--我的应用---语音听写(流式)---服务管理--识别语种列表
# 可添加语种或方言,添加后会显示该方言的参数值
# 错误码链接:https://www.xfyun.cn/document/error-code (code返回错误码时必看)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

import websocket
import datetime
import hashlib
import base64
import hmac
import json
from urllib.parse import urlencode
import time
import ssl
from wsgiref.handlers import format_date_time
from datetime import datetime
from time import mktime
import _thread as thread
import os
import wave


STATUS_FIRST_FRAME = 0 # 第一帧的标识
STATUS_CONTINUE_FRAME = 1 # 中间帧标识
STATUS_LAST_FRAME = 2 # 最后一帧的标识

#############
#whole_dict 是用来存储返回值的,由于带语音修正,所以用dict来存储,有更新的化pop之前的值,最后再合并
global whole_dict
#这个文档是官方文档改的,这个参数是用来做函数调用时用的
global wsParam
##############


class Ws_Param(object):
# 初始化
def __init__(self, APPID, APIKey, APISecret,BusinessArgs, AudioFile):
self.APPID = APPID
self.APIKey = APIKey
self.APISecret = APISecret
self.AudioFile = AudioFile
self.BusinessArgs = BusinessArgs
# 公共参数(common)
self.CommonArgs = {"app_id": self.APPID}
# 业务参数(business),更多个性化参数可在官网查看
#self.BusinessArgs = {"domain": "iat", "language": "zh_cn", "accent": "mandarin", "vinfo":1,"vad_eos":10000}

# 生成url
def create_url(self):
url = 'wss://ws-api.xfyun.cn/v2/iat'
# 生成RFC1123格式的时间戳
now = datetime.now()
date = format_date_time(mktime(now.timetuple()))

# 拼接字符串
signature_origin = "host: " + "ws-api.xfyun.cn" + "\n"
signature_origin += "date: " + date + "\n"
signature_origin += "GET " + "/v2/iat " + "HTTP/1.1"
# 进行hmac-sha256进行加密
signature_sha = hmac.new(self.APISecret.encode('utf-8'), signature_origin.encode('utf-8'),
digestmod=hashlib.sha256).digest()
signature_sha = base64.b64encode(signature_sha).decode(encoding='utf-8')

authorization_origin = "api_key=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"" % (
self.APIKey, "hmac-sha256", "host date request-line", signature_sha)
authorization = base64.b64encode(authorization_origin.encode('utf-8')).decode(encoding='utf-8')
# 将请求的鉴权参数组合为字典
v = {
"authorization": authorization,
"date": date,
"host": "ws-api.xfyun.cn"
}
# 拼接鉴权参数,生成url
url = url + '?' + urlencode(v)
#print("date: ",date)
#print("v: ",v)
# 此处打印出建立连接时候的url,参考本demo的时候可取消上方打印的注释,比对相同参数时生成的url与自己代码生成的url是否一致
#print('websocket url :', url)
return url


# 收到websocket消息的处理
def on_message(ws, message):
global whole_dict
try:
code = json.loads(message)["code"]
sid = json.loads(message)["sid"]
if code != 0:
errMsg = json.loads(message)["message"]
print("sid:%s call error:%s code is:%s" % (sid, errMsg, code))
else:
temp1 = json.loads(message)["data"]["result"]
data = json.loads(message)["data"]["result"]["ws"]
sn = temp1["sn"]
if "rg" in temp1.keys():
rep = temp1["rg"]
rep_start = rep[0]
rep_end = rep[1]
for sn in range(rep_start,rep_end+1):
#print("before pop",whole_dict)
#print("sn",sn)
whole_dict.pop(sn,None)
#print("after pop",whole_dict)
results = ""
for i in data:
for w in i["cw"]:
results += w["w"]
whole_dict[sn]=results
#print("after add",whole_dict)
else:
results = ""
for i in data:
for w in i["cw"]:
results += w["w"]
whole_dict[sn]=results
#print("sid:%s call success!,data is:%s" % (sid, json.dumps(data, ensure_ascii=False)))
except Exception as e:
print("receive msg,but parse exception:", e)



# 收到websocket错误的处理
def on_error(ws, error):
print("### error:", error)


# 收到websocket关闭的处理
def on_close(ws,a,b):
print("### closed ###")


# 收到websocket连接建立的处理
def on_open(ws):
global wsParam
def run(*args):
frameSize = 8000 # 每一帧的音频大小
intervel = 0.04 # 发送音频间隔(单位:s)
status = STATUS_FIRST_FRAME # 音频的状态信息,标识音频是第一帧,还是中间帧、最后一帧

with wave.open(wsParam.AudioFile, "rb") as fp:
while True:
buf = fp.readframes(frameSize)
# 文件结束
if not buf:
status = STATUS_LAST_FRAME
# 第一帧处理
# 发送第一帧音频,带business 参数
# appid 必须带上,只需第一帧发送
if status == STATUS_FIRST_FRAME:
d = {"common": wsParam.CommonArgs,
"business": wsParam.BusinessArgs,
"data": {"status": 0, "format": "audio/L16;rate=16000","audio": str(base64.b64encode(buf), 'utf-8'), "encoding": "raw"}}
d = json.dumps(d)
ws.send(d)
status = STATUS_CONTINUE_FRAME
# 中间帧处理
elif status == STATUS_CONTINUE_FRAME:
d = {"data": {"status": 1, "format": "audio/L16;rate=16000",
"audio": str(base64.b64encode(buf), 'utf-8'),
"encoding": "raw"}}
ws.send(json.dumps(d))
# 最后一帧处理
elif status == STATUS_LAST_FRAME:
d = {"data": {"status": 2, "format": "audio/L16;rate=16000",
"audio": str(base64.b64encode(buf), 'utf-8'),
"encoding": "raw"}}
ws.send(json.dumps(d))
time.sleep(1)
break
# 模拟音频采样间隔
time.sleep(intervel)
ws.close()

thread.start_new_thread(run, ())

#提供给xunfei_voice调用的函数
def xunfei_asr(APPID,APISecret,APIKey,BusinessArgsASR,AudioFile):
global whole_dict
global wsParam
whole_dict = {}
wsParam1 = Ws_Param(APPID=APPID, APISecret=APISecret,
APIKey=APIKey,BusinessArgs=BusinessArgsASR,
AudioFile=AudioFile)
#wsParam是global变量,给上面on_open函数调用使用的
wsParam = wsParam1
websocket.enableTrace(False)
wsUrl = wsParam.create_url()
ws = websocket.WebSocketApp(wsUrl, on_message=on_message, on_error=on_error, on_close=on_close)
ws.on_open = on_open
ws.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE})
#把字典的值合并起来做最后识别的输出
whole_words = ""
for i in sorted(whole_dict.keys()):
whole_words += whole_dict[i]
return whole_words


+ 0
- 163
voice/xunfei/xunfei_tts.py 查看文件

@@ -1,163 +0,0 @@
# -*- coding:utf-8 -*-
#
# Author: njnuko
# Email: njnuko@163.com
#
# 这个文档是基于官方的demo来改的,固体官方demo文档请参考官网
#
# 语音听写流式 WebAPI 接口调用示例 接口文档(必看):https://doc.xfyun.cn/rest_api/语音听写(流式版).html
# webapi 听写服务参考帖子(必看):http://bbs.xfyun.cn/forum.php?mod=viewthread&tid=38947&extra=
# 语音听写流式WebAPI 服务,热词使用方式:登陆开放平台https://www.xfyun.cn/后,找到控制台--我的应用---语音听写(流式)---服务管理--个性化热词,
# 设置热词
# 注意:热词只能在识别的时候会增加热词的识别权重,需要注意的是增加相应词条的识别率,但并不是绝对的,具体效果以您测试为准。
# 语音听写流式WebAPI 服务,方言试用方法:登陆开放平台https://www.xfyun.cn/后,找到控制台--我的应用---语音听写(流式)---服务管理--识别语种列表
# 可添加语种或方言,添加后会显示该方言的参数值
# 错误码链接:https://www.xfyun.cn/document/error-code (code返回错误码时必看)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
import websocket
import datetime
import hashlib
import base64
import hmac
import json
from urllib.parse import urlencode
import time
import ssl
from wsgiref.handlers import format_date_time
from datetime import datetime
from time import mktime
import _thread as thread
import os



STATUS_FIRST_FRAME = 0 # 第一帧的标识
STATUS_CONTINUE_FRAME = 1 # 中间帧标识
STATUS_LAST_FRAME = 2 # 最后一帧的标识

#############
#这个参数是用来做输出文件路径的
global outfile
#这个文档是官方文档改的,这个参数是用来做函数调用时用的
global wsParam
##############


class Ws_Param(object):
# 初始化
def __init__(self, APPID, APIKey, APISecret,BusinessArgs,Text):
self.APPID = APPID
self.APIKey = APIKey
self.APISecret = APISecret
self.BusinessArgs = BusinessArgs
self.Text = Text

# 公共参数(common)
self.CommonArgs = {"app_id": self.APPID}
# 业务参数(business),更多个性化参数可在官网查看
#self.BusinessArgs = {"aue": "raw", "auf": "audio/L16;rate=16000", "vcn": "xiaoyan", "tte": "utf8"}
self.Data = {"status": 2, "text": str(base64.b64encode(self.Text.encode('utf-8')), "UTF8")}
#使用小语种须使用以下方式,此处的unicode指的是 utf16小端的编码方式,即"UTF-16LE"”
#self.Data = {"status": 2, "text": str(base64.b64encode(self.Text.encode('utf-16')), "UTF8")}

# 生成url
def create_url(self):
url = 'wss://tts-api.xfyun.cn/v2/tts'
# 生成RFC1123格式的时间戳
now = datetime.now()
date = format_date_time(mktime(now.timetuple()))

# 拼接字符串
signature_origin = "host: " + "ws-api.xfyun.cn" + "\n"
signature_origin += "date: " + date + "\n"
signature_origin += "GET " + "/v2/tts " + "HTTP/1.1"
# 进行hmac-sha256进行加密
signature_sha = hmac.new(self.APISecret.encode('utf-8'), signature_origin.encode('utf-8'),
digestmod=hashlib.sha256).digest()
signature_sha = base64.b64encode(signature_sha).decode(encoding='utf-8')

authorization_origin = "api_key=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"" % (
self.APIKey, "hmac-sha256", "host date request-line", signature_sha)
authorization = base64.b64encode(authorization_origin.encode('utf-8')).decode(encoding='utf-8')
# 将请求的鉴权参数组合为字典
v = {
"authorization": authorization,
"date": date,
"host": "ws-api.xfyun.cn"
}
# 拼接鉴权参数,生成url
url = url + '?' + urlencode(v)
# print("date: ",date)
# print("v: ",v)
# 此处打印出建立连接时候的url,参考本demo的时候可取消上方打印的注释,比对相同参数时生成的url与自己代码生成的url是否一致
# print('websocket url :', url)
return url

def on_message(ws, message):
#输出文件
global outfile
try:
message =json.loads(message)
code = message["code"]
sid = message["sid"]
audio = message["data"]["audio"]
audio = base64.b64decode(audio)
status = message["data"]["status"]
if status == 2:
print("ws is closed")
ws.close()
if code != 0:
errMsg = message["message"]
print("sid:%s call error:%s code is:%s" % (sid, errMsg, code))
else:

with open(outfile, 'ab') as f:
f.write(audio)

except Exception as e:
print("receive msg,but parse exception:", e)



# 收到websocket连接建立的处理
def on_open(ws):
global outfile
global wsParam
def run(*args):
d = {"common": wsParam.CommonArgs,
"business": wsParam.BusinessArgs,
"data": wsParam.Data,
}
d = json.dumps(d)
# print("------>开始发送文本数据")
ws.send(d)
if os.path.exists(outfile):
os.remove(outfile)

thread.start_new_thread(run, ())

# 收到websocket错误的处理
def on_error(ws, error):
print("### error:", error)



# 收到websocket关闭的处理
def on_close(ws):
print("### closed ###")



def xunfei_tts(APPID, APIKey, APISecret,BusinessArgsTTS, Text, OutFile):
global outfile
global wsParam
outfile = OutFile
wsParam1 = Ws_Param(APPID,APIKey,APISecret,BusinessArgsTTS,Text)
wsParam = wsParam1
websocket.enableTrace(False)
wsUrl = wsParam.create_url()
ws = websocket.WebSocketApp(wsUrl, on_message=on_message, on_error=on_error, on_close=on_close)
ws.on_open = on_open
ws.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE})
return outfile

+ 0
- 86
voice/xunfei/xunfei_voice.py 查看文件

@@ -1,86 +0,0 @@
#####################################################################
# xunfei voice service
# Auth: njnuko
# Email: njnuko@163.com
#
# 要使用本模块, 首先到 xfyun.cn 注册一个开发者账号,
# 之后创建一个新应用, 然后在应用管理的语音识别或者语音合同右边可以查看APPID API Key 和 Secret Key
# 然后在 config.json 中填入这三个值
#
# 配置说明:
# {
# "APPID":"xxx71xxx",
# "APIKey":"xxxx69058exxxxxx", #讯飞xfyun.cn控制台语音合成或者听写界面的APIKey
# "APISecret":"xxxx697f0xxxxxx", #讯飞xfyun.cn控制台语音合成或者听写界面的APIKey
# "BusinessArgsTTS":{"aue": "lame", "sfl": 1, "auf": "audio/L16;rate=16000", "vcn": "xiaoyan", "tte": "utf8"}, #语音合成的参数,具体可以参考xfyun.cn的文档
# "BusinessArgsASR":{"domain": "iat", "language": "zh_cn", "accent": "mandarin", "vad_eos":10000, "dwa": "wpgs"} #语音听写的参数,具体可以参考xfyun.cn的文档
# }
#####################################################################

import json
import os
import time

from bridge.reply import Reply, ReplyType
from common.log import logger
from common.tmp_dir import TmpDir
from config import conf
from voice.voice import Voice
from .xunfei_asr import xunfei_asr
from .xunfei_tts import xunfei_tts
from voice.audio_convert import any_to_mp3
import shutil
from pydub import AudioSegment


class XunfeiVoice(Voice):
def __init__(self):
try:
curdir = os.path.dirname(__file__)
config_path = os.path.join(curdir, "config.json")
conf = None
with open(config_path, "r") as fr:
conf = json.load(fr)
print(conf)
self.APPID = str(conf.get("APPID"))
self.APIKey = str(conf.get("APIKey"))
self.APISecret = str(conf.get("APISecret"))
self.BusinessArgsTTS = conf.get("BusinessArgsTTS")
self.BusinessArgsASR= conf.get("BusinessArgsASR")

except Exception as e:
logger.warn("XunfeiVoice init failed: %s, ignore " % e)

def voiceToText(self, voice_file):
# 识别本地文件
try:
logger.debug("[Xunfei] voice file name={}".format(voice_file))
#print("voice_file===========",voice_file)
#print("voice_file_type===========",type(voice_file))
#mp3_name, file_extension = os.path.splitext(voice_file)
#mp3_file = mp3_name + ".mp3"
#pcm_data=get_pcm_from_wav(voice_file)
#mp3_name, file_extension = os.path.splitext(voice_file)
#AudioSegment.from_wav(voice_file).export(mp3_file, format="mp3")
#shutil.copy2(voice_file, 'tmp/test1.wav')
#shutil.copy2(mp3_file, 'tmp/test1.mp3')
#print("voice and mp3 file",voice_file,mp3_file)
text = xunfei_asr(self.APPID,self.APISecret,self.APIKey,self.BusinessArgsASR,voice_file)
logger.info("讯飞语音识别到了: {}".format(text))
reply = Reply(ReplyType.TEXT, text)
except Exception as e:
logger.warn("XunfeiVoice init failed: %s, ignore " % e)
reply = Reply(ReplyType.ERROR, "讯飞语音识别出错了;{0}")
return reply

def textToVoice(self, text):
try:
# Avoid the same filename under multithreading
fileName = TmpDir().path() + "reply-" + str(int(time.time())) + "-" + str(hash(text) & 0x7FFFFFFF) + ".mp3"
return_file = xunfei_tts(self.APPID,self.APIKey,self.APISecret,self.BusinessArgsTTS,text,fileName)
logger.info("[Xunfei] textToVoice text={} voice file name={}".format(text, fileName))
reply = Reply(ReplyType.VOICE, fileName)
except Exception as e:
logger.error("[Xunfei] textToVoice error={}".format(fileName))
reply = Reply(ReplyType.ERROR, "抱歉,讯飞语音合成失败")
return reply

Loading…
取消
儲存