Skip to content

Commit 47cc65a

Browse files
authored
Merge pull request #720 from lanvent/master
wechaty支持插件
2 parents 4ad2997 + cda9d58 commit 47cc65a

File tree

9 files changed

+623
-590
lines changed

9 files changed

+623
-590
lines changed

app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def run():
1414
# create channel
1515
channel_name=conf().get('channel_type', 'wx')
1616
channel = channel_factory.create_channel(channel_name)
17-
if channel_name=='wx':
17+
if channel_name in ['wx','wxy']:
1818
PluginManager().load_plugins()
1919

2020
# startup channel

channel/channel.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ def handle_text(self, msg):
2020
"""
2121
raise NotImplementedError
2222

23-
def send(self, msg, receiver):
23+
# 统一的发送函数,每个Channel自行实现,根据reply的type字段发送不同类型的消息
24+
def send(self, reply: Reply, context: Context):
2425
"""
2526
send message to user
2627
:param msg: message content

channel/chat_channel.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
2+
3+
4+
import os
5+
import re
6+
import time
7+
from common.expired_dict import ExpiredDict
8+
from channel.channel import Channel
9+
from bridge.reply import *
10+
from bridge.context import *
11+
from config import conf
12+
from common.log import logger
13+
from plugins import *
14+
try:
15+
from voice.audio_convert import any_to_wav
16+
except Exception as e:
17+
pass
18+
19+
# 抽象类, 它包含了与消息通道无关的通用处理逻辑
20+
class ChatChannel(Channel):
21+
name = None # 登录的用户名
22+
user_id = None # 登录的用户id
23+
def __init__(self):
24+
pass
25+
26+
# 根据消息构造context,消息内容相关的触发项写在这里
27+
def _compose_context(self, ctype: ContextType, content, **kwargs):
28+
context = Context(ctype, content)
29+
context.kwargs = kwargs
30+
# context首次传入时,origin_ctype是None,
31+
# 引入的起因是:当输入语音时,会嵌套生成两个context,第一步语音转文本,第二步通过文本生成文字回复。
32+
# origin_ctype用于第二步文本回复时,判断是否需要匹配前缀,如果是私聊的语音,就不需要匹配前缀
33+
if 'origin_ctype' not in context:
34+
context['origin_ctype'] = ctype
35+
# context首次传入时,receiver是None,根据类型设置receiver
36+
first_in = 'receiver' not in context
37+
# 群名匹配过程,设置session_id和receiver
38+
if first_in: # context首次传入时,receiver是None,根据类型设置receiver
39+
config = conf()
40+
cmsg = context['msg']
41+
if cmsg.from_user_id == self.user_id:
42+
logger.debug("[WX]self message skipped")
43+
return None
44+
if context["isgroup"]:
45+
group_name = cmsg.other_user_nickname
46+
group_id = cmsg.other_user_id
47+
48+
group_name_white_list = config.get('group_name_white_list', [])
49+
group_name_keyword_white_list = config.get('group_name_keyword_white_list', [])
50+
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)]):
51+
group_chat_in_one_session = conf().get('group_chat_in_one_session', [])
52+
session_id = cmsg.actual_user_id
53+
if any([group_name in group_chat_in_one_session, 'ALL_GROUP' in group_chat_in_one_session]):
54+
session_id = group_id
55+
else:
56+
return None
57+
context['session_id'] = session_id
58+
context['receiver'] = group_id
59+
else:
60+
context['session_id'] = cmsg.other_user_id
61+
context['receiver'] = cmsg.other_user_id
62+
63+
# 消息内容匹配过程,并处理content
64+
if ctype == ContextType.TEXT:
65+
if first_in and "」\n- - - - - - -" in content: # 初次匹配 过滤引用消息
66+
logger.debug("[WX]reference query skipped")
67+
return None
68+
69+
if context["isgroup"]: # 群聊
70+
# 校验关键字
71+
match_prefix = check_prefix(content, conf().get('group_chat_prefix'))
72+
match_contain = check_contain(content, conf().get('group_chat_keyword'))
73+
if match_prefix is not None or match_contain is not None:
74+
if match_prefix:
75+
content = content.replace(match_prefix, '', 1).strip()
76+
elif context['msg'].is_at and not conf().get("group_at_off", False):
77+
logger.info("[WX]receive group at, continue")
78+
pattern = f'@{self.name}(\u2005|\u0020)'
79+
content = re.sub(pattern, r'', content)
80+
elif context["origin_ctype"] == ContextType.VOICE:
81+
logger.info("[WX]receive group voice, checkprefix didn't match")
82+
return None
83+
else:
84+
return None
85+
else: # 单聊
86+
match_prefix = check_prefix(content, conf().get('single_chat_prefix'))
87+
if match_prefix is not None: # 判断如果匹配到自定义前缀,则返回过滤掉前缀+空格后的内容
88+
content = content.replace(match_prefix, '', 1).strip()
89+
elif context["origin_ctype"] == ContextType.VOICE: # 如果源消息是私聊的语音消息,允许不匹配前缀,放宽条件
90+
pass
91+
else:
92+
return None
93+
94+
img_match_prefix = check_prefix(content, conf().get('image_create_prefix'))
95+
if img_match_prefix:
96+
content = content.replace(img_match_prefix, '', 1).strip()
97+
context.type = ContextType.IMAGE_CREATE
98+
else:
99+
context.type = ContextType.TEXT
100+
context.content = content
101+
elif context.type == ContextType.VOICE:
102+
if 'desire_rtype' not in context and conf().get('voice_reply_voice'):
103+
context['desire_rtype'] = ReplyType.VOICE
104+
105+
106+
return context
107+
108+
# 处理消息 TODO: 如果wechaty解耦,此处逻辑可以放置到父类
109+
def _handle(self, context: Context):
110+
if context is None or not context.content:
111+
return
112+
logger.debug('[WX] ready to handle context: {}'.format(context))
113+
# reply的构建步骤
114+
reply = self._generate_reply(context)
115+
116+
logger.debug('[WX] ready to decorate reply: {}'.format(reply))
117+
# reply的包装步骤
118+
reply = self._decorate_reply(context, reply)
119+
120+
# reply的发送步骤
121+
self._send_reply(context, reply)
122+
123+
def _generate_reply(self, context: Context, reply: Reply = Reply()) -> Reply:
124+
e_context = PluginManager().emit_event(EventContext(Event.ON_HANDLE_CONTEXT, {
125+
'channel': self, 'context': context, 'reply': reply}))
126+
reply = e_context['reply']
127+
if not e_context.is_pass():
128+
logger.debug('[WX] ready to handle context: type={}, content={}'.format(context.type, context.content))
129+
if context.type == ContextType.TEXT or context.type == ContextType.IMAGE_CREATE: # 文字和图片消息
130+
reply = super().build_reply_content(context.content, context)
131+
elif context.type == ContextType.VOICE: # 语音消息
132+
cmsg = context['msg']
133+
cmsg.prepare()
134+
file_path = context.content
135+
wav_path = os.path.splitext(file_path)[0] + '.wav'
136+
try:
137+
any_to_wav(file_path, wav_path)
138+
except Exception as e: # 转换失败,直接使用mp3,对于某些api,mp3也可以识别
139+
logger.warning("[WX]any to wav error, use raw path. " + str(e))
140+
wav_path = file_path
141+
# 语音识别
142+
reply = super().build_voice_to_text(wav_path)
143+
# 删除临时文件
144+
try:
145+
os.remove(file_path)
146+
os.remove(wav_path)
147+
except Exception as e:
148+
logger.warning("[WX]delete temp file error: " + str(e))
149+
150+
if reply.type == ReplyType.TEXT:
151+
new_context = self._compose_context(
152+
ContextType.TEXT, reply.content, **context.kwargs)
153+
if new_context:
154+
reply = self._generate_reply(new_context)
155+
else:
156+
return
157+
else:
158+
logger.error('[WX] unknown context type: {}'.format(context.type))
159+
return
160+
return reply
161+
162+
def _decorate_reply(self, context: Context, reply: Reply) -> Reply:
163+
if reply and reply.type:
164+
e_context = PluginManager().emit_event(EventContext(Event.ON_DECORATE_REPLY, {
165+
'channel': self, 'context': context, 'reply': reply}))
166+
reply = e_context['reply']
167+
desire_rtype = context.get('desire_rtype')
168+
if not e_context.is_pass() and reply and reply.type:
169+
if reply.type == ReplyType.TEXT:
170+
reply_text = reply.content
171+
if desire_rtype == ReplyType.VOICE:
172+
reply = super().build_text_to_voice(reply.content)
173+
return self._decorate_reply(context, reply)
174+
if context['isgroup']:
175+
reply_text = '@' + context['msg'].actual_user_nickname + ' ' + reply_text.strip()
176+
reply_text = conf().get("group_chat_reply_prefix", "")+reply_text
177+
else:
178+
reply_text = conf().get("single_chat_reply_prefix", "")+reply_text
179+
reply.content = reply_text
180+
elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
181+
reply.content = str(reply.type)+":\n" + reply.content
182+
elif reply.type == ReplyType.IMAGE_URL or reply.type == ReplyType.VOICE or reply.type == ReplyType.IMAGE:
183+
pass
184+
else:
185+
logger.error('[WX] unknown reply type: {}'.format(reply.type))
186+
return
187+
if desire_rtype and desire_rtype != reply.type and reply.type not in [ReplyType.ERROR, ReplyType.INFO]:
188+
logger.warning('[WX] desire_rtype: {}, but reply type: {}'.format(context.get('desire_rtype'), reply.type))
189+
return reply
190+
191+
def _send_reply(self, context: Context, reply: Reply):
192+
if reply and reply.type:
193+
e_context = PluginManager().emit_event(EventContext(Event.ON_SEND_REPLY, {
194+
'channel': self, 'context': context, 'reply': reply}))
195+
reply = e_context['reply']
196+
if not e_context.is_pass() and reply and reply.type:
197+
logger.debug('[WX] ready to send reply: {} to {}'.format(reply, context))
198+
self._send(reply, context)
199+
200+
def _send(self, reply: Reply, context: Context, retry_cnt = 0):
201+
try:
202+
self.send(reply, context)
203+
except Exception as e:
204+
logger.error('[WX] sendMsg error: {}'.format(e))
205+
if retry_cnt < 2:
206+
time.sleep(3+3*retry_cnt)
207+
self._send(reply, context, retry_cnt+1)
208+
209+
210+
211+
def check_prefix(content, prefix_list):
212+
for prefix in prefix_list:
213+
if content.startswith(prefix):
214+
return prefix
215+
return None
216+
217+
def check_contain(content, keyword_list):
218+
if not keyword_list:
219+
return None
220+
for ky in keyword_list:
221+
if content.find(ky) != -1:
222+
return True
223+
return None

