letterm commited on
Commit
0258eba
·
verified ·
1 Parent(s): 99434c6

Rename index.js to app.py

Browse files
Files changed (2) hide show
  1. app.py +2064 -0
  2. index.js +0 -1173
app.py ADDED
@@ -0,0 +1,2064 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ import json
4
+ import uuid
5
+ import io
6
+ import base64
7
+ import asyncio
8
+ import re # <-- 新增: 用于触发词和预设解析
9
+ import random # <-- 新增: 用于随机回复
10
+ import time # <-- 新增: 用于内存冷却
11
+ from typing import List, Dict, Any, Optional, Set
12
+
13
+ # 运行: pip install python-telegram-bot upstash-redis httpx python-dotenv
14
+ import httpx # 使用 httpx 直接请求 API
15
+ from dotenv import load_dotenv
16
+ from upstash_redis import Redis
17
+ from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, BotCommand, Message
18
+ from telegram.constants import ChatAction, ParseMode
19
+ from telegram.error import BadRequest
20
+ from telegram.ext import (
21
+ Application,
22
+ ApplicationBuilder,
23
+ ContextTypes,
24
+ CommandHandler,
25
+ MessageHandler,
26
+ CallbackQueryHandler,
27
+ filters,
28
+ Job,
29
+ JobQueue
30
+ )
31
+
32
+ # --- 日志配置 ---
33
+ # 配置日志记录
34
+ logging.basicConfig(
35
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
36
+ level=logging.INFO
37
+ )
38
+ # 将 httpx 的日志级别调高,因为它在 DEBUG 级别下过于嘈杂
39
+ logging.getLogger("httpx").setLevel(logging.WARNING)
40
+ # (新增) 禁用 httpx 的 SSL 警告
41
+ import warnings
42
+ import urllib3
43
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
44
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ # --- 1. 配置类 ---
50
+
51
+ class Config:
52
+ """
53
+ 管理所有从环境变量加载的配置。
54
+ """
55
+ def __init__(self):
56
+ # 加载 .env 文件 (如果存在)
57
+ load_dotenv()
58
+ logger.info("正在加载环境变量...")
59
+
60
+ # Telegram
61
+ self.TELEGRAM_BOT_TOKEN: str = self.get_env_required("TELEGRAM_BOT_TOKEN")
62
+
63
+ # OpenAI 兼容 API
64
+ self.OPENAI_API_URL: str = self.get_env_required("OPENAI_COMPATIBLE_URL")
65
+ self.OPENAI_API_KEY: str = self.get_env_required("OPENAI_COMPATIBLE_KEY")
66
+ self.DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "gpt-3.5-turbo")
67
+
68
+ # Upstash Redis (upstash-redis 库需要这两个)
69
+ self.UPSTASH_REDIS_URL: str = self.get_env_required("UPSTASH_REDIS_REST_URL")
70
+ self.UPSTASH_REDIS_TOKEN: str = self.get_env_required("UPSTASH_REDIS_REST_TOKEN")
71
+
72
+ # 权限
73
+ self.ADMIN_USERS: Set[int] = self.parse_int_set_from_env("ADMIN_USERS")
74
+
75
+ # (新增) RSS
76
+ self.RSS_URL: str = os.getenv("RSS_URL", "https://ci-en.dlsite.com/creator/4551/article/xml/rss")
77
+ # (V4.5) 移除 RSS_CHAT_IDS, 改为动态订阅
78
+
79
+ if not self.ADMIN_USERS:
80
+ logger.warning("未在环境变量中定义 'ADMIN_USERS'。某些管理功能将受限。")
81
+
82
+ logger.info("环境变量加载完毕。")
83
+
84
+ def get_env_required(self, var_name: str) -> str:
85
+ """获取必需的环境变量,如果缺失则抛出异常。"""
86
+ value = os.getenv(var_name)
87
+ if value is None:
88
+ logger.error(f"严重错误: 环境变量 '{var_name}' 未设置。")
89
+ raise ValueError(f"环境变量 '{var_name}' 必须被设置。")
90
+ return value
91
+
92
+ def parse_int_set_from_env(self, var_name: str) -> Set[int]:
93
+ """从逗号分隔的环境变量字符串解析为整数集合。"""
94
+ value_str = os.getenv(var_name)
95
+ if not value_str:
96
+ return set()
97
+ try:
98
+ return {int(x.strip()) for x in value_str.split(',') if x.strip()}
99
+ except ValueError:
100
+ logger.error(f"无法解析环境变量 '{var_name}'。请确保它是逗号分隔的整数ID。")
101
+ return set()
102
+
103
+ @property
104
+ def OP_USER_ID(self) -> Optional[int]:
105
+ """
106
+ 获取"超级管理员" (OP) 的ID,即 ADMIN_USERS 列表中的第一个。
107
+ 用于接收启动通知。
108
+ """
109
+ if self.ADMIN_USERS:
110
+ return next(iter(self.ADMIN_USERS))
111
+ return None
112
+
113
+
114
+ # --- 2. Redis 管理类 ---
115
+
116
+ class RedisManager:
117
+ """
118
+ 封装所有与 Upstash Redis 的交互。
119
+ (V4.5: 主要负责*写入*和*初始加载*,读取操作由内存缓存处理)
120
+ """
121
+ # Redis 键名常量
122
+ KEY_MODEL_LIST = "bot:model_list"
123
+ KEY_ADMIN_USERS = "bot:admin_users"
124
+ # V4.3 重构:
125
+ KEY_WHITELISTED_GROUPS = "bot:whitelisted_groups" # (Set) 允许的群组 ID
126
+ KEY_BLACKLISTED_TOPICS = "bot:blacklisted_topics" # (Set) 禁言的话题 Key (chat_id:thread_id)
127
+ KEY_GROUP_TRIGGERS = "bot:group_triggers" # HASH, chat_id -> trigger_word
128
+
129
+ # 预设键
130
+ KEY_PRESETS_PREFIX = "bot:presets:" # HASH, {user_id} -> {preset_name} -> json(messages)
131
+ KEY_ACTIVE_PRESET_PREFIX = "bot:active_preset:" # STRING, {user_id} -> preset_name
132
+
133
+ # RSS 键
134
+ KEY_LAST_RSS_LINK = "bot:last_rss_link" # STRING, 存储最新的 article_id
135
+ KEY_RSS_SUBSCRIPTIONS = "bot:rss_subscriptions" # (V4.5 新增) Set, 存储订阅的 chat_id
136
+
137
+ # V4.7 新增: 活跃度
138
+ KEY_LAST_RESPONSE_TIMES = "bot:last_response_times" # HASH, context_key -> timestamp
139
+
140
+ # 会话超时时间 (例如: 24 小时)
141
+ SESSION_EXPIRATION_SEC = 86400 # 24 * 60 * 60
142
+
143
+ # 上下文历史消息限制
144
+ CONTEXT_HISTORY_LIMIT = 200
145
+
146
+ def __init__(self, config: Config):
147
+ try:
148
+ self.redis = Redis(
149
+ url=config.UPSTASH_REDIS_URL,
150
+ token=config.UPSTASH_REDIS_TOKEN
151
+ )
152
+ # 测试连接
153
+ self.redis.ping()
154
+ logger.info("成功连接到 Upstash Redis。")
155
+ except Exception as e:
156
+ logger.error(f"无法连接到 Upstash Redis: {e}", exc_info=True)
157
+ raise
158
+
159
+ # --- 键生成辅助方法 ---
160
+ def _get_context_key(self, user_id: int, chat_id: int, thread_id: Optional[int]) -> str:
161
+ """
162
+ 重构: 获取上下文的唯一键。
163
+ - 私聊: context:user:{user_id}
164
+ - 群组话题: context:group:{chat_id}:{thread_id_key}
165
+ """
166
+ if chat_id > 0: # V4.3 修复: 私聊 (chat_id == user_id)
167
+ return f"context:user:{user_id}"
168
+
169
+ # V4.3 重构: 统一处理 "常规" 话题 (None -> 0)
170
+ thread_id_key = thread_id if thread_id is not None else 0
171
+ return f"context:group:{chat_id}:{thread_id_key}"
172
+
173
+ def _user_model_key(self, user_id: int) -> str:
174
+ """获取跟踪用户当前模型的键 (保持不变)。"""
175
+ return f"user:{user_id}:current_model"
176
+
177
+ def _user_presets_key(self, user_id: int) -> str:
178
+ """(新增) 获取用户预设 HASH 的键。"""
179
+ return f"{self.KEY_PRESETS_PREFIX}{user_id}"
180
+
181
+ def _user_active_preset_key(self, user_id: int) -> str:
182
+ """(新增) 获取用户当前激活预设的键。"""
183
+ return f"{self.KEY_ACTIVE_PRESET_PREFIX}{user_id}"
184
+
185
+ # --- 启动初始化 ---
186
+ def initialize_from_env(self, initial_admins: Set[int]): # V4.2: 移除了 initial_chats
187
+ """
188
+ 在启动时,将环境变量中的初始管理员同步到 Redis。
189
+ (V4.2: 话题白名单现在必须通过命令添加)
190
+ """
191
+ try:
192
+ if initial_admins:
193
+ # SADD 返回成功添加的新成员数量
194
+ added_admins = self.redis.sadd(self.KEY_ADMIN_USERS, *initial_admins)
195
+ logger.info(f"已将 {added_admins} 个新管理员从环境变量同步到 Redis。")
196
+ except Exception as e:
197
+ logger.error(f"初始化 Redis 数据时出错: {e}", exc_info=True)
198
+
199
+ # --- 会话管理 (私聊) ---
200
+
201
+ def clear_session(self, user_id: int, chat_id: int, thread_id: Optional[int]):
202
+ """重构: 清除当前上下文 (/new)。(私聊或群组均可调用)"""
203
+ context_key = self._get_context_key(user_id, chat_id, thread_id)
204
+ # 注意: 这只会清除 Redis。群组的内存缓存将在下一次消息时自动重置。
205
+ # (或者在 /new 命令中单独清除内存)
206
+ self.redis.delete(context_key)
207
+ logger.info(f"已为键 {context_key} 清除 Redis 会话。")
208
+
209
+ def get_conversation_history(self, user_id: int, chat_id: int, thread_id: Optional[int]) -> List[Dict[str, Any]]:
210
+ """
211
+ 重构: 根据上下文键获取对话历史。
212
+ (现在主要用于私聊,或群组的初始加载)
213
+ """
214
+ context_key = self._get_context_key(user_id, chat_id, thread_id)
215
+ raw_data = self.redis.get(context_key)
216
+
217
+ if not raw_data:
218
+ return []
219
+
220
+ try:
221
+ history = json.loads(raw_data)
222
+ if not isinstance(history, list):
223
+ logger.warning(f"上下文 {context_key} 的数据格式不正确 (非列表),已重置。")
224
+ return []
225
+
226
+ # 刷新 TTL
227
+ self.redis.expire(context_key, self.SESSION_EXPIRATION_SEC)
228
+
229
+ # 只返回最后 200 条
230
+ return history[-self.CONTEXT_HISTORY_LIMIT:]
231
+
232
+ except json.JSONDecodeError:
233
+ logger.error(f"无法解析上下文 {context_key} 的JSON数据,已重置。")
234
+ return []
235
+
236
+ def add_to_conversation(self, user_id: int, chat_id: int, thread_id: Optional[int], role: str, content: Any):
237
+ """
238
+ 重构: 向指定的上下文添加一条消息。
239
+ (现在主要用于私聊)
240
+ (V4.1: content 已经是新格式的列表)
241
+ """
242
+ context_key = self._get_context_key(user_id, chat_id, thread_id)
243
+ try:
244
+ raw_data = self.redis.get(context_key)
245
+
246
+ if not raw_data:
247
+ history = []
248
+ else:
249
+ try:
250
+ history = json.loads(raw_data)
251
+ if not isinstance(history, list):
252
+ history = []
253
+ except json.JSONDecodeError:
254
+ history = []
255
+
256
+ # 添加新消息 (content 已经是新格式)
257
+ history.append({"role": role, "content": content})
258
+
259
+ # 写入 (私聊立即写)
260
+ self.redis.set(context_key, json.dumps(history), ex=self.SESSION_EXPIRATION_SEC)
261
+
262
+ logger.debug(f"已将上下文 {context_key} 的 {role} 消息存入 Redis。")
263
+
264
+ except Exception as e:
265
+ logger.error(f"向上下文 {context_key} (私聊) 添加消息时出错: {e}", exc_info=True)
266
+
267
+
268
+ # --- 模型管理 ---
269
+
270
+ def get_current_model(self, user_id: int, default_model: str) -> str:
271
+ """获取用户的首选模型,如果未设置则返回默认值。"""
272
+ model = self.redis.get(self._user_model_key(user_id))
273
+ # V4.6 修复: 移除 .decode()
274
+ return model if model else default_model
275
+
276
+ def set_current_model(self, user_id: int, model_name: str):
277
+ """设置用户的首选模型。"""
278
+ self.redis.set(self._user_model_key(user_id), model_name)
279
+ logger.info(f"用户 {user_id} 将模型切换为: {model_name}")
280
+
281
+ def cache_model_list(self, models: List[str]):
282
+ """将从API获取的模型列表缓存到 Redis。"""
283
+ if not models:
284
+ return
285
+ # 缓存 1 小时
286
+ self.redis.set(self.KEY_MODEL_LIST, json.dumps(models), ex=3600)
287
+ logger.info(f"已缓存 {len(models)} 个模型到 Redis。")
288
+
289
+ def get_cached_model_list(self) -> Optional[List[str]]:
290
+ """从 Redis 获取缓存的模型列表。"""
291
+ raw_data = self.redis.get(self.KEY_MODEL_LIST)
292
+ if not raw_data:
293
+ return None
294
+ try:
295
+ return json.loads(raw_data)
296
+ except json.JSONDecodeError:
297
+ return None
298
+
299
+ # --- 权限管理 (Admin) (V4.5: 读/写) ---
300
+
301
+ def get_admin_users(self) -> Set[int]:
302
+ """(V4.5) 从 Redis *读取*所有管理员ID (用于启动加载)。"""
303
+ try:
304
+ # V4.6 修复: 移除 .decode()
305
+ return {int(uid) for uid in self.redis.smembers(self.KEY_ADMIN_USERS)}
306
+ except Exception as e:
307
+ logger.error(f"获取管理员列表时出错: {e}", exc_info=True)
308
+ return set()
309
+
310
+ def add_admin(self, user_id: int) -> bool:
311
+ """(V4.5) *写入*管理员到 Redis。"""
312
+ try:
313
+ return self.redis.sadd(self.KEY_ADMIN_USERS, user_id) == 1
314
+ except Exception as e:
315
+ logger.error(f"添加管理员 {user_id} 时出错: {e}")
316
+ return False
317
+
318
+ def remove_admin(self, user_id: int) -> bool:
319
+ """(V4.5) 从 Redis *移除*管理员。"""
320
+ try:
321
+ return self.redis.srem(self.KEY_ADMIN_USERS, user_id) == 1
322
+ except Exception as e:
323
+ logger.error(f"移除管理员 {user_id} 时出错: {e}")
324
+ return False
325
+
326
+ # --- 权限管理 (V4.5: Whitelist Group / Blacklist Topic) (读/写) ---
327
+
328
+ # (V4.5 新增) 群组白名单
329
+ def get_whitelisted_groups(self) -> Set[int]:
330
+ """(V4.5) 从 Redis *读取*所有白名单群组 (用于启动加载)。"""
331
+ try:
332
+ # V4.6 修复: 移除 .decode()
333
+ return {int(cid) for cid in self.redis.smembers(self.KEY_WHITELISTED_GROUPS)}
334
+ except Exception as e:
335
+ logger.error(f"获取白名单群组时出错: {e}", exc_info=True)
336
+ return set()
337
+
338
+ def add_group_whitelist(self, chat_id: int) -> bool:
339
+ """(V4.5) *写入*群组到白名单 Redis。"""
340
+ try:
341
+ return self.redis.sadd(self.KEY_WHITELISTED_GROUPS, chat_id) == 1
342
+ except Exception as e:
343
+ logger.error(f"添加白名单群组 {chat_id} 时出错: {e}")
344
+ return False
345
+
346
+ # (V4.5 重构) 话题黑名单
347
+ def get_blacklisted_topics(self) -> Set[str]:
348
+ """(V4.5) 从 Redis *读取*所有黑名单话题 (用于启动加载)。"""
349
+ try:
350
+ # V4.6 修复: 移除 .decode()
351
+ return {k for k in self.redis.smembers(self.KEY_BLACKLISTED_TOPICS)}
352
+ except Exception as e:
353
+ logger.error(f"获取黑名单话题时出错: {e}", exc_info=True)
354
+ return set()
355
+
356
+ def add_blacklisted_topic(self, topic_key: str) -> bool:
357
+ """(V4.5) *写入*话题到黑名单 Redis。"""
358
+ try:
359
+ return self.redis.sadd(self.KEY_BLACKLISTED_TOPICS, topic_key) == 1
360
+ except Exception as e:
361
+ logger.error(f"添加黑名单话题 {topic_key} 时出错: {e}")
362
+ return False
363
+
364
+ # --- 群组触发词管理 (V4.5: 读/写) ---
365
+
366
+ def get_all_group_triggers(self) -> Dict[int, str]:
367
+ """(V4.5) 从 Redis *读取*所有群组触发词 (用于启动加载)。"""
368
+ try:
369
+ # V4.6 修复: 移除 .decode()
370
+ return {int(k): v for k, v in self.redis.hgetall(self.KEY_GROUP_TRIGGERS).items()}
371
+ except Exception as e:
372
+ logger.error(f"获取所有群组触发词时出错: {e}", exc_info=True)
373
+ return {}
374
+
375
+ def set_group_trigger(self, chat_id: int, word: str):
376
+ """(V4.5) *写入*群组触发词到 Redis。"""
377
+ self.redis.hset(self.KEY_GROUP_TRIGGERS, str(chat_id), word)
378
+
379
+ # --- 预设 (Preset) 管理 ---
380
+
381
+ def set_preset(self, user_id: int, name: str, messages: List[Dict[str, Any]]) -> bool:
382
+ """
383
+ 保存一个预设 (HSET)。
384
+ (V4.1: messages 已经是新格式)
385
+ """
386
+ try:
387
+ key = self._user_presets_key(user_id)
388
+ self.redis.hset(key, name, json.dumps(messages))
389
+ return True
390
+ except Exception as e:
391
+ logger.error(f"为用户 {user_id} 保存预设 '{name}' 时出错: {e}")
392
+ return False
393
+
394
+ def get_preset(self, user_id: int, name: str) -> Optional[List[Dict[str, Any]]]:
395
+ """获取一个特定的预设 (HGET)。"""
396
+ try:
397
+ key = self._user_presets_key(user_id)
398
+ raw_data = self.redis.hget(key, name)
399
+ if not raw_data:
400
+ return None
401
+ return json.loads(raw_data)
402
+ except Exception as e:
403
+ logger.error(f"为用户 {user_id} 获取预设 '{name}' 时出错: {e}")
404
+ return None
405
+
406
+ def delete_preset(self, user_id: int, name: str) -> bool:
407
+ """删除一个预设 (HDEL)。"""
408
+ try:
409
+ key = self._user_presets_key(user_id)
410
+ # 检查是否删除了当前激活的预设
411
+ active_preset = self.get_active_preset_name(user_id)
412
+ if active_preset == name:
413
+ self.set_active_preset(user_id, "") # 清空激活的预设
414
+
415
+ return self.redis.hdel(key, name) > 0
416
+ except Exception as e:
417
+ logger.error(f"为用户 {user_id} 删除预设 '{name}' 时出错: {e}")
418
+ return False
419
+
420
+ def list_presets(self, user_id: int) -> List[str]:
421
+ """列出用户的所有预设名称 (HKEYS)。"""
422
+ try:
423
+ key = self._user_presets_key(user_id)
424
+ # (V4.6) 修复 bytes decode
425
+ return self.redis.hkeys(key)
426
+ except Exception as e:
427
+ logger.error(f"为用户 {user_id} 列出预设时出错: {e}")
428
+ return []
429
+
430
+ def set_active_preset(self, user_id: int, name: str):
431
+ """设置当前激活的预设 (SET)。"""
432
+ key = self._user_active_preset_key(user_id)
433
+ if name:
434
+ self.redis.set(key, name)
435
+ else:
436
+ # 如果 name 为空,则删除键
437
+ self.redis.delete(key)
438
+
439
+ def get_active_preset_name(self, user_id: int) -> Optional[str]:
440
+ """获取当前激活的预设名称 (GET)。"""
441
+ key = self._user_active_preset_key(user_id)
442
+ # (V4.6) 修复 bytes decode
443
+ return self.redis.get(key)
444
+
445
+ def get_active_preset_messages(self, user_id: int) -> List[Dict[str, Any]]:
446
+ """(核心) 获取当前激活的预设的消息列表。"""
447
+ name = self.get_active_preset_name(user_id)
448
+ if not name:
449
+ return []
450
+
451
+ messages = self.get_preset(user_id, name)
452
+ return messages if messages else []
453
+
454
+ # --- (新增) RSS 管理 ---
455
+ def get_last_rss_link(self) -> Optional[str]:
456
+ """获取最后已知的 RSS 链接 ID。"""
457
+ # (V4.6) 修复 bytes decode
458
+ return self.redis.get(self.KEY_LAST_RSS_LINK)
459
+
460
+ def set_last_rss_link(self, link_id: str):
461
+ """设置最新的 RSS 链接 ID。"""
462
+ self.redis.set(self.KEY_LAST_RSS_LINK, link_id)
463
+
464
+ def get_rss_subscribers(self) -> Set[int]:
465
+ """(V4.5) 从 Redis *读取*所有 RSS 订阅者。"""
466
+ try:
467
+ # (V4.6) 修复 bytes decode
468
+ return {int(cid) for cid in self.redis.smembers(self.KEY_RSS_SUBSCRIPTIONS)}
469
+ except Exception as e:
470
+ logger.error(f"获取 RSS 订阅者时出错: {e}")
471
+ return set()
472
+
473
+ def add_rss_subscriber(self, chat_id: int) -> bool:
474
+ """(V4.5) *写入* RSS 订阅者。"""
475
+ try:
476
+ return self.redis.sadd(self.KEY_RSS_SUBSCRIPTIONS, chat_id) == 1
477
+ except Exception as e:
478
+ logger.error(f"添加 RSS 订阅者 {chat_id} 时出错: {e}")
479
+ return False
480
+
481
+ def remove_rss_subscriber(self, chat_id: int) -> bool:
482
+ """(V4.5) *移除* RSS 订阅者。"""
483
+ try:
484
+ return self.redis.srem(self.KEY_RSS_SUBSCRIPTIONS, chat_id) == 1
485
+ except Exception as e:
486
+ logger.error(f"移除 RSS 订阅者 {chat_id} 时出错: {e}")
487
+ return False
488
+
489
+ # --- (V4.7 新增) 活跃度管理 ---
490
+ def update_last_response_time(self, context_key: str):
491
+ """(V4.7) 更新一个话题的最后响应时间戳。"""
492
+ try:
493
+ self.redis.hset(self.KEY_LAST_RESPONSE_TIMES, context_key, time.time())
494
+ except Exception as e:
495
+ logger.error(f"更新最后响应时间失败 {context_key}: {e}")
496
+
497
+ def get_all_last_response_times(self) -> Dict[str, float]:
498
+ """(V4.7) 获取所有话题的最后响应时间戳 (用于启动检查)。"""
499
+ try:
500
+ # V4.6 修复: 移除 .decode()
501
+ return {k: float(v) for k, v in self.redis.hgetall(self.KEY_LAST_RESPONSE_TIMES).items()}
502
+ except Exception as e:
503
+ logger.error(f"获取所有最后响应时间失败: {e}", exc_info=True)
504
+ return {}
505
+
506
+
507
+ # --- 3. OpenAI API 客户端类 (使用 httpx) ---
508
+
509
+ class OpenAIClient:
510
+ """
511
+ 封装所有与 OpenAI 兼容 API 的交互。
512
+ 使用 httpx.AsyncClient 进行异步 API 请求。
513
+ """
514
+ def __init__(self, config: Config):
515
+ self.base_url = config.OPENAI_API_URL
516
+ self.headers = {
517
+ "Authorization": f"Bearer {config.OPENAI_API_KEY}",
518
+ "Content-Type": "application/json"
519
+ }
520
+ # 修复 V4.2: 禁用 SSL 验证 (verify=False) 以修复 ConnectError
521
+ logger.warning("!!! 安全警告: 正在为 OpenAIClient 禁用 SSL 验证 (verify=False)。")
522
+ logger.warning("!!! 这可以修复 ConnectError,但会带来安全风险。请确保 API 端点可信。")
523
+ self.client = httpx.AsyncClient(
524
+ base_url=self.base_url,
525
+ headers=self.headers,
526
+ timeout=30.0, # 设置 30 秒超时
527
+ follow_redirects=True,
528
+ verify=False # <-- 修复: 禁用 SSL 验证
529
+ )
530
+ self.default_model = config.DEFAULT_MODEL
531
+ logger.info(f"OpenAI 客户端 (httpx) 已初始化,指向: {config.OPENAI_API_URL}")
532
+
533
+ async def get_models(self) -> List[str]:
534
+ """
535
+ 从 API 获取可用模型列表。
536
+ """
537
+ try:
538
+ logger.info("正在从 API (httpx) 获取模型列表...")
539
+ # 兼容的 API 端点通常是 /v1/models
540
+ response = await self.client.get("/v1/models")
541
+ response.raise_for_status() # 如果状态码不是 2xx,则抛出异常
542
+
543
+ models_data = response.json()
544
+
545
+ # 过滤并返回所有模型ID (接受所有模型)
546
+ model_ids = [model['id'] for model in models_data.get('data', []) if model.get('id')]
547
+ logger.info(f"成功获取到 {len(model_ids)} 个模型。")
548
+ return sorted(model_ids)
549
+ except httpx.HTTPStatusError as e:
550
+ logger.error(f"API 请求失败 (状态码 {e.response.status_code}): {e.response.text}", exc_info=True)
551
+ return []
552
+ except Exception as e:
553
+ logger.error(f"从 API 获取模型列表失败 (httpx): {e}", exc_info=True)
554
+ return []
555
+
556
+ async def generate_response(self, model: str, history: List[Dict[str, Any]]) -> Optional[str]:
557
+ """
558
+ 调用聊天补全 (Chat Completions) API。
559
+ 'history' 必须是符合 API 格式的列表。
560
+ 修复: 增加了对空 'choices' 列表的检查,防止 'list index out of range'。
561
+ """
562
+ logger.debug(f"向模型 {model} (httpx) 发送请求,包含 {len(history)} 条历史消息。")
563
+ if history and "image_url" in str(history[-1]):
564
+ logger.debug("请求中包含图片。")
565
+
566
+ # 兼容的 API 端点通常是 /v1/chat/completions
567
+ endpoint = "/v1/chat/completions"
568
+ payload = {
569
+ "model": model,
570
+ "messages": history,
571
+ "stream": False # 不使用流式响应,简化处理
572
+ }
573
+
574
+ try:
575
+ response = await self.client.post(endpoint, json=payload)
576
+ response.raise_for_status() # 检查 HTTP 错误
577
+
578
+ data = response.json()
579
+
580
+ # 修复: 检查 'choices' 是否存在且不为空
581
+ choices = data.get('choices', [])
582
+ if not choices or 'message' not in choices[0]:
583
+ logger.warning(f"API 响应中未找到 'choices' 或 'message'。响应: {data}")
584
+ return "抱歉,AI 响应为空或格式不正确。"
585
+
586
+ # 修复 V4.1: AI 的回复也在 content 列表中
587
+ # (但大多数兼容 API 仍然返回 {role:"...", content:"..."})
588
+ # 我们必须检查两种情况
589
+ message = choices[0].get('message', {})
590
+ response_content = message.get('content')
591
+
592
+ if response_content is None:
593
+ logger.warning(f"API 响应中 'content' 为空。响应: {data}")
594
+ return "抱歉,AI 响应格式不正确 (content 为 null)。"
595
+
596
+ # 检查 content 是字符串 (标准) 还是列表 (新格式)
597
+ if isinstance(response_content, str):
598
+ response_text = response_content
599
+ elif isinstance(response_content, list) and len(response_content) > 0 and response_content[0].get('type') == 'text':
600
+ response_text = response_content[0].get('text', '')
601
+ else:
602
+ logger.warning(f"API 响应的 'content' 格式未知: {response_content}")
603
+ return "抱歉,AI 响应格式未知。"
604
+
605
+ logger.debug(f"模型 {model} 成功返回响应。")
606
+ return response_text
607
+
608
+ except httpx.ConnectError as e: # 捕获特定的 ConnectError
609
+ logger.error(f"调用 OpenAI API ({model}) 时(httpx)遇到连接错误 (ConnectError): {e}", exc_info=True)
610
+ return f"抱歉,AI 连接失败 (ConnectError): {str(e)}"
611
+ except httpx.HTTPStatusError as e:
612
+ error_message = f"API 返回错误 (状态码 {e.response.status_code})。详情: {e.response.text}"
613
+ logger.error(f"调用 OpenAI API ({model}) 时出错: {error_message}", exc_info=True)
614
+ return f"抱歉,调用 AI 模型时出错: {error_message}"
615
+ except Exception as e:
616
+ logger.error(f"调用 OpenAI API ({model}) 时(httpx)遇到意外错误: {e}", exc_info=True)
617
+ # 向用户返回一个更友好的错误信息
618
+ return f"抱歉,调用 AI 模型时出错: {str(e)}"
619
+
620
+ async def close(self):
621
+ """关闭 httpx 客户端。"""
622
+ await self.client.aclose()
623
+
624
+
625
+ # --- 4. 自定义过滤器 ---
626
+
627
+ class AdminFilter(filters.BaseFilter):
628
+ """
629
+ (V4.5) 自定义 PTB 过滤器,用于检查用户是否为管理员 (从内存缓存读取)。
630
+ """
631
+ def __init__(self, bot: 'TelegramBot'): # V4.5: 传入 bot 实例
632
+ self.bot = bot
633
+ super().__init__(name="AdminFilter")
634
+
635
+ def filter(self, message: Message) -> bool:
636
+ # V4.5: 从内存缓存读取
637
+ return message.from_user.id in self.bot.admin_users
638
+
639
+
640
+ class ScopeFilter(filters.BaseFilter):
641
+ """
642
+ (V4.5) 自定义 PTB 过滤器,用于控制机器人的响应范围 (从内存缓存读取)。
643
+
644
+ 允许的条件 (OR):
645
+ 1. 私聊 (private)
646
+ 2. (群组在白名单中 AND 话题*未*在黑名单中)
647
+ """
648
+ def __init__(self, bot: 'TelegramBot'): # V4.5: 传入 bot 实例
649
+ self.bot = bot
650
+ super().__init__(name="ScopeFilter")
651
+
652
+ def filter(self, message: Message) -> bool:
653
+ # 1. 允许私聊
654
+ if message.chat.type == "private":
655
+ return True
656
+
657
+ # 2. 检查群组
658
+ if message.chat.type in ("group", "supergroup"):
659
+ # 2.1 检查群组是否在白名单中 (V4.5: 从内存读取)
660
+ if not message.chat.id in self.bot.whitelisted_groups:
661
+ logger.debug(f"忽略来自 {message.chat.id} 的消息 (群组未在白名单)。")
662
+ return False
663
+
664
+ # 2.2 检查话题是否在黑名单中 (V4.5: 从内存读取)
665
+ thread_id = message.message_thread_id
666
+ thread_id_key = thread_id if thread_id is not None else 0
667
+ topic_key = f"{message.chat.id}:{thread_id_key}"
668
+
669
+ if topic_key in self.bot.blacklisted_topics:
670
+ logger.debug(f"忽略来自 {topic_key} 的消息 (话题在黑名单)。")
671
+ return False
672
+
673
+ # 群组在白名单中,且话题不在黑名单中
674
+ return True
675
+
676
+ # 3. 忽略其他所有情况 (如频道)
677
+ return False
678
+
679
+
680
+ # --- 5. Telegram 机器人主类 ---
681
+
682
+ class TelegramBot:
683
+ """
684
+ 组织所有 Telegram 机器人逻辑、命令和消息处理器。
685
+ """
686
+ # 群组回复配置
687
+ DEFAULT_GROUP_REPLY_CHANCE = 0.15 # 15% 随机回复概率
688
+ TRIGGER_COOLDOWN_SEC = 30 # 30 秒触发词冷却
689
+
690
+ # 预设解析正则表达式
691
+ PRESET_REGEX = re.compile(
692
+ r"^(name|user|system|assistant)[\s::]+(.+)",
693
+ re.IGNORECASE | re.MULTILINE
694
+ )
695
+
696
+ def __init__(self, config: Config, redis: RedisManager, openai: OpenAIClient):
697
+ self.config = config
698
+ self.redis = redis
699
+ self.openai = openai
700
+
701
+ # (V4.5) 内存缓存
702
+ self.group_context_cache: Dict[str, List[Dict[str, Any]]] = {}
703
+ self.cache_lock = asyncio.Lock() # 用于 group_context_cache
704
+ self.permission_lock = asyncio.Lock() # (V4.5) 用于权限/触发词缓存
705
+ self.save_job: Optional[Job] = None
706
+ self.activity_check_job: Optional[Job] = None # V4.7 新增
707
+
708
+ # (V4.5) 权限和触发词的内存缓存
709
+ self.admin_users: Set[int] = set()
710
+ self.whitelisted_groups: Set[int] = set()
711
+ self.blacklisted_topics: Set[str] = set()
712
+ self.group_triggers: Dict[int, str] = {}
713
+ self.trigger_cooldowns: Dict[str, float] = {} # Key: "chat_id:word", Val: expiry_timestamp
714
+
715
+ # (新增) RSS 客户端
716
+ self.rss_client = httpx.AsyncClient(timeout=10.0, verify=False) # 同样禁用 SSL
717
+
718
+ # (V4.8 新增) Setu 客户端
719
+ self.setu_client = httpx.AsyncClient(timeout=20.0, verify=False, follow_redirects=True)
720
+
721
+ # (新增 V4.4) 存储机器人名字
722
+ self.bot_name: Optional[str] = "Bot" # 默认值
723
+
724
+ self.application = ApplicationBuilder() \
725
+ .token(config.TELEGRAM_BOT_TOKEN) \
726
+ .post_init(self.post_init_setup) \
727
+ .post_shutdown(self.post_shutdown_cleanup) \
728
+ .job_queue(JobQueue()) \
729
+ .build()
730
+
731
+ # 实例化自定义过滤器 (V4.5: 传入 self)
732
+ self.admin_filter = AdminFilter(self)
733
+ self.scope_filter = ScopeFilter(self)
734
+
735
+ # 存储模型列表,避免频繁查询 Redis
736
+ self._model_list_cache: List[str] = []
737
+
738
+ def setup_handlers(self):
739
+ """注册所有的命令和消息处理器。"""
740
+ logger.info("正在注册处理器...")
741
+
742
+ # --- 核心命令 ---
743
+ start_handler = CommandHandler("start", self.start_command, filters=filters.ChatType.PRIVATE)
744
+ # (V4.4) 为基础指令添加 scope_filter 以实现绝对黑名单
745
+ help_handler = CommandHandler("help", self.help_command, filters=self.scope_filter)
746
+ new_handler = CommandHandler("new", self.new_command, filters=self.scope_filter)
747
+ switch_model_handler = CommandHandler("switchmodel", self.switch_model_command, filters=self.scope_filter)
748
+ # (V4.8 新增) Setu 命令
749
+ setu_handler = CommandHandler("setu", self.setu_command, filters=self.scope_filter)
750
+
751
+ # --- 管理员命令 (使用 admin_filter) ---
752
+ add_admin_handler = CommandHandler("addadmin", self.add_admin_command, filters=self.admin_filter)
753
+ del_admin_handler = CommandHandler("deladmin", self.del_admin_command, filters=self.admin_filter)
754
+ save_history_handler = CommandHandler("savehistory", self.save_history_command, filters=self.admin_filter)
755
+
756
+ # V4.3 重构:
757
+ add_group_handler = CommandHandler(
758
+ "addgroup",
759
+ self.add_group_whitelist_command,
760
+ filters=self.admin_filter & filters.ChatType.GROUPS
761
+ )
762
+ blacklist_topic_handler = CommandHandler(
763
+ "blacklisttopic",
764
+ self.blacklist_topic_command,
765
+ filters=self.admin_filter & filters.ChatType.GROUPS
766
+ )
767
+
768
+ group_admin_filters = self.admin_filter & filters.ChatType.GROUPS
769
+ set_trigger_handler = CommandHandler(
770
+ "settrigger",
771
+ self.set_trigger_command,
772
+ filters=group_admin_filters
773
+ )
774
+
775
+ # --- 预设 (Preset) 命令 ---
776
+ set_preset_handler = CommandHandler(
777
+ "setpreset",
778
+ self.set_preset_command,
779
+ # 允许私聊, 允许文本(用于粘贴)和 .txt 文档
780
+ filters=filters.ChatType.PRIVATE & (filters.TEXT | filters.Document.TXT)
781
+ )
782
+ list_presets_handler = CommandHandler("listpresets", self.list_presets_command, filters=filters.ChatType.PRIVATE)
783
+ switch_preset_handler = CommandHandler("switchpreset", self.switch_preset_command, filters=filters.ChatType.PRIVATE)
784
+ del_preset_handler = CommandHandler("delpreset", self.del_preset_command, filters=filters.ChatType.PRIVATE)
785
+
786
+ # --- (V4.5 新增) RSS 命令 ---
787
+ # (V4.5) scope_filter 确保 /subrss 不能在黑名单话题中运行
788
+ sub_rss_handler = CommandHandler("subrss", self.sub_rss_command, filters=self.scope_filter)
789
+ unsub_rss_handler = CommandHandler("unsubrss", self.unsub_rss_command, filters=self.scope_filter)
790
+
791
+ # --- 回调处理器 (用于模型切换) ---
792
+ model_callback_handler = CallbackQueryHandler(self.select_model_callback, pattern="^model_select_")
793
+
794
+ # --- 核心消息处理器 (使用 scope_filter) ---
795
+ message_handler = MessageHandler(
796
+ (filters.TEXT | filters.PHOTO | filters.Document.TXT) & self.scope_filter & (~filters.COMMAND),
797
+ self.handle_message
798
+ )
799
+
800
+ # 注册所有处理器
801
+ handlers = [
802
+ start_handler, help_handler, new_handler, switch_model_handler,
803
+ setu_handler, # V4.8 新增
804
+ add_admin_handler, del_admin_handler,
805
+ add_group_handler, blacklist_topic_handler, # V4.3 更改
806
+ set_trigger_handler, save_history_handler,
807
+ set_preset_handler, list_presets_handler, switch_preset_handler, del_preset_handler,
808
+ sub_rss_handler, unsub_rss_handler, # V4.5 新增
809
+ model_callback_handler,
810
+ message_handler
811
+ ]
812
+ self.application.add_handlers(handlers)
813
+ logger.info("处理器注册完毕。")
814
+
815
+ async def post_init_setup(self, application: Application):
816
+ """
817
+ 在机器人启动时(run_polling被调用后)执行的异步初始化任务。
818
+ (V4.7 更新)
819
+ """
820
+ logger.info("正在执行机器人启动后数据初始化 (post_init)...")
821
+
822
+ # (V4.4 新增) 获取机器人名字
823
+ try:
824
+ bot_user = await application.bot.get_me()
825
+ self.bot_name = bot_user.name
826
+ logger.info(f"成功获取机器人名字: {self.bot_name}")
827
+ except Exception as e:
828
+ logger.error(f"无法获取机器人名字: {e}", exc_info=True)
829
+ self.bot_name = "Assistant" # 设置回退
830
+
831
+ # (V4.5) 1. 从 Redis 加载权限和配置到内存
832
+ self.admin_users = self.redis.get_admin_users()
833
+ self.whitelisted_groups = self.redis.get_whitelisted_groups()
834
+ self.blacklisted_topics = self.redis.get_blacklisted_topics()
835
+ self.group_triggers = self.redis.get_all_group_triggers()
836
+ logger.info(f"成功加载 {len(self.admin_users)} 个管理员, {len(self.whitelisted_groups)} 个白名单群组, {len(self.blacklisted_topics)} 个黑名单话题, {len(self.group_triggers)} 个触发词到内存。")
837
+
838
+ # 2. 同步环境变量 (Admin) 到 Redis (如果 Redis 为空)
839
+ self.redis.initialize_from_env(self.config.ADMIN_USERS)
840
+
841
+ # 3. 获取和缓存模型
842
+ models = await self.openai.get_models()
843
+ if models:
844
+ self._model_list_cache = models
845
+ self.redis.cache_model_list(models)
846
+ else:
847
+ logger.warning("无法从API获取模型列表,将尝试使用 Redis 缓存 (如果存在)。")
848
+ cached_models = self.redis.get_cached_model_list()
849
+ if cached_models:
850
+ self._model_list_cache = cached_models
851
+ logger.info("已从 Redis 缓存加载模型列表。")
852
+ else:
853
+ logger.error("严重: API 和 Redis 缓存均无模型列表。 \switchmodel 将无法工作。")
854
+
855
+ # 4. 从 Redis 加载群组历史到内存
856
+ await self.load_group_history_from_redis()
857
+
858
+ # 5. 启动定时任务 (群组保存 + RSS + 活跃度)
859
+ if not self.application.job_queue:
860
+ logger.error("JobQueue 未初始化! 无法启动定时任务。")
861
+ else:
862
+ # 群组保存
863
+ self.save_job = self.application.job_queue.run_repeating(
864
+ self.save_all_group_history_to_redis,
865
+ interval=3600, # 1 hour
866
+ first=3600, # 1 小时后开始
867
+ name="hourly_save"
868
+ )
869
+ logger.info("已启动每小时群组历史缓存定时器。")
870
+
871
+ # (新增) RSS 检查
872
+ if self.config.RSS_URL: # V4.5: 只要 URL 存在就启动
873
+ self.application.job_queue.run_repeating(
874
+ self.check_rss_feed,
875
+ interval=600, # 10 minutes
876
+ first=10, # 10 秒后开始
877
+ name="rss_check"
878
+ )
879
+ logger.info(f"已启动每 10 分钟 RSS 检查任务 (URL: {self.config.RSS_URL})。")
880
+ else:
881
+ logger.info("未配置 RSS_URL,跳过 RSS 任务。")
882
+
883
+ # (V4.7 新增) 启动群组活跃度检查
884
+ self.activity_check_job = self.application.job_queue.run_repeating(
885
+ self.check_group_activity,
886
+ interval=3600, # 每小时检查一次
887
+ first=60, # 启动 1 分钟后
888
+ name="group_activity_check"
889
+ )
890
+ logger.info("已启动每小时群组活跃度检查定时器。")
891
+
892
+ # 6. 注册 Telegram 命令
893
+ commands = [
894
+ BotCommand("help", "查看所有指令和帮助"),
895
+ BotCommand("new", "开始一个新的对话会话"),
896
+ BotCommand("switchmodel", "切换聊天模型"),
897
+ BotCommand("setu", "(V4.8) 随机获取一张图片 (可加 tag)"), # <-- 新增
898
+ BotCommand("subrss", "(V4.5) 订阅 RSS 更新到此聊天"),
899
+ BotCommand("unsubrss", "(V4.5) 取消此聊天的 RSS 订阅"),
900
+ BotCommand("setpreset", "(私聊) 设置AI预设 (通过文本或文件)"),
901
+ BotCommand("listpresets", "(私聊) 查看我的AI预设"),
902
+ BotCommand("switchpreset", "(私聊) 切换AI预设"),
903
+ BotCommand("delpreset", "(私聊) 删除AI预设"),
904
+ BotCommand("savehistory", "(管理员) 手动保存群组缓存到Redis"),
905
+ BotCommand("addgroup", "(管理员/群组中) 将此群组添加至白名单"), # V4.3 新增
906
+ BotCommand("blacklisttopic", "(管理员/话题中) 将此话题添加至黑名单"), # V4.3 新增
907
+ BotCommand("settrigger", "(管理员/群组中) 设置本群的AI回复触发词"),
908
+ BotCommand("addadmin", "(管理员) 添加管理员 (需回复或提供ID)"),
909
+ BotCommand("deladmin", "(管理员) 移除管理员 (需回复或提供ID)"),
910
+ ]
911
+ await application.bot.set_my_commands(commands)
912
+ logger.info("Telegram Bot 命令已注册。")
913
+
914
+ # 7. 发送启动通知
915
+ op_id = self.config.OP_USER_ID
916
+ if not op_id:
917
+ logger.warning("未配置 OP_USER_ID,跳过启动通知。")
918
+ return
919
+
920
+ try:
921
+ help_text = self._get_help_text()
922
+ startup_msg = f"✅ **机器人 {self.bot_name} 已成功启动!**\n\n{help_text}"
923
+ await application.bot.send_message(
924
+ chat_id=op_id,
925
+ text=startup_msg,
926
+ parse_mode="Markdown"
927
+ )
928
+ logger.info(f"已成功向 OP ({op_id}) 发送启动通知。")
929
+ except Exception as e:
930
+ logger.error(f"向 OP ({op_id}) 发送启动消息失败: {e}", exc_info=True)
931
+
932
+ async def post_shutdown_cleanup(self, application: Application):
933
+ """
934
+ 在机器人关闭时(shutdown被调用后)执行的异步清理任务。
935
+ (V4.8 更新)
936
+ """
937
+ logger.info("正在执行异步清理 (post_shutdown)...")
938
+
939
+ # 关机前最后保存一次
940
+ if self.group_context_cache:
941
+ logger.info("正在执行关机前最后一次群组历史保存...")
942
+ # 创建一个临时的 context 对象
943
+ fake_context = ContextTypes.DEFAULT_TYPE(application=self.application, chat_id=None, user_id=None)
944
+ await self.save_all_group_history_to_redis(fake_context)
945
+ logger.info("关机前保存完毕。")
946
+
947
+ if self.openai:
948
+ logger.info("正在关闭 OpenAI (httpx) 客户端...")
949
+ await self.openai.close()
950
+ logger.info("OpenAI (httpx) 客户端已关闭。")
951
+
952
+ if self.rss_client:
953
+ logger.info("正在关闭 RSS (httpx) 客户端...")
954
+ await self.rss_client.aclose()
955
+ logger.info("RSS (httpx) 客户端已关闭。")
956
+
957
+ # (V4.8 新增)
958
+ if self.setu_client:
959
+ logger.info("正在关闭 Setu (httpx) 客户端...")
960
+ await self.setu_client.aclose()
961
+ logger.info("Setu (httpx) 客户端已关闭。")
962
+
963
+ # --- 命令处理器实现 ---
964
+
965
+ def _get_help_text(self) -> str:
966
+ """生成帮助文本。"""
967
+ return """
968
+ 欢迎使用!我是您的 AI 助手。
969
+
970
+ **基础指令:**
971
+ /help - 显示此帮助信息
972
+ /new - 忘记上下文,开始新对话
973
+ /switchmodel - 选择要对话的 AI 模型
974
+ /setu [tag] [tag...] - (V4.8) 随机获取一张图片 (可加 tag)
975
+ /subrss - 订阅 RSS 更新到此聊天
976
+ /unsubrss - 取消此聊天的 RSS 订阅
977
+
978
+ **AI 预设 (System Prompt) [仅限私聊]:**
979
+ /setpreset - (回复此消息或上传 .txt) 设置预设。格式:
980
+ name: 预设名称
981
+ system: 你是一个...
982
+ user: 示例输入
983
+ assistant: 示例回复
984
+ /listpresets - 查看我所有的预设
985
+ /switchpreset [名称] - 切换预设
986
+ /delpreset [名称] - 删除预设
987
+
988
+ **管理员指令:**
989
+ /addgroup - (在群组中) 将此群组加入白名单
990
+ /blacklisttopic - (在*话题中*) 将此话题加入黑名单
991
+ /settrigger [词] - (在群组中) 设置一个词,包含它将必定触发回复
992
+ /savehistory - (任意位置) 手动保存群组缓存到Redis
993
+ /addadmin [user_id] - 添加管理员
994
+ /deladmin [user_id] - 移除管理员
995
+
996
+ **使用方法:**
997
+ 1. (群组) 管理员需要先使用 /addgroup 将群组加入白名单。
998
+ 2. (群组) 我会回复所有话题,除非你使用 /blacklisttopic 禁言特定话题 (包括 "常规" 话题)。
999
+ 3. (群组) 默认我只会随机回复 (15%)。如果设置了 /settrigger,包含触发词的消息我会 @ 您并 100% 回复。
1000
+ 4. (私聊) 您可以直接向我发送消息、图片或 .txt 文件。
1001
+ """
1002
+
1003
+ async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1004
+ """私聊 \start 命令处理器。"""
1005
+ await update.message.reply_text(self._get_help_text(), parse_mode=ParseMode.MARKDOWN)
1006
+
1007
+ async def help_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1008
+ """\help 命令处理器。"""
1009
+ await update.message.reply_text(self._get_help_text(), parse_mode=ParseMode.MARKDOWN)
1010
+
1011
+ async def new_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1012
+ """
1013
+ \new 命令处理器。
1014
+ 重构: 清除当前上下文。
1015
+ (新增) 同时清除内存缓存。
1016
+ """
1017
+ user_id = update.effective_user.id
1018
+ chat_id = update.effective_chat.id
1019
+ chat_type = update.effective_chat.type
1020
+ thread_id = update.message.message_thread_id if update.message else None
1021
+
1022
+ # V4.3 修复: 确保 thread_id 键一致
1023
+ thread_id_key = thread_id if thread_id is not None else 0
1024
+
1025
+ # 1. 清除 Redis
1026
+ self.redis.clear_session(user_id, chat_id, thread_id)
1027
+
1028
+ # 2. (新增) 如果是群组,也清除内存缓存
1029
+ if chat_type in ("group", "supergroup"):
1030
+ # V4.3 修复: 使用正确的 context_key
1031
+ context_key = self.redis._get_context_key(user_id, chat_id, thread_id)
1032
+ async with self.cache_lock:
1033
+ if context_key in self.group_context_cache:
1034
+ del self.group_context_cache[context_key]
1035
+ logger.info(f"已为键 {context_key} 清除内存缓存。")
1036
+
1037
+ logger.info(f"用户 {user_id} 在 {chat_id}:{thread_id_key} 使用了 /new")
1038
+ await update.message.reply_text("💡 好的,我们来开始一个全新的对话吧!(当前上下文已清除)")
1039
+
1040
+ async def switch_model_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1041
+ """\switchmodel 命令处理器。显示模型选择键盘。"""
1042
+ models = self._model_list_cache
1043
+ if not models:
1044
+ models = self.redis.get_cached_model_list() or []
1045
+
1046
+ if not models:
1047
+ await update.message.reply_text("抱歉,暂时无法获取模型列表。请稍后再试。")
1048
+ return
1049
+
1050
+ current_model = self.redis.get_current_model(
1051
+ update.effective_user.id,
1052
+ self.config.DEFAULT_MODEL
1053
+ )
1054
+
1055
+ keyboard: List[List[InlineKeyboardButton]] = []
1056
+ # 每行最多放 2 个模型
1057
+ row = []
1058
+ for i, model_id in enumerate(models):
1059
+ # 标记当前选中的模型
1060
+ button_text = f"✅ {model_id}" if model_id == current_model else model_id
1061
+
1062
+ # 按钮文本 (button_text) 也可能超长 (64 字节限制)
1063
+ # 我们需要按字节截断
1064
+ max_bytes = 60 # 留 4 字节给 "..."
1065
+ button_text_bytes = button_text.encode('utf-8')
1066
+
1067
+ if len(button_text_bytes) > max_bytes:
1068
+ # 从末尾开始解码,直到找到一个有效的 UTF-8 截断点
1069
+ while max_bytes > 0:
1070
+ try:
1071
+ button_text = button_text_bytes[:max_bytes].decode('utf-8') + "..."
1072
+ break
1073
+ except UnicodeDecodeError:
1074
+ max_bytes -= 1
1075
+ else:
1076
+ # 极端情况,无法截断
1077
+ button_text = "Model..."
1078
+
1079
+ row.append(
1080
+ InlineKeyboardButton(
1081
+ button_text,
1082
+ # 使用索引 (i) 作为 callback_data,避免超长
1083
+ callback_data=f"model_select_{i}"
1084
+ )
1085
+ )
1086
+
1087
+ if len(row) == 2:
1088
+ keyboard.append(row)
1089
+ row = []
1090
+ if row: # 添加最后一行 (如果不满)
1091
+ keyboard.append(row)
1092
+
1093
+ reply_markup = InlineKeyboardMarkup(keyboard)
1094
+ await update.message.reply_text(f"请选择一个模型 (当前: {current_model}):", reply_markup=reply_markup)
1095
+
1096
+ # --- 回调处理器 ---
1097
+
1098
+ async def select_model_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1099
+ """处理模型选择键盘的回调。"""
1100
+ query = update.callback_query
1101
+ await query.answer() # 立即响应回调,消除加载状态
1102
+
1103
+ user_id = query.from_user.id
1104
+ model_name = "" # 预定义
1105
+
1106
+ try:
1107
+ model_index_str = query.data.split("model_select_", 1)[1]
1108
+ model_index = int(model_index_str)
1109
+
1110
+ models = self._model_list_cache
1111
+ if not models:
1112
+ models = self.redis.get_cached_model_list() or []
1113
+
1114
+ if not models:
1115
+ await query.edit_message_text("错误: 模型列表缓存已过期,请重试 /switchmodel。")
1116
+ return
1117
+
1118
+ if 0 <= model_index < len(models):
1119
+ model_name = models[model_index]
1120
+ else:
1121
+ raise ValueError("模型索引越界")
1122
+
1123
+ except (IndexError, TypeError, ValueError) as e:
1124
+ logger.warning(f"解析模型回调时出错: {e}. Data: {query.data}")
1125
+ await query.edit_message_text("选择出错,请重试。")
1126
+ return
1127
+
1128
+ if not model_name:
1129
+ await query.edit_message_text("无法确定所选模型,请重试。")
1130
+ return
1131
+
1132
+ self.redis.set_current_model(user_id, model_name)
1133
+ logger.info(f"用户 {user_id} 通过回调将模型切换为 {model_name}")
1134
+
1135
+ # 更新原始消息,移除键盘
1136
+ await query.edit_message_text(f"✅ 模型已切换为: {model_name}")
1137
+
1138
+ # --- 管理员命令实现 ---
1139
+
1140
+ async def _get_id_from_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> Optional[int]:
1141
+ """从命令参数或回复的消息中提取 User ID。"""
1142
+ # 1. 检查回复
1143
+ if update.message.reply_to_message:
1144
+ return update.message.reply_to_message.from_user.id
1145
+
1146
+ # 2. 检查参数
1147
+ if context.args:
1148
+ try:
1149
+ return int(context.args[0])
1150
+ except (ValueError, IndexError):
1151
+ return None
1152
+ return None
1153
+
1154
+ async def add_admin_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1155
+ """\addadmin 命令处理器。"""
1156
+ user_id_to_add = await self._get_id_from_command(update, context)
1157
+
1158
+ if not user_id_to_add:
1159
+ await update.message.reply_text("请回复一个用户或提供 User ID。用法: /addadmin [user_id]")
1160
+ return
1161
+
1162
+ if user_id_to_add in self.admin_users: # V4.5: 读内存
1163
+ await update.message.reply_text(f"用户 {user_id_to_add} 已经是管理员了。")
1164
+ return
1165
+
1166
+ if self.redis.add_admin(user_id_to_add): # 写 Redis
1167
+ async with self.permission_lock: # 写内存
1168
+ self.admin_users.add(user_id_to_add)
1169
+ logger.info(f"管理员 {update.effective_user.id} 添加了新管理员 {user_id_to_add}")
1170
+ await update.message.reply_text(f"✅ 成功添加管理员: {user_id_to_add}")
1171
+ else:
1172
+ await update.message.reply_text("添加失败,请查看日志。")
1173
+
1174
+ async def del_admin_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1175
+ """\deladmin 命令处理器。"""
1176
+ user_id_to_remove = await self._get_id_from_command(update, context)
1177
+
1178
+ if not user_id_to_remove:
1179
+ await update.message.reply_text("请回复一个用户或提供 User ID。用法: /deladmin [user_id]")
1180
+ return
1181
+
1182
+ # 阻止 OP (第一个管理员) 被移除
1183
+ if user_id_to_remove == self.config.OP_USER_ID:
1184
+ await update.message.reply_text("无法移除超级管理员 (OP)。")
1185
+ return
1186
+
1187
+ if user_id_to_remove not in self.admin_users: # V4.5: 读内存
1188
+ await update.message.reply_text(f"用户 {user_id_to_remove} 不是管理员。")
1189
+ return
1190
+
1191
+ if self.redis.remove_admin(user_id_to_remove): # 写 Redis
1192
+ async with self.permission_lock: # 写内存
1193
+ self.admin_users.discard(user_id_to_remove)
1194
+ logger.info(f"管理员 {update.effective_user.id} 移除了管理员 {user_id_to_remove}")
1195
+ await update.message.reply_text(f"✅ 成功移除管理员: {user_id_to_remove}")
1196
+ else:
1197
+ await update.message.reply_text("移除失败,请查看日志。")
1198
+
1199
+ async def add_group_whitelist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1200
+ """(V4.3 新增) /addgroup 命令处理器。"""
1201
+ chat_id = update.message.chat.id
1202
+ chat_title = update.message.chat.title
1203
+
1204
+ if chat_id in self.whitelisted_groups: # V4.5: 读内存
1205
+ await update.message.reply_text(f"群组 '{chat_title}' (ID: {chat_id}) 已经在白名单中了。")
1206
+ return
1207
+
1208
+ if self.redis.add_group_whitelist(chat_id): # 写 Redis
1209
+ async with self.permission_lock: # 写内存
1210
+ self.whitelisted_groups.add(chat_id)
1211
+ logger.info(f"管理员 {update.effective_user.id} 将群组 '{chat_title}' ({chat_id}) 添加到白名单。")
1212
+ await update.message.reply_text(f"✅ 成功将群组 '{chat_title}' (ID: {chat_id}) 加入白名单。\n我现在会在此群组的*所有*未黑名单话题中回复消息。")
1213
+ else:
1214
+ await update.message.reply_text("添加白名单失败,请查看日志。")
1215
+
1216
+ async def blacklist_topic_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1217
+ """(V4.3 重构) /blacklisttopic 命令处理器。"""
1218
+ chat_id = update.message.chat.id
1219
+ chat_title = update.message.chat.title
1220
+ thread_id = update.message.message_thread_id
1221
+
1222
+ # V4.3 修复: 统一 "常规" 话题
1223
+ thread_id_key = thread_id if thread_id is not None else 0
1224
+ topic_key = f"{chat_id}:{thread_id_key}"
1225
+ topic_name = "常规话题 (General)" if thread_id_key == 0 else f"话题ID {thread_id_key}"
1226
+
1227
+ if topic_key in self.blacklisted_topics: # V4.5: 读内存
1228
+ await update.message.reply_text(f"话题 '{topic_name}' (ID: {topic_key}) 已经在黑名单中了。")
1229
+ return
1230
+
1231
+ if self.redis.add_blacklisted_topic(topic_key): # 写 Redis
1232
+ async with self.permission_lock: # 写内存
1233
+ self.blacklisted_topics.add(topic_key)
1234
+ logger.info(f"管理员 {update.effective_user.id} 将话题 '{topic_name}' ({topic_key}) 添加到黑名单。")
1235
+ await update.message.reply_text(f"✅ 成功将*此话题* ({topic_name}, ID: {topic_key}) 加入黑名单。\n我现在会忽略此话题的消息。")
1236
+ else:
1237
+ await update.message.reply_text("添加黑名单失败,请查看日志。")
1238
+
1239
+ async def set_trigger_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1240
+ """/settrigger [词] 命令处理器。"""
1241
+ chat_id = update.message.chat.id
1242
+
1243
+ if not context.args:
1244
+ await update.message.reply_text("请提供一个触发词。用法: /settrigger [词]")
1245
+ return
1246
+
1247
+ trigger_word = context.args[0].strip()
1248
+
1249
+ if not trigger_word:
1250
+ await update.message.reply_text("触发词不能为空。")
1251
+ return
1252
+
1253
+ try:
1254
+ self.redis.set_group_trigger(chat_id, trigger_word) # 写 Redis
1255
+ async with self.permission_lock: # 写内存
1256
+ self.group_triggers[chat_id] = trigger_word
1257
+ logger.info(f"管理员 {update.effective_user.id} 在群组 {chat_id} 设置了触发词: {trigger_word}")
1258
+ await update.message.reply_text(f"✅ 成功! 本群的回复触发词已设置为: \"{trigger_word}\"")
1259
+ except Exception as e:
1260
+ logger.error(f"设置群组 {chat_id} 触发词时出错: {e}", exc_info=True)
1261
+ await update.message.reply_text("设置触发词失败,请查看日志。")
1262
+
1263
+ async def save_history_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1264
+ """(新增) /savehistory (Admin) 手动保存并重置定时器。"""
1265
+ user_id = update.effective_user.id
1266
+ logger.info(f"管理员 {user_id} 手动触发了历史保存。")
1267
+
1268
+ if not context.job_queue:
1269
+ logger.error(f"用户 {user_id} 尝试保存历史,但 JobQueue 不可用。")
1270
+ await update.message.reply_text("❌ 错误: JobQueue 不可用。")
1271
+ return
1272
+
1273
+ # 1. 移除旧
1274
+ if self.save_job:
1275
+ self.save_job.schedule_removal()
1276
+ logger.info("移除了旧的保存定时器。")
1277
+
1278
+ # 2. 立即运行
1279
+ await self.save_all_group_history_to_redis(context)
1280
+
1281
+ # 3. 启动新
1282
+ self.save_job = context.job_queue.run_repeating(
1283
+ self.save_all_group_history_to_redis,
1284
+ interval=3600, # 1 hour
1285
+ first=3600, # 1 小时后
1286
+ name="hourly_save"
1287
+ )
1288
+ logger.info("已启动新的保存定时器。")
1289
+ await update.message.reply_text("✅ 已手动保存所有群组缓存到 Redis,并重置1小时定时器。")
1290
+
1291
+
1292
+ # --- 预设 (Preset) 命令实现 ---
1293
+
1294
+ def _parse_preset_text(self, text: str) -> Optional[Dict[str, Any]]:
1295
+ """
1296
+ 辅助函数: 解析预设文本。
1297
+ 返回 {"name": str, "messages": List[Dict]} 或 None。
1298
+ """
1299
+ name: Optional[str] = None
1300
+ messages: List[Dict[str, str]] = []
1301
+
1302
+ matches = self.PRESET_REGEX.finditer(text)
1303
+ for match in matches:
1304
+ key = match.group(1).lower()
1305
+ value = match.group(2).strip()
1306
+
1307
+ if key == "name":
1308
+ name = value
1309
+ elif key in ("user", "system", "assistant"):
1310
+ # 修复 V4.1: 将 content 包装为新格式
1311
+ messages.append({"role": key, "content": [{"type": "text", "text": value}]})
1312
+
1313
+ if not name or not messages:
1314
+ return None
1315
+
1316
+ return {"name": name, "messages": messages}
1317
+
1318
+ async def set_preset_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1319
+ """(新增) /setpreset 命令处理器 (文本或 .txt 文件)。"""
1320
+ user_id = update.effective_user.id
1321
+ text_to_parse: Optional[str] = None
1322
+
1323
+ # 1. 检查 .txt 文件
1324
+ if update.message.document and update.message.document.mime_type == "text/plain":
1325
+ try:
1326
+ doc_file = await update.message.document.get_file()
1327
+ with io.BytesIO() as file_bytes_io:
1328
+ await doc_file.download_to_memory(file_bytes_io)
1329
+ file_bytes_io.seek(0)
1330
+ text_to_parse = file_bytes_io.getvalue().decode('utf-8')
1331
+ logger.info(f"用户 {user_id} 正在通过 .txt 文件设置预设。")
1332
+ except Exception as e:
1333
+ logger.error(f"下载用户 {user_id} 的 .txt 预设文件时出错: {e}")
1334
+ await update.message.reply_text(f"抱歉,我无法读取 .txt 文件: {e}")
1335
+ return
1336
+
1337
+ # 2. 检查命令文本 (如果不是文件)
1338
+ if not text_to_parse:
1339
+ # 移除 /setpreset 命令本身
1340
+ if context.args:
1341
+ text_to_parse = update.message.text.split(None, 1)[-1]
1342
+ else:
1343
+ await update.message.reply_text(
1344
+ "用法错误。\n"
1345
+ "请使用 /setpreset [预设内容]...\n"
1346
+ "...或上传一个 .txt 文件并附上 /setpreset 命令。\n\n"
1347
+ "格式:\n"
1348
+ "name: 预设名称\n"
1349
+ "system: 你是一个...\n"
1350
+ "user: 示例...\n"
1351
+ "assistant: 好的..."
1352
+ )
1353
+ return
1354
+
1355
+ # 3. 解析
1356
+ preset_data = self._parse_preset_text(text_to_parse)
1357
+
1358
+ if not preset_data:
1359
+ await update.message.reply_text(
1360
+ "❌ 解析失败!\n"
1361
+ "请确保您的格式正确,并且至少包含 'name' 和一个 'system/user/assistant' 角色。"
1362
+ )
1363
+ return
1364
+
1365
+ name = preset_data["name"]
1366
+ messages = preset_data["messages"]
1367
+
1368
+ # 4. 保存
1369
+ if self.redis.set_preset(user_id, name, messages):
1370
+ logger.info(f"用户 {user_id} 成功设置了预设: {name}")
1371
+ await update.message.reply_text(
1372
+ f"✅ 预设已保存!\n"
1373
+ f"名称: **{name}**\n"
1374
+ f"包含 {len(messages)} 条消息。\n\n"
1375
+ f"使用 `/switchpreset {name}` 来激活它。",
1376
+ parse_mode=ParseMode.MARKDOWN
1377
+ )
1378
+ else:
1379
+ await update.message.reply_text("❌ 预设保存失败,请查看日志。")
1380
+
1381
+ async def list_presets_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1382
+ """(新增) /listpresets 命令处理器。"""
1383
+ user_id = update.effective_user.id
1384
+ presets = self.redis.list_presets(user_id)
1385
+ active_preset = self.redis.get_active_preset_name(user_id)
1386
+
1387
+ if not presets:
1388
+ await update.message.reply_text("您还没有保存任何预设。\n使用 /setpreset 来创建一个。")
1389
+ return
1390
+
1391
+ message = "以下是您保存的预设:\n\n"
1392
+ for name in presets:
1393
+ if name == active_preset:
1394
+ message += f"▶️ **{name}** (当前激活)\n"
1395
+ else:
1396
+ message += f"• {name}\n"
1397
+
1398
+ message += "\n使用 `/switchpreset [名称]` 来切换。"
1399
+ await update.message.reply_text(message, parse_mode=ParseMode.MARKDOWN)
1400
+
1401
+ async def switch_preset_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1402
+ """(新增) /switchpreset [name] 命令处理器。"""
1403
+ user_id = update.effective_user.id
1404
+
1405
+ if not context.args:
1406
+ # 如果没有参数,则关闭预设
1407
+ self.redis.set_active_preset(user_id, "")
1408
+ await update.message.reply_text("✅ 已关闭所有预设。")
1409
+ return
1410
+
1411
+ preset_name = context.args[0].strip()
1412
+
1413
+ # 检查预设是否存在
1414
+ if not self.redis.get_preset(user_id, preset_name):
1415
+ await update.message.reply_text(f"❌ 找不到名为 '{preset_name}' 的预设。")
1416
+ return
1417
+
1418
+ self.redis.set_active_preset(user_id, preset_name)
1419
+ logger.info(f"用户 {user_id} 切换预设为: {preset_name}")
1420
+ await update.message.reply_text(f"✅ 成功激活预设: **{preset_name}**", parse_mode=ParseMode.MARKDOWN)
1421
+
1422
+ async def del_preset_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1423
+ """(新增) /delpreset [name] 命令处理器。"""
1424
+ user_id = update.effective_user.id
1425
+
1426
+ if not context.args:
1427
+ await update.message.reply_text("请提供要删除的预设名称。用法: /delpreset [名称]")
1428
+ return
1429
+
1430
+ preset_name = context.args[0].strip()
1431
+
1432
+ if self.redis.delete_preset(user_id, preset_name):
1433
+ logger.info(f"用户 {user_id} 删除了预设: {preset_name}")
1434
+ await update.message.reply_text(f"✅ 成功删除预设: {preset_name}")
1435
+ else:
1436
+ await update.message.reply_text(f"❌ 找不到名为 '{preset_name}' 的预设,或删除失败。")
1437
+
1438
+ # --- (V4.5 新增) RSS 命令实现 ---
1439
+ async def sub_rss_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1440
+ """(V4.5 新增) /subrss 命令处理器。"""
1441
+ chat_id = update.effective_chat.id
1442
+
1443
+ if self.redis.add_rss_subscriber(chat_id):
1444
+ logger.info(f"RSS: 新订阅者: {chat_id}")
1445
+ await update.message.reply_text("✅ 成功订阅 RSS 更新到此聊天。")
1446
+ else:
1447
+ await update.message.reply_text("ℹ️ 此聊天已经订阅了 RSS。")
1448
+
1449
+ async def unsub_rss_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1450
+ """(V4.5 新增) /unsubrss 命令处理器。"""
1451
+ chat_id = update.effective_chat.id
1452
+
1453
+ if self.redis.remove_rss_subscriber(chat_id):
1454
+ logger.info(f"RSS: 取消订阅: {chat_id}")
1455
+ await update.message.reply_text("✅ 已取消此聊天的 RSS 订阅。")
1456
+ else:
1457
+ await update.message.reply_text("ℹ️ 此聊天未订阅 RSS。")
1458
+
1459
+ # --- (V4.8 新增) Setu 命令实现 ---
1460
+ async def setu_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1461
+ """(V4.8) /setu [tag...] 命令处理器。"""
1462
+ user_id = update.effective_user.id
1463
+ chat_id = update.effective_chat.id
1464
+ thread_id = update.message.message_thread_id
1465
+
1466
+ await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
1467
+
1468
+ base_url = "https://api.lolicon.app/setu/v2"
1469
+ params = {}
1470
+ params["r18"] = "regular"
1471
+ if context.args:
1472
+ params["tag"] = context.args
1473
+ logger.info(f"用户 {user_id} 在 {chat_id}:{thread_id} 请求带 tags 的 Setu: {context.args}")
1474
+ else:
1475
+ logger.info(f"用户 {user_id} 在 {chat_id}:{thread_id} 请求随机 Setu")
1476
+
1477
+ try:
1478
+ # 1. 请求 API
1479
+ async with self.setu_client as client:
1480
+ response = await client.get(base_url, params=params)
1481
+ response.raise_for_status()
1482
+ data = response.json()
1483
+
1484
+ if data.get("error") or not data.get("data"):
1485
+ logger.warning(f"Setu API 返回错误或空数据: {data.get('error')}")
1486
+ await update.message.reply_text("抱歉,API 返回错误或未找到图片。")
1487
+ return
1488
+
1489
+ image_data = data["data"][0]
1490
+ image_url = image_data.get("urls", {}).get("original")
1491
+ if not image_url:
1492
+ logger.warning(f"Setu API 响应中缺少 original URL: {image_data}")
1493
+ await update.message.reply_text("抱歉,API 响应格式不正确,缺少图片 URL。")
1494
+ return
1495
+
1496
+ # 2. 下载图片
1497
+ async with self.setu_client as client:
1498
+ # API 返回的 URL 可能需要替换为 pixiv.re 代理
1499
+ image_url = image_url.replace("i.pixiv.cat", "i.pixiv.re")
1500
+ logger.debug(f"正在下载 Setu 图片: {image_url}")
1501
+ image_response = await client.get(image_url)
1502
+ image_response.raise_for_status()
1503
+ image_bytes = image_response.content
1504
+
1505
+ # 3. 准备信息
1506
+ caption = (
1507
+ f"Title: {image_data.get('title', 'N/A')}\n"
1508
+ f"Author: {image_data.get('author', 'N/A')}\n"
1509
+ f"PID: {image_data.get('pid', 'N/A')}"
1510
+ )
1511
+
1512
+ # 4. 发送图片
1513
+ await update.message.reply_photo(
1514
+ photo=image_bytes,
1515
+ caption=caption,
1516
+ message_thread_id=thread_id
1517
+ )
1518
+
1519
+ # (V4.8) 更新活跃度时间戳
1520
+ if chat_type in ("group", "supergroup"):
1521
+ context_key = self.redis._get_context_key(user_id, chat_id, thread_id)
1522
+ self.redis.update_last_response_time(context_key)
1523
+
1524
+ except httpx.HTTPStatusError as e:
1525
+ logger.error(f"Setu API 请求失败 (状态码 {e.response.status_code}): {e.response.text}", exc_info=True)
1526
+ await update.message.reply_text(f"抱歉,获取图片失败 (HTTP 错误): {e.response.status_code}")
1527
+ except Exception as e:
1528
+ logger.error(f"Setu 命令执行失败: {e}", exc_info=True)
1529
+ await update.message.reply_text(f"抱歉,获取图片时遇到未知错误。")
1530
+
1531
+ # --- (V4.5 新增) 内存缓存辅助方法 ---
1532
+
1533
+ async def _add_to_group_cache(self, context_key: str, role: str, content: Any):
1534
+ """
1535
+ (新增) 安全地向内存缓存添加消息并截断。
1536
+ (V4.1: content 已经是新格式的列表)
1537
+ """
1538
+ async with self.cache_lock:
1539
+ history = self.group_context_cache.get(context_key, [])
1540
+ history.append({"role": role, "content": content})
1541
+ # 截断
1542
+ truncated_history = history[-self.redis.CONTEXT_HISTORY_LIMIT:]
1543
+ self.group_context_cache[context_key] = truncated_history
1544
+
1545
+ def _is_trigger_on_cooldown(self, chat_id: int, word: str) -> bool:
1546
+ """(V4.5) 检查内存中的触发词冷却"""
1547
+ cooldown_key = f"{chat_id}:{word}"
1548
+ expiry_time = self.trigger_cooldowns.get(cooldown_key)
1549
+
1550
+ if expiry_time and time.time() < expiry_time:
1551
+ return True # Still on cooldown
1552
+ return False
1553
+
1554
+ def _set_trigger_cooldown(self, chat_id: int, word: str):
1555
+ """(V4.5) 设置内存中的触发词冷却"""
1556
+ cooldown_key = f"{chat_id}:{word}"
1557
+ self.trigger_cooldowns[cooldown_key] = time.time() + self.TRIGGER_COOLDOWN_SEC
1558
+
1559
+ async def load_group_history_from_redis(self):
1560
+ """(新增) 启动时从 Redis 加载所有群组上下文到内存。"""
1561
+ logger.info("正在从 Redis 加载群组历史到内存缓存...")
1562
+ count = 0
1563
+ try:
1564
+ # 使用 scan 替代 keys,避免阻塞
1565
+ cursor = 0
1566
+ while True:
1567
+ # V4.2 修复: 确保 scan 使用 self.redis.redis (原始客户端)
1568
+ cursor, keys = self.redis.redis.scan(cursor, match="context:group:*", count=100)
1569
+ if keys:
1570
+ async with self.cache_lock:
1571
+ for key_bytes in keys:
1572
+ key = key_bytes.decode('utf-8') # 解码
1573
+ raw_data = self.redis.redis.get(key)
1574
+ if raw_data:
1575
+ try:
1576
+ history = json.loads(raw_data)
1577
+ # 截断以防万一
1578
+ self.group_context_cache[key] = history[-self.redis.CONTEXT_HISTORY_LIMIT:]
1579
+ count += 1
1580
+ except json.JSONDecodeError:
1581
+ logger.warning(f"无法解析 Redis key {key} 的JSON数据")
1582
+ if cursor == 0:
1583
+ break
1584
+ logger.info(f"成功加载 {count} 个群组上下文到内存。")
1585
+ except Exception as e:
1586
+ logger.error(f"从 Redis 加载群组历史时出错: {e}", exc_info=True)
1587
+
1588
+ async def save_all_group_history_to_redis(self, context: ContextTypes.DEFAULT_TYPE):
1589
+ """(新增) 将所有内存中的群组缓存保存回 Redis。"""
1590
+ logger.info("定时任务: 正在将群组历史缓存保存到 Redis...")
1591
+ count = 0
1592
+ cache_copy = {}
1593
+ async with self.cache_lock:
1594
+ # 复制一份以避免在迭代时修改
1595
+ cache_copy = self.group_context_cache.copy()
1596
+
1597
+ if not cache_copy:
1598
+ logger.info("定时任务: 内存缓存为空,无需保存。")
1599
+ return
1600
+
1601
+ try:
1602
+ # (upstash-redis 的 pipeline 不支持循环外定义)
1603
+ # 逐个 set
1604
+ for key, history in cache_copy.items():
1605
+ self.redis.redis.set(
1606
+ key,
1607
+ json.dumps(history),
1608
+ ex=self.redis.SESSION_EXPIRATION_SEC
1609
+ )
1610
+ count += 1
1611
+ logger.info(f"定时任务: 成功将 {count} 个群组上下文保存到 Redis。")
1612
+ except Exception as e:
1613
+ logger.error(f"定时保存群组历史到 Redis 时出错: {e}", exc_info=True)
1614
+
1615
+ # --- (新增) RSS 检查任务 ---
1616
+ async def check_rss_feed(self, context: ContextTypes.DEFAULT_TYPE):
1617
+ """(V4.5 升级) 每 10 分钟检查一次 RSS 订阅。"""
1618
+ logger.info("RSS: 正在检查 RSS... ")
1619
+ try:
1620
+ # (V4.5) 从 Redis 读取订阅者
1621
+ subscriber_chat_ids = self.redis.get_rss_subscribers()
1622
+ if not subscriber_chat_ids:
1623
+ logger.info("RSS: 没有订阅者,跳过检查。")
1624
+ return
1625
+
1626
+ async with self.rss_client as client:
1627
+ response = await client.get(self.config.RSS_URL)
1628
+ response.raise_for_status()
1629
+ xmlText = response.text
1630
+
1631
+ # 使用用户提供的正则表达式
1632
+ match = re.search(r'<rdf:li\s+rdf:resource="[^"]*\/article\/(\d+)"[\s\/>]', xmlText)
1633
+
1634
+ if not match:
1635
+ logger.warning("RSS: 无法在 XML 中提取到文章链接。")
1636
+ return
1637
+
1638
+ latest_link_id = match.group(1)
1639
+ last_known_link = self.redis.get_last_rss_link()
1640
+
1641
+ if latest_link_id != last_known_link and latest_link_id:
1642
+ logger.info(f"RSS: 发现新文章! ID: {latest_link_id}. 正在推送给 {len(subscriber_chat_ids)} 个聊天。")
1643
+ message = f"主人,您订阅的魔装影姫cien更新啦!\n\nhttps://ci-en.dlsite.com/creator/4551/article/{latest_link_id}"
1644
+
1645
+ tasks = []
1646
+ for chat_id in subscriber_chat_ids:
1647
+ tasks.append(context.bot.send_message(chat_id, message))
1648
+
1649
+ results = await asyncio.gather(*tasks, return_exceptions=True)
1650
+
1651
+ # 检查发送结果
1652
+ for i, result in enumerate(results):
1653
+ if isinstance(result, Exception):
1654
+ chat_id = list(subscriber_chat_ids)[i]
1655
+ logger.error(f"RSS: 推送给 {chat_id} 失败: {result}")
1656
+
1657
+ # 更新 Redis
1658
+ self.redis.set_last_rss_link(latest_link_id)
1659
+ else:
1660
+ logger.info("RSS: 未发现新文章。")
1661
+ except Exception as e:
1662
+ logger.error(f"RSS 任务失败: {e}", exc_info=True)
1663
+
1664
+ # --- (V4.7 新增) 活跃度检查任务 ---
1665
+ async def check_group_activity(self, context: ContextTypes.DEFAULT_TYPE):
1666
+ """(V4.7) 每小时检查一次群组活跃度。"""
1667
+ logger.info("定时任务: 正在检查群组活跃度...")
1668
+ now = time.time()
1669
+
1670
+ # 1. 从 Redis 读取所有时间戳
1671
+ last_times = self.redis.get_all_last_response_times()
1672
+
1673
+ # 2. 从内存读取黑名单 (V4.5)
1674
+ blacklisted_topics_mem = self.blacklisted_topics
1675
+
1676
+ # 3. 获取 OP 的预设 (如果 OP 存在)
1677
+ op_preset_messages = []
1678
+ if self.config.OP_USER_ID:
1679
+ op_preset_messages = self.redis.get_active_preset_messages(self.config.OP_USER_ID)
1680
+
1681
+ # 4. 准备硬编码的提示 (V4.1 格式)
1682
+ reengage_prompt = [
1683
+ {"type": "text", "text": "【现在在这个群聊里已经过去24小时没有人回复消息了,说句话活跃一下气氛吧】"}
1684
+ ]
1685
+
1686
+ tasks_to_run = []
1687
+
1688
+ for context_key, last_time in last_times.items():
1689
+ try:
1690
+ # 只检查群组
1691
+ if not context_key.startswith("context:group:"):
1692
+ continue
1693
+
1694
+ # 检查是否在黑名单中 (从内存读取)
1695
+ if context_key in blacklisted_topics_mem:
1696
+ continue
1697
+
1698
+ # 检查是否超过 24 小时 (86400 秒)
1699
+ if now - last_time > 86400:
1700
+ logger.info(f"活跃度检查: 话题 {context_key} 已超过 24 小时未响应。")
1701
+
1702
+ # 解析 chat_id 和 thread_id
1703
+ parts = context_key.split(':')
1704
+ chat_id = int(parts[2])
1705
+ thread_id = int(parts[3])
1706
+
1707
+ # 准备 API 请求
1708
+ history_for_api = op_preset_messages + [{"role": "user", "content": reengage_prompt}]
1709
+
1710
+ # (V4.7) 添加一个辅助 coroutine 来处理单个请求
1711
+ tasks_to_run.append(self.send_reengage_message(
1712
+ context, chat_id, thread_id, history_for_api, context_key
1713
+ ))
1714
+
1715
+ except Exception as e:
1716
+ logger.error(f"处理活跃度检查 {context_key} 时出错: {e}")
1717
+
1718
+ if tasks_to_run:
1719
+ logger.info(f"活跃度检查: 发现 {len(tasks_to_run)} 个不活跃的话题,正在发送消息...")
1720
+ await asyncio.gather(*tasks_to_run)
1721
+ else:
1722
+ logger.info("定时任务: 所有群组均在 24 小时内活跃。")
1723
+
1724
+ async def send_reengage_message(
1725
+ self,
1726
+ context: ContextTypes.DEFAULT_TYPE,
1727
+ chat_id: int,
1728
+ thread_id: int,
1729
+ history_for_api: List[Dict[str, Any]],
1730
+ context_key: str
1731
+ ):
1732
+ """(V4.7) 活跃度检查的辅助函数,用于生成和发送消息。"""
1733
+ try:
1734
+ # 1. 生成 AI 回复 (使用默认模型)
1735
+ bot_response_text = await self.openai.generate_response(
1736
+ self.config.DEFAULT_MODEL,
1737
+ history_for_api
1738
+ )
1739
+
1740
+ is_error = False
1741
+ if bot_response_text is None or bot_response_text.startswith("抱歉,"):
1742
+ is_error = True
1743
+ logger.warning(f"活跃度检查: AI 为 {context_key} 生成了错误/空回复。")
1744
+ return # 不发送错误消息
1745
+
1746
+ # 2. 准备机器人回复 (V4.4 格式)
1747
+ bot_name = self.bot_name or "Assistant"
1748
+ # (V4.7) 活跃度消息不 @ 任何人
1749
+ prefixed_response = f"{bot_name}: {bot_response_text}"
1750
+ assistant_api_content = [{"type": "text", "text": prefixed_response}]
1751
+
1752
+ thread_id_to_send = thread_id if thread_id != 0 else None
1753
+
1754
+ # 3. 发送消息
1755
+ try:
1756
+ await context.bot.send_message(
1757
+ chat_id=chat_id,
1758
+ text=bot_response_text, # V4.7: 不带前缀
1759
+ message_thread_id=thread_id_to_send,
1760
+ parse_mode=ParseMode.MARKDOWN
1761
+ )
1762
+ except BadRequest as e:
1763
+ if "Can't parse entities" in str(e):
1764
+ logger.warning(f"活跃度检查 (Markdown 失败): {e}. 正在作为纯文本重试。")
1765
+ await context.bot.send_message(
1766
+ chat_id=chat_id,
1767
+ text=bot_response_text, # V4.7: 不带前缀
1768
+ message_thread_id=thread_id_to_send,
1769
+ parse_mode=None
1770
+ )
1771
+ else:
1772
+ raise # 抛出其他 BadRequest
1773
+
1774
+ # 4. 更新内存缓存
1775
+ await self._add_to_group_cache(context_key, "assistant", assistant_api_content)
1776
+
1777
+ # 5. 更新 Redis 中的最后响应时间 (!!)
1778
+ self.redis.update_last_response_time(context_key)
1779
+ logger.info(f"活跃度检查: 成功发送消息到 {context_key} 并更新时间戳。")
1780
+
1781
+ except Exception as e:
1782
+ logger.error(f"发送活跃度消息到 {context_key} 时失败: {e}", exc_info=True)
1783
+
1784
+
1785
+ # --- 核心消息处理器 ---
1786
+
1787
+ async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
1788
+ """
1789
+ 处理所有符合 ScopeFilter 的文本、图片和 .txt 文件消息。
1790
+ (V4.6 更新)
1791
+ """
1792
+ user = update.effective_user
1793
+ if not user:
1794
+ logger.warning("无法获取用户信息 (消息可能来自频道?),忽略。")
1795
+ return
1796
+
1797
+ user_id = user.id
1798
+ chat_id = update.effective_chat.id
1799
+ chat_type = update.effective_chat.type
1800
+ thread_id = update.message.message_thread_id if update.message else None
1801
+
1802
+ text_content = update.message.text or update.message.caption
1803
+
1804
+ # (V4.4 新增) 获取用户名
1805
+ user_name = user.first_name or user.full_name or "User"
1806
+
1807
+ # --- 1. 准备用户消息 (多模态) ---
1808
+ user_api_content: List[Dict[str, Any]] = []
1809
+
1810
+ # 提取文本 (来自消息或图片标题)
1811
+ if text_content:
1812
+ # (V4.4) 群聊添加前缀
1813
+ prefixed_text = f"{user_name}: {text_content}" if chat_type != "private" else text_content
1814
+ user_api_content.append({"type": "text", "text": prefixed_text})
1815
+
1816
+ # (新增) 提取 .txt 文件
1817
+ if update.message.document and update.message.document.mime_type == "text/plain":
1818
+ try:
1819
+ doc_file = await update.message.document.get_file()
1820
+ with io.BytesIO() as file_bytes_io:
1821
+ await doc_file.download_to_memory(file_bytes_io)
1822
+ file_bytes_io.seek(0)
1823
+ file_text = file_bytes_io.getvalue().decode('utf-8')
1824
+
1825
+ # (V4.4) 群聊添加前缀
1826
+ file_info = f"[上传了 .txt 文件]: {file_text}"
1827
+ prefixed_text = f"{user_name}: {file_info}" if chat_type != "private" else file_info
1828
+
1829
+ user_api_content.append({"type": "text", "text": prefixed_text})
1830
+ logger.info(f"已为用户 {user_id} 成功读取 .txt 文件。")
1831
+ except Exception as e:
1832
+ logger.error(f"处理用户 {user_id} 的 .txt 文件时出错: {e}", exc_info=True)
1833
+ await update.message.reply_text("抱歉,我无法读取这个 .txt 文件。")
1834
+ return
1835
+
1836
+ # 提取图片 (如果有)
1837
+ if update.message.photo:
1838
+ try:
1839
+ photo = update.message.photo[-1] # 获取最高清的图片
1840
+ photo_file = await photo.get_file()
1841
+
1842
+ with io.BytesIO() as file_bytes_io:
1843
+ await photo_file.download_to_memory(file_bytes_io)
1844
+ file_bytes_io.seek(0)
1845
+ base64_image = base64.b64encode(file_bytes_io.getvalue()).decode('utf-8')
1846
+
1847
+ # (V4.1) 图像部分不需要名字前缀
1848
+ image_url_payload = {"url": f"data:image/jpeg;base64,{base64_image}"}
1849
+ user_api_content.append({"type": "image_url", "image_url": image_url_payload})
1850
+ logger.info(f"已为用户 {user_id} 成功编码图片。")
1851
+
1852
+ except Exception as e:
1853
+ logger.error(f"处理用户 {user_id} 的图片时出错: {e}", exc_info=True)
1854
+ await update.message.reply_text("抱歉,我无法处理这张图片。")
1855
+ return
1856
+
1857
+ # 如果没有提取到任何内容 (例如,只有贴纸),则忽略
1858
+ if not user_api_content:
1859
+ logger.debug(f"来自用户 {user_id} 的消息没有可处理的内容,忽略。")
1860
+ return
1861
+
1862
+ # --- 2. (新增) 立即更新群组内存缓存 (用户消息) ---
1863
+ if chat_type in ("group", "supergroup"):
1864
+ context_key = self.redis._get_context_key(user_id, chat_id, thread_id)
1865
+ await self._add_to_group_cache(context_key, "user", user_api_content)
1866
+
1867
+ # --- 3. 决策: 是否回复? (群组逻辑) ---
1868
+ should_reply = False
1869
+ is_random_reply = False # (V4.5 新增) 标记是否为随机回复
1870
+
1871
+ if chat_type == "private":
1872
+ should_reply = True
1873
+
1874
+ elif chat_type in ("group", "supergroup"):
1875
+ trigger_word = self.group_triggers.get(chat_id) # V4.5: 读内存
1876
+ # 修复 V4.0: 移除 \b (单词边界),实现包含匹配
1877
+ if text_content and trigger_word and re.search(re.escape(trigger_word), text_content, re.IGNORECASE):
1878
+ # V4.5: 读/写内存冷却
1879
+ if not self._is_trigger_on_cooldown(chat_id, trigger_word):
1880
+ should_reply = True
1881
+ is_random_reply = False # (V4.5) 这是触发词回复
1882
+ logger.info(f"群组 {chat_id} 触发词 '{trigger_word}' 被命中,设置冷却。")
1883
+ self._set_trigger_cooldown(chat_id, trigger_word)
1884
+ else:
1885
+ logger.debug(f"群组 {chat_id} 触发词 '{trigger_word}' 仍在冷却中,忽略。")
1886
+ return # 冷却中,不回复
1887
+
1888
+ if not should_reply:
1889
+ if random.random() < self.DEFAULT_GROUP_REPLY_CHANCE:
1890
+ should_reply = True
1891
+ is_random_reply = True # (V4.5) 这是随机回复
1892
+ logger.info(f"群组 {chat_id} 随机回复 (15%) 命中。")
1893
+ else:
1894
+ logger.debug(f"群组 {chat_id} 随机回复 (15%) 未命中,忽略。")
1895
+ return # 未命中随机,不回复
1896
+
1897
+ # --- 决策完毕 ---
1898
+ if not should_reply:
1899
+ return # 最终决定不回复
1900
+
1901
+ # 4. 发送 "正在输入..." 状态
1902
+ try:
1903
+ await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
1904
+ except Exception:
1905
+ pass # 忽略错误
1906
+
1907
+ # 5. 获取会话、模型和历史
1908
+ model = self.redis.get_current_model(user_id, self.config.DEFAULT_MODEL)
1909
+ preset_messages = self.redis.get_active_preset_messages(user_id)
1910
+
1911
+ # --- 重构: 获取历史记录 ---
1912
+ history_from_cache = []
1913
+ if chat_type == "private":
1914
+ # 私聊: 从 Redis 读取
1915
+ history_from_cache = self.redis.get_conversation_history(user_id, chat_id, thread_id)
1916
+ elif chat_type in ("group", "supergroup"):
1917
+ # 群组: 从内存缓存读取
1918
+ context_key = self.redis._get_context_key(user_id, chat_id, thread_id)
1919
+ async with self.cache_lock:
1920
+ # 注意: 缓存中已包含当前的用户消息,所以我们不再需要 + user_api_content
1921
+ history_from_cache = self.group_context_cache.get(context_key, [])
1922
+
1923
+ # 准备 API 请求历史
1924
+ if chat_type == "private":
1925
+ # 私聊: 预设 + Redis历史 + 当前消息
1926
+ history_for_api = preset_messages + history_from_cache + [{"role": "user", "content": user_api_content}]
1927
+ else:
1928
+ # 群组: 预设 + 内存历史 (已包含当前消息)
1929
+ history_for_api = preset_messages + history_from_cache
1930
+
1931
+ # 6. 调用 API
1932
+ bot_response_text = await self.openai.generate_response(model, history_for_api)
1933
+
1934
+ # --- 7. 错误检查 ---
1935
+ is_error = False
1936
+ if bot_response_text is None:
1937
+ is_error = True
1938
+ bot_response_text = "抱歉,AI 响应为空 (None),请重试。"
1939
+ elif bot_response_text.startswith("抱歉,"):
1940
+ is_error = True
1941
+
1942
+ # 8. (V4.6 更改) @ 提及 (仅限触发词回复)
1943
+ mention_prefix = ""
1944
+ if chat_type != "private" and not is_random_reply: # 仅在群组的*触发词*回复时
1945
+ try:
1946
+ # [⁠](tg://user?id=12345) (使用 U+2060 WORD JOINER 作为 "不可见" 链接文本)
1947
+ mention_prefix = f"[\u2060](tg://user?id={user_id}) "
1948
+ logger.info(f"为触发词回复添加 @{user_name} 提及。")
1949
+ except Exception as e:
1950
+ logger.warning(f"为触发词回复添加 @ 提及失败: {e}")
1951
+ # 失败也无妨,继续执行
1952
+
1953
+ # (V4.5) 将提及(如果有)添加到回复文本的开头
1954
+ bot_response_text = mention_prefix + bot_response_text
1955
+
1956
+ # 9. 回复用户
1957
+ try:
1958
+ # (新增 V4.2) 尝试 Markdown
1959
+ await update.message.reply_text(
1960
+ bot_response_text,
1961
+ message_thread_id=thread_id,
1962
+ parse_mode=ParseMode.MARKDOWN
1963
+ )
1964
+ except BadRequest as e:
1965
+ # (V4.3 修复) 如果 Markdown 解析失败 (例如格式错误),则作为普通文本发送
1966
+ # 停止使用 reply_text 以避免 "Message not found"
1967
+ if "Can't parse entities" in str(e):
1968
+ logger.warning(f"Markdown 解析失败: {e}. 正在作为纯文本重试。")
1969
+ try:
1970
+ await context.bot.send_message(
1971
+ chat_id=chat_id,
1972
+ text=bot_response_text,
1973
+ message_thread_id=thread_id,
1974
+ parse_mode=None # 纯文本
1975
+ )
1976
+ except Exception as fallback_e:
1977
+ logger.error(f"纯文本回退发送失败: {fallback_e}", exc_info=True)
1978
+ else:
1979
+ logger.error(f"发送消息时发生意外的 BadRequest: {e}", exc_info=True)
1980
+ except Exception as e:
1981
+ logger.error(f"发送回复时发生未知错误: {e}", exc_info=True)
1982
+
1983
+
1984
+ # 10. (重要) 只有在 *没有* 错误时才保存上下文
1985
+ if not is_error:
1986
+ # (V4.4 新增) 为群聊 AI 回复添加前缀
1987
+ if chat_type != "private":
1988
+ bot_name = self.bot_name or "Assistant"
1989
+ user_name = user.first_name or "User"
1990
+ # (V4.5 修复) 存储 *不带* @ 提及的原始回复
1991
+ original_response_text = bot_response_text[len(mention_prefix):]
1992
+ prefixed_response = f"{bot_name}: [回复 @{user_name}]: {original_response_text}"
1993
+ assistant_api_content = [{"type": "text", "text": prefixed_response}]
1994
+ else:
1995
+ assistant_api_content = [{"type": "text", "text": bot_response_text}]
1996
+
1997
+ if chat_type == "private":
1998
+ # 私聊: 将 "用户" 和 "助手" 都写入 Redis
1999
+ self.redis.add_to_conversation(user_id, chat_id, thread_id, "user", user_api_content)
2000
+ self.redis.add_to_conversation(user_id, chat_id, thread_id, "assistant", assistant_api_content)
2001
+ # (V4.7) 私聊也更新时间戳 (如果用户想设置活跃度定时器)
2002
+ # context_key = self.redis._get_context_key(user_id, chat_id, thread_id)
2003
+ # self.redis.update_last_response_time(context_key)
2004
+ elif chat_type in ("group", "supergroup"):
2005
+ # 群组: 仅将 "助手" 写入内存缓存 (用户消息已在第2步写入)
2006
+ context_key = self.redis._get_context_key(user_id, chat_id, thread_id)
2007
+ await self._add_to_group_cache(context_key, "assistant", assistant_api_content)
2008
+ # (V4.7) 更新群组的最后响应时间
2009
+ self.redis.update_last_response_time(context_key)
2010
+ else:
2011
+ logger.info(f"检测到 AI 错误,跳过历史记录保存。 (User: {user_id}, Chat: {chat_id})")
2012
+
2013
+
2014
+ # --- 6. 启动器 ---
2015
+
2016
+ def main():
2017
+ """
2018
+ 主函数:初始化所有组件并启动机器人。
2019
+ """
2020
+ openai_client = None # 在 try 外部定义
2021
+ app = None # 在 try 外部定义
2022
+
2023
+ try:
2024
+ # 1. 初始化
2025
+ config = Config()
2026
+ redis_manager = RedisManager(config)
2027
+ openai_client = OpenAIClient(config)
2028
+ bot = TelegramBot(config, redis_manager, openai_client)
2029
+
2030
+ # 2. 注册处理器
2031
+ bot.setup_handlers()
2032
+
2033
+ # 3. 获取 application 实例
2034
+ app = bot.application
2035
+
2036
+ # 4. 移除手动的 await app.initialize() 和其他异步设置
2037
+ # 它们现在由 post_init 自动处理。
2038
+
2039
+ # 5. 开始轮询 (这是阻塞的,直到机器人停止)
2040
+ logger.info("机器人启动,开始轮⚫询...")
2041
+ app.run_polling(allowed_updates=Update.ALL_TYPES)
2042
+
2043
+ except ValueError as e:
2044
+ # Config 缺失必要变量时会触发
2045
+ logger.critical(f"启动失败: {e}")
2046
+ # 退出,因为没有配置无法运行
2047
+ return
2048
+ except Exception as e:
2049
+ logger.critical(f"机器人主程序遇到致命错误: {e}", exc_info=True)
2050
+
2051
+ finally:
2052
+ # 优雅关闭
2053
+ # post_shutdown_cleanup 会自动处理它们
2054
+ if app:
2055
+ logger.info("Telegram 轮询已停止。")
2056
+ logger.info("机器人已停止。")
2057
+
2058
+
2059
+ if __name__ == "__main__":
2060
+ try:
2061
+ main()
2062
+ except KeyboardInterrupt:
2063
+ logger.info("检测到 Ctrl+C,正在关闭...")
2064
+
index.js DELETED
@@ -1,1173 +0,0 @@
1
- // === 导入 ===
2
- import express from 'express';
3
- import * as dotenv from 'dotenv';
4
- import cron from 'node-cron';
5
- import { btoa, atob } from 'buffer';
6
-
7
- // 立即调用 dotenv.config() 来加载 .env 变量
8
- dotenv.config();
9
-
10
- // === 日志类 ===
11
- /**
12
- * 一个简单的日志类,用于标准化控制台输出。
13
- */
14
- class Logger {
15
- static #log(level, message, context = '') {
16
- const timestamp = new Date().toISOString();
17
- const contextStr = context ? ` [${context}]` : '';
18
- console.log(`${timestamp} [${level}]${contextStr} ${message}`);
19
- }
20
- static info(message, context = '') {
21
- this.#log('INFO', message, context);
22
- }
23
- static warn(message, context = '') {
24
- this.#log('WARN', message, context);
25
- }
26
- static error(message, error, context = '') {
27
- const errorMsg = error instanceof Error ? error.message : String(error);
28
- this.#log('ERROR', `${message} - ${errorMsg}`, context);
29
- if (error instanceof Error && error.stack) {
30
- console.error(error.stack);
31
- }
32
- }
33
- }
34
-
35
- // === 配置类 ===
36
- /**
37
- * 解析并验证环境变量。
38
- * 此版本已简化,仅适用于 OpenAI 兼容的服务。
39
- */
40
- class Config {
41
- constructor(env) {
42
- const getEnvOrDefault = (key, defaultValue) => env[key] || defaultValue;
43
-
44
- // 验证必要的模型配置
45
- const hasOpenAICompatible = !!env.OPENAI_COMPATIBLE_KEY && !!env.OPENAI_COMPATIBLE_URL;
46
- if (!hasOpenAICompatible) {
47
- throw new Error("必须设置 OPENAI_COMPATIBLE_KEY 和 OPENAI_COMPATIBLE_URL");
48
- }
49
-
50
- // 服务器配置
51
- this.port = parseInt(getEnvOrDefault('PORT', '3000'));
52
- this.webhookHost = getEnvOrDefault('WEBHOOK_HOST', `http://localhost:${this.port}`); // 例如 'https://your.domain.com'
53
-
54
- // 机器人配置
55
- this.telegramBotToken = env.TELEGRAM_BOT_TOKEN;
56
- if (!this.telegramBotToken) {
57
- throw new Error('未设置 TELEGRAM_BOT_TOKEN');
58
- }
59
-
60
- this.whitelistedUsers = env.WHITELISTED_USERS ? env.WHITELISTED_USERS.split(",").map((id) => id.trim()) : [];
61
- this.whitelistedChats = env.WHITELISTED_CHATS ? env.WHITELISTED_CHATS.split(",").map((id) => parseInt(id.trim())) : [];
62
- this.systemInitMessage = getEnvOrDefault("SYSTEM_INIT_MESSAGE", "You are a helpful assistant.");
63
- this.systemInitMessageRole = getEnvOrDefault("SYSTEM_INIT_MESSAGE_ROLE", "system");
64
- this.defaultModel = "gemini-flash-latest-nothinking"; // 这可以是你的代理支持的任何模型名称
65
-
66
- // Redis (Upstash) 配置
67
- this.upstashRedisRestUrl = env.UPSTASH_REDIS_REST_URL;
68
- this.upstashRedisRestToken = env.UPSTASH_REDIS_REST_TOKEN;
69
- if (!this.upstashRedisRestUrl || !this.upstashRedisRestToken) {
70
- Logger.warn('Upstash Redis 未配置。上下文将不会被保存。', 'Config');
71
- }
72
- this.contextTTL = 60 * 60 * 24 * 30; // 30 天
73
-
74
- // OpenAI 兼容模型配置
75
- this.openaiCompatibleKey = env.OPENAI_COMPATIBLE_KEY;
76
- this.openaiCompatibleUrl = env.OPENAI_COMPATIBLE_URL;
77
- this.openaiCompatibleModels = env.OPENAI_COMPATIBLE_MODELS ? env.OPENAI_COMPATIBLE_MODELS.split(",").map((model) => model.trim()) : [];
78
- this.openaiImageModel = getEnvOrDefault(env, "OPENAI_IMAGE_MODEL", "dall-e-3");
79
- }
80
- }
81
-
82
- // === 工具类 ===
83
- /**
84
- * 辅助函数集合。
85
- */
86
- class Utils {
87
- static formatCodeBlock(code) {
88
- return `\`\`\`
89
- ${code}
90
- \`\`\``;
91
- }
92
-
93
- static formatMarkdown(text) {
94
- if (!text) return '';
95
- text = text.replace(/```(\w*)\n([\s\S]+?)```/g, (_, lang, code) => {
96
- const escapedCode = code.replace(/\*/g, "\\*").replace(/_/g, "\\_");
97
- return Utils.formatCodeBlock(escapedCode.trim());
98
- });
99
- return text
100
- .replace(/([^\n])```/g, "$1\n```")
101
- .replace(/```([^\n])/g, "```\n$1")
102
- .replace(/([^\s`])`([^`]+)`([^\s`])/g, "$1 `$2` $3")
103
- .replace(/\*\*\*([^*]+)\*\*\*/g, "*$1*")
104
- .replace(/\*\*([^*]+)\*\*/g, "*$1*")
105
- .replace(/\[([^\]]+)\]\s*\(([^)]+)\)/g, "[$1]($2)")
106
- .replace(/^(\s*)-\s+(.+)$/gm, "$1\u2022 $2")
107
- .replace(/^>\s*(.+)$/gm, "\u258E _$1_")
108
- .replace(/\\([*_`\[\]()#+-=|{}.!])/g, "$1");
109
- }
110
-
111
- static stripFormatting(text) {
112
- if (!text) return '';
113
- return text
114
- .replace(/^(#{1,6})\s+(.+)$/gm, (_, hashes, content) => {
115
- const level = hashes.length;
116
- const indent = " ".repeat(level - 1);
117
- return `${indent}\u25C6 ${content.trim()}`;
118
- })
119
- .replace(/\*\*\*(.*?)\*\*\*/g, "$1")
120
- .replace(/\*\*(.*?)\*\*/g, "$1")
121
- .replace(/\*(.*?)\*/g, "$1")
122
- .replace(/`(.*?)`/g, "$1")
123
- .replace(/```[\s\S]*?```/g, "")
124
- .replace(/\[([^\]]+)\]\(([^\)]+)\)/g, "$1 ($2)")
125
- .replace(/^(\s*)-\s+(.+)$/gm, "$1\u2022 $2")
126
- .replace(/^>\s*(.+)$/gm, "\u258E $1");
127
- }
128
-
129
- static splitMessage(text, maxLength = 4096) {
130
- const messages = [];
131
- if (!text) return messages;
132
-
133
- const parts = text.split(/(```[\s\S]*?```)/);
134
- let currentMessage = "";
135
-
136
- for (const part of parts) {
137
- if (part.startsWith("```")) {
138
- if (currentMessage.length + part.length > maxLength) {
139
- if (currentMessage) {
140
- messages.push(currentMessage.trim());
141
- }
142
- // 如果代码块本身太长,就(简单地)分割它
143
- if (part.length > maxLength) {
144
- for (let i = 0; i < part.length; i += maxLength) {
145
- messages.push(part.substring(i, i + maxLength));
146
- }
147
- } else {
148
- messages.push(part);
149
- }
150
- currentMessage = "";
151
- } else {
152
- currentMessage += part;
153
- }
154
- } else {
155
- const lines = part.split("\n");
156
- for (const line of lines) {
157
- if (currentMessage.length + line.length + 1 > maxLength) {
158
- if (currentMessage) {
159
- messages.push(currentMessage.trim());
160
- }
161
- // 如果单行太长,分割它
162
- if (line.length > maxLength) {
163
- for (let i = 0; i < line.length; i += maxLength) {
164
- messages.push(line.substring(i, i + maxLength));
165
- }
166
- currentMessage = "";
167
- } else {
168
- currentMessage = line;
169
- }
170
- } else {
171
- currentMessage += (currentMessage ? "\n" : "") + line;
172
- }
173
- }
174
- }
175
- }
176
- if (currentMessage) {
177
- messages.push(currentMessage.trim());
178
- }
179
- return messages;
180
- }
181
-
182
- static async sendChatAction(chatId, action, config) {
183
- const token = config.telegramBotToken;
184
- const url = `https://api.telegram.org/bot${token}/sendChatAction`;
185
- try {
186
- await fetch(url, {
187
- method: "POST",
188
- headers: { "Content-Type": "application/json" },
189
- body: JSON.stringify({ chat_id: chatId, action }),
190
- });
191
- } catch (error) {
192
- Logger.warn(`发送聊天动作 '${action}' 失败`, 'Utils.sendChatAction');
193
- }
194
- }
195
-
196
- // 翻译映射表
197
- static translations = {
198
- welcome: "\u{1F44B} \u563F\uFF0C\u6B22\u8FCE\u4F7F\u7528\u4F60\u7684\u4E13\u5C5E\u52A9\u624B\u673A\u5668\u4EBA\uFF01",
199
- unauthorized: "\u{1F6AB} \u62B1\u6B49\uFF0C\u60A8\u8FD8\u6CA1\u6709\u6743\u9650\u4F7F\u7528\u8FD9\u4E2A\u673A\u5668\u4EBA\u54E6\u3002",
200
- error: "\u{1F605} \u54CE\u5440\uFF0C\u51FA\u4E86\u70B9\u5C0F\u95EE\u9898\u3002\u8981\u4E0D\u8981\u518D\u8BD5\u4E00\u6B21\uFF1F",
201
- current_language: "\u{1F30D} \u60A8\u5F53\u524D\u7684\u8BED\u8A00\u8BBE\u7F6E\u662F\uFF1A\u4E2D\u6587",
202
- language_changed: "\u{1F389} \u592A\u597D\u4E86\uFF01\u8BED\u8A00\u5DF2\u7ECF\u5207\u6362\u4E3A\uFF1A",
203
- new_conversation: "\u{1F195} \u597D\u7684\uFF0C\u8BA9\u6211\u4EEC\u5F00\u59CB\u4E00\u6BB5\u5168\u65B0\u7684\u5BF9\u8BDD\u5427\uFF01\u4E4B\u524D\u7684\u804A\u5929\u8BB0\u5F55\u5DF2\u7ECF\u6E05\u9664\u5566\u3002",
204
- no_history: "\u{1F914} \u55EF...\u770B\u8D77\u6765\u6211\u4EEC\u8FD8\u6CA1\u6709\u804A\u8FC7\u5929\u5462\u3002",
205
- history_summary: "\u{1F4DC} \u6765\u56DE\u987E\u4E00\u4E0B\u6211\u4EEC\u4E4B\u524D\u804A\u4E86\u4E9B\u4EC0\u4E48\uFF1A",
206
- current_model: "\u{1F916} \u60A8\u73B0\u5728\u4F7F\u7528\u7684 AI \u6A21\u578B\u662F\uFF1A",
207
- available_models: "\u{1F522} \u54C7\uFF0C\u6211\u4EEC\u6709\u8FD9\u4E48\u591A\u6A21\u578B\u53EF\u4EE5\u9009\u62E9\uFF1A",
208
- model_changed: "\u{1F504} \u6362\u6A21\u578B\u6210\u529F\uFF01\u73B0\u5728\u6211\u4F7F\u7528\u7684\u662F\uFF1A",
209
- help_intro: "\u{1F9ED} \u6765\u770B\u770B\u6211\u90FD\u80FD\u505A\u4E9B\u4EC0\u4E48\u5427\uFF1A",
210
- start_description: "\u{1F680} \u548C\u6211\u6253\u4E2A\u62DB\u547C\uFF0C\u5F00\u59CB\u804A\u5929",
211
- language_description: "\u{1F5E3}\uFE0F \u60F3\u6362\u4E2A\u8BED\u8A00\uFF1F\u7528\u8FD9\u4E2A",
212
- new_description: "\u{1F504} \u5F00\u59CB\u5168\u65B0\u7684\u5BF9\u8BDD",
213
- cien_description:"\u67e5\u770b\u9ca8\u9c7c\u7684\u0063\u0069\u0065\u006e\u6700\u65b0\u6587\u7ae0",
214
- history_description: "\u{1F4DA} \u56DE\u987E\u4E00\u4E0B\u6211\u4EEC\u4E4B\u524D\u804A\u4E86\u4EC0\u4E48",
215
- switchmodel_description: "\u{1F500} \u6362\u4E2A\u6A21\u578B\u6765\u804A\u5929",
216
- help_description: "\u2753 \u67E5\u770B\u6240\u6709\u53ef\u7528\u7684\u547D\u4EE4",
217
- choose_language: "\u{1F310} \u4F60\u60F3\u7528\u54EA\u79CD\u8BED\u8A00\u548C\u6211\u804A\u5929\u5462\uFF1F",
218
- choose_model: "\u{1F916} \u6765\u9009\u62E9\u4E00\u4E2A AI \u6A21\u578B\u5427\uFF1A",
219
- language_zh: "\u{1F1E8}\u{1F1F3} \u7B80\u4F53\u4E2D\u6587",
220
- image_prompt_required: "\u{1F5BC}\uFE0F \u8981\u521B\u5EFA\u56FE\u50CF\uFF0C\u8BF7\u544A\u8BC9\u6211\u4F60\u60F3\u770B\u5230\u4EC0\u4E48~",
221
- image_generation_error: "\u{1F61E} \u54CE\u5440\uFF0C\u521B\u5EFA\u56FE\u50CF\u65F6\u51FA\u73B0\u4E86\u95EE\u9898\u3002\u8981\u4E0D\u8981\u518D\u8BD5\u4E00\u6B21\uFF1F",
222
- invalid_size: "\u{1F4CF} \u54CE\u5440\uFF0C\u8FD9\u4E2A\u5C3A\u5BF8\u4E0D\u884C\u3002\u4E0D\u5982\u8BD5\u8BD5\u8FD9\u4E9B\uFF1A",
223
- // 新的 /image 命令翻译
224
- image_description: "\u{1F5BC}\uFE0F \u4F7F\u7528 AI \u521B\u5EFA\u4E00\u5F20\u56FE\u50CF",
225
- image_usage: "\ud83d\udcdd\u0020\u7528\u6cd5: /image <\u63cf\u8ff0> [aspect_ratio]\n\u652f\u6301\u7684\u6bd4\u4f8b: 1:1, 16:9, 9:16",
226
- invalid_aspect_ratio: "\u{1F522} \u4e0d\u652f\u6301\u7684\u957f\u5bbd\u6bd4\u3002\u8bf7\u4f7f\u7528: 1:1, 16:9, \u6216 9:16",
227
-
228
- original_prompt: "\u{1F3A8} \u539F\u59CB\u63CF\u8FF0",
229
- prompt_generation_model: "\u{1F4AC} \u63D0\u793A\u751F\u6210\u6A21\u578B",
230
- optimized_prompt: "\u{1F310} \u4F18\u5316\u540E\u7684\u63CF\u8FF0",
231
- image_specs: "\u{1F4D0} \u56FE\u50CF\u8BE6\u60C5",
232
- command_not_found: "\u2753 \u55EF\uFF0C\u6211\u4E0D\u8BA4\u8BC6\u8FD9\u4E2A\u547D\u4EE4\u3002\u8F93\u5165 /help \u770B\u770B\u6211\u80FD\u505A\u4EC0\u4E48\uFF01",
233
- image_analysis_not_supported: "\u{1F6AB} \u5F53\u524D\u6A21\u578B\u4E0D\u652F\u6301\u56FE\u50CF\u5206\u6790\u3002\u8BF7\u5207\u6362\u5230\u652F\u6301\u591A\u6A21\u6001\u8F93\u5165\u7684\u6A21\u578B\u3002",
234
- image_analysis_error: "\u274C \u7CDF\u7CD5\uff01\u56FE\u50CF\u5206\u6790\u8FC7\u7A0B\u4E2D\u53D1\u751F\u9519\u8BEF\u3002\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002",
235
- image_analysis_description: "\u{1F4F8} \u56FE\u7247\u5206\u6790\uff1A\u53D1\u9001\u4e00\u5F20\u7167\u7247\uFF0C\u673A\u5668\u4EBA\u5c06\u4f7f\u7528\u5f53\u524d\u9009\u62e9\u7684 AI \u6A21\u578B\u8fdb\u884c\u5206\u6790\u3002",
236
- image_analysis_error2:"\u274C 2",
237
- };
238
-
239
- static translate(key) {
240
- return this.translations?.[key] || key; // 如果找不到翻译,返回 key 本身
241
- }
242
- }
243
-
244
- // === 服务类 ===
245
-
246
- /**
247
- * Upstash Redis REST API 客户端
248
- */
249
- class RedisClient {
250
- constructor(config) {
251
- this.config = config;
252
- this.url = config.upstashRedisRestUrl;
253
- this.token = config.upstashRedisRestToken;
254
- this.isEnabled = this.url && this.token;
255
- }
256
-
257
- async #request(command, ...args) {
258
- if (!this.isEnabled) {
259
- Logger.warn('Redis 未配置。跳过命令。', 'RedisClient');
260
- return { result: null }; // 模拟 "未找到" 或 "成功"
261
- }
262
-
263
- const url = `${this.url}/${command}/${args.join('/')}`;
264
- const headers = { Authorization: `Bearer ${this.token}` };
265
-
266
- let body = null;
267
- if (command === 'set') {
268
- // 处理带 TTL 的 SET
269
- const key = args[0];
270
- const value = args[1];
271
- const ttl = this.config.contextTTL;
272
- const fullUrl = `${this.url}/set/${key}?EX=${ttl}`;
273
-
274
- try {
275
- const response = await fetch(fullUrl, { method: 'POST', headers, body: value });
276
- if (!response.ok) throw new Error(`HTTP 错误! 状态: ${response.status}`);
277
- return await response.json();
278
- } catch(error) {
279
- Logger.error(`Redis SET 命令失败, key: ${key}`, error, 'RedisClient');
280
- return { result: null };
281
- }
282
-
283
- } else if (command === 'del') {
284
- const key = args[0];
285
- const delUrl = `${this.url}/del/${key}`;
286
- try {
287
- const response = await fetch(delUrl, { method: 'POST', headers }); // DEL 是 POST
288
- if (!response.ok) throw new Error(`HTTP 错误! 状态: ${response.status}`);
289
- return await response.json();
290
- } catch(error) {
291
- Logger.error(`Redis DEL 命令失败, key: ${key}`, error, 'RedisClient');
292
- return { result: null };
293
- }
294
- }
295
-
296
- // 处理 GET 和 KEYS
297
- try {
298
- const response = await fetch(url, { method: 'GET', headers });
299
- if (!response.ok) {
300
- if (response.status === 404) return { result: null }; // 未找到
301
- throw new Error(`HTTP 错误! 状态: ${response.status}`);
302
- }
303
- return await response.json();
304
- } catch (error) {
305
- Logger.error(`Redis ${command} 命令失败`, error, 'RedisClient');
306
- return { result: null };
307
- }
308
- }
309
-
310
- async get(key) {
311
- const data = await this.#request('get', key);
312
- return data.result;
313
- }
314
-
315
- async set(key, value) {
316
- // 现在会正确使用构造函数中的 TTL
317
- const data = await this.#request('set', key, value);
318
- return data.result;
319
- }
320
-
321
- async del(key) {
322
- const data = await this.#request('del', key);
323
- return data.result;
324
- }
325
-
326
- async appendContext(userId, newContext) {
327
- if (!this.isEnabled) return;
328
- const key = `context:${userId}`;
329
- const existingContext = await this.get(key) || '';
330
- const updatedContext = existingContext ? `${existingContext}\n${newContext}` : newContext;
331
- await this.set(key, updatedContext); // 'set' 会自动应用 TTL
332
- }
333
-
334
- async keys(pattern) {
335
- const data = await this.#request('keys', pattern);
336
- return data.result;
337
- }
338
- }
339
-
340
- /**
341
- * 统一的 OpenAI 兼容 API 客户端 (文本, 视觉, 图像生成)
342
- */
343
- class OpenAICompatibleAPI {
344
- constructor(config) {
345
- this.config = config;
346
- this.apiKey = config.openaiCompatibleKey || "";
347
- this.baseUrl = config.openaiCompatibleUrl || "";
348
- this.models = []; // 模型缓存
349
- this.defaultModel = config.openaiCompatibleModels[0] || config.defaultModel;
350
- this.imageModel = config.openaiImageModel;
351
- this.isFetchingModels = false;
352
- this.fetchModels().catch((error) => Logger.error("初���化时获取模型失败", error, 'OpenAICompatibleAPI'));
353
- }
354
-
355
- /**
356
- * 从 URL 获取图像并返回 base64 数据 URL。
357
- */
358
- async #getBase64Image(imageUrl) {
359
- const response = await fetch(imageUrl);
360
- const arrayBuffer = await response.arrayBuffer();
361
- const base64 = Buffer.from(arrayBuffer).toString('base64');
362
- // 尝试从响应头获取 mime 类型,默认为 jpeg
363
- const mimeType = response.headers.get('content-type') || 'image/jpeg';
364
- return `data:${mimeType};base64,${base64}`;
365
- }
366
-
367
- /**
368
- * 清理模型响应,移除 <thinking> 等标签。
369
- */
370
- async cleanString(inputString) {
371
- if (!inputString) return "";
372
- return inputString
373
- .replace(/<thinking>\s*[\s\S]*?<\/thinking>/g, '')
374
- .replace(/<!--\s*[\s\S]*? -->/g, '');
375
- }
376
-
377
- /**
378
- * 从聊天记录生成文本响应。
379
- */
380
- async generateResponse(messages, model) {
381
- if (!this.apiKey || !this.baseUrl) {
382
- throw new Error("OpenAI 兼容 API 未配置");
383
- }
384
- await this.fetchModels(); // 确保模型列表已加载
385
-
386
- const useModel = model || this.defaultModel;
387
- if (!useModel) {
388
- throw new Error("未指定模型,且没有可用的默认模型");
389
- }
390
-
391
- const url = `${this.baseUrl}/v1/chat/completions`;
392
- const requestBody = { model: useModel, messages, temperature: 0.9, max_tokens: 4096 };
393
-
394
- const response = await fetch(url, {
395
- method: "POST",
396
- headers: {
397
- "Content-Type": "application/json",
398
- "Authorization": `Bearer ${this.apiKey}`,
399
- },
400
- body: JSON.stringify(requestBody),
401
- });
402
-
403
- if (!response.ok) {
404
- const errorText = await response.text();
405
- Logger.error(`OpenAI API 错误: ${response.statusText}`, errorText, 'OpenAICompatibleAPI');
406
- throw new Error(`OpenAI 兼容 API 错误: ${response.statusText}\n${errorText}`);
407
- }
408
-
409
- const data = await response.json();
410
- if (!data.choices || data.choices.length === 0) {
411
- throw new Error("OpenAI 兼容 API 未生成响应");
412
- }
413
-
414
- const messageContent = await this.cleanString(data.choices[0].message.content.trim());
415
- return messageContent.trim();
416
- }
417
-
418
- /**
419
- * 获取并缓存可用的文本模型列表。
420
- */
421
- async fetchModels() {
422
- // 防止并发请求
423
- if (this.isFetchingModels) return;
424
- if (this.models.length > 0) return; // 已经获取过了
425
- if (!this.apiKey || !this.baseUrl) return; // 未配置
426
-
427
- this.isFetchingModels = true;
428
- try {
429
- const url = `${this.baseUrl}/v1/models`;
430
- Logger.info(`正在从: ${url} 获取模型列表`, 'OpenAICompatibleAPI');
431
- const response = await fetch(url, { headers: { "Authorization": `Bearer ${this.apiKey}` } });
432
-
433
- if (!response.ok) {
434
- const errorText = await response.text();
435
- throw new Error(`获取模型列表失败: ${response.statusText}\n${errorText}`);
436
- }
437
-
438
- const data = await response.json();
439
- this.models = data.data.map((model) => model.id);
440
- this.defaultModel = this.models[0] || this.defaultModel;
441
- Logger.info(`获取了 ${this.models.length} 个模型。默认: ${this.defaultModel}`, 'OpenAICompatibleAPI');
442
- } catch (error) {
443
- Logger.error('获取模型列表失败', error, 'OpenAICompatibleAPI');
444
- } finally {
445
- this.isFetchingModels = false;
446
- }
447
- }
448
-
449
- async getModels() {
450
- await this.fetchModels();
451
- return this.models;
452
- }
453
-
454
- isValidModel(model) { return this.models.includes(model); }
455
- getDefaultModel() { return this.defaultModel; }
456
- getAvailableModels() { return this.models; }
457
-
458
- /**
459
- * 使用视觉模型分析图像,发送 base64 数据。
460
- */
461
- async analyzeImage(imageUrl, prompt, model) {
462
- if (!this.apiKey || !this.baseUrl) {
463
- throw new Error("OpenAI 兼容 API 未配置");
464
- }
465
-
466
- // 获取并编码图像为 base64 数据 URL
467
- const base64ImageData = await this.#getBase64Image(imageUrl);
468
-
469
- // 使用用户当前选择的模型,假设它具有视觉功能
470
- const useModel = model || this.defaultModel;
471
-
472
- Logger.info(`使用 base64 数据分析图像。模型: ${useModel}`, 'OpenAICompatibleAPI');
473
-
474
- const url = `${this.baseUrl}/v1/chat/completions`;
475
- const response = await fetch(url, {
476
- method: "POST",
477
- headers: {
478
- "Content-Type": "application/json",
479
- "Authorization": `Bearer ${this.apiKey}`,
480
- },
481
- body: JSON.stringify({
482
- model: useModel,
483
- messages: [{
484
- role: "user",
485
- content: [
486
- { type: "text", text: prompt },
487
- {
488
- type: "image_url",
489
- image_url: {
490
- "url": base64ImageData // 发送 base64 数据 URL
491
- }
492
- }
493
- ],
494
- }],
495
- max_tokens: 1024, // 增加 token 数量以便进行更好的分析
496
- }),
497
- });
498
-
499
- if (!response.ok) {
500
- const errorText = await response.text();
501
- Logger.error(`OpenAI 图像 API 错误: ${response.statusText}`, errorText, 'OpenAICompatibleAPI');
502
- throw new Error(`OpenAI 兼容图像分析 API 错误: ${response.statusText}\n${errorText}`);
503
- }
504
-
505
- const data = await response.json();
506
- const content = data.choices?.[0]?.message?.content;
507
- if (!content) {
508
- throw new Error("OpenAI 兼容 API 响应中无内容");
509
- }
510
- return content.trim();
511
- }
512
-
513
- /**
514
- * 使用 OpenAI 图像生成 API 生成图像。
515
- */
516
- async generateImage(prompt, size = "1024x1024", quality = "standard", style = "vivid") {
517
- if (!this.apiKey || !this.baseUrl) {
518
- throw new Error("OpenAI 兼容 API 未配置");
519
- }
520
-
521
- const url = `${this.baseUrl}/v1/images/generations`;
522
- Logger.info(`正在生成图像。模型: ${this.imageModel}, 尺寸: ${size}`, 'OpenAICompatibleAPI');
523
-
524
- const response = await fetch(url, {
525
- method: "POST",
526
- headers: {
527
- "Content-Type": "application/json",
528
- "Authorization": `Bearer ${this.apiKey}`,
529
- },
530
- body: JSON.stringify({
531
- model: this.imageModel,
532
- prompt: prompt,
533
- n: 1,
534
- size: size,
535
- quality: quality,
536
- style: style,
537
- response_format: "b64_json", // 请求 base64 数据
538
- }),
539
- });
540
-
541
- if (!response.ok) {
542
- const errorText = await response.text();
543
- Logger.error(`OpenAI 图像生成错误: ${response.statusText}`, errorText, 'OpenAICompatibleAPI');
544
- throw new Error(`OpenAI 兼容图像生成错误: ${response.statusText}\n${errorText}`);
545
- }
546
-
547
- const data = await response.json();
548
- const b64Json = data.data?.[0]?.b64_json;
549
-
550
- if (!b64Json) {
551
- throw new Error("API 未返回图像数据");
552
- }
553
-
554
- // 将 base64 图像解码为字节 (Uint8Array)
555
- const binaryString = atob(b64Json);
556
- const len = binaryString.length;
557
- const bytes = new Uint8Array(len);
558
- for (let i = 0; i < len; i++) {
559
- bytes[i] = binaryString.charCodeAt(i);
560
- }
561
-
562
- // 返回原始图像字节
563
- return {
564
- imageData: bytes,
565
- revisedPrompt: data.data?.[0]?.revised_prompt // 传递 API 可能返回的优化后的提示
566
- };
567
- }
568
- }
569
-
570
- // === 机器人核心 ===
571
- /**
572
- * Telegram 机器人主类。
573
- */
574
- class TelegramBot {
575
- constructor(config) {
576
- this.config = config;
577
- this.apiUrl = `https://api.telegram.org/bot${config.telegramBotToken}`;
578
- this.redis = new RedisClient(config);
579
- // *唯一* 的 AI 服务客户端
580
- this.openaiApi = new OpenAICompatibleAPI(config);
581
-
582
- // 定义允许的命令
583
- this.commands = [
584
- { name: "start", description: "start_description", action: this.commandStart.bind(this) },
585
- { name: "switchmodel", description: "switchmodel_description", action: this.commandSwitchModel.bind(this) },
586
- { name: "new", description: "new_description", action: this.commandNew.bind(this) },
587
- { name: "cien", description: "cien_description", action: this.commandCien.bind(this) },
588
- { name: "history", description: "history_description", action: this.commandHistory.bind(this) },
589
- { name: "help", description: "help_description", action: this.commandHelp.bind(this) },
590
- // 用 /image 替换了 /flux
591
- { name: "image", description: "image_description", action: this.commandImage.bind(this) },
592
- ];
593
- }
594
-
595
- // --- 命令实现 ---
596
- async commandStart(chatId, userId, args) {
597
- const currentModel = await this.getCurrentModel(userId);
598
- const welcomeMessage = Utils.translate("welcome") + "\n" + Utils.translate("current_model") + currentModel;
599
- await this.sendMessageWithFallback(chatId, welcomeMessage);
600
- }
601
-
602
- async commandSwitchModel(chatId, userId, args) {
603
- if (this.config.whitelistedUsers.includes(userId)) {
604
- try {
605
- Logger.info("正在执行 switchmodel 命令", 'Bot.command');
606
- let compatibleModels = await this.openaiApi.getModels();
607
- let availableModels = [...this.config.openaiCompatibleModels, ...compatibleModels];
608
-
609
- const keyboard = {
610
- inline_keyboard: availableModels.map((model) => [{ text: model, callback_data: `model_${model}` }]),
611
- };
612
- await this.sendMessage(chatId, Utils.translate("choose_model"), { reply_markup: JSON.stringify(keyboard) });
613
- } catch (error) {
614
- Logger.error("switchmodel 命令出错", error, 'Bot.command');
615
- await this.sendMessage(chatId, Utils.translate("error") + ": " + (error.message || "未知错误"));
616
- }
617
- } else {
618
- await this.sendMessageWithFallback(chatId, `\ud83d\udeab\u0020\u62b1\u6b49\uff0c\u60a8\u8fd8\u6ca1\u6709\u6743\u9650\u4f7f\u7528\u8fd9\u4e2a\u547d\u4ee4\u54e6\uff0c\u8bf7\u66f4\u6362\u5176\u4ed6\u547d\u4ee4\u5427\u3002`);
619
- }
620
- }
621
-
622
- async commandNew(chatId, userId, args) {
623
- await this.clearContext(userId, chatId);
624
- }
625
-
626
- async commandCien(chatId, userId, args) {
627
- try {
628
- await this.getcienResource(chatId);
629
- } catch (error) {
630
- await this.sendMessage(chatId, "呜呜呜,报错了");
631
- }
632
- }
633
-
634
- async commandHistory(chatId, userId, args) {
635
- const summary = await this.summarizeHistory(userId);
636
- await this.sendMessage(chatId, summary || Utils.translate("no_history"));
637
- }
638
-
639
- async commandHelp(chatId, userId, args) {
640
- let helpMessage = Utils.translate("help_intro") + "\n\n";
641
- for (const command of this.commands) {
642
- const descriptionKey = `${command.name}_description`;
643
- helpMessage += `/${command.name} - ${Utils.translate(descriptionKey)}\n`;
644
- }
645
- helpMessage += "\n" + Utils.translate("image_analysis_description");
646
- await this.sendMessage(chatId, helpMessage);
647
- }
648
-
649
- /**
650
- * OpenAI 图像生成的新命令
651
- */
652
- async commandImage(chatId, userId, args) {
653
- if (!args.length) {
654
- await this.sendMessage(chatId, Utils.translate("image_usage"));
655
- return;
656
- }
657
-
658
- let aspectRatio = "1:1";
659
- let prompt;
660
- const validRatios = ["1:1", "16:9", "9:16"];
661
-
662
- if (validRatios.includes(args[args.length - 1])) {
663
- aspectRatio = args[args.length - 1];
664
- prompt = args.slice(0, -1).join(" ");
665
- } else {
666
- prompt = args.join(" ");
667
- }
668
-
669
- // 将宽高比映射到 OpenAI 尺寸
670
- const sizeMap = {
671
- "1:1": "1024x1024",
672
- "16:9": "1792x1024",
673
- "9:16": "1024x1792"
674
- };
675
- const size = sizeMap[aspectRatio];
676
-
677
- try {
678
- await Utils.sendChatAction(chatId, "upload_photo", this.config);
679
- const { imageData, revisedPrompt } = await this.openaiApi.generateImage(prompt, size);
680
-
681
- let caption = `${Utils.translate("original_prompt")}: ${prompt}\n`;
682
- caption += `${Utils.translate("image_specs")}: ${aspectRatio}\n`;
683
- if (revisedPrompt) {
684
- caption += `${Utils.translate("optimized_prompt")}: ${revisedPrompt}\n`;
685
- }
686
- await this.sendPhoto(chatId, imageData, { caption });
687
- } catch (error) {
688
- Logger.error(`生成 OpenAI 图像时出错`, error, 'Bot.command');
689
- await this.sendMessage(chatId, Utils.translate("image_generation_error"));
690
- }
691
- }
692
-
693
- // --- 机器人核心逻辑 ---
694
-
695
- /**
696
- * 处理来自 webhook 的更新的主入口点。
697
- */
698
- async processUpdate(update) {
699
- Logger.info(`收到更新 ID: ${update.update_id}`, 'Bot.processUpdate');
700
-
701
- if (update.callback_query) {
702
- await this.handleCallbackQuery(update.callback_query);
703
- } else if (update.message) {
704
- const msg = update.message;
705
- const chatId = msg.chat.id;
706
- const userId = msg.from?.id?.toString();
707
- const userName = msg.from?.first_name?.toString();
708
- const messageId = msg.message_id;
709
- const chatType = msg.chat.type;
710
-
711
- if (!userId) {
712
- Logger.warn("更新中没有用户 ID", update, 'Bot.processUpdate');
713
- return;
714
- }
715
-
716
- if (!this.isUserWhitelisted(userId) && !this.isChatWhitelisted(chatId)) {
717
- Logger.warn(`未授权的用户: ${userId} 在聊天: ${chatId}`, 'Bot.processUpdate');
718
- await this.sendMessage(chatId, Utils.translate("unauthorized"));
719
- return;
720
- }
721
-
722
- // 处理不同的消息类型
723
- if (msg.photo) {
724
- await this.handleImageAnalysis(chatId, msg);
725
- } else if (msg.text) {
726
- if (msg.text.startsWith("/")) {
727
- const [commandNameFull, ...args] = msg.text.slice(1).split(" ");
728
- const commandName = commandNameFull.split('@')[0]; // 移除 @BotName
729
- await this.executeCommand(commandName, chatId, userId, messageId, args);
730
- } else {
731
- await this.handleTextMessage(chatId, userId, userName, msg.text, messageId, chatType);
732
- }
733
- }
734
- }
735
- }
736
-
737
- /**
738
- * 查找并执行命令。
739
- */
740
- async executeCommand(commandName, chatId, userId, messageId, args) {
741
- const command = this.commands.find((cmd) => cmd.name === commandName);
742
- if (command) {
743
- try {
744
- await command.action(chatId, userId, args);
745
- } catch (error) {
746
- Logger.error(`执行命令出错: /${commandName}`, error, 'Bot.executeCommand');
747
- await this.sendMessage(chatId, Utils.translate("error"));
748
- }
749
- } else {
750
- Logger.info(`未知命令: ${commandName}`, 'Bot.executeCommand');
751
- await this.sendMessage(chatId, Utils.translate("command_not_found"), {
752
- reply_to_message_id: messageId,
753
- });
754
- }
755
- }
756
-
757
- /**
758
- * 处理常规文本消息 (非命令)。
759
- */
760
- async handleTextMessage(chatId, userId, userName, text, messageId, chatType) {
761
- let shouldReply = false;
762
- let messageText = text;
763
-
764
- if (chatType === 'private') {
765
- shouldReply = true;
766
- } else if (chatType === 'group' || chatType === 'supergroup') {
767
- if (text.includes(`@xiao_ye_mbot`)) { // 硬编码的机器人名称,最好来自配置
768
- shouldReply = true;
769
- messageText = text.replace(/@xiao_ye_mbot\s*/, '').trim();
770
- }
771
- }
772
-
773
- if (!shouldReply || !messageText) {
774
- return; // 消息与我们无关或是空消息
775
- }
776
-
777
- try {
778
- await Utils.sendChatAction(chatId, "typing", this.config);
779
-
780
- const context = await this.getContext(userId);
781
- const currentModel = await this.getCurrentModel(userId);
782
-
783
- const messages = this.buildHistory(context, messageText);
784
-
785
- const response = await this.openaiApi.generateResponse(messages, currentModel);
786
-
787
- const formattedResponse = Utils.formatMarkdown(response);
788
-
789
- await this.sendMessageWithFallback(chatId, formattedResponse, {
790
- reply_to_message_id: messageId,
791
- });
792
-
793
- // 存储上下文
794
- await this.storeContext(userId, `User: ${messageText}\nAssistant: ${response}`);
795
- } catch (error) {
796
- Logger.error(`处理文本消息时出错`, error, 'Bot.handleTextMessage');
797
- await this.sendMessageWithFallback(chatId, Utils.translate("error") + ": " + error.message);
798
- }
799
- }
800
-
801
- /**
802
- * 处理图像消息。
803
- */
804
- async handleImageAnalysis(chatId, message) {
805
- const fileId = message.photo[message.photo.length - 1].file_id; // 获取最高分辨率
806
- const caption = message.caption || "请分析这张图片。"; // 默认提示
807
-
808
- try {
809
- await Utils.sendChatAction(chatId, "typing", this.config);
810
- const fileUrl = await this.getFileUrl(fileId);
811
- const currentModel = await this.getCurrentModel(chatId.toString()); // 获取用户的首选模型
812
-
813
- // 使用 OpenAI API 的视觉能力
814
- const analysisResult = await this.openaiApi.analyzeImage(fileUrl, caption, currentModel);
815
- await this.sendMessageWithFallback(chatId, analysisResult);
816
- } catch (error) {
817
- Logger.error("图像分析出错", error, 'Bot.handleImageAnalysis');
818
- await this.sendMessage(chatId, Utils.translate("image_analysis_error"));
819
- }
820
- }
821
-
822
- /**
823
- * 处理来自内联键盘的回调查询。
824
- */
825
- async handleCallbackQuery(query) {
826
- const chatId = query.message.chat.id;
827
- const userId = query.from.id.toString();
828
- const data = query.data;
829
-
830
- Logger.info(`正在处理回调查询: ${data}`, 'Bot.handleCallbackQuery');
831
-
832
- if (data.startsWith("model_")) {
833
- const newModel = data.split("_")[1];
834
- try {
835
- await this.setCurrentModel(userId, newModel);
836
- await this.sendMessageWithFallback(chatId, Utils.translate("model_changed") + newModel);
837
- await this.clearContext(userId, chatId);
838
- } catch (error) {
839
- Logger.error("切换模型出错", error, 'Bot.handleCallbackQuery');
840
- await this.sendMessageWithFallback(chatId, Utils.translate("error") + ": " + error.message);
841
- }
842
- }
843
-
844
- // 确认查询
845
- try {
846
- await fetch(`${this.apiUrl}/answerCallbackQuery`, {
847
- method: "POST",
848
- headers: { "Content-Type": "application/json" },
849
- body: JSON.stringify({ callback_query_id: query.id }),
850
- });
851
- } catch (error) {
852
- Logger.warn("确认回调查询时出错", error, 'Bot.handleCallbackQuery');
853
- }
854
- }
855
-
856
- // --- 历史与上下文 ---
857
-
858
- buildHistory(contextString, currentMessage) {
859
- const messages = [];
860
-
861
- // 系统提示
862
- messages.push({
863
- role: this.config.systemInitMessageRole,
864
- content: this.config.systemInitMessage,
865
- });
866
-
867
- // 解析历史
868
- if (contextString) {
869
- const regex = /(User|Assistant): ([\s\S]*?)(?=\nUser:|\nAssistant:|$)/g;
870
- let match;
871
- while ((match = regex.exec(contextString)) !== null) {
872
- messages.push({
873
- role: match[1].toLowerCase(),
874
- content: match[2].trim(),
875
- });
876
- }
877
- }
878
-
879
- // 添加当前消息
880
- messages.push({
881
- role: "user",
882
- content: currentMessage,
883
- });
884
-
885
- // 添加最后的“优化”提示 (来自原始代码)
886
- messages.push({
887
- role: "system",
888
- content: `</Story history>\n## 须完全遵守的规范\n<CAUTION>\n乐于助人,仔细的编写高质量代码</CAUTION>`
889
- });
890
-
891
- return messages;
892
- }
893
-
894
- async storeContext(userId, context) {
895
- await this.redis.appendContext(userId, context);
896
- }
897
-
898
- async getContext(userId) {
899
- return await this.redis.get(`context:${userId}`);
900
- }
901
-
902
- async clearContext(userId, chatId) {
903
- await this.redis.del(`context:${userId}`);
904
- await this.sendMessageWithFallback(chatId, Utils.translate("new_conversation"));
905
- }
906
-
907
- async summarizeHistory(userId) {
908
- const context = await this.getContext(userId);
909
- if (!context) {
910
- return Utils.translate("no_history");
911
- }
912
- // 这不是摘要,只是转储。
913
- // 真正的摘要需要另一次 AI 调用。
914
- return `Context:\n${context}`;
915
- }
916
-
917
- // --- 模型管理 ---
918
- async getCurrentModel(userId) {
919
- const model = await this.redis.get(`model:${userId}`);
920
- if (model) return model;
921
-
922
- // 回退逻辑
923
- return this.config.openaiCompatibleModels[0]
924
- || this.openaiApi.getDefaultModel()
925
- || this.config.defaultModel;
926
- }
927
-
928
- async setCurrentModel(userId, model) {
929
- await this.redis.set(`model:${userId}`, model);
930
- Logger.info(`已为用户 ${userId} 设置模型为 ${model}`, 'Bot.setCurrentModel');
931
- }
932
-
933
- // --- Telegram API 包装器 ---
934
- async sendMessage(chatId, text, options = {}) {
935
- const messages = Utils.splitMessage(text);
936
- const results = [];
937
- for (const message of messages) {
938
- try {
939
- const response = await fetch(`${this.apiUrl}/sendMessage`, {
940
- method: "POST",
941
- headers: { "Content-Type": "application/json" },
942
- body: JSON.stringify({
943
- chat_id: chatId,
944
- text: message,
945
- ...options,
946
- }),
947
- });
948
- if (!response.ok) {
949
- const errorData = await response.json();
950
- throw new Error(`Telegram API 错误: ${errorData.description}`);
951
- }
952
- const result = await response.json();
953
- results.push(result);
954
- } catch (error) {
955
- Logger.error(`发送消息部分失败`, error, 'Bot.sendMessage');
956
- throw error; // 抛出错误
957
- }
958
- }
959
- return results;
960
- }
961
-
962
- async sendMessageWithFallback(chatId, text, options = {}) {
963
- try {
964
- const markdownMessage = Utils.formatMarkdown(text);
965
- await this.sendMessage(chatId, markdownMessage, { ...options, parse_mode: "Markdown" });
966
- } catch (error) {
967
- Logger.warn(`Markdown 发送失败,回退到纯文本`, error, 'Bot.sendMessageWithFallback');
968
- try {
969
- const plainText = Utils.stripFormatting(text);
970
- await this.sendMessage(chatId, plainText, options);
971
- } catch (fallbackError) {
972
- Logger.error(`纯文本回退失败`, fallbackError, 'Bot.sendMessageWithFallback');
973
- }
974
- }
975
- }
976
-
977
- async sendPhoto(chatId, photo, options = {}) {
978
- const formData = new FormData();
979
- formData.append("chat_id", chatId.toString());
980
-
981
- if (typeof photo === "string") {
982
- formData.append("photo", photo); // 作为 URL 发送
983
- } else {
984
- const blob = new Blob([photo], { type: "image/png" });
985
- formData.append("photo", blob, "image.png");
986
- }
987
-
988
- if (options.caption) {
989
- formData.append("caption", options.caption);
990
- }
991
-
992
- try {
993
- const response = await fetch(`${this.apiUrl}/sendPhoto`, {
994
- method: "POST",
995
- body: formData, // fetch 会自动处理 multipart/form-data
996
- });
997
- if (!response.ok) {
998
- const errorData = await response.json();
999
- throw new Error(`Telegram API 错误: ${errorData.description}`);
1000
- }
1001
- } catch (error) {
1002
- Logger.error(`发送照片失败`, error, 'Bot.sendPhoto');
1003
- throw error;
1004
- }
1005
- }
1006
-
1007
- async getFileUrl(fileId) {
1008
- const response = await fetch(`${this.apiUrl}/getFile?file_id=${fileId}`);
1009
- const data = await response.json();
1010
- if (data.ok) {
1011
- return `https://api.telegram.org/file/bot${this.config.telegramBotToken}/${data.result.file_path}`;
1012
- }
1013
- throw new Error("获取文件 URL 失败");
1014
- }
1015
-
1016
- // --- 白名单 ---
1017
- isUserWhitelisted(userId) {
1018
- if (this.config.whitelistedUsers.length === 0) return true; // 没有白名单 = 公开
1019
- return this.config.whitelistedUsers.includes(userId);
1020
- }
1021
- isChatWhitelisted(chatId) {
1022
- if (this.config.whitelistedChats.length === 0) return true;
1023
- return this.config.whitelistedChats.includes(chatId);
1024
- }
1025
-
1026
- // --- 设置 & 定时任务 ---
1027
- async setWebhook() {
1028
- const url = `${this.config.webhookHost}/webhook`;
1029
- Logger.info(`正在设置 webhook: ${url}`, 'Bot.setWebhook');
1030
-
1031
- const response = await fetch(`${this.apiUrl}/setWebhook`, {
1032
- method: "POST",
1033
- headers: { "Content-Type": "application/json" },
1034
- body: JSON.stringify({ url, allowed_updates: ["message", "callback_query"] }),
1035
- });
1036
-
1037
- const result = await response.json();
1038
- if (!result.ok) {
1039
- Logger.error(`设置 webhook 失败: ${result.description}`, result, 'Bot.setWebhook');
1040
- throw new Error(`设置 webhook 失败: ${result.description}`);
1041
- }
1042
- Logger.info("Webhook 设置成功", 'Bot.setWebhook');
1043
- }
1044
-
1045
- async setMenuButton() {
1046
- const defaultCommands = this.commands.map((cmd) => ({
1047
- command: cmd.name,
1048
- description: Utils.translate(cmd.description),
1049
- }));
1050
-
1051
- try {
1052
- await fetch(`${this.apiUrl}/setMyCommands`, {
1053
- method: "POST",
1054
- headers: { "Content-Type": "application/json" },
1055
- body: JSON.stringify({ commands: defaultCommands }),
1056
- });
1057
- Logger.info("菜单按钮命令设置成功", 'Bot.setMenuButton');
1058
- } catch (error) {
1059
- Logger.warn("设置菜单按钮时出错", error, 'Bot.setMenuButton');
1060
- }
1061
- }
1062
-
1063
- async handleRSSUpdate() {
1064
- Logger.info('正在运行定时 RSS 更新...', 'Bot.handleRSSUpdate');
1065
- const chatIds = [-1002052237675, 6486168606, -1002357672489]; // 硬编码
1066
- const RSS_URL = 'https://ci-en.dlsite.com/creator/4551/article/xml/rss'; // 修复: 移除了 Markdown
1067
- const REDIS_KEY = "LAST_RSS_LINK";
1068
-
1069
- try {
1070
- const response = await fetch(RSS_URL);
1071
- const xmlText = await response.text();
1072
-
1073
- const match = /<rdf:li\s+rdf:resource="[^"]*\/article\/(\d+)"[\s\/>]/g.exec(xmlText);
1074
- if (!match) {
1075
- throw new Error("无法解析 RSS feed");
1076
- }
1077
-
1078
- const latestLink = match[1];
1079
- const lastKnownLink = await this.redis.get(REDIS_KEY);
1080
-
1081
- if (latestLink !== lastKnownLink) {
1082
- Logger.info(`发现新的 RSS 链接: ${latestLink}。旧: ${lastKnownLink}`, 'Bot.handleRSSUpdate');
1083
- const message = `主人,您订阅的魔装影姫cien更新啦!\n\nhttps://ci-en.dlsite.com/creator/4551/article/${latestLink}`;
1084
-
1085
- const sendPromises = chatIds.map(chatId => this.sendMessage(chatId, message));
1086
- await Promise.all(sendPromises);
1087
-
1088
- await this.redis.set(REDIS_KEY, latestLink); // 'set' 会使用默认 TTL
1089
- } else {
1090
- Logger.info('未发现新的 RSS 更新。', 'Bot.handleRSSUpdate');
1091
- }
1092
- } catch (error) {
1093
- Logger.error("RSS 更新期间出错", error, 'Bot.handleRSSUpdate');
1094
- }
1095
- }
1096
-
1097
- async getcienResource(chatId) {
1098
- // 这是 /cien 命令的实现
1099
- try {
1100
- const response = await fetch('https://ci-en.dlsite.com/creator/4551/article/xml/rss'); // 修复: 移除了 Markdown
1101
- const xmlText = await response.text();
1102
- const match = /<rdf:li\s+rdf:resource="[^"]*\/article\/(\d+)"[\s\/>]/g.exec(xmlText);
1103
- if (!match) throw new Error("无法提取 RSS 链接。");
1104
-
1105
- const latestLink = match[1];
1106
- await this.sendMessage(chatId, `主人,这是您订阅的魔装影姫cien最新的内容哦,请查收!\n\nhttps://ci-en.dlsite.com/creator/4551/article/${latestLink}`);
1107
- } catch (error) {
1108
- Logger.error("/cien 命令出错", error, 'Bot.getcienResource');
1109
- throw error;
1110
- }
1111
- }
1112
- }
1113
-
1114
- // === 主服务器 ===
1115
- /**
1116
- * 初始化并启动 Express 服务器。
1117
- */
1118
- async function main() {
1119
- Logger.info('正在启动机器人服务器...');
1120
-
1121
- // 1. 初始化配置
1122
- const config = new Config(process.env);
1123
-
1124
- // 2. 初始化机器人
1125
- const bot = new TelegramBot(config);
1126
-
1127
- // 3. 初始化 Express 应用
1128
- const app = express();
1129
- app.use(express.json()); // 用于解析 JSON 请求体的中间件
1130
-
1131
- // 4. 定义路由
1132
- // 健康检查路由
1133
- app.get('/', (req, res) => {
1134
- res.status(200).send('你好! 这是你的 Telegram 机器人服务器。');
1135
- });
1136
-
1137
- // Webhook 路由
1138
- app.post('/webhook', async (req, res) => {
1139
- try {
1140
- await bot.processUpdate(req.body);
1141
- res.status(200).send('OK'); // 总是快速向 Telegram 返回 200
1142
- } catch (error) {
1143
- Logger.error('webhook 处理中发生未捕获的错误', error, 'Server.webhook');
1144
- res.status(200).send('OK'); // 仍然发送 OK 以避免 Telegram 重试
1145
- }
1146
- });
1147
-
1148
- // 6. 启动定时任务
1149
- // 每 10 分钟运行一次
1150
- cron.schedule('*/10 * * * *', () => {
1151
- bot.handleRSSUpdate();
1152
- });
1153
- Logger.info('已注册 RSS 定时任务 (每 10 分钟运行一次)', 'Server.main');
1154
-
1155
- // 7. 启动服务器
1156
- app.listen(config.port, () => {
1157
- Logger.info(`服务器正在监听 http://localhost:${config.port}`, 'Server.main');
1158
- });
1159
- // 5. 设置机器人 (Webhook & 命令)
1160
- try {
1161
- if (process.env.NODE_ENV !== 'development') { // 通常不在本地开发时设置 webhook
1162
- await bot.setWebhook();
1163
- }
1164
- await bot.setMenuButton();
1165
- } catch (error) {
1166
- Logger.error('机器人设置失败', error, 'Server.main');
1167
- process.exit(1);
1168
- }
1169
- }
1170
-
1171
- // 运行服务器
1172
- main();
1173
-