├── .gitignore ├── LICENSE ├── README.md ├── SimpleTimeTask.py ├── Task.py ├── __init__.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | simple_time_task.db -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimpleTimeTask 插件 2 | 3 | ## 简介 4 | **SimpleTimeTask** 是一个基于 chatgpt-on-wechat 的插件,用于触发定时任务,感谢[timetask](https://github.com/haikerapples/timetask)项目提供的帮助,部分实现参考于此。 5 | 6 | **V1.0.1 (2025-01-15)** 7 | - 新增多种任务频率,如:每周一,不含周日,每月1号,详见帮助文档。 8 | - 修复在#scanp之后,由于线程未回收导致的任务重复触发问题。 9 | 10 | 11 | ## 安装 12 | - 方法一: 13 | - 载的插件文件都解压到`plugins`文件夹的一个单独的文件夹,最终插件的代码都位于`plugins/PLUGIN_NAME/*`中。启动程序后,如果插件的目录结构正确,插件会自动被扫描加载。除此以外,注意你还需要安装文件夹中`requirements.txt`中的依赖。 14 | 15 | - 方法二(推荐): 16 | - 借助`Godcmd`插件,它是预置的管理员插件,能够让程序在运行时就能安装插件,它能够自动安装依赖。 17 | - 使用 `#installp git@github.com:Sakura7301/SimpleTimeTask.git` 命令自动安装插件 18 | - 在安装之后,需要执行`#scanp`命令来扫描加载新安装的插件。 19 | - 插件扫描成功之后需要手动使用`#enablep SimpleTimeTask`命令来启用插件。 20 | 21 | ## 功能 22 | 23 | - **添加任务**:用户可以设置特定时间的任务提醒。 24 | - **查看任务列表**:用户可以查看已设置的所有定时任务。 25 | - **取消任务**:用户可以取消不再需要的任务。 26 | - **支持群聊和私聊**:可在群聊和私聊中均可使用。 27 | 28 | ## 使用说明 29 | 30 | ### 指令格式 31 | 32 | 用户可以通过以下格式发送消息来控制插件: 33 | 34 | 1. **查看任务列表** 35 | 36 | ``` 37 | /time 任务列表 38 | ``` 39 | 40 | 2. **取消任务** 41 | 42 | ``` 43 | /time 取消任务 <任务ID> 44 | ``` 45 | 46 | 3. **添加任务** 47 | 48 | ``` 49 | /time <频率> <时间> <内容> 50 | ``` 51 | 52 | 例如: 53 | - `/time 今天 17:00 提醒喝水` 54 | - `/time 每天 18:00 GPT 提醒运动` 55 | - `/time 明天 9:00 提醒开会 group[办公室]` 56 | - `/time 今天 17:00 提醒喝水` 57 | - `/time 今天 17:00 GPT 提醒喝水` 58 | - `/time 每周日 08:00 GPT 提醒我逛超市` 59 | - `/time 不含周日 08:55 摸鱼` 60 | - `/time 每月10号 17:00 GPT 提醒我存钱` 61 | 62 | 注意:如果本月没有指定的日期,任务会在本月的最后一天触发。 63 | 64 | ### 频率参数 65 | 66 | - 今天 67 | - 明天 68 | - 每天 69 | - 工作日 70 | - 每周二 71 | - 不含周日 72 | - 每月10号 73 | 74 | ### 注意事项 75 | 76 | - 确保任务时间的有效性,插件会在处理时进行验证。 77 | - 发送指令的用户需要具有相应的权限。 78 | - 取消任务时,请提供有效的任务 `ID`。 79 | 80 | ## 数据库 81 | 82 | 插件会自动创建一个 SQLite 数据库,用于持久化保存任务信息。数据库文件位于 `plugins/SimpleTimeTask/simple_time_task.db`。用户可以根据需要手动查看或修改数据库内容。 83 | 84 | ### 数据表结构 85 | 86 | ``` 87 | CREATE TABLE IF NOT EXISTS tasks ( 88 | id TEXT PRIMARY KEY, 89 | time TEXT NOT NULL, 90 | frequency TEXT CHECK(frequency IN ('once', 'work_day', 'every_day')), 91 | content TEXT NOT NULL, 92 | target_type INTEGER DEFAULT 0, 93 | user_id TEXT, 94 | user_name TEXT, 95 | user_group_name TEXT, 96 | group_title TEXT, 97 | is_processed INTEGER DEFAULT 0 98 | ) 99 | ``` 100 | 101 | ## 错误处理 102 | 103 | 如果在使用过程中出现错误,插件会记录错误日志。用户可以通过检查日志来获取详细的错误信息。 104 | 105 | ## 帮助指令 106 | 107 | 要获取插件的帮助信息,可以使用以下指令: 108 | 109 | ``` 110 | #help SimpleTimeTask 111 | ``` 112 | 113 | 输入该指令后,插件会返回帮助文本,包含使用方法及示例。 114 | 115 | ## 记录日志 116 | 本插件支持日志记录,所有请求和响应将被记录,方便调试和优化。日志信息将输出到指定的日志文件中,确保可以追踪插件的使用情况。 117 | 118 | ## 贡献 119 | 欢迎任何形式的贡献,包括报告问题、请求新功能或提交代码。你可以通过以下方式与我们联系: 120 | 121 | - 提交 issues 到项目的 GitHub 页面。 122 | - 发送邮件至 [sakuraduck@foxmail.com]。 123 | 124 | ## 赞助 125 | 开发不易,我的朋友,如果你想请我喝杯咖啡的话(笑) 126 | 127 | image 128 | 129 | ## 许可 130 | 此项目采用 Apache License 版本 2.0,详细信息请查看 [LICENSE](LICENSE)。 131 | -------------------------------------------------------------------------------- /SimpleTimeTask.py: -------------------------------------------------------------------------------- 1 | # encoding:utf-8 2 | import re 3 | import gc 4 | import time 5 | import random 6 | import plugins 7 | import sqlite3 8 | import calendar 9 | import shutil 10 | import datetime 11 | import threading 12 | from plugins import * 13 | from lib import itchat 14 | from config import conf 15 | import config as RobotConfig 16 | from common.log import logger 17 | from bridge.bridge import Bridge 18 | from channel import channel_factory 19 | from wcwidth import wcswidth, wcwidth 20 | from bridge.reply import Reply, ReplyType 21 | from bridge.context import ContextType, Context 22 | from channel.chat_message import ChatMessage 23 | from channel.wechat.wechat_channel import WechatChannel 24 | from plugins.SimpleTimeTask.Task import Task 25 | 26 | 27 | @plugins.register( 28 | name="SimpleTimeTask", 29 | desire_priority=100, 30 | hidden=False, 31 | desc="一个简易的定时器", 32 | version="1.0.1", 33 | author="Sakura7301", 34 | ) 35 | class SimpleTimeTask(Plugin): 36 | def __init__(self): 37 | super().__init__() 38 | try: 39 | self.config = super().load_config() 40 | self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context 41 | self.chatrooms = {} 42 | # 获取协议类型 43 | self.channel_type = conf().get("channel_type") 44 | if self.channel_type == "gewechat": 45 | # 设置群映射关系 46 | self.get_group_map() 47 | # 线程名 48 | self.daemon_name = "SimpleTimeTask_daemon" 49 | # 定义数据库路径 50 | self.DB_FILE_PATH = "plugins/SimpleTimeTask/simple_time_task.db" 51 | # 创建数据库锁 52 | self.db_lock = threading.Lock() 53 | # 初始化数据库并加载任务到内存 54 | self.tasks = {} 55 | self.init_db_and_load_tasks() 56 | # 此值用于记录上一次重置任务状态的时间()初始化 57 | self.last_reset_task_date = "1970-01-01" 58 | # 防抖动字典 59 | self.user_last_processed_time = {} 60 | # 检查线程是否关闭 61 | self.check_daemon() 62 | # 启动任务检查线程 63 | self.check_thread = threading.Thread(target=self.check_and_trigger_tasks, name=self.daemon_name) 64 | self.check_thread.daemon = True 65 | self.check_thread.start() 66 | # 初始化完成 67 | logger.info("[SimpleTimeTask] initialized") 68 | 69 | except Exception as e: 70 | logger.error(f"[SimpleTimeTask] initialization error: {e}") 71 | raise "[SimpleTimeTask] init failed, ignore " 72 | 73 | def get_group_map(self): 74 | from lib.gewechat.client import GewechatClient 75 | try: 76 | self.gewe_base_url = conf().get("gewechat_base_url") 77 | self.gewe_token = conf().get("gewechat_token") 78 | self.gewe_app_id = conf().get("gewechat_app_id") 79 | self.gewe_client = GewechatClient(self.gewe_base_url, self.gewe_token) 80 | 81 | # 获取通讯录列表 82 | result = self.gewe_client.fetch_contacts_list(self.gewe_app_id) 83 | if result and result['ret'] == 200: 84 | chatrooms = result['data']['chatrooms'] 85 | brief_info = self.gewe_client.get_brief_info(self.gewe_app_id, chatrooms) 86 | logger.info(f"[SimpleTimeTask] 群聊简要信息: \n{brief_info}") 87 | if brief_info and brief_info['ret'] == 200: 88 | self.chatrooms = brief_info['data'] 89 | else: 90 | logger.error(f"[SimpleTimeTask] 获取群聊标题映射失败! group_id: {chatrooms}") 91 | logger.debug(f"[SimpleTimeTask] 群聊映射关系: \n{self.chatrooms}") 92 | else: 93 | error_info = None 94 | if result: 95 | error_info = f"ret: {result['ret']} msg: {result['msg']}" 96 | logger.error(f"[SimpleTimeTask] 获取WX通讯录列表失败! {error_info}") 97 | except Exception as e: 98 | logger.error(f"[SimpleTimeTask] 设置群聊映射关系失败! {e}") 99 | 100 | def check_daemon(self): 101 | target_thread = None 102 | for thread in threading.enumerate(): # 获取所有活动线程 103 | if thread.name == self.daemon_name: 104 | # 找到同名线程 105 | target_thread = thread 106 | break 107 | # 回收线程 108 | if target_thread: 109 | # 关闭线程 110 | target_thread._stop() 111 | # 没有找到同名线程 112 | return None 113 | 114 | def init_db_and_load_tasks(self): 115 | """ 初始化数据库,创建任务表并加载现有任务 """ 116 | with self.db_lock: 117 | # 创建数据库连接 118 | with sqlite3.connect(self.DB_FILE_PATH) as conn: 119 | cursor = conn.cursor() 120 | 121 | # 检查表是否存在并获取元数据 122 | cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks';") 123 | table_exists = cursor.fetchone() is not None 124 | 125 | if table_exists: 126 | # 表存在,检查字段兼容性 127 | cursor.execute("PRAGMA table_info(tasks);") 128 | columns = cursor.fetchall() 129 | column_names = [column[1] for column in columns] # 提取字段名 130 | 131 | expected_columns = [ 132 | 'id', 'time', 'frequency', 'content', 133 | 'target_type', 'user_id', 'user_name', 134 | 'user_group_name', 'group_title', 'is_processed' 135 | ] 136 | 137 | # 检查字段数量与名称是否兼容 138 | if len(column_names) != len(expected_columns) or set(column_names) != set(expected_columns): 139 | logger.warning("[SimpleTimeTask] Database schema is incompatible. Dropping and recreating the tasks table.") 140 | cursor.execute("DROP TABLE tasks;") 141 | 142 | # 创建数据表(如果不存在) 143 | cursor.execute(''' 144 | CREATE TABLE IF NOT EXISTS tasks ( 145 | id TEXT PRIMARY KEY, 146 | time TEXT NOT NULL, 147 | frequency TEXT CHECK(frequency IN ('once', 'work_day', 'every_day')), 148 | content TEXT NOT NULL, 149 | target_type INTEGER DEFAULT 0, 150 | user_id TEXT, 151 | user_name TEXT, 152 | user_group_name TEXT, 153 | group_title TEXT, 154 | is_processed INTEGER DEFAULT 0 155 | ) 156 | ''') 157 | 158 | # 从数据库中加载当前的任务 159 | cursor.execute('SELECT * FROM tasks') 160 | # 读取所有任务行 161 | rows = cursor.fetchall() 162 | logger.info(f"[SimpleTimeTask] Loaded tasks from database: {rows}") 163 | 164 | # 创建 Task 对象并添加到 self.tasks 列表 165 | for row in rows: 166 | task = Task( 167 | task_id=row[0], 168 | time_value=row[1], 169 | frequency=row[2], 170 | content=row[3], 171 | target_type=row[4], 172 | user_id=row[5], 173 | user_name=row[6], 174 | user_group_name=row[7], 175 | group_title=row[8], 176 | is_processed=row[9] 177 | ) 178 | # 添加 Task 实例到 self.tasks 字典,以 task_id 作为键 179 | self.tasks[task.task_id] = task 180 | 181 | def pad_string(self, s: str, total_width: int) -> str: 182 | """ 183 | 根据显示宽度填充字符串,使其达到指定的总宽度。 184 | 中英文混合时,中文字符占用两个宽度,英文字符占用一个宽度。 185 | """ 186 | current_width = wcswidth(s) 187 | if current_width < total_width: 188 | # 计算需要填充的空格数 189 | padding = total_width - current_width 190 | return s + ' ' * padding 191 | return s 192 | 193 | def truncate_string(self, s: str, max_width: int, truncate_width: int) -> str: 194 | """ 195 | 截断字符串,使其在超过 max_width 时,显示前 truncate_width 个宽度的字符, 196 | 并在末尾添加省略号 '...' 197 | """ 198 | # 检查字符串宽度是否超过最大宽度 199 | if wcswidth(s) > max_width: 200 | # 计算需要截断的字符数 201 | truncated = '' 202 | current_width = 0 203 | # 遍历字符串的每个字符 204 | for char in s: 205 | # 计算当前字符的宽度 206 | char_width = wcwidth(char) 207 | # 如果当前宽度超过限制,截断并添加省略号 208 | if current_width + char_width > truncate_width: 209 | break 210 | # 添加字符到截断后的字符串 211 | truncated += char 212 | current_width += char_width 213 | return truncated + '...' 214 | return s 215 | 216 | def print_tasks_info(self): 217 | """ 218 | 打印当前 self.tasks 中的所有任务信息,以整齐的表格形式,使用一次 logger 调用。 219 | """ 220 | try: 221 | # 如果没有任务,记录相应日志并返回 222 | if not self.tasks: 223 | logger.info("[SimpleTimeTask] 当前没有任务。") 224 | return 225 | 226 | # 定义表头 227 | headers = [ 228 | "task_id", "time", "frequency", "content", 229 | "type", "your_name", 230 | "group_name", "group_title", "executed" 231 | ] 232 | 233 | # 定义每个列的最大显示宽度 234 | max_widths = { 235 | "task_id": 10, 236 | "time": 5, 237 | "frequency": 25, # 增大频率列宽,确保完整打印 238 | "content": 20, 239 | "type": 4, 240 | "your_name": 10, 241 | "group_name": 10, 242 | "group_title": 14, 243 | "executed": 8 244 | } 245 | 246 | # 收集所有任务的数据,并应用截断 247 | tasks_data = [] 248 | for task in self.tasks.values(): 249 | # 处理任务ID,如果超过最大宽度,截断并添加省略号 250 | task_id = self.truncate_string(task.task_id, max_widths["task_id"], max_widths["task_id"] - 3) if wcswidth(task.task_id) > max_widths["task_id"] else self.pad_string(task.task_id, max_widths["task_id"]) 251 | 252 | # 处理时间,按原样打印(假设时间格式固定,不需要截断) 253 | time_value = self.pad_string(task.time_value, max_widths["time"]) 254 | 255 | # 频率部分完整打印,不进行截断 256 | frequency = self.pad_string(task.frequency, max_widths["frequency"]) 257 | 258 | # 处理内容,按要求进行截断 259 | content = self.truncate_string(task.content, max_widths["content"], 17) if wcswidth(task.content) > max_widths["content"] else self.pad_string(task.content, max_widths["content"]) 260 | 261 | # 目标类型,转换为中文描述 262 | target_type = self.pad_string("group" if task.target_type else "user", max_widths["type"]) 263 | 264 | # 处理用户昵称,按原样或截断 265 | user_nickname = self.truncate_string(task.user_name, max_widths["your_name"], max_widths["your_name"] - 3) if wcswidth(task.user_name) > max_widths["your_name"] else self.pad_string(task.user_name, max_widths["your_name"]) 266 | 267 | # 处理用户群昵称,按原样或截断 268 | if task.user_group_name: 269 | user_group_nickname = self.truncate_string(task.user_group_name, max_widths["group_name"], max_widths["group_name"] - 3) if wcswidth(task.user_group_name) > max_widths["group_name"] else self.pad_string(task.user_group_name, max_widths["group_name"]) 270 | else: 271 | user_group_nickname = self.pad_string("None", max_widths["group_name"]) 272 | 273 | # 处理群标题,按要求进行截断 274 | if task.group_title: 275 | group_title = self.truncate_string(task.group_title, max_widths["group_title"], 11) if wcswidth(task.group_title) > max_widths["group_title"] else self.pad_string(task.group_title, max_widths["group_title"]) 276 | else: 277 | group_title = self.pad_string("None", max_widths["group_title"]) 278 | 279 | # 处理是否已处理,转换为中文描述 280 | is_processed = self.pad_string("yes" if task.is_processed else "no", max_widths["executed"]) 281 | 282 | # 构建任务行 283 | row = [ 284 | task_id, 285 | time_value, 286 | frequency, 287 | content, 288 | target_type, 289 | user_nickname, 290 | user_group_nickname, 291 | group_title, 292 | is_processed 293 | ] 294 | tasks_data.append(row) 295 | 296 | # 计算每列的实际宽度(取表头和数据中的最大值,不超过设定的最大宽度) 297 | actual_widths = [] 298 | for idx, header in enumerate(headers): 299 | # 获取当前列所有数据的最大显示宽度 300 | max_data_width = max(wcswidth(str(row[idx])) for row in tasks_data) if tasks_data else wcswidth(header) 301 | # 计算实际宽度,不超过设定的最大宽度 302 | actual_width = min(max(wcswidth(header), max_data_width), max_widths[header]) 303 | actual_widths.append(actual_width) 304 | 305 | # 构建分隔线,例如:+----------+-----+--------+ 306 | separator = "+------------+-------+---------------------------+----------------------+------+------------+------------+----------------+----------+" 307 | 308 | # 构建表头行,例如:| task_id | time | frequency | 309 | header_row = "|" + "|".join( 310 | f" {self.pad_string(header, actual_widths[idx])} " for idx, header in enumerate(headers) 311 | ) + "|" 312 | 313 | # 构建所有数据行 314 | data_rows = [] 315 | for row in tasks_data: 316 | formatted_row = "|" + "|".join( 317 | f" {self.pad_string(str(item), actual_widths[idx])} " for idx, item in enumerate(row) 318 | ) + "|" 319 | data_rows.append(formatted_row) 320 | 321 | # 组合完整的表格 322 | table = "\n".join([ 323 | separator, 324 | header_row, 325 | separator 326 | ] + data_rows + [ 327 | separator 328 | ]) 329 | 330 | # 使用一次 logger 调用打印所有任务信息 331 | logger.info(f"[SimpleTimeTask] 当前任务列表如下:\n{table}") 332 | except Exception as e: 333 | # 如果在打印过程中发生异常,记录错误日志 334 | logger.error(f"[SimpleTimeTask] 打印任务信息时发生错误: {e}") 335 | 336 | def find_user_name_by_user_id(self, msg, user_id): 337 | """查找指定 UserName 的昵称""" 338 | user_name = None 339 | try: 340 | # 获取成员列表 341 | members = msg['User']['MemberList'] 342 | # 遍历成员列表 343 | for member in members: 344 | # 检查 UserName 是否匹配 345 | if member['UserName'] == user_id: 346 | # 找到昵称 347 | user_name = member['NickName'] 348 | except Exception as e: 349 | logger.error(f"[DarkRoom] 查找用户昵称失败: {e}") 350 | return user_name 351 | 352 | def get_group_id(self, group_title): 353 | tempRoomId = None 354 | if self.channel_type == "gewechat": 355 | # 获取群聊 356 | for chat_room in self.chatrooms: 357 | # 根据群聊名称匹配群聊ID 358 | userName = chat_room["userName"] 359 | NickName = chat_room["nickName"] 360 | if NickName == group_title: 361 | tempRoomId = userName 362 | break 363 | else: 364 | # 获取群聊ID 365 | chatrooms = itchat.get_chatrooms() 366 | # 获取群聊 367 | for chat_room in chatrooms: 368 | # 根据群聊名称匹配群聊ID 369 | userName = chat_room["UserName"] 370 | NickName = chat_room["NickName"] 371 | if NickName == group_title: 372 | tempRoomId = userName 373 | break 374 | return tempRoomId 375 | 376 | def has_frequency_check_constraint(self): 377 | """ 378 | 检查 tasks 表的 frequency 字段是否有 CHECK 约束。 379 | 380 | 返回: 381 | bool: 如果有 CHECK 约束则返回 True,否则返回 False。 382 | """ 383 | try: 384 | conn = sqlite3.connect(self.DB_FILE_PATH) 385 | cursor = conn.cursor() 386 | cursor.execute(""" 387 | SELECT sql FROM sqlite_master 388 | WHERE type='table' AND name='tasks'; 389 | """) 390 | result = cursor.fetchone() 391 | if result: 392 | create_table_sql = result[0] 393 | # 打印 CREATE TABLE 语句用于调试 394 | logger.debug(f"CREATE TABLE 语句: {create_table_sql}") 395 | 396 | # 查找所有 CHECK 约束 397 | checks = re.findall(r'CHECK\s*\((.*?)\)', create_table_sql, re.IGNORECASE | re.DOTALL) 398 | logger.debug(f"检测到的 CHECK 约束: {checks}") 399 | 400 | for check in checks: 401 | if 'frequency' in check.lower(): 402 | logger.info("检测到涉及 'frequency' 字段的 CHECK 约束。") 403 | return True 404 | return False 405 | except sqlite3.Error as e: 406 | logger.error(f"检查约束失败: {e}") 407 | return False 408 | 409 | def migrate_tasks_table(self): 410 | """ 411 | 迁移 tasks 表,移除 frequency 字段的 CHECK 约束。 412 | 数据表路径为 self.DB_FILE_PATH。 413 | 迁移成功后,删除备份文件。 414 | 415 | 返回: 416 | bool: 迁移是否成功。 417 | """ 418 | backup_path = self.DB_FILE_PATH + "_backup.db" 419 | 420 | try: 421 | # 备份数据库文件 422 | shutil.copyfile(self.DB_FILE_PATH, backup_path) 423 | logger.info(f"数据库已成功备份到 {backup_path}") 424 | except IOError as e: 425 | logger.error(f"备份数据库失败: {e}") 426 | return False 427 | 428 | try: 429 | # 连接到SQLite数据库 430 | conn = sqlite3.connect(self.DB_FILE_PATH) 431 | cursor = conn.cursor() 432 | 433 | # 开始事务 434 | cursor.execute("BEGIN TRANSACTION;") 435 | 436 | # 重命名现有的 tasks 表为 tasks_old 437 | cursor.execute("ALTER TABLE tasks RENAME TO tasks_old;") 438 | logger.info("表 'tasks' 已成功重命名为 'tasks_old'") 439 | 440 | # 创建新的 tasks 表,不包含 CHECK 约束 441 | cursor.execute(''' 442 | CREATE TABLE IF NOT EXISTS tasks ( 443 | id TEXT PRIMARY KEY, 444 | time TEXT NOT NULL, 445 | frequency TEXT, 446 | content TEXT NOT NULL, 447 | target_type INTEGER DEFAULT 0, 448 | user_id TEXT, 449 | user_name TEXT, 450 | user_group_name TEXT, 451 | group_title TEXT, 452 | is_processed INTEGER DEFAULT 0 453 | ); 454 | ''') 455 | logger.info("新的表 'tasks' 已成功创建,不包含 CHECK 约束") 456 | 457 | # 复制数据从 tasks_old 到 新的 tasks 表 458 | cursor.execute(''' 459 | INSERT INTO tasks (id, time, frequency, content, target_type, user_id, user_name, user_group_name, group_title, is_processed) 460 | SELECT id, time, frequency, content, target_type, user_id, user_name, user_group_name, group_title, is_processed 461 | FROM tasks_old; 462 | ''') 463 | logger.info("数据已成功从 'tasks_old' 复制到新的 'tasks' 表") 464 | 465 | # 删除旧的 tasks_old 表 466 | cursor.execute("DROP TABLE tasks_old;") 467 | logger.info("旧表 'tasks_old' 已成功删除") 468 | 469 | # 提交事务 470 | conn.commit() 471 | logger.info("数据库迁移已成功完成。") 472 | 473 | # 删除备份文件 474 | try: 475 | os.remove(backup_path) 476 | logger.info(f"备份文件 {backup_path} 已成功删除。") 477 | except OSError as e: 478 | logger.info(f"删除备份文件失败: {e}") 479 | 480 | return True 481 | 482 | except sqlite3.Error as e: 483 | logger.error(f"数据库迁移失败: {e}") 484 | if conn: 485 | conn.rollback() 486 | logger.info("事务已回滚。") 487 | return False 488 | 489 | def is_valid_monthly(self, frequency): 490 | """ 491 | 检查 monthly_x 是否合法。 492 | 493 | 规则: 494 | 1. 如果 x 小于等于当前月份的天数,返回 True。 495 | 2. 如果 x 超过当前月份的天数且当前日期是本月的最后一天,返回 True。 496 | 3. 其他情况返回 False。 497 | 498 | :param frequency: 字符串,例如 "monthly_30" 499 | :return: 布尔值 500 | """ 501 | if not frequency.startswith("monthly_"): 502 | return False 503 | 504 | try: 505 | expected_day = int(frequency.split("_")[1]) 506 | except (IndexError, ValueError): 507 | # 格式不正确或不是整数 508 | return False 509 | 510 | # 获取当前时间 511 | now = time.localtime() 512 | year = now.tm_year 513 | month = now.tm_mon 514 | current_day = now.tm_mday 515 | 516 | # 获取当前月份的总天数 517 | total_days = calendar.monthrange(year, month)[1] 518 | 519 | if expected_day < current_day: 520 | # 时间未到,无需触发 521 | return False 522 | elif expected_day == current_day: 523 | # 达到约定日期,返回True,准备触发 524 | logger.debug(f"[SimpleTimeTask] trigger month_task frequency: {frequency}") 525 | return True 526 | else: 527 | # 约定日期大于本月最后一天(如设定为31号,但是本月最多只到30号) 528 | if current_day == total_days: 529 | # 今天已经是本月的最后一天,触发。 530 | logger.debug(f"[SimpleTimeTask] expected day({expected_day}) is not in this month, today is the last day, now trigger it!") 531 | return True 532 | else: 533 | return False 534 | 535 | def add_task(self, command_args, user_id, user_name, user_group_name): 536 | """ 添加任务 """ 537 | # 初始化返回内容 538 | reply_str = None 539 | target_type = 0 540 | with self.db_lock: 541 | # 获取参数 542 | frequency = command_args[1] 543 | time_value = command_args[2] 544 | content = ' '.join(command_args[3:]) 545 | 546 | # 检查频率和时间是否为空 547 | if len(frequency) < 1 or len(time_value) < 1 or len(content) < 1: 548 | reply_str = f"[SimpleTimeTask] 任务格式错误: {command_args}\n请使用 '/time 频率 时间 内容' 的格式。" 549 | logger.warning(reply_str) 550 | return reply_str 551 | 552 | logger.debug(f"[SimpleTimeTask] {frequency} {time_value} {content}") 553 | 554 | # 解析目标群 555 | group_title = None 556 | if command_args[-1].startswith('group['): 557 | # 获取群聊名称 558 | group_title = command_args[-1][6:-1] 559 | # 获取任务内容 560 | content = ' '.join(command_args[3:-1]) 561 | 562 | # 生成任务ID 563 | task_id = self.generate_unique_id() 564 | 565 | # 处理时间字符串 566 | if frequency in ["今天", "明天"]: 567 | # 为一次性任务设置具体时分 568 | date_str = time.strftime("%Y-%m-%d") if frequency == "今天" else time.strftime("%Y-%m-%d", time.localtime(time.time() + 86400)) 569 | # 格式化为 年-月-日 时:分 570 | time_value = f"{date_str} {time_value}" 571 | frequency = "once" 572 | elif frequency == "工作日": 573 | frequency = "work_day" 574 | elif frequency == "每天": 575 | frequency = "every_day" 576 | elif re.match(r"每周[一二三四五六日天]", frequency): 577 | # 处理每周x 578 | weekday_map = { 579 | "一": "Monday", 580 | "二": "Tuesday", 581 | "三": "Wednesday", 582 | "四": "Thursday", 583 | "五": "Friday", 584 | "六": "Saturday", 585 | "日": "Sunday", 586 | "天": "Sunday" 587 | } 588 | day = frequency[-1] 589 | english_day = weekday_map.get(day) 590 | if english_day: 591 | frequency = f"weekly_{english_day}" 592 | else: 593 | # 处理未知的星期 594 | frequency = "undefined" 595 | elif re.match(r"每月([1-9]|[12][0-9]|3[01])号", frequency): 596 | # 处理每月x号,确保x为1到31 597 | day_of_month = re.findall(r"每月([1-9]|[12][0-9]|3[01])号", frequency)[0] 598 | frequency = f"monthly_{day_of_month}" 599 | elif re.match(r"不含周[一二三四五六日天]", frequency): 600 | # 处理不含周x 601 | weekday_map = { 602 | "一": "Monday", 603 | "二": "Tuesday", 604 | "三": "Wednesday", 605 | "四": "Thursday", 606 | "五": "Friday", 607 | "六": "Saturday", 608 | "日": "Sunday", 609 | "天": "Sunday" 610 | } 611 | day = frequency[-1] 612 | english_day = weekday_map.get(day) 613 | if english_day: 614 | frequency = f"excludeWeekday_{english_day}" 615 | else: 616 | # 处理未知的星期 617 | frequency = "undefined" 618 | else: 619 | # 处理其他未定义的频率 620 | frequency = "undefined" 621 | 622 | logger.debug(f"即将设置的频率为:{frequency}") 623 | 624 | # 检查任务时间的有效性 625 | if self.validate_time(frequency, time_value): 626 | if group_title: 627 | target_type = 1 628 | # 创建任务 629 | new_task = Task(task_id, time_value, frequency, content, target_type, user_id, user_name, user_group_name, group_title, 0) 630 | 631 | allowed_frequencies = ('once', 'work_day', 'every_day') 632 | frequency_valid = new_task.frequency in allowed_frequencies 633 | 634 | # 检查是否有 CHECK 约束 635 | has_check = self.has_frequency_check_constraint() 636 | logger.debug(f"检查 'tasks' 表中 'frequency' 字段是否有 CHECK 约束: {'有' if has_check else '没有'}") 637 | 638 | # 决定是否需要迁移 639 | if not frequency_valid: 640 | if has_check: 641 | logger.info(f"检测到 frequency '{new_task.frequency}' 不在 {allowed_frequencies} 中,并且存在 CHECK 约束,开始迁移数据库以移除 CHECK 约束。") 642 | migration_success = self.migrate_tasks_table() 643 | if not migration_success: 644 | logger.error("数据库迁移失败,无法添加任务。") 645 | return 646 | else: 647 | logger.info("数据库迁移成功,已移除 CHECK 约束。") 648 | else: 649 | logger.debug(f"检测到 frequency '{new_task.frequency}' 不在 {allowed_frequencies} 中,但 'frequency' 字段没有 CHECK 约束,无需迁移。") 650 | 651 | # 将新任务添加到内存中 652 | self.tasks[new_task.task_id] = new_task 653 | # 将新任务更新到数据库 654 | self.update_task_in_db(new_task) 655 | # 格式化回复内容 656 | reply_str = f"[SimpleTimeTask] 😸 任务已添加: \n\n[{task_id}] {frequency} {time_value} {content} {'group[' + group_title + ']' if group_title else ''}" 657 | 658 | # 打印当前任务信息 659 | self.print_tasks_info() 660 | else: 661 | reply_str = "[SimpleTimeTask] 添加任务失败,时间格式不正确或已过期." 662 | 663 | # 打印任务列表 664 | logger.debug(f"[SimpleTimeTask] 任务列表: {self.tasks}") 665 | 666 | return reply_str 667 | 668 | def update_task_in_db(self, task: Task): 669 | """ 更新任务到数据库 """ 670 | # 由于我们该方法是对任务的插入,因此可以简化锁的使用 671 | with sqlite3.connect(self.DB_FILE_PATH) as conn: 672 | cursor = conn.cursor() 673 | # is_processed 默认值设为 0(未处理) 674 | cursor.execute(''' 675 | INSERT INTO tasks (id, time, frequency, content, target_type, user_id, user_name, user_group_name, group_title, is_processed) 676 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 677 | ''', (task.task_id, task.time_value, task.frequency, task.content, 678 | task.target_type, task.user_id, task.user_name, 679 | task.user_group_name, task.group_title, task.is_processed)) 680 | # 提交更改 681 | conn.commit() 682 | logger.info(f"[SimpleTimeTask] Task added to DB: {task.task_id}") 683 | 684 | def show_task_list(self): 685 | """ 显示所有任务 """ 686 | with self.db_lock: 687 | tasks_list = "[SimpleTimeTask] 😸 任务列表:\n\n" 688 | for task in self.tasks.values(): 689 | group_info = f"group[{task.group_title}]" if task.target_type else "" 690 | tasks_list += f"💼[{task.user_name}|{task.task_id}] {task.frequency} {task.time_value} {task.content} {group_info}\n" 691 | return tasks_list 692 | 693 | def cancel_task(self, task_id: str) -> str: 694 | """取消任务""" 695 | try: 696 | with self.db_lock: 697 | if not self.tasks: 698 | logger.warning("[SimpleTimeTask] 没有可取消的任务。") 699 | return "[SimpleTimeTask] 没有可取消的任务。" 700 | 701 | # 尝试从字典中移除任务 702 | task = self.tasks.pop(task_id, None) 703 | if task: 704 | logger.info(f"[SimpleTimeTask] 任务已取消: {task_id}") 705 | # 从数据库中删除任务 706 | self.remove_task_from_db(task_id) 707 | # 打印当前任务信息 708 | self.print_tasks_info() 709 | return f"[SimpleTimeTask] 😸 任务 [{task_id}] 已取消。" 710 | else: 711 | logger.warning(f"[SimpleTimeTask] 未找到任务 ID [{task_id}] 以供取消。") 712 | return f"[SimpleTimeTask] 未找到任务 [{task_id}]。" 713 | 714 | except Exception as e: 715 | logger.error(f"[SimpleTimeTask] 取消任务时发生错误: {e}") 716 | return "[SimpleTimeTask] 取消任务时发生错误,请稍后重试。" 717 | 718 | def remove_task_from_db(self, task_id): 719 | """ 从数据库中删除任务 """ 720 | # 确保线程安全 721 | with sqlite3.connect(self.DB_FILE_PATH) as conn: 722 | cursor = conn.cursor() 723 | cursor.execute('DELETE FROM tasks WHERE id = ?', (task_id,)) 724 | conn.commit() 725 | logger.info(f"[SimpleTimeTask] Task removed from DB: {task_id}") 726 | 727 | def is_weekday(self): 728 | today = datetime.datetime.now() 729 | # weekday() 返回值:0 = 星期一, 1 = 星期二, ..., 6 = 星期日 730 | return today.weekday() < 5 731 | 732 | def update_task_status(self, task_id, is_processed=1): 733 | """ 更新任务的处理状态到数据库 """ 734 | try: 735 | # 获取任务 736 | task = self.tasks.get(task_id) 737 | # 更新任务状态 738 | task.is_processed = 1 739 | with sqlite3.connect(self.DB_FILE_PATH) as conn: 740 | # 连接数据库 741 | cursor = conn.cursor() 742 | # 更新任务状态 743 | cursor.execute('UPDATE tasks SET is_processed = ? WHERE id = ?', (is_processed, task_id)) 744 | # 提交更改 745 | conn.commit() 746 | logger.info(f"[SimpleTimeTask] Task status updated in DB: {task_id} to {is_processed}") 747 | except Exception as e: 748 | logger.error(f"[SimpleTimeTask] update task status failed: {e}") 749 | 750 | def check_and_trigger_tasks(self): 751 | """定时检查和触发任务""" 752 | while True: 753 | try: 754 | once_tasks = [] 755 | loop_tasks = [] 756 | # 获取当前时间、日期和星期 757 | now = time.strftime("%H:%M") 758 | today_date = time.strftime("%Y-%m-%d") 759 | # e.g., "Monday" 760 | current_weekday = time.strftime("%A", time.localtime()) 761 | 762 | logger.debug(f"[SimpleTimeTask] 正在检查任务, 当前时间: {today_date}-{now}, 最后重置时间: {self.last_reset_task_date}") 763 | 764 | # 每天重置未处理状态 765 | if now == "00:00" and today_date != self.last_reset_task_date: 766 | self.reset_processed_status() 767 | # 更新最后重置日期 768 | self.last_reset_task_date = today_date 769 | logger.info(f"[SimpleTimeTask] 已重置所有任务的处理状态。记录最后重置日期为 {self.last_reset_task_date}。") 770 | 771 | # 使用 list() 避免在遍历过程中修改字典 772 | tasks_copy = list(self.tasks.values()) 773 | 774 | # 创建任务副本以避免在遍历时修改列表 775 | for task in tasks_copy: 776 | # 检查任务是否应该被触发 777 | if self.should_trigger(task, now, today_date, current_weekday): 778 | # 处理任务 779 | self.process_task(task.task_id) 780 | if task.frequency == "once": 781 | once_tasks.append(task.task_id) 782 | else: 783 | loop_tasks.append(task.task_id) 784 | 785 | # 删除一次性任务 786 | for task_id in once_tasks: 787 | # 删除对应ID的任务缓存 788 | self.del_task_from_id(task_id) 789 | # 从数据库中删除任务 790 | self.remove_task_from_db(task_id) 791 | 792 | # 更新任务状态 793 | for task_id in loop_tasks: 794 | self.update_task_status(task_id) 795 | 796 | except Exception as e: 797 | logger.error(f"[SimpleTimeTask] An unexpected error occurred: {e}") 798 | # 每5秒检查一次 799 | time.sleep(5) 800 | 801 | def remove_task(self, task_id): 802 | """从任务列表和数据库中移除任务""" 803 | try: 804 | # 从任务列表和数据库中移除任务 805 | self.del_task_from_id(task_id) 806 | self.remove_task_from_db(task_id) 807 | except Exception as e: 808 | logger.error(f"[SimpleTimeTask] Failed to remove task ID {task_id}: {e}") 809 | 810 | def should_trigger(self, task, now, today_date, current_weekday): 811 | """判断任务是否应该被触发""" 812 | frequency = task.frequency 813 | task_time = task.time_value 814 | 815 | # 一次性任务 816 | if frequency == "once": 817 | try: 818 | task_date, task_time = task_time.split(' ') 819 | except ValueError: 820 | logger.error(f"[SimpleTimeTask] Invalid time format for task ID {task.task_id}") 821 | self.remove_task(task.task_id) 822 | return False 823 | if task_date != today_date or task_time != now: 824 | return False 825 | # 工作日任务 826 | elif frequency == "work_day": 827 | if not self.is_weekday() or task_time != now or task.is_processed == 1: 828 | return False 829 | # 每天任务 830 | elif frequency == "every_day": 831 | if task_time != now or task.is_processed == 1: 832 | return False 833 | # 每周任务 834 | elif frequency.startswith("weekly_"): 835 | try: 836 | _, weekday = frequency.split("_") 837 | except ValueError: 838 | logger.error(f"[SimpleTimeTask] Invalid weekly frequency format for task ID {task.task_id}") 839 | self.remove_task(task.task_id) 840 | return False 841 | if current_weekday != weekday or task_time != now or task.is_processed == 1: 842 | return False 843 | # 每周除星期x外任务 844 | elif frequency.startswith("excludeWeekday_"): 845 | try: 846 | _, excluded_weekday = frequency.split("_") 847 | except ValueError: 848 | logger.error(f"[SimpleTimeTask] Invalid excludeWeekday frequency format for task ID {task.task_id}") 849 | self.remove_task(task.task_id) 850 | return False 851 | if current_weekday == excluded_weekday or task_time != now or task.is_processed == 1: 852 | return False 853 | # 每月x号任务 854 | elif frequency.startswith("monthly_"): 855 | if not self.is_valid_monthly(frequency) or task_time != now or task.is_processed == 1: 856 | return False 857 | # 未知频率 858 | else: 859 | logger.warning(f"[SimpleTimeTask] Unknown frequency '{frequency}' for task ID {task.task_id}") 860 | return False 861 | 862 | return True 863 | 864 | def get_task(self, task_id): 865 | """获取任务""" 866 | return self.tasks.get(task_id) 867 | 868 | def del_task_from_id(self, task_id: str) -> bool: 869 | """删除任务并返回是否成功""" 870 | # 从内存中删除任务 871 | self.tasks.pop(task_id, None) 872 | # 打印当前任务信息 873 | self.print_tasks_info() 874 | 875 | def process_task(self, task_id): 876 | """处理并触发任务""" 877 | try: 878 | # 获取任务 879 | task = self.get_task(task_id) 880 | if task is None: 881 | # 任务不存在 882 | logger.error(f"[SimpleTimeTask] Task ID {task_id} not found.") 883 | else: 884 | # 运行任务 885 | self.run_task_in_thread(task) 886 | except Exception as e: 887 | logger.error(f"[SimpleTimeTask] Failed to process task ID {task_id}: {e}") 888 | self.remove_task(task_id) 889 | 890 | def reset_processed_status(self): 891 | """ 重置所有任务的已处理状态 """ 892 | try: 893 | with self.db_lock: 894 | for task in self.tasks.values(): 895 | # 如果 is_processed 为 True 896 | if task.is_processed == 1: 897 | # 重置为 False 898 | task.is_processed = 0 899 | # 更新数据库中的状态 900 | with sqlite3.connect(self.DB_FILE_PATH) as conn: 901 | cursor = conn.cursor() 902 | cursor.execute('UPDATE tasks SET is_processed = ? WHERE id = ?', (0, task.task_id)) 903 | conn.commit() 904 | logger.info(f"[SimpleTimeTask] Task status updated in DB: {task.task_id} to {0}") 905 | except Exception as e: 906 | logger.error(f"[SimpleTimeTask] Failed to reset processed status: {e}") 907 | 908 | def generate_unique_id(self): 909 | """ 生成唯一任务ID """ 910 | return ''.join(random.choices('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', k=10)) 911 | 912 | def validate_time(self, frequency, time_value): 913 | """ 验证时间和频率 """ 914 | if frequency not in [ "once", "work_day", "every_day", "weekly_Monday", "weekly_Tuesday", "weekly_Wednesday", "weekly_Thursday", "weekly_Friday", "weekly_Saturday", "weekly_Sunday", "monthly_1", "monthly_2", "monthly_3", "monthly_4", "monthly_5", "monthly_6", "monthly_7", "monthly_8", "monthly_9", "monthly_10", "monthly_11", "monthly_12", "monthly_13", "monthly_14", "monthly_15", "monthly_16", "monthly_17", "monthly_18", "monthly_19", "monthly_20", "monthly_21", "monthly_22", "monthly_23", "monthly_24", "monthly_25", "monthly_26", "monthly_27", "monthly_28", "monthly_29", "monthly_30", "monthly_31", "excludeWeekday_Monday", "excludeWeekday_Tuesday", "excludeWeekday_Wednesday", "excludeWeekday_Thursday", "excludeWeekday_Friday", "excludeWeekday_Saturday", "excludeWeekday_Sunday"]: 915 | return False 916 | # 初始化返回值 917 | ret = True 918 | # 获取当前时间 919 | current_time = time.strftime("%H:%M") 920 | 921 | if frequency == "once": 922 | # 如果是一次性任务,检查时间格式 923 | if time_value < f"{time.strftime('%Y-%m-%d')} {current_time}": 924 | # 今天的时间已过期 925 | ret = False 926 | 927 | return ret 928 | 929 | def trigger_task(self, task: Task): 930 | """ 触发任务的实际逻辑 """ 931 | try: 932 | # 初始化变量 933 | content = task.content 934 | receiver = None 935 | is_group = False 936 | is_group_str = "用户消息" 937 | if task.target_type == 1: 938 | is_group = True 939 | receiver = self.get_group_id(task.group_title) 940 | if receiver is None: 941 | # 未获取到群id,跳过此次任务处理 942 | return 943 | is_group_str = "群组消息" 944 | else: 945 | receiver = task.user_id 946 | 947 | logger.info(f"[SimpleTimeTask] 触发[{task.user_name}]的{is_group_str}: [{content}] to {receiver}") 948 | 949 | # 构造消息 950 | orgin_string = "id=0, create_time=0, ctype=TEXT, content=/time 每天 17:55 text, from_user_id=@, from_user_nickname=用户昵称, to_user_id==, to_user_nickname=, other_user_id=@123, other_user_nickname=用户昵称, is_group=False, is_at=False, actual_user_id=None, actual_user_nickname=None, at_list=None" 951 | pattern = r'(\w+)\s*=\s*([^,]+)' 952 | matches = re.findall(pattern, orgin_string) 953 | content_dict = {match[0]: match[1] for match in matches} 954 | content_dict["content"] = content 955 | content_dict["receiver"] = receiver 956 | content_dict["session_id"] = receiver 957 | content_dict["isgroup"] = is_group 958 | content_dict["ActualUserName"] = task.user_name 959 | content_dict["from_user_nickname"] = task.user_name 960 | content_dict["from_user_id"] = task.user_id 961 | content_dict["User"] = { 962 | 'MemberList': [{'UserName': task.user_id, 'NickName': task.user_name}] 963 | } 964 | 965 | # 构建上下文 966 | msg: ChatMessage = ChatMessage(content_dict) 967 | for key, value in content_dict.items(): 968 | if hasattr(msg, key): 969 | setattr(msg, key, value) 970 | msg.is_group = is_group 971 | content_dict["msg"] = msg 972 | context = Context(ContextType.TEXT, content, content_dict) 973 | 974 | # reply默认值 975 | reply_text = f"[SimpleTimeTask]\n--定时提醒任务--\n{content}" 976 | replyType = ReplyType.TEXT 977 | 978 | # 以下部分保持不变 979 | if "GPT" in content: 980 | content = content.replace("GPT", "") 981 | reply: Reply = Bridge().fetch_reply_content(content, context) 982 | 983 | # 检查reply是否有效 984 | if reply and reply.type: 985 | # 替换reply类型和内容 986 | reply_text = reply.content 987 | replyType = reply.type 988 | else: 989 | e_context = None 990 | # 初始化插件上下文 991 | channel = WechatChannel() 992 | channel.channel_type = "wx" 993 | content_dict["content"] = content 994 | context.__setitem__("content", content) 995 | logger.info(f"[SimpleTimeTask] content: {content}") 996 | try: 997 | # 获取插件回复 998 | e_context = PluginManager().emit_event( 999 | EventContext(Event.ON_HANDLE_CONTEXT, {"channel": channel, "context": context, "reply": Reply()}) 1000 | ) 1001 | except Exception as e: 1002 | logger.info(f"路由插件异常!将使用原消息回复。错误信息:{e}") 1003 | 1004 | # 如果插件回复为空,则使用原消息回复 1005 | if e_context and e_context["reply"]: 1006 | reply = e_context["reply"] 1007 | # 检查reply是否有效 1008 | if reply and reply.type: 1009 | # 替换reply类型和内容 1010 | reply_text = reply.content 1011 | replyType = reply.type 1012 | 1013 | # 构建回复 1014 | reply = Reply() 1015 | reply.type = replyType 1016 | reply.content = reply_text 1017 | self.replay_use_custom(reply, context) 1018 | 1019 | except Exception as e: 1020 | logger.error(f"[SimpleTimeTask] 发送消息失败: {e}") 1021 | 1022 | def run_task_in_thread(self, task: Task): 1023 | """ 在新线程中运行任务 """ 1024 | try: 1025 | logger.info(f"[SimpleTimeTask] 开始运行任务 {task.task_id}") 1026 | # 控制线程的事件 1027 | task_thread = threading.Thread(target=self.run_with_timeout, args=(task,)) 1028 | task_thread.start() 1029 | # 设置超时为60秒 1030 | task_thread.join(timeout=60) 1031 | 1032 | if task_thread.is_alive(): 1033 | logger.warning(f"[SimpleTimeTask] 任务 {task.task_id} 超时结束") 1034 | # 结束线程 1035 | task_thread.join() 1036 | except Exception as e: 1037 | logger.error(f"[SimpleTimeTask] 运行任务时发生异常: {e}") 1038 | 1039 | def run_with_timeout(self, task: Task): 1040 | """ 运行任务并捕获异常 """ 1041 | try: 1042 | self.trigger_task(task) 1043 | except Exception as e: 1044 | logger.error(f"[SimpleTimeTask] 触发任务时发生异常: {e}") 1045 | 1046 | def replay_use_custom(self, reply, context : Context, retry_cnt=0): 1047 | try: 1048 | # 发送消息 1049 | channel_name = RobotConfig.conf().get("channel_type", "wx") 1050 | channel = channel_factory.create_channel(channel_name) 1051 | channel.send(reply, context) 1052 | 1053 | #释放 1054 | channel = None 1055 | gc.collect() 1056 | 1057 | except Exception as e: 1058 | if retry_cnt < 2: 1059 | # 重试(最多三次) 1060 | time.sleep(3 + 3 * retry_cnt) 1061 | logger.warning(f"[SimpleTimeTask] 发送消息失败,正在重试: {e}") 1062 | self.replay_use_custom(reply, context, retry_cnt + 1) 1063 | else: 1064 | logger.error(f"[SimpleTimeTask] 发送消息失败,重试次数达到上限: {e}") 1065 | 1066 | def detect_time_command(self, text): 1067 | # 判断输入是否为空 1068 | if not text: 1069 | return None 1070 | 1071 | # 查找/time在文本中的位置 1072 | time_index = text.find('/time') 1073 | 1074 | # 如果找到,就返回包含/time之后的文本 1075 | if time_index != -1: 1076 | result = text[time_index:] 1077 | # 包含/time及后面的内容 1078 | return result 1079 | else: 1080 | # 如果没有找到,返回None 1081 | return None 1082 | 1083 | def on_handle_context(self, e_context: EventContext): 1084 | """ 处理用户指令 """ 1085 | # 检查消息类型 1086 | if e_context["context"].type not in [ContextType.TEXT]: 1087 | return 1088 | 1089 | # 初始化变量 1090 | user_id = None 1091 | user_name = None 1092 | user_group_name = None 1093 | # 获取用户ID 1094 | msg = e_context['context']['msg'] 1095 | if self.channel_type == "gewechat": 1096 | # gewe协议无需区分真实ID 1097 | user_id = msg.actual_user_id 1098 | else: 1099 | # 检查是否为群消息 1100 | if msg.is_group: 1101 | # 群消息,获取真实ID 1102 | user_id = msg._rawmsg['ActualUserName'] 1103 | else: 1104 | # 私聊消息,获取用户ID 1105 | user_id = msg.from_user_id 1106 | 1107 | # 获取当前时间(以毫秒为单位) 1108 | current_time = time.monotonic() * 1000 # 转换为毫秒 1109 | # 防抖动检查 1110 | last_time = self.user_last_processed_time.get(user_id, 0) 1111 | # 防抖动间隔为100毫秒 1112 | if current_time - last_time < 100: 1113 | # 如果在100毫秒内重复触发,不做处理 1114 | logger.debug(f"[SimpleTimeTask] Ignored duplicate command from {user_id}.") 1115 | return 1116 | # 更新用户最后处理时间 1117 | self.user_last_processed_time[user_id] = current_time 1118 | 1119 | # 获取用户指令 1120 | command = self.detect_time_command(msg.content.strip()) 1121 | logger.debug(f"[SimpleTimeTask] Command received: {command}") 1122 | 1123 | # 检查指令是否有效 1124 | if command is not None: 1125 | # 初始化回复字符串 1126 | reply_str = '' 1127 | if self.channel_type == "gewechat": 1128 | # gewe协议获取群名 1129 | user_name = msg.actual_user_nickname 1130 | if msg.is_group: 1131 | user_group_name = msg.other_user_nickname 1132 | else: 1133 | # 检查是否为群消息 1134 | if msg.is_group: 1135 | # itchat协议获取群名 1136 | user_name = self.find_user_name_by_user_id(msg._rawmsg, user_id) 1137 | user_group_name = msg.actual_user_nickname 1138 | else: 1139 | # 获取用户昵称 1140 | user_name = msg.from_user_nickname 1141 | logger.info(f"[SimpleTimeTask] 收到来自[{user_name}|{user_group_name}|{user_id}]的指令: {command}") 1142 | 1143 | # 解析指令 1144 | command_args = command.split(' ') 1145 | if command_args[1] == '任务列表': 1146 | # 获取任务列表 1147 | reply_str = self.show_task_list() 1148 | elif command_args[1] == '取消任务': 1149 | # 取消任务 1150 | if len(command_args) != 3: 1151 | reply_str = "[SimpleTimeTask] 请输入有效任务ID" 1152 | else: 1153 | reply_str = self.cancel_task(command_args[2]) 1154 | else: 1155 | # 添加任务 1156 | if len(command_args) < 4: 1157 | reply_str = f"[SimpleTimeTask] 任务格式错误: {command_args}\n请使用 '/time 频率 时间 内容' 的格式。" 1158 | logger.warning(reply_str) 1159 | else: 1160 | reply_str = self.add_task(command_args, user_id, user_name, user_group_name) 1161 | 1162 | if reply_str is not None: 1163 | # 创建回复对象 1164 | reply = Reply() 1165 | reply.type = ReplyType.TEXT 1166 | reply.content = reply_str 1167 | e_context['reply'] = reply 1168 | e_context.action = EventAction.BREAK_PASS 1169 | return 1170 | 1171 | def get_help_text(self, **kwargs): 1172 | """获取帮助文本""" 1173 | help_text = "- [任务列表]:/time 任务列表\n- [取消任务]:/time 取消任务 任务ID\n- [添加任务]:/time