From 042f2df3bf44a05c5758ad2cd4f52c7e30778d2d Mon Sep 17 00:00:00 2001 From: diudiu <37395207+cype62@users.noreply.github.com> Date: Tue, 7 May 2024 22:44:13 +0800 Subject: [PATCH] feat: support for accepting friends and inviting to join group chats automatically through keywords (#28) --- bridge/reply.py | 3 +- channel/chat_channel.py | 16 +++++++- channel/wechat/wechat_channel.py | 69 +++++++++++++++++++++++++++++++- channel/wechat/wechat_message.py | 5 ++- config-template.json | 3 +- config.py | 1 + plugins/source.json | 4 ++ 7 files changed, 96 insertions(+), 5 deletions(-) diff --git a/bridge/reply.py b/bridge/reply.py index 00314845e..0dac3371a 100644 --- a/bridge/reply.py +++ b/bridge/reply.py @@ -11,12 +11,13 @@ class ReplyType(Enum): VIDEO_URL = 5 # 视频URL FILE = 6 # 文件 CARD = 7 # 微信名片,仅支持ntchat - InviteRoom = 8 # 邀请好友进群 + INVITE_ROOM = 8 # 邀请好友进群 INFO = 9 ERROR = 10 TEXT_ = 11 # 强制文本 VIDEO = 12 MINIAPP = 13 # 小程序 + ACCEPT_FRIEND = 19 # 接受好友申请 def __str__(self): return self.name diff --git a/channel/chat_channel.py b/channel/chat_channel.py index fe7120721..848f2f291 100644 --- a/channel/chat_channel.py +++ b/channel/chat_channel.py @@ -38,6 +38,8 @@ def __init__(self): def _compose_context(self, ctype: ContextType, content, **kwargs): context = Context(ctype, content) context.kwargs = kwargs + if ctype == ContextType.ACCEPT_FRIEND: + return context # context首次传入时,origin_ctype是None, # 引入的起因是:当输入语音时,会嵌套生成两个context,第一步语音转文本,第二步通过文本生成文字回复。 # origin_ctype用于第二步文本回复时,判断是否需要匹配前缀,如果是私聊的语音,就不需要匹配前缀 @@ -221,6 +223,8 @@ def _generate_reply(self, context: Context, reply: Reply = Reply()) -> Reply: "path": context.content, "msg": context.get("msg") } + elif context.type == ContextType.ACCEPT_FRIEND: # 好友申请,匹配字符串 + reply = self._build_friend_request_reply(context) elif context.type == ContextType.SHARING: # 分享信息,当前无默认逻辑 pass elif context.type == ContextType.FUNCTION or context.type == ContextType.FILE: # 文件消息及函数调用等,当前无默认逻辑 @@ -262,6 +266,8 @@ def _decorate_reply(self, context: Context, reply: Reply) -> Reply: 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 + elif reply.type == ReplyType.ACCEPT_FRIEND: + pass else: logger.error("[WX] unknown reply type: {}".format(reply.type)) return @@ -293,6 +299,14 @@ def _send(self, reply: Reply, context: Context, retry_cnt=0): if retry_cnt < 2: time.sleep(3 + 3 * retry_cnt) self._send(reply, context, retry_cnt + 1) + # 处理好友申请 + def _build_friend_request_reply(self, context): + logger.info("friend request content: {}".format(context.content["Content"])) + logger.info("accept_friend_commands list: {}".format(conf().get("accept_friend_commands", []))) + if context.content["Content"] in conf().get("accept_friend_commands", []): + return Reply(type=ReplyType.ACCEPT_FRIEND, content=True) + else: + return Reply(type=ReplyType.ACCEPT_FRIEND, content=False) def _success_callback(self, session_id, **kwargs): # 线程正常结束时的回调函数 logger.debug("Worker return success, session_id = {}".format(session_id)) @@ -318,7 +332,7 @@ def func(worker: Future): return func def produce(self, context: Context): - session_id = context["session_id"] + session_id = context.get("session_id", 0) with self.lock: if session_id not in self.sessions: self.sessions[session_id] = [ diff --git a/channel/wechat/wechat_channel.py b/channel/wechat/wechat_channel.py index 717b06880..473168b81 100644 --- a/channel/wechat/wechat_channel.py +++ b/channel/wechat/wechat_channel.py @@ -47,6 +47,16 @@ def handler_group_msg(msg): WechatChannel().handle_group(cmsg) return None +# 自动接受加好友 +@itchat.msg_register(FRIENDS) +def deal_with_friend(msg): + try: + cmsg = WechatMessage(msg, False) + except NotImplementedError as e: + logger.debug("[WX]friend request {} skipped: {}".format(msg["MsgId"], e)) + return None + WechatChannel().handle_friend_request(cmsg) + return None def _check(func): def wrapper(self, cmsg: ChatMessage): @@ -206,9 +216,21 @@ def handle_group(self, cmsg: ChatMessage): if context: self.produce(context) + @time_checker + @_check + def handle_friend_request(self, cmsg: ChatMessage): + if cmsg.ctype == ContextType.ACCEPT_FRIEND: + logger.debug("[WX]receive friend request: {}".format(cmsg.content["NickName"])) + else: + logger.debug("[WX]receive friend request: {}, cmsg={}".format(cmsg.content["NickName"], cmsg)) + context = self._compose_context(cmsg.ctype, cmsg.content, msg=cmsg) + if context: + self.produce(context) + + # 统一的发送函数,每个Channel自行实现,根据reply的type字段发送不同类型的消息 def send(self, reply: Reply, context: Context): - receiver = context["receiver"] + receiver = context.get("receiver") if reply.type == ReplyType.TEXT: itchat.send(reply.content, toUserName=receiver) logger.info("[WX] sendMsg={}, receiver={}".format(reply, receiver)) @@ -258,6 +280,51 @@ def send(self, reply: Reply, context: Context): itchat.send_video(video_storage, toUserName=receiver) logger.info("[WX] sendVideo url={}, receiver={}".format(video_url, receiver)) + elif reply.type == ReplyType.ACCEPT_FRIEND: # 新增接受好友申请回复类型 + # 假设 reply.content 包含了新好友的用户名 + is_accept = reply.content + if is_accept: + try: + # 自动接受好友申请 + debug_msg = itchat.accept_friend(userName=context.content["UserName"], v4=context.content["Ticket"]) + logger.debug("[WX] accept_friend return: {}".format(debug_msg)) + logger.info("[WX] Accepted new friend, UserName={}, NickName={}".format(context.content["UserName"], context.content["NickName"])) + except Exception as e: + logger.error("[WX] Failed to add friend. Error: {}".format(e)) + else: + logger.info("[WX] Ignored new friend, username={}".format(context.content["NickName"])) + elif reply.type == ReplyType.INVITE_ROOM: # 新增邀请好友进群回复类型 + # 假设 reply.content 包含了群聊的名字 + + def get_group_id(group_name): + """ + 根据群聊名称获取群聊ID。 + :param group_name: 群聊的名称。 + :return: 群聊的ID (UserName)。 + """ + group_list = itchat.search_chatrooms(name=group_name) + if group_list: + return group_list[0]["UserName"] + else: + return None + + try: + chatroomUserName = reply.content + group_id = get_group_id(chatroomUserName) + logger.debug("[WX] find group_id={}, where chatroom={}".format(group_id, chatroomUserName)) + if group_id is None: + raise ValueError("The specified group chat was not found: {}".format(chatroomUserName)) + # 调用 itchat 的 add_member_into_chatroom 方法来添加成员 + debug_msg = itchat.add_member_into_chatroom(group_id, receiver) + logger.debug("[WX] add_member_into_chatroom return: {}".format(debug_msg)) + logger.info("[WX] invite members={}, to chatroom={}".format(receiver, chatroomUserName)) + except ValueError as ve: + # 记录查找群聊失败的错误信息 + logger.error("[WX] {}".format(ve)) + except Exception as e: + # 记录添加成员失败的错误信息 + logger.error("[WX] Failed to invite members to chatroom. Error: {}".format(e)) + def _send_login_success(): try: from common.linkai_client import chat_client diff --git a/channel/wechat/wechat_message.py b/channel/wechat/wechat_message.py index b8b1d91c5..42250dfea 100644 --- a/channel/wechat/wechat_message.py +++ b/channel/wechat/wechat_message.py @@ -59,7 +59,10 @@ def __init__(self, itchat_msg, is_group=False): elif itchat_msg["Type"] == SHARING: self.ctype = ContextType.SHARING self.content = itchat_msg.get("Url") - + elif itchat_msg["Type"] == FRIENDS: + self.ctype = ContextType.ACCEPT_FRIEND + self.content = itchat_msg.get("RecommendInfo") + else: raise NotImplementedError("Unsupported message type: Type:{} MsgType:{}".format(itchat_msg["Type"], itchat_msg["MsgType"])) diff --git a/config-template.json b/config-template.json index 0796532fe..41f2cb9db 100644 --- a/config-template.json +++ b/config-template.json @@ -7,5 +7,6 @@ "single_chat_prefix": [""], "single_chat_reply_prefix": "", "group_chat_prefix": ["@bot"], - "group_name_white_list": ["ALL_GROUP"] + "group_name_white_list": ["ALL_GROUP"], + "accept_friend_commands": ["加好友"] } diff --git a/config.py b/config.py index 655a09e74..5a1041b75 100644 --- a/config.py +++ b/config.py @@ -24,6 +24,7 @@ "single_chat_prefix": ["bot", "@bot"], # 私聊时文本需要包含该前缀才能触发机器人回复 "single_chat_reply_prefix": "[bot] ", # 私聊时自动回复的前缀,用于区分真人 "single_chat_reply_suffix": "", # 私聊时自动回复的后缀,\n 可以换行 + "accept_friend_commands": ["加好友"], # 自动接受好友请求的申请信息 "group_chat_prefix": ["@bot"], # 群聊时包含该前缀则会触发机器人回复 "group_chat_reply_prefix": "", # 群聊时自动回复的前缀 "group_chat_reply_suffix": "", # 群聊时自动回复的后缀,\n 可以换行 diff --git a/plugins/source.json b/plugins/source.json index d53c996ba..05e7d41b4 100644 --- a/plugins/source.json +++ b/plugins/source.json @@ -19,6 +19,10 @@ "Apilot": { "url": "https://github.com/6vision/Apilot.git", "desc": "通过api直接查询早报、热榜、快递、天气等实用信息的插件" + }, + "GroupInvitation": { + "url": "https://github.com/dfldylan/GroupInvitation.git", + "desc": "根据特定关键词自动邀请用户加入指定的群聊" } } }