chore: sync current WechatHookBot workspace

This commit is contained in:
2026-03-09 15:48:45 +08:00
parent 4016c1e6eb
commit 9119e2307d
195 changed files with 24438 additions and 17498 deletions

View 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可选留空则尝试自动获取

View File

@@ -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("&amp;", "&")
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()}"
# 缓存到 Redis5分钟过期
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):
"""处理文件消息"""