chore: sync current WechatHookBot workspace
This commit is contained in:
38
plugins/MessageLogger/config.toml
Normal file
38
plugins/MessageLogger/config.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
# 消息记录插件配置
|
||||
|
||||
[database]
|
||||
host = "43.137.46.150"
|
||||
port = 3306
|
||||
user = "80012029Lz"
|
||||
password = "wechat_message"
|
||||
database = "wechat_message"
|
||||
charset = "utf8mb4"
|
||||
|
||||
[minio]
|
||||
enabled = true
|
||||
endpoint = "115.190.113.141:19000"
|
||||
access_key = "admin"
|
||||
secret_key = "80012029Lz"
|
||||
bucket = "wechat"
|
||||
secure = false
|
||||
public_base_url = "http://115.190.113.141:19000"
|
||||
|
||||
[redis]
|
||||
enabled = true # 是否启用 Redis 缓存
|
||||
host = "localhost" # Redis 服务器地址
|
||||
port = 6379 # Redis 端口
|
||||
password = "" # Redis 密码(无密码留空)
|
||||
db = 0 # Redis 数据库编号
|
||||
ttl = 259200 # 缓存过期时间(秒),3天 = 3*24*60*60
|
||||
|
||||
[behavior]
|
||||
enabled = true # 是否启用消息记录
|
||||
log_text = true # 记录文本消息
|
||||
log_image = true # 记录图片消息
|
||||
log_voice = true # 记录语音消息
|
||||
log_video = true # 记录视频消息
|
||||
log_file = true # 记录文件消息
|
||||
log_emoji = true # 记录表情包消息
|
||||
log_bot_messages = true # 记录机器人自身发送的消息
|
||||
fetch_avatar = true # 是否获取头像URL
|
||||
bot_avatar_url = "https://img.functen.cn/file/1763546795877_image.png" # 机器人头像URL(可选,留空则尝试自动获取)
|
||||
@@ -5,6 +5,7 @@
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
@@ -15,10 +16,17 @@ from utils.decorators import (
|
||||
on_image_message,
|
||||
on_voice_message,
|
||||
on_video_message,
|
||||
on_link_message,
|
||||
on_card_message,
|
||||
on_miniapp_message,
|
||||
on_file_message,
|
||||
on_emoji_message
|
||||
on_emoji_message,
|
||||
on_quote_message,
|
||||
)
|
||||
from utils.redis_cache import RedisCache, get_cache
|
||||
from utils.member_info_service import get_member_service
|
||||
from utils.config_manager import get_config
|
||||
import aiosqlite
|
||||
import pymysql
|
||||
from WechatHook import WechatHookClient
|
||||
from minio import Minio
|
||||
@@ -41,6 +49,9 @@ class MessageLogger(PluginBase):
|
||||
self.config = None
|
||||
self.db_config = None
|
||||
self.redis_cache = None # Redis 缓存实例
|
||||
self.minio_client = None
|
||||
self.minio_bucket = ""
|
||||
self.minio_public_base_url = ""
|
||||
|
||||
# 创建独立的日志记录器
|
||||
self._setup_logger()
|
||||
@@ -98,14 +109,23 @@ class MessageLogger(PluginBase):
|
||||
else:
|
||||
self.log.info("Redis 缓存未启用")
|
||||
|
||||
# 初始化 MinIO 客户端
|
||||
self.minio_client = Minio(
|
||||
"115.190.113.141:19000",
|
||||
access_key="admin",
|
||||
secret_key="80012029Lz",
|
||||
secure=False
|
||||
)
|
||||
self.minio_bucket = "wechat"
|
||||
# 初始化 MinIO 客户端(优先读取配置/环境变量,不再硬编码)
|
||||
minio_config = self._build_minio_config()
|
||||
if minio_config.get("enabled"):
|
||||
self.minio_client = Minio(
|
||||
minio_config["endpoint"],
|
||||
access_key=minio_config["access_key"],
|
||||
secret_key=minio_config["secret_key"],
|
||||
secure=minio_config["secure"],
|
||||
)
|
||||
self.minio_bucket = minio_config["bucket"]
|
||||
self.minio_public_base_url = minio_config["public_base_url"].rstrip("/")
|
||||
self.log.success(f"MinIO 初始化成功: endpoint={minio_config['endpoint']}, bucket={self.minio_bucket}")
|
||||
else:
|
||||
self.minio_client = None
|
||||
self.minio_bucket = ""
|
||||
self.minio_public_base_url = ""
|
||||
self.log.warning("MinIO 未配置或已禁用,媒体消息将不上传,仅记录文本信息")
|
||||
|
||||
# 设置全局实例,供其他地方调用
|
||||
MessageLogger._instance = self
|
||||
@@ -113,8 +133,8 @@ class MessageLogger(PluginBase):
|
||||
|
||||
# 测试数据库连接
|
||||
try:
|
||||
with self.get_db_connection() as conn:
|
||||
self.log.info("MessageLogger 数据库连接测试成功")
|
||||
await asyncio.to_thread(self._test_db_connection)
|
||||
self.log.info("MessageLogger 数据库连接测试成功")
|
||||
except Exception as e:
|
||||
self.log.error(f"MessageLogger 数据库连接测试失败: {e}")
|
||||
|
||||
@@ -136,6 +156,44 @@ class MessageLogger(PluginBase):
|
||||
logger.warning("MessageLogger 全局实例为空,可能插件未正确初始化")
|
||||
return instance
|
||||
|
||||
def _build_minio_config(self) -> dict:
|
||||
"""构建 MinIO 配置,环境变量优先于 config.toml。"""
|
||||
minio_config = (self.config or {}).get("minio", {})
|
||||
secure = bool(minio_config.get("secure", False))
|
||||
endpoint = os.getenv("MESSAGE_LOGGER_MINIO_ENDPOINT") or minio_config.get("endpoint", "")
|
||||
access_key = os.getenv("MESSAGE_LOGGER_MINIO_ACCESS_KEY") or minio_config.get("access_key", "")
|
||||
secret_key = os.getenv("MESSAGE_LOGGER_MINIO_SECRET_KEY") or minio_config.get("secret_key", "")
|
||||
bucket = os.getenv("MESSAGE_LOGGER_MINIO_BUCKET") or minio_config.get("bucket", "wechat")
|
||||
public_base_url = os.getenv("MESSAGE_LOGGER_MINIO_PUBLIC_BASE_URL") or minio_config.get("public_base_url", "")
|
||||
if not public_base_url and endpoint:
|
||||
public_base_url = f"{'https' if secure else 'http'}://{endpoint}"
|
||||
|
||||
enabled = bool(minio_config.get("enabled", True)) and all([endpoint, access_key, secret_key, bucket])
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"endpoint": endpoint,
|
||||
"access_key": access_key,
|
||||
"secret_key": secret_key,
|
||||
"bucket": bucket,
|
||||
"secure": secure,
|
||||
"public_base_url": public_base_url,
|
||||
}
|
||||
|
||||
def _test_db_connection(self):
|
||||
with self.get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
|
||||
async def on_unload(self):
|
||||
if getattr(MessageLogger, "_instance", None) is self:
|
||||
MessageLogger._instance = None
|
||||
if getattr(self, "logger_id", None):
|
||||
try:
|
||||
logger.remove(self.logger_id)
|
||||
except Exception:
|
||||
pass
|
||||
await super().on_unload()
|
||||
|
||||
|
||||
def get_db_connection(self):
|
||||
"""获取数据库连接"""
|
||||
@@ -149,6 +207,126 @@ class MessageLogger(PluginBase):
|
||||
autocommit=True
|
||||
)
|
||||
|
||||
def _fetch_latest_profile_from_history(self, sender_wxid: str) -> dict:
|
||||
with self.get_db_connection() as conn:
|
||||
with conn.cursor(pymysql.cursors.DictCursor) as cursor:
|
||||
sql = """
|
||||
SELECT nickname, avatar_url
|
||||
FROM messages
|
||||
WHERE sender_wxid = %s AND nickname != '' AND avatar_url != ''
|
||||
ORDER BY create_time DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
cursor.execute(sql, (sender_wxid,))
|
||||
return cursor.fetchone() or {}
|
||||
|
||||
def _insert_message_record(self, sender_wxid: str, nickname: str, avatar_url: str,
|
||||
content: str, msg_type: str, is_group: bool,
|
||||
group_id: str, media_url: str, create_time: datetime):
|
||||
with self.get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
sql = """
|
||||
INSERT INTO messages
|
||||
(sender_wxid, nickname, avatar_url, content, msg_type,
|
||||
is_group, group_id, media_url, create_time)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
cursor.execute(sql, (
|
||||
sender_wxid,
|
||||
nickname,
|
||||
avatar_url,
|
||||
content,
|
||||
msg_type,
|
||||
int(is_group),
|
||||
group_id,
|
||||
media_url,
|
||||
create_time,
|
||||
))
|
||||
|
||||
def _load_bot_profile_from_main_config(self) -> tuple[str, str, str]:
|
||||
bot_config = get_config().get_section("Bot")
|
||||
bot_wxid = bot_config.get("wxid", "bot")
|
||||
bot_nickname = bot_config.get("nickname", "机器人")
|
||||
bot_avatar_url = bot_config.get("avatar_url", "")
|
||||
return bot_wxid, bot_nickname, bot_avatar_url
|
||||
|
||||
def _get_member_sync_db_path(self):
|
||||
"""获取 MemberSync SQLite 数据库路径(带缓存)"""
|
||||
if hasattr(self, "_member_sync_db_path"):
|
||||
return self._member_sync_db_path
|
||||
try:
|
||||
config_path = Path(__file__).parent.parent / "MemberSync" / "config.toml"
|
||||
if not config_path.exists():
|
||||
self.log.warning(f"MemberSync 配置不存在: {config_path}")
|
||||
self._member_sync_db_path = None
|
||||
return None
|
||||
with open(config_path, "rb") as f:
|
||||
cfg = tomllib.load(f)
|
||||
db_rel = (cfg.get("database") or {}).get("db_path", "data/member_sync.db")
|
||||
self._member_sync_db_path = Path(__file__).parent.parent / "MemberSync" / db_rel
|
||||
except Exception as e:
|
||||
self.log.warning(f"读取 MemberSync 配置失败: {e}")
|
||||
self._member_sync_db_path = None
|
||||
return self._member_sync_db_path
|
||||
|
||||
async def _update_member_activity(self, chatroom_wxid: str, wxid: str, msg_time: datetime):
|
||||
"""更新群成员最后发言时间与周期统计(MemberSync)"""
|
||||
if not chatroom_wxid or not wxid:
|
||||
return
|
||||
db_path = self._get_member_sync_db_path()
|
||||
if not db_path or not db_path.exists():
|
||||
return
|
||||
|
||||
last_msg_at = msg_time.strftime("%Y年%m月%d日%H时%M分钟")
|
||||
daily_key = msg_time.strftime("%Y-%m-%d")
|
||||
iso_year, iso_week, _ = msg_time.isocalendar()
|
||||
weekly_key = f"{iso_year}-W{iso_week:02d}"
|
||||
monthly_key = msg_time.strftime("%Y-%m")
|
||||
try:
|
||||
async with aiosqlite.connect(db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE group_members
|
||||
SET
|
||||
last_msg_at = CASE
|
||||
WHEN last_msg_at IS NULL OR last_msg_at = '' OR last_msg_at < ?
|
||||
THEN ?
|
||||
ELSE last_msg_at
|
||||
END,
|
||||
daily_key = ?,
|
||||
daily_count = CASE
|
||||
WHEN daily_key = ? THEN COALESCE(daily_count, 0) + 1
|
||||
ELSE 1
|
||||
END,
|
||||
weekly_key = ?,
|
||||
weekly_count = CASE
|
||||
WHEN weekly_key = ? THEN COALESCE(weekly_count, 0) + 1
|
||||
ELSE 1
|
||||
END,
|
||||
monthly_key = ?,
|
||||
monthly_count = CASE
|
||||
WHEN monthly_key = ? THEN COALESCE(monthly_count, 0) + 1
|
||||
ELSE 1
|
||||
END
|
||||
WHERE chatroom_wxid = ? AND wxid = ?
|
||||
""",
|
||||
(
|
||||
last_msg_at,
|
||||
last_msg_at,
|
||||
daily_key,
|
||||
daily_key,
|
||||
weekly_key,
|
||||
weekly_key,
|
||||
monthly_key,
|
||||
monthly_key,
|
||||
chatroom_wxid,
|
||||
wxid,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
self.log.warning(f"更新 MemberSync 最后发言时间失败: {e}")
|
||||
|
||||
def extract_image_info(self, raw_msg: str) -> tuple:
|
||||
"""从图片消息中提取 CDN URL 和 AES Key"""
|
||||
try:
|
||||
@@ -192,7 +370,9 @@ class MessageLogger(PluginBase):
|
||||
def extract_cdn_url(self, raw_msg: str) -> str:
|
||||
"""从消息中提取 CDN URL(表情包等)"""
|
||||
try:
|
||||
match = re.search(r'cdnurl="([^"]+)"', raw_msg)
|
||||
match = re.search(r'cdnurl\s*=\s*"([^"]+)"', raw_msg)
|
||||
if not match:
|
||||
match = re.search(r"cdnurl\s*=\s*'([^']+)'", raw_msg)
|
||||
if match:
|
||||
url = match.group(1).replace("&", "&")
|
||||
return url
|
||||
@@ -200,6 +380,43 @@ class MessageLogger(PluginBase):
|
||||
self.log.error(f"提取 CDN URL 失败: {e}")
|
||||
return ""
|
||||
|
||||
def _summarize_content_for_storage(self, msg_type: str, content: str) -> str:
|
||||
"""将非文本 XML 正文转换为可读摘要,避免把整段 XML 写入 content 字段"""
|
||||
if not isinstance(content, str):
|
||||
return str(content) if content is not None else ""
|
||||
|
||||
text = content.strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# 仅处理明显的 XML 载荷,普通文本保持原样
|
||||
is_xml_payload = text.startswith("<msg") or text.startswith("<?xml")
|
||||
if msg_type == "text" or not is_xml_payload:
|
||||
return content
|
||||
|
||||
if msg_type == "emoji":
|
||||
return "[表情消息]"
|
||||
if msg_type == "voice":
|
||||
try:
|
||||
root = ET.fromstring(text)
|
||||
voice_node = root.find(".//voicemsg")
|
||||
if voice_node is not None:
|
||||
voice_length = voice_node.get("voicelength", "")
|
||||
if voice_length:
|
||||
return f"[语音消息] 时长={voice_length}ms"
|
||||
except Exception:
|
||||
pass
|
||||
return "[语音消息]"
|
||||
if msg_type == "image":
|
||||
return "[图片消息]"
|
||||
if msg_type == "video":
|
||||
return "[视频消息]"
|
||||
if msg_type == "file":
|
||||
filename, _, _, _, _ = self.extract_file_info(text)
|
||||
return f"[文件消息] {filename}" if filename else "[文件消息]"
|
||||
|
||||
return f"[{msg_type}消息]"
|
||||
|
||||
def extract_file_info(self, raw_msg: str) -> tuple:
|
||||
"""从文件消息中提取文件信息"""
|
||||
try:
|
||||
@@ -230,66 +447,73 @@ class MessageLogger(PluginBase):
|
||||
self.log.error(f"提取文件信息失败: {e}")
|
||||
return ("", "", "", "", "0")
|
||||
|
||||
async def download_image_and_upload(self, bot, cdnurl: str, aeskey: str) -> str:
|
||||
"""下载图片并上传到 MinIO,同时缓存 base64 供其他插件使用"""
|
||||
async def download_image_and_upload(self, bot, message: dict) -> str:
|
||||
"""下载图片并上传到 MinIO(使用新协议),同时缓存 base64 供其他插件使用"""
|
||||
try:
|
||||
temp_file = Path(__file__).parent / f"temp_{uuid.uuid4().hex}.jpg"
|
||||
success = await bot.cdn_download(cdnurl, aeskey, str(temp_file), file_type=2)
|
||||
if not success:
|
||||
success = await bot.cdn_download(cdnurl, aeskey, str(temp_file), file_type=1)
|
||||
|
||||
# 等待文件下载完成
|
||||
import asyncio
|
||||
import base64
|
||||
for _ in range(50):
|
||||
if temp_file.exists() and temp_file.stat().st_size > 0:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
temp_file = Path(__file__).parent / f"temp_{uuid.uuid4().hex}.jpg"
|
||||
|
||||
if temp_file.exists() and temp_file.stat().st_size > 0:
|
||||
self.log.info(f"开始下载图片到: {temp_file}")
|
||||
|
||||
# 使用统一入口下载图片
|
||||
result = await bot.download_wechat_media("image", message=message, save_path=str(temp_file))
|
||||
|
||||
self.log.info(f"下载图片返回: result={result}")
|
||||
|
||||
# 使用实际返回的路径(可能与请求路径不同)
|
||||
actual_file = Path(result) if result and result != "expired" else temp_file
|
||||
|
||||
if result and actual_file.exists() and actual_file.stat().st_size > 0:
|
||||
self.log.info(f"图片文件已生成: {actual_file}, size={actual_file.stat().st_size}")
|
||||
# 读取文件并缓存 base64(供 AIChat 等插件使用)
|
||||
with open(temp_file, "rb") as f:
|
||||
with open(actual_file, "rb") as f:
|
||||
image_data = f.read()
|
||||
base64_data = f"data:image/jpeg;base64,{base64.b64encode(image_data).decode()}"
|
||||
|
||||
# 缓存到 Redis(5分钟过期)
|
||||
redis_cache = get_cache()
|
||||
if redis_cache and redis_cache.enabled:
|
||||
media_key = RedisCache.generate_media_key(cdnurl, aeskey)
|
||||
if media_key:
|
||||
redis_cache.cache_media(media_key, base64_data, "image", ttl=300)
|
||||
self.log.debug(f"图片已缓存到 Redis: {media_key[:20]}...")
|
||||
# 使用消息ID作为缓存key
|
||||
msg_id = message.get("_raw", message).get("msgId", "")
|
||||
new_msg_id = message.get("_raw", message).get("newMsgId", "")
|
||||
self.log.info(f"准备缓存图片: msgId={msg_id}, newMsgId={new_msg_id}")
|
||||
if msg_id:
|
||||
media_key = f"image:{msg_id}"
|
||||
redis_cache.cache_media(media_key, base64_data, "image", ttl=900)
|
||||
self.log.info(f"图片已缓存到 Redis: {media_key}")
|
||||
# 同时使用 newMsgId (svrid) 作为缓存key,供引用消息使用
|
||||
if new_msg_id:
|
||||
media_key_svrid = f"image:svrid:{new_msg_id}"
|
||||
redis_cache.cache_media(media_key_svrid, base64_data, "image", ttl=900)
|
||||
self.log.info(f"图片已缓存到 Redis (svrid): {media_key_svrid}")
|
||||
|
||||
media_url = await self.upload_file_to_minio(str(temp_file), "images")
|
||||
temp_file.unlink()
|
||||
media_url = await self.upload_file_to_minio(str(actual_file), "images")
|
||||
actual_file.unlink()
|
||||
return media_url
|
||||
else:
|
||||
self.log.error("图片下载超时或失败")
|
||||
self.log.error(f"图片下载失败: result={result}, actual_file={actual_file}, exists={actual_file.exists() if actual_file else False}")
|
||||
return ""
|
||||
except Exception as e:
|
||||
self.log.error(f"下载图片并上传失败: {e}")
|
||||
return ""
|
||||
|
||||
async def download_video_and_upload(self, bot, cdnurl: str, aeskey: str) -> str:
|
||||
"""下载视频并上传到 MinIO"""
|
||||
async def download_video_and_upload(self, bot, message: dict) -> str:
|
||||
"""下载视频并上传到 MinIO(使用新协议)"""
|
||||
try:
|
||||
temp_file = Path(__file__).parent / f"temp_{uuid.uuid4().hex}.mp4"
|
||||
# file_type=4 是视频
|
||||
success = await bot.cdn_download(cdnurl, aeskey, str(temp_file), file_type=4)
|
||||
|
||||
# 等待文件下载完成(视频较大,等待时间更长)
|
||||
import asyncio
|
||||
for _ in range(100):
|
||||
if temp_file.exists() and temp_file.stat().st_size > 0:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
# 使用统一入口下载视频
|
||||
result = await bot.download_wechat_media("video", message=message, save_path=str(temp_file))
|
||||
|
||||
if temp_file.exists() and temp_file.stat().st_size > 0:
|
||||
media_url = await self.upload_file_to_minio(str(temp_file), "videos")
|
||||
temp_file.unlink()
|
||||
# 使用实际返回的路径(可能与请求路径不同)
|
||||
actual_file = Path(result) if result and result != "expired" else temp_file
|
||||
|
||||
if result and actual_file.exists() and actual_file.stat().st_size > 0:
|
||||
media_url = await self.upload_file_to_minio(str(actual_file), "videos")
|
||||
actual_file.unlink()
|
||||
return media_url
|
||||
else:
|
||||
self.log.error("视频下载超时或失败")
|
||||
self.log.error("视频下载失败")
|
||||
return ""
|
||||
except Exception as e:
|
||||
self.log.error(f"下载视频并上传失败: {e}")
|
||||
@@ -305,10 +529,14 @@ class MessageLogger(PluginBase):
|
||||
temp_filename = f"temp_{uuid.uuid4().hex}_{filename}"
|
||||
|
||||
temp_file = Path(__file__).parent / temp_filename
|
||||
|
||||
|
||||
# 新接口不支持 CDN 下载文件,暂时跳过
|
||||
self.log.warning(f"新接口暂不支持文件下载: {filename}")
|
||||
return ""
|
||||
|
||||
# file_type=5 是文件
|
||||
self.log.info(f"开始下载文件: {filename}")
|
||||
success = await bot.cdn_download(cdnurl, aeskey, str(temp_file), file_type=5)
|
||||
# self.log.info(f"开始下载文件: {filename}")
|
||||
# success = await bot.cdn_download(cdnurl, aeskey, str(temp_file), file_type=5)
|
||||
|
||||
# 等待文件下载完成
|
||||
import asyncio
|
||||
@@ -389,6 +617,10 @@ class MessageLogger(PluginBase):
|
||||
async def upload_file_to_minio(self, local_file: str, file_type: str, original_filename: str = "") -> str:
|
||||
"""上传文件到 MinIO"""
|
||||
try:
|
||||
if not self.minio_client or not self.minio_bucket:
|
||||
self.log.warning("MinIO 未启用,跳过媒体上传")
|
||||
return ""
|
||||
|
||||
# 生成唯一文件名
|
||||
file_ext = Path(local_file).suffix
|
||||
unique_id = uuid.uuid4().hex
|
||||
@@ -415,7 +647,10 @@ class MessageLogger(PluginBase):
|
||||
)
|
||||
|
||||
# 返回访问 URL
|
||||
url = f"http://115.190.113.141:19000/{self.minio_bucket}/{object_name}"
|
||||
if self.minio_public_base_url:
|
||||
url = f"{self.minio_public_base_url}/{self.minio_bucket}/{object_name}"
|
||||
else:
|
||||
url = f"/{self.minio_bucket}/{object_name}"
|
||||
self.log.debug(f"文件上传成功: {url}")
|
||||
return url
|
||||
|
||||
@@ -433,6 +668,7 @@ class MessageLogger(PluginBase):
|
||||
from_wxid = message.get("FromWxid", "")
|
||||
is_group = message.get("IsGroup", False)
|
||||
content = message.get("Content", "")
|
||||
content_for_storage = self._summarize_content_for_storage(msg_type, content)
|
||||
create_time = message.get("CreateTime", 0)
|
||||
|
||||
# 转换时间戳
|
||||
@@ -441,70 +677,42 @@ class MessageLogger(PluginBase):
|
||||
else:
|
||||
msg_time = datetime.now()
|
||||
|
||||
# 获取昵称和头像
|
||||
# 获取昵称和头像(优先使用 MemberSync 数据库)
|
||||
nickname = ""
|
||||
avatar_url = ""
|
||||
|
||||
if is_group and self.config["behavior"]["fetch_avatar"]:
|
||||
cache_hit = False
|
||||
# 1. 优先从 MemberSync 数据库获取
|
||||
member_service = get_member_service()
|
||||
member_info = await member_service.get_chatroom_member_info(from_wxid, sender_wxid)
|
||||
if not member_info:
|
||||
member_info = await member_service.get_member_info(sender_wxid)
|
||||
if member_info:
|
||||
nickname = member_info.get("nickname", "")
|
||||
avatar_url = member_info.get("avatar_url", "")
|
||||
self.log.debug(f"[MemberSync数据库命中] {sender_wxid}: {nickname}")
|
||||
|
||||
# 1. 先尝试从 Redis 缓存获取
|
||||
if self.redis_cache and self.redis_cache.enabled:
|
||||
cached_info = self.redis_cache.get_user_basic_info(from_wxid, sender_wxid)
|
||||
if cached_info:
|
||||
nickname = cached_info.get("nickname", "")
|
||||
avatar_url = cached_info.get("avatar_url", "")
|
||||
if nickname and avatar_url:
|
||||
cache_hit = True
|
||||
self.log.debug(f"[缓存命中] {sender_wxid}: {nickname}")
|
||||
# 2. 数据库未命中,尝试 Redis 缓存
|
||||
if not nickname or not avatar_url:
|
||||
if self.redis_cache and self.redis_cache.enabled:
|
||||
cached_info = self.redis_cache.get_user_basic_info(from_wxid, sender_wxid)
|
||||
if cached_info:
|
||||
nickname = cached_info.get("nickname", "") or nickname
|
||||
avatar_url = cached_info.get("avatar_url", "") or avatar_url
|
||||
if nickname and avatar_url:
|
||||
self.log.debug(f"[Redis缓存命中] {sender_wxid}: {nickname}")
|
||||
|
||||
# 2. 缓存未命中,调用 API 获取
|
||||
if not cache_hit:
|
||||
try:
|
||||
self.log.info(f"[缓存未命中] 调用API获取用户信息: {sender_wxid}")
|
||||
user_info = await bot.get_user_info_in_chatroom(from_wxid, sender_wxid)
|
||||
|
||||
if user_info:
|
||||
# 处理不同的数据结构
|
||||
if isinstance(user_info.get("nickName"), dict):
|
||||
nickname = user_info.get("nickName", {}).get("string", "")
|
||||
else:
|
||||
nickname = user_info.get("nickName", "")
|
||||
|
||||
avatar_url = user_info.get("bigHeadImgUrl", "")
|
||||
self.log.info(f"API获取成功: nickname={nickname}, avatar_url={avatar_url[:50] if avatar_url else ''}...")
|
||||
|
||||
# 3. 将用户信息存入 Redis 缓存
|
||||
if self.redis_cache and self.redis_cache.enabled and nickname:
|
||||
self.redis_cache.set_user_info(from_wxid, sender_wxid, user_info)
|
||||
self.log.debug(f"[已缓存] {sender_wxid}: {nickname}")
|
||||
else:
|
||||
self.log.warning(f"用户信息为空: {sender_wxid}")
|
||||
|
||||
except Exception as e:
|
||||
self.log.error(f"获取用户信息失败: {e}")
|
||||
|
||||
# 4. 如果仍然没有获取到,从历史记录中查找
|
||||
# 3. 如果仍然没有获取到,从历史记录中查找
|
||||
if not nickname or not avatar_url:
|
||||
self.log.info(f"尝试从历史记录获取用户信息: {sender_wxid}")
|
||||
try:
|
||||
with self.get_db_connection() as conn:
|
||||
with conn.cursor(pymysql.cursors.DictCursor) as cursor:
|
||||
sql = """
|
||||
SELECT nickname, avatar_url
|
||||
FROM messages
|
||||
WHERE sender_wxid = %s AND nickname != '' AND avatar_url != ''
|
||||
ORDER BY create_time DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
cursor.execute(sql, (sender_wxid,))
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
if not nickname:
|
||||
nickname = result.get("nickname", "")
|
||||
if not avatar_url:
|
||||
avatar_url = result.get("avatar_url", "")
|
||||
self.log.success(f"从历史记录获取成功: nickname={nickname}, avatar_url={avatar_url[:50] if avatar_url else '(空)'}...")
|
||||
result = await asyncio.to_thread(self._fetch_latest_profile_from_history, sender_wxid)
|
||||
if result:
|
||||
if not nickname:
|
||||
nickname = result.get("nickname", "")
|
||||
if not avatar_url:
|
||||
avatar_url = result.get("avatar_url", "")
|
||||
self.log.success(f"从历史记录获取成功: nickname={nickname}, avatar_url={avatar_url[:50] if avatar_url else '(空)'}...")
|
||||
except Exception as e:
|
||||
self.log.error(f"从历史记录获取用户信息失败: {e}")
|
||||
elif not is_group and self.config["behavior"]["fetch_avatar"]:
|
||||
@@ -528,32 +736,18 @@ class MessageLogger(PluginBase):
|
||||
if cdn_url and cdn_url.startswith("http"):
|
||||
media_url = await self.download_and_upload(cdn_url, "emojis", ".gif")
|
||||
|
||||
# 图片消息 - 使用 CDN 下载 API
|
||||
# 图片消息 - 使用新协议下载
|
||||
elif msg_type == "image":
|
||||
cdnurl, aeskey = self.extract_image_info(content)
|
||||
if cdnurl and aeskey:
|
||||
media_url = await self.download_image_and_upload(bot, cdnurl, aeskey)
|
||||
media_url = await self.download_image_and_upload(bot, message)
|
||||
|
||||
# 视频消息 - 使用 CDN 下载 API
|
||||
# 视频消息 - 使用新协议下载
|
||||
elif msg_type == "video":
|
||||
self.log.info(f"处理视频消息: from={from_wxid}, sender={sender_wxid}")
|
||||
cdnurl, aeskey = self.extract_video_info(content)
|
||||
if cdnurl and aeskey:
|
||||
self.log.info(f"开始下载并上传视频: {cdnurl[:50]}...")
|
||||
media_url = await self.download_video_and_upload(bot, cdnurl, aeskey)
|
||||
if media_url:
|
||||
self.log.success(f"视频上传成功: {media_url}")
|
||||
else:
|
||||
self.log.error("视频上传失败")
|
||||
elif message.get("Video"):
|
||||
self.log.info("使用消息中的视频数据")
|
||||
video_data = message["Video"]
|
||||
temp_file = Path(__file__).parent / f"temp_{uuid.uuid4().hex}.mp4"
|
||||
temp_file.write_bytes(video_data)
|
||||
media_url = await self.upload_file_to_minio(str(temp_file), "videos")
|
||||
temp_file.unlink()
|
||||
media_url = await self.download_video_and_upload(bot, message)
|
||||
if media_url:
|
||||
self.log.success(f"视频上传成功: {media_url}")
|
||||
else:
|
||||
self.log.warning("视频消息中没有找到可用的CDN信息或视频数据")
|
||||
self.log.error("视频下载或上传失败")
|
||||
|
||||
# 语音消息
|
||||
elif msg_type == "voice":
|
||||
@@ -586,28 +780,25 @@ class MessageLogger(PluginBase):
|
||||
else:
|
||||
self.log.warning("文件消息中没有找到可用的CDN信息或文件数据")
|
||||
|
||||
# 保存到数据库
|
||||
with self.get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
sql = """
|
||||
INSERT INTO messages
|
||||
(sender_wxid, nickname, avatar_url, content, msg_type,
|
||||
is_group, group_id, media_url, create_time)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
cursor.execute(sql, (
|
||||
sender_wxid,
|
||||
nickname,
|
||||
avatar_url,
|
||||
content,
|
||||
msg_type,
|
||||
int(is_group),
|
||||
group_id,
|
||||
media_url,
|
||||
msg_time
|
||||
))
|
||||
# 保存到数据库(放到线程池,避免阻塞事件循环)
|
||||
await asyncio.to_thread(
|
||||
self._insert_message_record,
|
||||
sender_wxid,
|
||||
nickname,
|
||||
avatar_url,
|
||||
content_for_storage,
|
||||
msg_type,
|
||||
is_group,
|
||||
group_id,
|
||||
media_url,
|
||||
msg_time,
|
||||
)
|
||||
|
||||
self.log.debug(f"消息已保存: {sender_wxid} - {content[:20]}...")
|
||||
self.log.debug(f"消息已保存: {sender_wxid} - {content_for_storage[:20]}...")
|
||||
|
||||
# 记录群成员最后发言时间与周期统计(仅群聊)
|
||||
if is_group:
|
||||
await self._update_member_activity(from_wxid, sender_wxid, msg_time)
|
||||
|
||||
except Exception as e:
|
||||
self.log.error(f"保存消息失败: {e}")
|
||||
@@ -622,19 +813,40 @@ class MessageLogger(PluginBase):
|
||||
|
||||
try:
|
||||
# 获取机器人信息
|
||||
import tomllib
|
||||
with open("main_config.toml", "rb") as f:
|
||||
main_config = tomllib.load(f)
|
||||
|
||||
bot_config = main_config.get("Bot", {})
|
||||
bot_wxid = bot_config.get("wxid", "bot")
|
||||
bot_nickname = bot_config.get("nickname", "机器人")
|
||||
bot_avatar_url = ""
|
||||
bot_wxid, bot_nickname, bot_avatar_url = self._load_bot_profile_from_main_config()
|
||||
main_config_avatar = bot_avatar_url
|
||||
|
||||
# 判断是否是群聊(需要先定义,后面会用到)
|
||||
is_group = to_wxid.endswith("@chatroom")
|
||||
group_id = to_wxid if is_group else None
|
||||
|
||||
# 机器人媒体消息:如果传入的是本地文件路径,先上传到 MinIO 再入库
|
||||
final_media_url = media_url
|
||||
if msg_type in {"image", "video", "file", "voice"} and media_url:
|
||||
is_remote_url = media_url.startswith("http://") or media_url.startswith("https://")
|
||||
if not is_remote_url:
|
||||
local_path = Path(media_url)
|
||||
if local_path.exists() and local_path.is_file():
|
||||
media_type_map = {
|
||||
"image": "images",
|
||||
"video": "videos",
|
||||
"file": "files",
|
||||
"voice": "voices",
|
||||
}
|
||||
upload_type = media_type_map.get(msg_type, "files")
|
||||
uploaded_url = await self.upload_file_to_minio(
|
||||
str(local_path),
|
||||
upload_type,
|
||||
local_path.name
|
||||
)
|
||||
if uploaded_url:
|
||||
final_media_url = uploaded_url
|
||||
self.log.info(f"机器人{msg_type}消息已上传到 MinIO: {uploaded_url}")
|
||||
else:
|
||||
self.log.warning(f"机器人{msg_type}消息上传 MinIO 失败,保留原 media_url")
|
||||
else:
|
||||
self.log.warning(f"机器人{msg_type}消息本地文件不存在: {media_url}")
|
||||
|
||||
# 获取机器人头像(如果启用了头像获取功能)
|
||||
if self.config["behavior"]["fetch_avatar"]:
|
||||
try:
|
||||
@@ -662,7 +874,7 @@ class MessageLogger(PluginBase):
|
||||
self.log.info("API无法获取机器人自己的头像,建议在配置中设置bot_avatar_url")
|
||||
|
||||
# 可以尝试从主配置获取
|
||||
main_avatar = bot_config.get("avatar_url", "")
|
||||
main_avatar = main_config_avatar
|
||||
if main_avatar:
|
||||
bot_avatar_url = main_avatar
|
||||
self.log.info(f"从主配置获取机器人头像: {bot_avatar_url}")
|
||||
@@ -675,26 +887,19 @@ class MessageLogger(PluginBase):
|
||||
self.log.warning(f"获取机器人头像失败: {e}")
|
||||
bot_avatar_url = ""
|
||||
|
||||
# 保存到数据库
|
||||
with self.get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
sql = """
|
||||
INSERT INTO messages
|
||||
(sender_wxid, nickname, avatar_url, content, msg_type,
|
||||
is_group, group_id, media_url, create_time)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
cursor.execute(sql, (
|
||||
bot_wxid,
|
||||
bot_nickname,
|
||||
bot_avatar_url, # 使用获取到的机器人头像URL
|
||||
content,
|
||||
msg_type,
|
||||
int(is_group),
|
||||
group_id,
|
||||
media_url,
|
||||
datetime.now()
|
||||
))
|
||||
# 保存到数据库(放到线程池,避免阻塞事件循环)
|
||||
await asyncio.to_thread(
|
||||
self._insert_message_record,
|
||||
bot_wxid,
|
||||
bot_nickname,
|
||||
bot_avatar_url,
|
||||
content,
|
||||
msg_type,
|
||||
is_group,
|
||||
group_id,
|
||||
final_media_url,
|
||||
datetime.now(),
|
||||
)
|
||||
|
||||
self.log.debug(f"机器人消息已保存: {bot_wxid} -> {to_wxid} - {content[:20]}...")
|
||||
|
||||
@@ -732,6 +937,34 @@ class MessageLogger(PluginBase):
|
||||
asyncio.create_task(self.save_message(message, "video", bot))
|
||||
return True
|
||||
|
||||
@on_link_message(priority=10)
|
||||
async def handle_link(self, bot: WechatHookClient, message: dict):
|
||||
"""处理链接消息"""
|
||||
if self.config and self.config["behavior"].get("log_text", True):
|
||||
asyncio.create_task(self.save_message(message, "link", bot))
|
||||
return True
|
||||
|
||||
@on_card_message(priority=10)
|
||||
async def handle_card(self, bot: WechatHookClient, message: dict):
|
||||
"""处理名片消息"""
|
||||
if self.config and self.config["behavior"].get("log_text", True):
|
||||
asyncio.create_task(self.save_message(message, "card", bot))
|
||||
return True
|
||||
|
||||
@on_miniapp_message(priority=10)
|
||||
async def handle_miniapp(self, bot: WechatHookClient, message: dict):
|
||||
"""处理小程序消息"""
|
||||
if self.config and self.config["behavior"].get("log_text", True):
|
||||
asyncio.create_task(self.save_message(message, "miniapp", bot))
|
||||
return True
|
||||
|
||||
@on_quote_message(priority=10)
|
||||
async def handle_quote(self, bot: WechatHookClient, message: dict):
|
||||
"""处理引用消息"""
|
||||
if self.config and self.config["behavior"].get("log_text", True):
|
||||
asyncio.create_task(self.save_message(message, "quote", bot))
|
||||
return True
|
||||
|
||||
@on_file_message(priority=10)
|
||||
async def handle_file(self, bot: WechatHookClient, message: dict):
|
||||
"""处理文件消息"""
|
||||
|
||||
Reference in New Issue
Block a user