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.
- nonebot_plugin_myom/__init__.py +576 -0
- nonebot_plugin_myom/client.py +216 -0
- nonebot_plugin_myom/config.py +80 -0
- nonebot_plugin_myom/energy.py +709 -0
- nonebot_plugin_myom/image_manager.py +167 -0
- nonebot_plugin_myom/mem.py +27 -0
- nonebot_plugin_myom/meme_manager.py +342 -0
- nonebot_plugin_myom/memory.py +621 -0
- nonebot_plugin_myom/presets.py +68 -0
- nonebot_plugin_myom/search.py +269 -0
- nonebot_plugin_myom/security.py +126 -0
- nonebot_plugin_myom/session.py +1025 -0
- nonebot_plugin_myom/time_context.py +49 -0
- nonebot_plugin_myom/usage.py +1273 -0
- nonebot_plugin_myom/vlm.py +48 -0
- nonebot_plugin_myom-0.5.0.dist-info/METADATA +732 -0
- nonebot_plugin_myom-0.5.0.dist-info/RECORD +20 -0
- nonebot_plugin_myom-0.5.0.dist-info/WHEEL +5 -0
- nonebot_plugin_myom-0.5.0.dist-info/licenses/LICENSE +661 -0
- nonebot_plugin_myom-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|