No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.

397 líneas
19KB

  1. import os
  2. import re
  3. import threading
  4. import time
  5. from asyncio import CancelledError
  6. from concurrent.futures import Future, ThreadPoolExecutor
  7. from bridge.context import *
  8. from bridge.reply import *
  9. from channel.channel import Channel
  10. from common.dequeue import Dequeue
  11. from common import memory
  12. from plugins import *
  13. try:
  14. from voice.audio_convert import any_to_wav
  15. except Exception as e:
  16. pass
  17. handler_pool = ThreadPoolExecutor(max_workers=8) # 处理消息的线程池
  18. # 抽象类, 它包含了与消息通道无关的通用处理逻辑
  19. class ChatChannel(Channel):
  20. name = None # 登录的用户名
  21. user_id = None # 登录的用户id
  22. futures = {} # 记录每个session_id提交到线程池的future对象, 用于重置会话时把没执行的future取消掉,正在执行的不会被取消
  23. sessions = {} # 用于控制并发,每个session_id同时只能有一个context在处理
  24. lock = threading.Lock() # 用于控制对sessions的访问
  25. def __init__(self):
  26. _thread = threading.Thread(target=self.consume)
  27. _thread.setDaemon(True)
  28. _thread.start()
  29. # 根据消息构造context,消息内容相关的触发项写在这里
  30. def _compose_context(self, ctype: ContextType, content, **kwargs):
  31. context = Context(ctype, content)
  32. context.kwargs = kwargs
  33. # context首次传入时,origin_ctype是None,
  34. # 引入的起因是:当输入语音时,会嵌套生成两个context,第一步语音转文本,第二步通过文本生成文字回复。
  35. # origin_ctype用于第二步文本回复时,判断是否需要匹配前缀,如果是私聊的语音,就不需要匹配前缀
  36. if "origin_ctype" not in context:
  37. context["origin_ctype"] = ctype
  38. # context首次传入时,receiver是None,根据类型设置receiver
  39. first_in = "receiver" not in context
  40. # 群名匹配过程,设置session_id和receiver
  41. if first_in: # context首次传入时,receiver是None,根据类型设置receiver
  42. config = conf()
  43. cmsg = context["msg"]
  44. user_data = conf().get_user_data(cmsg.from_user_id)
  45. context["openai_api_key"] = user_data.get("openai_api_key")
  46. context["gpt_model"] = user_data.get("gpt_model")
  47. if context.get("isgroup", False):
  48. group_name = cmsg.other_user_nickname
  49. group_id = cmsg.other_user_id
  50. group_name_white_list = config.get("group_name_white_list", [])
  51. group_name_keyword_white_list = config.get("group_name_keyword_white_list", [])
  52. if any(
  53. [
  54. group_name in group_name_white_list,
  55. "ALL_GROUP" in group_name_white_list,
  56. check_contain(group_name, group_name_keyword_white_list),
  57. ]
  58. ):
  59. group_chat_in_one_session = conf().get("group_chat_in_one_session", [])
  60. session_id = cmsg.actual_user_id
  61. if any(
  62. [
  63. group_name in group_chat_in_one_session,
  64. "ALL_GROUP" in group_chat_in_one_session,
  65. ]
  66. ):
  67. session_id = group_id
  68. else:
  69. logger.debug(f"No need reply, groupName not in whitelist, group_name={group_name}")
  70. return None
  71. context["session_id"] = session_id
  72. context["receiver"] = group_id
  73. else:
  74. context["session_id"] = cmsg.other_user_id
  75. context["receiver"] = cmsg.other_user_id
  76. e_context = PluginManager().emit_event(EventContext(Event.ON_RECEIVE_MESSAGE, {"channel": self, "context": context}))
  77. context = e_context["context"]
  78. if e_context.is_pass() or context is None:
  79. return context
  80. if cmsg.from_user_id == self.user_id and not config.get("trigger_by_self", True):
  81. logger.debug("[WX]self message skipped")
  82. return None
  83. # 消息内容匹配过程,并处理content
  84. if ctype == ContextType.TEXT:
  85. if first_in and "」\n- - - - - - -" in content: # 初次匹配 过滤引用消息
  86. logger.debug(content)
  87. logger.debug("[WX]reference query skipped")
  88. return None
  89. nick_name_black_list = conf().get("nick_name_black_list", [])
  90. if context.get("isgroup", False): # 群聊
  91. # 校验关键字
  92. match_prefix = check_prefix(content, conf().get("group_chat_prefix"))
  93. match_contain = check_contain(content, conf().get("group_chat_keyword"))
  94. flag = False
  95. if context["msg"].to_user_id != context["msg"].actual_user_id:
  96. if match_prefix is not None or match_contain is not None:
  97. flag = True
  98. if match_prefix:
  99. content = content.replace(match_prefix, "", 1).strip()
  100. if context["msg"].is_at:
  101. nick_name = context["msg"].actual_user_nickname
  102. if nick_name and nick_name in nick_name_black_list:
  103. # 黑名单过滤
  104. logger.warning(f"[WX] Nickname {nick_name} in In BlackList, ignore")
  105. return None
  106. logger.info("[WX]receive group at")
  107. if not conf().get("group_at_off", False):
  108. flag = True
  109. pattern = f"@{re.escape(self.name)}(\u2005|\u0020)"
  110. subtract_res = re.sub(pattern, r"", content)
  111. if isinstance(context["msg"].at_list, list):
  112. for at in context["msg"].at_list:
  113. pattern = f"@{re.escape(at)}(\u2005|\u0020)"
  114. subtract_res = re.sub(pattern, r"", subtract_res)
  115. if subtract_res == content and context["msg"].self_display_name:
  116. # 前缀移除后没有变化,使用群昵称再次移除
  117. pattern = f"@{re.escape(context['msg'].self_display_name)}(\u2005|\u0020)"
  118. subtract_res = re.sub(pattern, r"", content)
  119. content = subtract_res
  120. if not flag:
  121. if context["origin_ctype"] == ContextType.VOICE:
  122. logger.info("[WX]receive group voice, but checkprefix didn't match")
  123. return None
  124. else: # 单聊
  125. nick_name = context["msg"].from_user_nickname
  126. if nick_name and nick_name in nick_name_black_list:
  127. # 黑名单过滤
  128. logger.warning(f"[WX] Nickname '{nick_name}' in In BlackList, ignore")
  129. return None
  130. match_prefix = check_prefix(content, conf().get("single_chat_prefix", [""]))
  131. if match_prefix is not None: # 判断如果匹配到自定义前缀,则返回过滤掉前缀+空格后的内容
  132. content = content.replace(match_prefix, "", 1).strip()
  133. elif context["origin_ctype"] == ContextType.VOICE: # 如果源消息是私聊的语音消息,允许不匹配前缀,放宽条件
  134. pass
  135. else:
  136. return None
  137. content = content.strip()
  138. img_match_prefix = check_prefix(content, conf().get("image_create_prefix"))
  139. if img_match_prefix:
  140. content = content.replace(img_match_prefix, "", 1)
  141. context.type = ContextType.IMAGE_CREATE
  142. else:
  143. context.type = ContextType.TEXT
  144. context.content = content.strip()
  145. if "desire_rtype" not in context and conf().get("always_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
  146. context["desire_rtype"] = ReplyType.VOICE
  147. elif context.type == ContextType.VOICE:
  148. if "desire_rtype" not in context and conf().get("voice_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
  149. context["desire_rtype"] = ReplyType.VOICE
  150. return context
  151. def _handle(self, context: Context):
  152. if context is None or not context.content:
  153. return
  154. logger.debug("[WX] ready to handle context: {}".format(context))
  155. # reply的构建步骤
  156. reply = self._generate_reply(context)
  157. logger.debug("[WX] ready to decorate reply: {}".format(reply))
  158. # reply的包装步骤
  159. if reply and reply.content:
  160. reply = self._decorate_reply(context, reply)
  161. # reply的发送步骤
  162. self._send_reply(context, reply)
  163. def _generate_reply(self, context: Context, reply: Reply = Reply()) -> Reply:
  164. e_context = PluginManager().emit_event(
  165. EventContext(
  166. Event.ON_HANDLE_CONTEXT,
  167. {"channel": self, "context": context, "reply": reply},
  168. )
  169. )
  170. reply = e_context["reply"]
  171. if not e_context.is_pass():
  172. logger.debug("[WX] ready to handle context: type={}, content={}".format(context.type, context.content))
  173. if context.type == ContextType.TEXT or context.type == ContextType.IMAGE_CREATE: # 文字和图片消息
  174. context["channel"] = e_context["channel"]
  175. reply = super().build_reply_content(context.content, context)
  176. elif context.type == ContextType.VOICE: # 语音消息
  177. cmsg = context["msg"]
  178. cmsg.prepare()
  179. file_path = context.content
  180. wav_path = os.path.splitext(file_path)[0] + ".wav"
  181. try:
  182. any_to_wav(file_path, wav_path)
  183. except Exception as e: # 转换失败,直接使用mp3,对于某些api,mp3也可以识别
  184. logger.warning("[WX]any to wav error, use raw path. " + str(e))
  185. wav_path = file_path
  186. # 语音识别
  187. reply = super().build_voice_to_text(wav_path)
  188. # 删除临时文件
  189. try:
  190. os.remove(file_path)
  191. if wav_path != file_path:
  192. os.remove(wav_path)
  193. except Exception as e:
  194. pass
  195. # logger.warning("[WX]delete temp file error: " + str(e))
  196. if reply.type == ReplyType.TEXT:
  197. new_context = self._compose_context(ContextType.TEXT, reply.content, **context.kwargs)
  198. if new_context:
  199. reply = self._generate_reply(new_context)
  200. else:
  201. return
  202. elif context.type == ContextType.IMAGE: # 图片消息,当前仅做下载保存到本地的逻辑
  203. memory.USER_IMAGE_CACHE[context["session_id"]] = {
  204. "path": context.content,
  205. "msg": context.get("msg")
  206. }
  207. elif context.type == ContextType.SHARING: # 分享信息,当前无默认逻辑
  208. pass
  209. elif context.type == ContextType.FUNCTION or context.type == ContextType.FILE: # 文件消息及函数调用等,当前无默认逻辑
  210. pass
  211. else:
  212. logger.warning("[WX] unknown context type: {}".format(context.type))
  213. return
  214. return reply
  215. def _decorate_reply(self, context: Context, reply: Reply) -> Reply:
  216. if reply and reply.type:
  217. e_context = PluginManager().emit_event(
  218. EventContext(
  219. Event.ON_DECORATE_REPLY,
  220. {"channel": self, "context": context, "reply": reply},
  221. )
  222. )
  223. reply = e_context["reply"]
  224. desire_rtype = context.get("desire_rtype")
  225. if not e_context.is_pass() and reply and reply.type:
  226. if reply.type in self.NOT_SUPPORT_REPLYTYPE:
  227. logger.error("[WX]reply type not support: " + str(reply.type))
  228. reply.type = ReplyType.ERROR
  229. reply.content = "不支持发送的消息类型: " + str(reply.type)
  230. if reply.type == ReplyType.TEXT:
  231. reply_text = reply.content
  232. if desire_rtype == ReplyType.VOICE and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
  233. reply = super().build_text_to_voice(reply.content)
  234. return self._decorate_reply(context, reply)
  235. if context.get("isgroup", False):
  236. if not context.get("no_need_at", False):
  237. reply_text = "@" + context["msg"].actual_user_nickname + "\n" + reply_text.strip()
  238. reply_text = conf().get("group_chat_reply_prefix", "") + reply_text + conf().get("group_chat_reply_suffix", "")
  239. else:
  240. reply_text = conf().get("single_chat_reply_prefix", "") + reply_text + conf().get("single_chat_reply_suffix", "")
  241. reply.content = reply_text
  242. elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
  243. reply.content = "[" + str(reply.type) + "]\n" + reply.content
  244. 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:
  245. pass
  246. else:
  247. logger.error("[WX] unknown reply type: {}".format(reply.type))
  248. return
  249. if desire_rtype and desire_rtype != reply.type and reply.type not in [ReplyType.ERROR, ReplyType.INFO]:
  250. logger.warning("[WX] desire_rtype: {}, but reply type: {}".format(context.get("desire_rtype"), reply.type))
  251. return reply
  252. def _send_reply(self, context: Context, reply: Reply):
  253. if reply and reply.type:
  254. e_context = PluginManager().emit_event(
  255. EventContext(
  256. Event.ON_SEND_REPLY,
  257. {"channel": self, "context": context, "reply": reply},
  258. )
  259. )
  260. reply = e_context["reply"]
  261. if not e_context.is_pass() and reply and reply.type:
  262. logger.debug("[WX] ready to send reply: {}, context: {}".format(reply, context))
  263. self._send(reply, context)
  264. def _send(self, reply: Reply, context: Context, retry_cnt=0):
  265. try:
  266. self.send(reply, context)
  267. except Exception as e:
  268. logger.error("[WX] sendMsg error: {}".format(str(e)))
  269. if isinstance(e, NotImplementedError):
  270. return
  271. logger.exception(e)
  272. if retry_cnt < 2:
  273. time.sleep(3 + 3 * retry_cnt)
  274. self._send(reply, context, retry_cnt + 1)
  275. def _success_callback(self, session_id, **kwargs): # 线程正常结束时的回调函数
  276. logger.debug("Worker return success, session_id = {}".format(session_id))
  277. def _fail_callback(self, session_id, exception, **kwargs): # 线程异常结束时的回调函数
  278. logger.exception("Worker return exception: {}".format(exception))
  279. def _thread_pool_callback(self, session_id, **kwargs):
  280. def func(worker: Future):
  281. try:
  282. worker_exception = worker.exception()
  283. if worker_exception:
  284. self._fail_callback(session_id, exception=worker_exception, **kwargs)
  285. else:
  286. self._success_callback(session_id, **kwargs)
  287. except CancelledError as e:
  288. logger.info("Worker cancelled, session_id = {}".format(session_id))
  289. except Exception as e:
  290. logger.exception("Worker raise exception: {}".format(e))
  291. with self.lock:
  292. self.sessions[session_id][1].release()
  293. return func
  294. def produce(self, context: Context):
  295. session_id = context["session_id"]
  296. with self.lock:
  297. if session_id not in self.sessions:
  298. self.sessions[session_id] = [
  299. Dequeue(),
  300. threading.BoundedSemaphore(conf().get("concurrency_in_session", 4)),
  301. ]
  302. if context.type == ContextType.TEXT and context.content.startswith("#"):
  303. self.sessions[session_id][0].putleft(context) # 优先处理管理命令
  304. else:
  305. self.sessions[session_id][0].put(context)
  306. # 消费者函数,单独线程,用于从消息队列中取出消息并处理
  307. def consume(self):
  308. while True:
  309. with self.lock:
  310. session_ids = list(self.sessions.keys())
  311. for session_id in session_ids:
  312. context_queue, semaphore = self.sessions[session_id]
  313. if semaphore.acquire(blocking=False): # 等线程处理完毕才能删除
  314. if not context_queue.empty():
  315. context = context_queue.get()
  316. logger.debug("[WX] consume context: {}".format(context))
  317. future: Future = handler_pool.submit(self._handle, context)
  318. future.add_done_callback(self._thread_pool_callback(session_id, context=context))
  319. if session_id not in self.futures:
  320. self.futures[session_id] = []
  321. self.futures[session_id].append(future)
  322. elif semaphore._initial_value == semaphore._value + 1: # 除了当前,没有任务再申请到信号量,说明所有任务都处理完毕
  323. self.futures[session_id] = [t for t in self.futures[session_id] if not t.done()]
  324. assert len(self.futures[session_id]) == 0, "thread pool error"
  325. del self.sessions[session_id]
  326. else:
  327. semaphore.release()
  328. time.sleep(0.1)
  329. # 取消session_id对应的所有任务,只能取消排队的消息和已提交线程池但未执行的任务
  330. def cancel_session(self, session_id):
  331. with self.lock:
  332. if session_id in self.sessions:
  333. for future in self.futures[session_id]:
  334. future.cancel()
  335. cnt = self.sessions[session_id][0].qsize()
  336. if cnt > 0:
  337. logger.info("Cancel {} messages in session {}".format(cnt, session_id))
  338. self.sessions[session_id][0] = Dequeue()
  339. def cancel_all_session(self):
  340. with self.lock:
  341. for session_id in self.sessions:
  342. for future in self.futures[session_id]:
  343. future.cancel()
  344. cnt = self.sessions[session_id][0].qsize()
  345. if cnt > 0:
  346. logger.info("Cancel {} messages in session {}".format(cnt, session_id))
  347. self.sessions[session_id][0] = Dequeue()
  348. def check_prefix(content, prefix_list):
  349. if not prefix_list:
  350. return None
  351. for prefix in prefix_list:
  352. if content.startswith(prefix):
  353. return prefix
  354. return None
  355. def check_contain(content, keyword_list):
  356. if not keyword_list:
  357. return None
  358. for ky in keyword_list:
  359. if content.find(ky) != -1:
  360. return True
  361. return None