├── config.json.template ├── __init__.py ├── README.md └── revocation.py /config.json.template: -------------------------------------------------------------------------------- 1 | { 2 | "receiver": { 3 | "type": "remark_name", 4 | "name": "123" 5 | }, 6 | "message_expire_time": 120, 7 | "cleanup_interval": 2 8 | } -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Author: sineom h.sineom@gmail.com 3 | Date: 2024-11-13 16:50:45 4 | LastEditors: sineom h.sineom@gmail.com 5 | LastEditTime: 2024-11-13 16:58:14 6 | FilePath: /anti_withdrawal/__init__.py 7 | Description: 8 | 9 | Copyright (c) 2024 by sineom, All Rights Reserved. 10 | ''' 11 | 12 | from .revocation import Revocation 13 | 14 | def get_class(): 15 | return Revocation -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # revocation Plugin (防撤回插件),基于cow issues[2192](https://github.com/zhayujie/chatgpt-on-wechat/issues/2192)修改 2 | 3 | 一个用于防止微信消息撤回的插件。当检测到消息被撤回时,会将原消息转发给指定接收者。 4 | 5 | ## 功能特性 6 | 7 | - 支持私聊和群聊消息的防撤回 8 | - 支持文字、图片、视频、文件等多种类型的消息 9 | - 自动清理过期消息,避免占用过多存储空间 10 | - 支持通过配置文件动态设置接收者和其他参数 11 | - 支持使用昵称或备注名匹配接收者 12 | - 自动保存多媒体文件并在撤回时重发 13 | 14 | ## 安装 15 | 16 | 1. 将插件目录复制到 `plugins/` 下 17 | 2. 安装依赖: `pip install -r requirements.txt` 18 | 3. 配置 `config.json` 中的接收者信息 19 | 4. 重启应用 20 | 21 | ## 配置说明 22 | 23 | ### 配置文件位置 24 | `plugins/anti_withdrawal/config.json` 25 | 26 | ### 配置项说明 27 | ```json 28 | { 29 | "receiver": { 30 | "type": "remark_name", // 接收者匹配类型: nickname(昵称) 或 remark_name(备注名) 31 | "name": "文件传输助手" // 接收者的昵称或备注名 32 | }, 33 | "message_expire_time": 120, // 消息过期时间(秒) 34 | "cleanup_interval": 2 // 清理检查间隔(秒) 35 | } 36 | ``` 37 | 38 | ### 详细说明 39 | 40 | 1. receiver: 接收撤回消息的微信好友 41 | - type: 匹配类型 42 | - nickname: 使用微信昵称匹配 43 | - remark_name: 使用备注名匹配 44 | - name: 要匹配的名称 45 | - 建议使用"文件传输助手"作为接收者,避免打扰他人 46 | 47 | 2. message_expire_time: 消息保存时间 48 | - 单位: 秒 49 | - 默认: 120秒 50 | - 超过此时间的消息会被自动清理 51 | - 建议设置合理的时间,避免占用过多内存 52 | 53 | 3. cleanup_interval: 清理检查间隔 54 | - 单位: 秒 55 | - 默认: 2秒 56 | - 每隔多久检查一次过期消息 57 | - 间隔太短会增加CPU占用,太长会延迟清理 58 | 59 | ## 使用说明 60 | 61 | 1. 插件会自动运行,无需手动操作 62 | 2. 修改配置后无需重启,会自动加载最新配置 63 | 3. 如果配置文件不存在,会使用默认配置并自动创建配置文件 64 | 4. 撤回消息的通知格式: 65 | - 私聊: "【发送者昵称】刚刚发过这条消息:xxx" 66 | - 群聊: "群:【群名称】的【发送者昵称】刚刚发过这条消息:xxx" 67 | 68 | 69 | ## 注意事项 70 | 71 | 1. 确保接收者配置正确,否则无法转发撤回消息 72 | 2. 合理设置过期时间,避免占用过多内存 73 | 3. 建议定期清理 downloads 目录下的历史文件 74 | 4. 首次使用时建议将接收者设为"文件传输助手"进行测试 75 | 5. 群聊消息较多时可能会占用较多存储空间 76 | 6. 不要将重要文件保存在 downloads 目录,可能会被自动清理 77 | 78 | ## 常见问题 79 | 80 | 1. 找不到接收者 81 | - 检查配置文件中的 type 和 name 是否正确 82 | - 确认该好友是否在联系人列表中 83 | 84 | 2. 媒体文件没有保存 85 | - 检查 downloads 目录权限 86 | - 确保磁盘空间充足 87 | 88 | 3. 消息转发失败 89 | - 检查网络连接 90 | - 确认微信登录状态 91 | 92 | ## 更新日志 93 | 94 | ### v1.0 95 | - 初始版本发布 96 | - 支持文本/图片/视频/文件的防撤回 97 | - 支持配置文件动态设置 98 | - 支持自动清理过期消息 99 | 100 | 101 | ## 作者 102 | 103 | sineom (h.sineom@gmail.com) -------------------------------------------------------------------------------- /revocation.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Author: sineom h.sineom@gmail.com 3 | Date: 2024-11-11 14:17:56 4 | LastEditors: sineom h.sineom@gmail.com 5 | LastEditTime: 2024-11-14 10:33:56 6 | FilePath: /anti_withdrawal/revocation.py 7 | Description: 防止撤回消息 8 | 9 | Copyright (c) 2024 by sineom, All Rights Reserved. 10 | ''' 11 | from bridge.context import ContextType 12 | from channel.chat_message import ChatMessage 13 | import plugins 14 | import json 15 | import os 16 | import re 17 | from threading import Timer 18 | import time 19 | from plugins import * 20 | from common.log import logger 21 | from lib.itchat.content import * 22 | from lib import itchat 23 | 24 | @plugins.register( 25 | name="Revocation", 26 | desire_priority=-1, 27 | namecn="防止撤回", 28 | desc="防止微信消息撤回插件", 29 | version="1.1", 30 | author="sineom", 31 | ) 32 | class Revocation(Plugin): 33 | def __init__(self): 34 | super().__init__() 35 | self.config = super().load_config() 36 | if not self.config: 37 | # 未加载到配置,使用模板中的配置 38 | self.config = self._load_config_template() 39 | self.handlers[Event.ON_RECEIVE_MESSAGE] = self.on_receive_message 40 | logger.info("[Revocation] inited") 41 | # 初始化存储 42 | self.msg_dict = {} 43 | self.out_date_msg_dict = [] 44 | self.target_friend = None 45 | self.download_directory = './plugins/revocation/downloads' 46 | 47 | # 确保下载目录存在 48 | if not os.path.exists(self.download_directory): 49 | os.makedirs(self.download_directory) 50 | 51 | # 启动定时清理 52 | self.start_cleanup_timer() 53 | self.clear_temp_files() 54 | 55 | def _load_config_template(self): 56 | logger.debug("No revocation plugin config.json, use plugins/revocation/config.json.template") 57 | try: 58 | plugin_config_path = os.path.join(self.path, "config.json.template") 59 | if os.path.exists(plugin_config_path): 60 | with open(plugin_config_path, "r", encoding="utf-8") as f: 61 | plugin_conf = json.load(f) 62 | return plugin_conf 63 | except Exception as e: 64 | logger.exception(e) 65 | 66 | def clear_temp_files(self): 67 | """首次启动,将临时目录下的文件全部清除""" 68 | for file in os.listdir(self.download_directory): 69 | os.remove(os.path.join(self.download_directory, file)) 70 | 71 | 72 | def get_help_text(self, **kwargs): 73 | help_text = "防撤回插件使用说明:\n" 74 | help_text += "本插件会自动保存最近消息并在检测到撤回时转发给指定接收者\n\n" 75 | return help_text 76 | 77 | def get_revoke_msg_receiver(self): 78 | """获取接收撤回消息的好友""" 79 | if self.target_friend is None: 80 | friends = itchat.get_friends(update=True) 81 | receiver_config = self.config.get("receiver", {}) 82 | match_type = receiver_config.get("type", "remark_name") 83 | match_name = receiver_config.get("name", "") 84 | 85 | for friend in friends: 86 | if match_type == "nickname" and friend['NickName'] == match_name: 87 | self.target_friend = friend 88 | break 89 | elif match_type == "remark_name" and friend['RemarkName'] == match_name: 90 | self.target_friend = friend 91 | break 92 | 93 | if self.target_friend is None: 94 | logger.error(f"[AntiWithdrawal] 未找到接收者: {match_type}={match_name}") 95 | 96 | return self.target_friend 97 | 98 | def start_cleanup_timer(self): 99 | """启动定时清理""" 100 | def delete_out_date_msg(): 101 | current_time = int(time.time()) 102 | expire_time = self.config.get("message_expire_time", 120) 103 | # 找出过期消息 104 | expired_msg_ids = [] 105 | for msg_id, msg in list(self.msg_dict.items()): 106 | if (current_time - msg.create_time) > expire_time: 107 | expired_msg_ids.append(msg_id) 108 | # 删除过期消息 109 | if msg.ctype == ContextType.TEXT: 110 | self.msg_dict.pop(msg_id) 111 | elif msg.ctype in [ContextType.IMAGE, ContextType.VIDEO, ContextType.FILE]: 112 | file_path = msg.content 113 | if os.path.exists(file_path): 114 | os.remove(file_path) 115 | self.msg_dict.pop(msg_id) 116 | 117 | cleanup_interval = self.config.get("cleanup_interval", 2) 118 | t = Timer(cleanup_interval, delete_out_date_msg) 119 | t.start() 120 | 121 | delete_out_date_msg() 122 | 123 | def download_files(self, msg): 124 | """下载文件""" 125 | if not msg._prepared: 126 | msg._prepare_fn() 127 | msg._prepared = True 128 | return msg.content 129 | 130 | 131 | def handle_revoke(self, msg, is_group=False): 132 | """处理撤回消息""" 133 | match = re.search('撤回了一条消息', msg.content) 134 | if not match: 135 | return 136 | 137 | old_msg_id = re.search(r"\(.*?)\<\/msgid\>", msg.content).group(1) 138 | if old_msg_id not in self.msg_dict: 139 | return 140 | 141 | old_msg = self.msg_dict[old_msg_id] 142 | target_friend = self.get_revoke_msg_receiver() 143 | if target_friend is None: 144 | return 145 | 146 | try: 147 | if old_msg.ctype == ContextType.TEXT: 148 | # 构造消息前缀: 149 | # 如果是群消息(is_group=True), 前缀格式为: "群:【群名称】的【发送者昵称】" 150 | # 如果是私聊消息(is_group=False), 前缀格式为: "【发送者昵称】" 151 | prefix = f"群:【{msg.from_user_nickname}】的【{msg.actual_user_nickname}】" if is_group else f"【{msg.from_user_nickname}】" 152 | text = f"{prefix}刚刚发过这条消息:{old_msg.content}" 153 | itchat.send(msg=text, toUserName=target_friend['UserName']) 154 | 155 | elif old_msg.ctype in [ContextType.IMAGE, ContextType.VIDEO, ContextType.FILE]: 156 | msg_type = {ContextType.IMAGE: '图片', ContextType.VIDEO: '视频', ContextType.FILE: '文件'}[old_msg.ctype] 157 | prefix = f"群:【{msg.from_user_nickname}】的【{msg.actual_user_nickname}】" if is_group else f"【{msg.from_user_nickname}】" 158 | text = f"{prefix}刚刚发过这条{msg_type}👇" 159 | itchat.send_msg(msg=text, toUserName=target_friend['UserName']) 160 | 161 | file_info = old_msg.content 162 | if old_msg.ctype == ContextType.IMAGE: 163 | itchat.send_image(file_info, toUserName=target_friend['UserName']) 164 | elif old_msg.ctype == ContextType.VIDEO: 165 | itchat.send_video(file_info, toUserName=target_friend['UserName']) 166 | else: 167 | itchat.send_file(file_info, toUserName=target_friend['UserName']) 168 | except Exception as e: 169 | logger.error(f"发送撤回消息失败: {str(e)}") 170 | 171 | def handle_msg(self, msg, is_group=False): 172 | """处理消息""" 173 | try: 174 | if msg.ctype == ContextType.REVOKE: 175 | self.handle_revoke(msg, is_group) 176 | return 177 | 178 | 179 | 180 | msg_id = msg.msg_id 181 | create_time = msg.create_time 182 | # 超过2分钟的消息丢弃 183 | if int(create_time) < int(time.time()) - 120: 184 | return 185 | 186 | if msg.ctype == ContextType.TEXT: 187 | self.msg_dict[msg_id] = msg 188 | elif msg.ctype in [ContextType.IMAGE, ContextType.VIDEO, ContextType.FILE]: 189 | # 将 原目录替换为下载目录 原目录只保留文件名称+下载目录 190 | msg.content = self.download_directory + "/" + os.path.basename(msg.content) 191 | self.msg_dict[msg_id] = msg 192 | self.download_files(msg) 193 | 194 | except Exception as e: 195 | logger.error(f"处理消息失败: {str(e)}") 196 | 197 | def on_receive_message(self, e_context: EventContext): 198 | try: 199 | logger.debug("[Revocation] on_receive_message: %s" % e_context) 200 | context = e_context['context'] 201 | cmsg: ChatMessage = context['msg'] 202 | if cmsg.is_group: 203 | self.handle_group_msg(cmsg) 204 | else: 205 | self.handle_single_msg(cmsg) 206 | except Exception as e: 207 | logger.error(f"处理消息失败: {str(e)}") 208 | 209 | def handle_single_msg(self, msg): 210 | """处理私聊消息""" 211 | self.handle_msg(msg, False) 212 | return None 213 | 214 | def handle_group_msg(self, msg): 215 | """处理群聊消息""" 216 | self.handle_msg(msg, True) 217 | return None --------------------------------------------------------------------------------