nonebot-plugin-myom 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,576 @@
1
+ # Copyright (C) 2025 shadow3aaa <shadow3aaaa@gmail.com>
2
+ # Modified by Funny1Potato, 2026
3
+ # Modified by XI-mouren, 2026
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU Affero General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU Affero General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU Affero General Public License
16
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import asyncio
19
+ import base64
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime
22
+ import re
23
+ import ssl
24
+ import traceback
25
+
26
+ import anyio
27
+ import httpx
28
+ from nonebot import logger, on_command, on_message, require
29
+ from nonebot.rule import to_me
30
+ from nonebot.adapters import Message
31
+ from nonebot.adapters.onebot.v11 import (
32
+ Bot, Event, GroupMessageEvent, MessageSegment, Message as OneBotMessage, PrivateMessageEvent,
33
+ )
34
+ from nonebot.params import CommandArg
35
+ from nonebot.permission import SUPERUSER
36
+ from nonebot.plugin import PluginMetadata
37
+ from openai import AsyncOpenAI
38
+
39
+ require("nonebot_plugin_localstore")
40
+
41
+ import nonebot_plugin_localstore as store
42
+
43
+
44
+ # 历史包名(从新到旧):内部代号 → 上游原名
45
+ LEGACY_PLUGIN_DIRS = ("nonebot_plugin_muelsyse", "nonebot_plugin_aigf")
46
+
47
+
48
+ def _migrate_legacy_dirs() -> None:
49
+ """包名变更后自动接管旧数据。
50
+
51
+ 历史包名:nonebot_plugin_aigf(上游原名)→ nonebot_plugin_muelsyse(内部代号)
52
+ → nonebot_plugin_myom(当前)。localstore 按插件名分配目录,改名后新目录是空的;
53
+ 这里按「新 → 旧」顺序找第一个存在的旧目录,把 data/config/cache 整体拷过来,
54
+ 避免群友画像、记忆、表情包和用量记录「凭空消失」。
55
+ 只在目标目录不存在或为空时执行,旧目录保留不删(确认无误后可自行清理)。
56
+ """
57
+ import shutil
58
+
59
+ for getter, label in (
60
+ (store.get_plugin_data_dir, "数据"),
61
+ (store.get_plugin_config_dir, "配置"),
62
+ (store.get_plugin_cache_dir, "缓存"),
63
+ ):
64
+ try:
65
+ new_dir = getter()
66
+ if new_dir.exists() and any(new_dir.iterdir()):
67
+ continue # 新目录已有内容,绝不覆盖
68
+ for old_name in LEGACY_PLUGIN_DIRS:
69
+ old_dir = new_dir.parent / old_name
70
+ if not old_dir.is_dir() or old_dir == new_dir:
71
+ continue
72
+ new_dir.mkdir(parents=True, exist_ok=True)
73
+ shutil.copytree(old_dir, new_dir, dirs_exist_ok=True)
74
+ logger.info(f"[myom] 已从旧目录接管{label}:{old_dir} -> {new_dir}")
75
+ break
76
+ except Exception as e:
77
+ logger.warning(f"[myom] 接管旧{label}目录失败(不影响新目录使用):{e}")
78
+
79
+
80
+ _migrate_legacy_dirs()
81
+
82
+ from .client import LLMClient, make_http_client
83
+ from .config import Config, plugin_config
84
+ from . import energy
85
+ from .image_manager import IMAGE_CACHE_DIR, image_manager
86
+ from .meme_manager import AtMessage, MemeMessage, TextMessage, meme_manager
87
+ from .mem import Message as MMessage
88
+ from .presets import PRESETS, load_presets
89
+ from .session import Session
90
+ from .security import check_leak, log_leak, redact
91
+ # 用量统计模块:必须随插件导入即加载,导入时注册查询命令、4点定时任务与启动补录。
92
+ # 若只在函数内懒加载,命令将永不注册(群聊/私聊都用不了)。
93
+ from . import usage # noqa: F401
94
+
95
+ __plugin_meta__ = PluginMetadata(
96
+ name="AI 群友(MYOM)",
97
+ description="群聊特化的拟人化 LLM 聊天机器人:分层记忆、群友画像、表情包学习、联网搜索、生物节律能量与 token 用量统计",
98
+ usage=(
99
+ "入群即用,无需 @ 也能自然接话。\n"
100
+ "用量查询:日用量 [n] / 周用量 [n] / 月用量 [n] / 总用量 / 群用量 [n] / 用量价格 / 用量配置 / 用量帮助\n"
101
+ "管理指令(需 @ 或私聊):状态 / 重载节律 / 预设 / 设置预设 / 重置 / 清除用量 日|周|月|全部"
102
+ ),
103
+ type="application",
104
+ config=Config,
105
+ supported_adapters={"~onebot.v11"},
106
+ homepage="https://github.com/XI-mouren/nonebot-plugin-myom",
107
+ extra={
108
+ "author": "XI-mouren",
109
+ "upstream": "https://github.com/Funny1Potato/nonebot-plugin-aigf",
110
+ "license": "AGPL-3.0-or-later",
111
+ },
112
+ )
113
+
114
+
115
+ async def is_group_message(event: Event) -> bool:
116
+ return isinstance(event, GroupMessageEvent)
117
+
118
+ async def is_private_message(event: Event) -> bool:
119
+ return isinstance(event, PrivateMessageEvent)
120
+
121
+
122
+ def _make_llm_client() -> "LLMClient":
123
+ """构造主 LLM 客户端。
124
+
125
+ 零配置友好:api_key / base_url 为空时给占位值,让 AsyncOpenAI 能完成构造
126
+ (新版 openai SDK 对空凭证直接抛 OpenAIError)。占位值不会被真正用到——
127
+ 发请求前 client._llm_config_ready() 会拦截并告警。
128
+ """
129
+ return LLMClient(
130
+ client=AsyncOpenAI(api_key=plugin_config.aigf_chat_openai_api_key or "sk-not-configured",
131
+ base_url=plugin_config.aigf_chat_openai_base_url or "https://placeholder.invalid/v1",
132
+ http_client=make_http_client()))
133
+
134
+
135
+ @dataclass
136
+ class GroupState:
137
+ event: Event | None = None
138
+ bot: Bot | None = None
139
+ session: Session = field(default_factory=Session)
140
+ messages_chunk: list[MMessage] = field(default_factory=list)
141
+ last_message_time: float = 0.0
142
+ client: LLMClient = field(default_factory=_make_llm_client)
143
+ lock = asyncio.Lock()
144
+
145
+
146
+ _tasks: set[asyncio.Task] = set()
147
+ group_states: dict[int, GroupState] = {}
148
+
149
+
150
+ async def _resolve_user_id(bot: Bot, event: Event, nickname: str) -> int | None:
151
+ if not isinstance(event, GroupMessageEvent):
152
+ return None
153
+ try:
154
+ members = await bot.get_group_member_list(group_id=event.group_id)
155
+ for m in members:
156
+ if m.get("nickname", "").strip() == nickname:
157
+ return m["user_id"]
158
+ except Exception as e:
159
+ logger.error(f"获取群成员列表失败: {e}")
160
+ return None
161
+
162
+
163
+ async def spawn_state(state: GroupState):
164
+ while True:
165
+ await asyncio.sleep(1.0)
166
+ async with state.lock:
167
+ if not state.bot or not state.event:
168
+ continue
169
+ if not state.messages_chunk:
170
+ continue
171
+ now = asyncio.get_event_loop().time()
172
+ reached_count = len(state.messages_chunk) >= plugin_config.aigf_batch_count
173
+ # 根据最后消息状态选择超时时间
174
+ last_msg = state.messages_chunk[-1] if state.messages_chunk else None
175
+ effective_timeout = plugin_config.aigf_batch_timeout
176
+ if last_msg and last_msg.is_at_only:
177
+ # 纯@消息,等待用户发送后续内容
178
+ effective_timeout = plugin_config.aigf_incomplete_timeout
179
+ elif len(state.messages_chunk) >= 2:
180
+ # 连续发送检测:最后两条消息来自同一用户且间隔在合并窗口内
181
+ prev_msg = state.messages_chunk[-2]
182
+ if (last_msg.user_id == prev_msg.user_id and
183
+ (last_msg.time - prev_msg.time).total_seconds() < plugin_config.aigf_merge_window):
184
+ effective_timeout = plugin_config.aigf_incomplete_timeout
185
+ reached_time = (now - state.last_message_time) >= effective_timeout
186
+ if not reached_count and not reached_time:
187
+ continue
188
+ messages_chunk = state.messages_chunk.copy()
189
+ state.messages_chunk.clear()
190
+
191
+
192
+ try:
193
+ # 从缓存中获取待处理的表情包
194
+ stickers = meme_manager.get_cached_stickers()
195
+ logger.debug(f"[spawn_state] 处理 batch: {len(messages_chunk)} 条消息, 缓存表情包: {len(stickers)} 个")
196
+ responses = await state.session.process(messages_chunk, state.client, cached_stickers=stickers)
197
+ logger.debug(f"[spawn_state] LLM 返回 {len(responses) if responses else 0} 条回复")
198
+ # 处理完成后清空缓存(只清空本次传给 LLM 的,避免跨 batch 重复)
199
+ if stickers:
200
+ meme_manager.clear_cache()
201
+ except Exception as e:
202
+ logger.error(f"Error: {e}")
203
+ traceback.print_exc()
204
+ continue
205
+
206
+ if not responses:
207
+ continue
208
+
209
+ try:
210
+ # 出站安全校验:任一回复命中疑似密钥 → 取消本次整批发送并记录
211
+ joined_text = "\n".join(
212
+ r.content if isinstance(r, TextMessage) else ""
213
+ for r in responses
214
+ )
215
+ leaked, reason = check_leak(joined_text)
216
+ if leaked:
217
+ log_leak("outgoing", getattr(state.event, "group_id", "-"), reason, joined_text)
218
+ logger.warning("[security] 已取消本次发送")
219
+ continue
220
+
221
+ pending = OneBotMessage()
222
+ for response in responses:
223
+ if isinstance(response, TextMessage):
224
+ pending.append(MessageSegment.text(response.content))
225
+ elif isinstance(response, AtMessage):
226
+ uid = await _resolve_user_id(state.bot, state.event, response.user_name)
227
+ if uid:
228
+ pending.append(MessageSegment.at(uid))
229
+ elif isinstance(response, MemeMessage):
230
+ if pending:
231
+ await state.bot.send(message=pending, event=state.event)
232
+ pending = OneBotMessage()
233
+ path = await meme_manager.resolve(response.meme_id)
234
+ if path:
235
+ await state.bot.send(message=MessageSegment.image(path), event=state.event)
236
+ else:
237
+ if pending:
238
+ await state.bot.send(message=pending, event=state.event)
239
+ pending = OneBotMessage()
240
+ await state.bot.send(message=str(response), event=state.event)
241
+ if pending:
242
+ await state.bot.send(message=pending, event=state.event)
243
+ logger.success("[发送] 消息已发送")
244
+ except Exception as e:
245
+ logger.error(f"发送消息失败: {e}")
246
+
247
+
248
+ # ---- 命令 ----
249
+
250
+ status_cmd = on_command(rule=to_me(), permission=SUPERUSER, cmd="status", aliases={"状态"}, priority=0, block=True)
251
+ set_role_cmd = on_command(rule=to_me() & is_group_message, permission=SUPERUSER, cmd="set_role", aliases={"设置角色"}, priority=0, block=True)
252
+ reset_cmd = on_command(rule=to_me() & is_group_message, permission=SUPERUSER, cmd="reset", aliases={"重置"}, priority=0, block=True)
253
+ presets_cmd = on_command(rule=to_me() & is_group_message, permission=SUPERUSER, cmd="presets", aliases={"preset"}, priority=0, block=True)
254
+ set_preset_cmd = on_command(rule=to_me() & is_group_message, permission=SUPERUSER, cmd="set_preset", aliases={"set_presets"}, priority=0, block=True)
255
+ reload_meme_cmd = on_command(rule=to_me() & is_group_message, permission=SUPERUSER, cmd="reload_meme", aliases={"重载表情包"}, priority=0, block=True)
256
+ reload_energy_cmd = on_command(rule=to_me() & is_group_message, permission=SUPERUSER, cmd="reload_energy", aliases={"重载节律"}, priority=0, block=True)
257
+ auto_chat = on_message(rule=is_group_message, priority=1, block=False)
258
+
259
+
260
+ def _ensure_group_state(group_id: int) -> GroupState:
261
+ if group_id not in group_states:
262
+ group_states[group_id] = GroupState(
263
+ session=Session(id=str(group_id), name="小助手", role="一个友好的群聊助手"))
264
+ group_states[group_id].client.group_id = str(group_id) # 分群用量统计
265
+ task = asyncio.create_task(spawn_state(group_states[group_id]))
266
+ _tasks.add(task)
267
+ task.add_done_callback(_tasks.discard)
268
+ return group_states[group_id]
269
+
270
+
271
+ def _resolve_group_id(event: Event, text: str) -> tuple[int | None, str]:
272
+ """解析目标群号:群聊取当前群;私聊需首个参数为群号。
273
+
274
+ 返回 (群号, 剩余参数文本);私聊未带合法群号时返回 (None, "")。
275
+ """
276
+ if isinstance(event, GroupMessageEvent):
277
+ return event.group_id, text
278
+ parts = text.split(maxsplit=1)
279
+ if parts and parts[0].isdigit():
280
+ return int(parts[0]), (parts[1] if len(parts) > 1 else "")
281
+ return None, ""
282
+
283
+
284
+ def _check_group_enabled(group_id: int) -> str | None:
285
+ """群未启用且无运行态时返回拒绝信息,否则 None"""
286
+ if group_id not in plugin_config.aigf_enabled_groups and group_id not in group_states:
287
+ return f"群 {group_id} 未在 AIGF_ENABLED_GROUPS 中启用"
288
+ return None
289
+
290
+
291
+ @status_cmd.handle()
292
+ async def _(event: Event, args: Message = CommandArg()):
293
+ gid, _ = _resolve_group_id(event, args.extract_plain_text().strip())
294
+ if gid is None:
295
+ await status_cmd.finish(
296
+ "私聊用法:状态 <群号>\n例如:状态 123456789\n(群聊中直接发「状态」即可)")
297
+ err = _check_group_enabled(gid)
298
+ if err:
299
+ await status_cmd.finish(err)
300
+ state = _ensure_group_state(gid)
301
+ await status_cmd.finish(f"群 {gid}\n" + state.session.status())
302
+
303
+
304
+ @set_role_cmd.handle()
305
+ async def _(event: GroupMessageEvent, args: Message = CommandArg()):
306
+ parts = args.extract_plain_text().strip().split(" ", 1)
307
+ if len(parts) != 2:
308
+ await set_role_cmd.finish("用法: 设置角色 <名字> <设定>")
309
+ state = _ensure_group_state(event.group_id)
310
+ state.session.name, state.session.role = parts[0], parts[1]
311
+ await set_role_cmd.finish(f"角色已设为: {parts[0]}\n设定: {parts[1]}")
312
+
313
+
314
+ @reset_cmd.handle()
315
+ async def _(event: GroupMessageEvent):
316
+ state = _ensure_group_state(event.group_id)
317
+ state.session = Session(id=str(event.group_id))
318
+ await state.session.load_preset(plugin_config.aigf_default_preset)
319
+ await reset_cmd.finish("已重置会话")
320
+
321
+
322
+ @presets_cmd.handle()
323
+ async def _():
324
+ msg = "可用预设:\n" + "\n".join(f"- {k}: {v.name} {v.role}" for k, v in PRESETS.items() if not v.hidden)
325
+ msg += "\n用法: set_preset <预设名>"
326
+ await presets_cmd.finish(msg)
327
+
328
+
329
+ @set_preset_cmd.handle()
330
+ async def _(event: GroupMessageEvent, args: Message = CommandArg()):
331
+ name = args.extract_plain_text().strip()
332
+ if not name:
333
+ await set_preset_cmd.finish("用法: set_preset <预设名>")
334
+ state = _ensure_group_state(event.group_id)
335
+ if await state.session.load_preset(name):
336
+ await set_preset_cmd.finish(f"预设已加载: {name}")
337
+ else:
338
+ await set_preset_cmd.finish(f"不存在的预设: {name}")
339
+
340
+
341
+ @reload_meme_cmd.handle()
342
+ async def _():
343
+ await meme_manager.load_all()
344
+ await reload_meme_cmd.finish(
345
+ f"已重载。管理员: {len(meme_manager.admin_memes)} 个, 自动收集: {len(meme_manager.collected_memes)} 个")
346
+
347
+
348
+ @reload_energy_cmd.handle()
349
+ async def _(event: GroupMessageEvent):
350
+ if not plugin_config.aigf_energy_enabled:
351
+ await reload_energy_cmd.finish("能量节律未启用(AIGF_ENERGY_ENABLED=false),当前走旧版线性能量逻辑")
352
+ try:
353
+ energy.load_config() # 校验:非法配置直接报错,不覆盖运行中的配置
354
+ except energy.EnergyConfigError as e:
355
+ await reload_energy_cmd.finish(
356
+ "energy.json 校验失败,未应用:\n- " + "\n- ".join(e.errors))
357
+ cfg = energy.reload_config()
358
+ # 应用到所有已存在的群会话
359
+ for st in group_states.values():
360
+ st.session._energy_cfg = cfg
361
+ if st.session._energy is None:
362
+ st.session._energy = energy.load_state(st.session.id, cfg)
363
+ st.session.social_energy = st.session._energy.energy
364
+ st.session._energy_on = True
365
+ labels = "/".join(f"{v['label']}{v['window'][0]}-{v['window'][1]}" for v in cfg["states"].values())
366
+ await reload_energy_cmd.finish(f"已重载 energy.json 并应用({labels})")
367
+
368
+
369
+ @auto_chat.handle()
370
+ async def handle_auto_chat(bot: Bot, event: GroupMessageEvent):
371
+ group_id = event.group_id
372
+ if group_id not in plugin_config.aigf_enabled_groups:
373
+ return
374
+ # 跳过机器人自己的消息
375
+ if event.get_user_id() == str(bot.self_id):
376
+ return
377
+ logger.success(f"[接收] 群{group_id} 收到消息 from {event.user_id}")
378
+
379
+ state = _ensure_group_state(group_id)
380
+ user_id = event.get_user_id()
381
+
382
+ # 能量节律预判:休眠/疲倦期且未被 @ → 连图片识别都跳过(省 token,也符合"睡着了")
383
+ block_reason = state.session.energy_blocked(event.is_tome())
384
+ if block_reason:
385
+ state.session.settle_blocked()
386
+ logger.debug(f"[energy] 群{group_id} {block_reason} 拦截非 @ 消息,直接忽略")
387
+ return
388
+
389
+ async with state.lock:
390
+ message_content, is_at_only = await message2MMessage(
391
+ bot_name=state.session.name, group_id=group_id,
392
+ message=event.original_message, bot=bot, state=state)
393
+
394
+ if not message_content:
395
+ return
396
+
397
+ # 入站安全校验:打码疑似密钥(防止第三方 Key 进入记忆与 LLM 上下文)
398
+ message_content, redacted = redact(message_content)
399
+ if redacted:
400
+ log_leak("incoming", group_id, "入站消息含疑似密钥(已打码)", message_content)
401
+
402
+ try:
403
+ user_info = await bot.get_group_member_info(group_id=group_id, user_id=int(user_id))
404
+ nickname = user_info.get("nickname") or str(user_id)
405
+ except Exception:
406
+ nickname = str(user_id)
407
+
408
+ async with state.lock:
409
+ state.event = event
410
+ state.bot = bot
411
+ state.messages_chunk.append(MMessage(time=datetime.now(), user_name=nickname, user_id=user_id, content=message_content, is_at_only=is_at_only))
412
+ state.last_message_time = asyncio.get_event_loop().time()
413
+ # 自动更新 QQ 昵称
414
+ try:
415
+ qq_nickname = user_info.get("nickname", "") if 'user_info' in dir() else ""
416
+ if qq_nickname:
417
+ await state.session.memory.record_member_activity(user_id, qq_nickname)
418
+ except Exception:
419
+ pass
420
+
421
+
422
+ async def _extract_text(message_content, bot: Bot, group_id: int) -> str:
423
+ """从消息中提取纯文本(用于 reply 上下文)"""
424
+ try:
425
+ if isinstance(message_content, str):
426
+ msg = OneBotMessage(message_content)
427
+ elif isinstance(message_content, list):
428
+ msg = OneBotMessage()
429
+ for seg in message_content:
430
+ if isinstance(seg, dict) and "type" in seg:
431
+ msg.append(MessageSegment(type=seg["type"], data=seg.get("data", {})))
432
+ else:
433
+ return str(message_content)[:50]
434
+ text = msg.extract_plain_text().strip()
435
+ return text[:50] if text else "(非文字消息)"
436
+ except Exception:
437
+ return "(无法获取)"
438
+
439
+
440
+ async def message2MMessage(bot_name: str, group_id: int, message: Message, bot: Bot, state: GroupState) -> tuple[str, bool]:
441
+ content = ""
442
+ has_non_at_content = False
443
+ logger.debug(f"[消息解析] 开始解析消息,共 {len(list(message))} 个 segment")
444
+ for seg in message:
445
+ logger.debug(f"[消息解析] segment type={seg.type}")
446
+ if seg.type == "text":
447
+ text = seg.data.get("text", "")
448
+ content += text
449
+ if text.strip():
450
+ has_non_at_content = True
451
+ elif seg.type in ("image", "emoji"):
452
+ try:
453
+ url = seg.data.get("url", "")
454
+ cache_path = IMAGE_CACHE_DIR / "raw"
455
+ cache_path.mkdir(parents=True, exist_ok=True)
456
+ key = re.search(r"[?&]fileid=([a-zA-Z0-9_-]+)", url)
457
+ key = key.group(1) if key else None
458
+ if key and (cache_path / key).exists():
459
+ async with await anyio.open_file(cache_path / key, "rb") as f:
460
+ image_bytes = await f.read()
461
+ else:
462
+ ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
463
+ ssl_ctx.set_ciphers("ALL:@SECLEVEL=1")
464
+ async with httpx.AsyncClient(verify=ssl_ctx) as client:
465
+ resp = await client.get(url)
466
+ resp.raise_for_status()
467
+ image_bytes = resp.content
468
+ if key:
469
+ async with await anyio.open_file(cache_path / key, "wb") as f:
470
+ await f.write(image_bytes)
471
+
472
+ is_sticker = seg.data.get("sub_type") == 1
473
+ image_base64 = base64.b64encode(image_bytes).decode()
474
+
475
+ if plugin_config.aigf_image_mode == "llm":
476
+ cache_id = await meme_manager.save_to_cache(image_bytes=image_bytes, description="", emotion="")
477
+ logger.debug(f"[缓存] LLM模式 - 图片已缓存, cache_id={cache_id}")
478
+ content += f"\n[发送了一张图片, id: {cache_id}]\n"
479
+ else:
480
+ desc = await image_manager.get_image_description(
481
+ image_base64=image_base64, is_sticker=is_sticker, group_id=str(group_id))
482
+ logger.info(f"[VLM] 描述: {desc.description if desc else None}")
483
+ if desc:
484
+ cache_id = await meme_manager.save_to_cache(
485
+ image_bytes=image_bytes, description=desc.description, emotion=desc.emotion,
486
+ )
487
+ if is_sticker:
488
+ content += f"\n[发送了一张可能是表情包的图片, id: {cache_id}] [情感:{desc.emotion}] [内容:{desc.description}]\n"
489
+ else:
490
+ content += f"\n[发送了一张图片, id: {cache_id}] [内容:{desc.description}]\n"
491
+ except Exception as e:
492
+ logger.error(f"图片处理错误: {e}")
493
+ content += "\n[图片加载失败]\n"
494
+ has_non_at_content = True
495
+ elif seg.type == "at":
496
+ uid = seg.data.get("qq")
497
+ if not uid:
498
+ continue
499
+ if str(uid) == str(bot.self_id):
500
+ content += f" @{bot_name} "
501
+ else:
502
+ try:
503
+ info = await bot.get_group_member_info(group_id=group_id, user_id=int(uid))
504
+ content += f" @{info.get('nickname') or uid} "
505
+ except Exception:
506
+ content += f" @{uid} "
507
+ elif seg.type == "reply":
508
+ has_non_at_content = True
509
+ try:
510
+ reply_msg_id = seg.data.get("message_id")
511
+ if reply_msg_id:
512
+ original = await bot.get_msg(message_id=int(reply_msg_id))
513
+ if original and "message" in original:
514
+ replied_text = await _extract_text(original["message"], bot, group_id)
515
+ replied_sender = original.get("sender", {}).get("nickname", "未知")
516
+ content += f"[回复 {replied_sender} 的消息: \"{replied_text}\"] "
517
+ except Exception:
518
+ pass
519
+ return content.strip(), not has_non_at_content
520
+
521
+
522
+ # ---- 启动 ----
523
+
524
+ import shutil
525
+
526
+ from nonebot import get_driver
527
+
528
+
529
+ def _migrate_cache_dirs():
530
+ """历史版本把 sticker_cache/image_cache 放在系统 cache 目录下(可能被系统自动清理),
531
+ 统一迁移到数据目录下,与记忆、表情包库集中存放。"""
532
+ data_base = store.get_plugin_data_dir()
533
+ old_cache_base = store.get_plugin_cache_dir()
534
+ for name in ("sticker_cache", "image_cache"):
535
+ old, new = old_cache_base / name, data_base / name
536
+ if not old.exists():
537
+ continue
538
+ new.mkdir(parents=True, exist_ok=True)
539
+ moved = 0
540
+ for f in old.iterdir():
541
+ target = new / f.name
542
+ if target.exists():
543
+ continue
544
+ try:
545
+ shutil.move(str(f), str(target))
546
+ moved += 1
547
+ except Exception as e:
548
+ logger.error(f"[迁移] {f.name} 移动失败: {e}")
549
+ try:
550
+ old.rmdir() # 旧目录空了就移除,留着也不影响
551
+ except OSError:
552
+ pass
553
+ logger.success(f"[迁移] {name} 已迁移至数据目录({moved} 个文件)")
554
+
555
+
556
+ @get_driver().on_startup
557
+ async def _():
558
+ _migrate_cache_dirs()
559
+ # 零配置友好:配置缺失只告警不抛异常,让插件在任何配置状态下都能完成加载
560
+ if not plugin_config.aigf_chat_openai_api_key or not plugin_config.aigf_chat_openai_base_url:
561
+ logger.warning(
562
+ "[启动] LLM 未配置(AIGF_CHAT_OPENAI_API_KEY / AIGF_CHAT_OPENAI_BASE_URL 为空):"
563
+ "插件已加载,但对话请求会被跳过。配置 .env 后重启生效。"
564
+ )
565
+ logger.success(f"[启动] 图片理解模式: {plugin_config.aigf_image_mode}")
566
+ if plugin_config.aigf_search_enabled:
567
+ logger.success(f"[启动] 联网搜索: 已启用 | 模式: {plugin_config.aigf_search_mode} | API: {plugin_config.aigf_search_api}")
568
+ else:
569
+ logger.info("[启动] 联网搜索: 未启用")
570
+ await load_presets()
571
+ await meme_manager.load_all()
572
+ # 加载默认预设到所有群
573
+ default = plugin_config.aigf_default_preset
574
+ for gid in plugin_config.aigf_enabled_groups:
575
+ state = _ensure_group_state(gid)
576
+ await state.session.load_preset(default)