channel/chat_message.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
2+
"""
3+
本类表示聊天消息,用于对itchat和wechaty的消息进行统一的封装
4+
5+
ChatMessage
6+
msg_id: 消息id
7+
create_time: 消息创建时间
8+
9+
ctype: 消息类型 : ContextType
10+
content: 消息内容, 如果是声音/图片,这里是文件路径
11+
12+
from_user_id: 发送者id
13+
from_user_nickname: 发送者昵称
14+
to_user_id: 接收者id
15+
to_user_nickname: 接收者昵称
16+
17+
other_user_id: 对方的id,如果你是发送者,那这个就是接收者id,如果你是接收者,那这个就是发送者id,如果是群消息,那这一直是群id
18+
other_user_nickname: 同上
19+
20+
is_group: 是否是群消息
21+
is_at: 是否被at
22+
23+
- (群消息时,一般会存在实际发送者,是群内某个成员的id和昵称,下列项仅在群消息时存在)
24+
actual_user_id: 实际发送者id
25+
actual_user_nickname:实际发送者昵称
26+
27+
28+
29+
30+
_prepare_fn: 准备函数,用于准备消息的内容,比如下载图片等,
31+
_prepared: 是否已经调用过准备函数
32+
_rawmsg: 原始消息对象
33+
34+
"""
35+
class ChatMessage(object):
36+
msg_id = None
37+
create_time = None
38+
39+
ctype = None
40+
content = None
41+
42+
from_user_id = None
43+
from_user_nickname = None
44+
to_user_id = None
45+
to_user_nickname = None
46+
other_user_id = None
47+
other_user_nickname = None
48+
49+
is_group = False
50+
is_at = False
51+
actual_user_id = None
52+
actual_user_nickname = None
53+
54+
_prepare_fn = None
55+
_prepared = False
56+
_rawmsg = None
57+
58+
59+
def __init__(self,_rawmsg):
60+
self._rawmsg = _rawmsg
61+
62+
def prepare(self):
63+
if self._prepare_fn and not self._prepared:
64+
self._prepared = True
65+
self._prepare_fn()
66+
67+
def __str__(self):
68+
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={}'.format(
69+
self.msg_id,
70+
self.create_time,
71+
self.ctype,
72+
self.content,
73+
self.from_user_id,
74+
self.from_user_nickname,
75+
self.to_user_id,
76+
self.to_user_nickname,
77+
self.other_user_id,
78+
self.other_user_nickname,
79+
self.is_group,
80+
self.is_at,
81+
self.actual_user_id,
82+
self.actual_user_nickname,
83+
)

0 commit comments

Comments
 (0)