nonebot-plugin-aigf-master 0.1.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_aigf_master/__init__.py +624 -0
- nonebot_plugin_aigf_master/api_hooks.py +229 -0
- nonebot_plugin_aigf_master/command_learner.py +164 -0
- nonebot_plugin_aigf_master/config.py +77 -0
- nonebot_plugin_aigf_master/context_bus.py +30 -0
- nonebot_plugin_aigf_master/image_handler.py +117 -0
- nonebot_plugin_aigf_master/llm_client.py +89 -0
- nonebot_plugin_aigf_master/meme_store.py +193 -0
- nonebot_plugin_aigf_master/memory_store.py +255 -0
- nonebot_plugin_aigf_master/models.py +82 -0
- nonebot_plugin_aigf_master/peer_client.py +41 -0
- nonebot_plugin_aigf_master/plugin_discovery.py +63 -0
- nonebot_plugin_aigf_master/plugin_invoker.py +86 -0
- nonebot_plugin_aigf_master/preset_store.py +54 -0
- nonebot_plugin_aigf_master/processor.py +375 -0
- nonebot_plugin_aigf_master/prompt_builder.py +325 -0
- nonebot_plugin_aigf_master/response_parser.py +91 -0
- nonebot_plugin_aigf_master/search_client.py +123 -0
- nonebot_plugin_aigf_master/vlm_client.py +24 -0
- nonebot_plugin_aigf_master-0.1.0.dist-info/METADATA +492 -0
- nonebot_plugin_aigf_master-0.1.0.dist-info/RECORD +24 -0
- nonebot_plugin_aigf_master-0.1.0.dist-info/WHEEL +5 -0
- nonebot_plugin_aigf_master-0.1.0.dist-info/licenses/LICENSE +21 -0
- nonebot_plugin_aigf_master-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""nonebot-plugin-aigf-master — 群聊 LLM 聊天机器人插件"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import ssl
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
import anyio
|
|
11
|
+
import httpx
|
|
12
|
+
from nonebot import get_driver, logger, on_command, on_message, require
|
|
13
|
+
from nonebot.adapters import Event, Message
|
|
14
|
+
from nonebot.adapters.onebot.v11 import (
|
|
15
|
+
Bot, GroupMessageEvent, MessageSegment, Message as OneBotMessage,
|
|
16
|
+
)
|
|
17
|
+
from nonebot.params import CommandArg
|
|
18
|
+
from nonebot.permission import SUPERUSER
|
|
19
|
+
from nonebot.plugin import PluginMetadata
|
|
20
|
+
|
|
21
|
+
require("nonebot_plugin_localstore")
|
|
22
|
+
import nonebot_plugin_localstore as store
|
|
23
|
+
|
|
24
|
+
from .api_hooks import register_hooks
|
|
25
|
+
from .config import PluginConfig, plugin_config
|
|
26
|
+
from .context_bus import ContextBus
|
|
27
|
+
from .command_learner import CommandLearner
|
|
28
|
+
from .llm_client import LLMClient
|
|
29
|
+
from .image_handler import ImageHandler
|
|
30
|
+
from .meme_store import MemeStore
|
|
31
|
+
from .memory_store import MemoryStore
|
|
32
|
+
from .models import ChatMessage
|
|
33
|
+
from .peer_client import PeerClient
|
|
34
|
+
from .plugin_invoker import PluginInvoker
|
|
35
|
+
from .preset_store import PresetStore
|
|
36
|
+
from .processor import MessageProcessor
|
|
37
|
+
from .search_client import create_search_client
|
|
38
|
+
|
|
39
|
+
__plugin_meta__ = PluginMetadata(
|
|
40
|
+
name="nonebot-plugin-aigf-master", description="群聊特化LLM聊天机器人(增强版),具有记忆、表情包和跨插件能力",
|
|
41
|
+
usage="群聊特化LLM聊天机器人", type="application",
|
|
42
|
+
config=PluginConfig, supported_adapters={"~onebot.v11"},
|
|
43
|
+
homepage="https://github.com/Funny1Potato/nonebot-plugin-aigf",
|
|
44
|
+
extra={"author": "Funny1Potato"},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# ========== 全局实例 ==========
|
|
48
|
+
|
|
49
|
+
_data_dir = store.get_plugin_data_dir()
|
|
50
|
+
_cache_dir = store.get_plugin_cache_dir()
|
|
51
|
+
_config_dir = store.get_plugin_config_dir()
|
|
52
|
+
|
|
53
|
+
_proxy = (plugin_config.aigfm_https_proxy or plugin_config.aigfm_http_proxy) if plugin_config.aigfm_proxy_enabled else None
|
|
54
|
+
_llm = LLMClient(plugin_config.aigfm_llm_api_key, plugin_config.aigfm_llm_base_url, _proxy)
|
|
55
|
+
_memes = MemeStore(_data_dir, _cache_dir)
|
|
56
|
+
_presets = PresetStore(_config_dir)
|
|
57
|
+
_bus = ContextBus(plugin_config.aigfm_context_max_messages)
|
|
58
|
+
_invoker = PluginInvoker()
|
|
59
|
+
_learner = CommandLearner(
|
|
60
|
+
_data_dir,
|
|
61
|
+
min_confidence=plugin_config.aigfm_learn_min_confidence,
|
|
62
|
+
)
|
|
63
|
+
_image_handler = ImageHandler(_cache_dir)
|
|
64
|
+
_search = create_search_client(
|
|
65
|
+
plugin_config.aigfm_search_api, plugin_config.aigfm_search_api_key,
|
|
66
|
+
plugin_config.aigfm_openwebsearch_url,
|
|
67
|
+
) if plugin_config.aigfm_search_enabled else None
|
|
68
|
+
_peer_client = PeerClient(plugin_config.aigfm_peer_bots) if plugin_config.aigfm_peer_bots else None
|
|
69
|
+
|
|
70
|
+
_processors: dict[int, MessageProcessor] = {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_processor(group_id: int) -> MessageProcessor:
|
|
74
|
+
if group_id not in _processors:
|
|
75
|
+
_processors[group_id] = MessageProcessor(
|
|
76
|
+
group_id=str(group_id), llm=_llm,
|
|
77
|
+
memory=MemoryStore(str(group_id), _data_dir),
|
|
78
|
+
memes=_memes, presets=_presets,
|
|
79
|
+
context_bus=_bus, invoker=_invoker, learner=_learner,
|
|
80
|
+
search=_search, config=plugin_config, peer_client=_peer_client,
|
|
81
|
+
peer_scanned_commands=_peer_scanned_commands,
|
|
82
|
+
)
|
|
83
|
+
return _processors[group_id]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ========== 消息解析 ==========
|
|
87
|
+
|
|
88
|
+
async def _resolve_user_id(bot: Bot, event: GroupMessageEvent, nickname: str) -> int | None:
|
|
89
|
+
try:
|
|
90
|
+
members = await bot.get_group_member_list(group_id=event.group_id)
|
|
91
|
+
for m in members:
|
|
92
|
+
if m.get("nickname", "").strip() == nickname:
|
|
93
|
+
return m["user_id"]
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.error(f"获取群成员列表失败: {e}")
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _parse_message(bot: Bot, event: GroupMessageEvent, message: Message, bot_name: str) -> tuple[str, bool]:
|
|
100
|
+
"""解析消息内容,返回 (content, is_at_only)"""
|
|
101
|
+
content = ""
|
|
102
|
+
has_non_at = False
|
|
103
|
+
|
|
104
|
+
for seg in message:
|
|
105
|
+
if seg.type == "text":
|
|
106
|
+
text = seg.data.get("text", "")
|
|
107
|
+
content += text
|
|
108
|
+
if text.strip():
|
|
109
|
+
has_non_at = True
|
|
110
|
+
elif seg.type in ("image", "emoji"):
|
|
111
|
+
has_non_at = True
|
|
112
|
+
try:
|
|
113
|
+
url = seg.data.get("url", "")
|
|
114
|
+
is_sticker = seg.data.get("sub_type") == 1
|
|
115
|
+
logger.debug(f"[消息解析] 图片: url={url[:60]}, is_sticker={is_sticker}")
|
|
116
|
+
cache_path = _cache_dir / "raw"
|
|
117
|
+
cache_path.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
key_match = re.search(r"[?&]fileid=([a-zA-Z0-9_-]+)", url)
|
|
119
|
+
key = key_match.group(1) if key_match else None
|
|
120
|
+
|
|
121
|
+
if key and (cache_path / key).exists():
|
|
122
|
+
async with await anyio.open_file(cache_path / key, "rb") as f:
|
|
123
|
+
image_bytes = await f.read()
|
|
124
|
+
else:
|
|
125
|
+
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
|
|
126
|
+
ssl_ctx.set_ciphers("ALL:@SECLEVEL=1")
|
|
127
|
+
async with httpx.AsyncClient(verify=ssl_ctx) as client:
|
|
128
|
+
resp = await client.get(url)
|
|
129
|
+
resp.raise_for_status()
|
|
130
|
+
image_bytes = resp.content
|
|
131
|
+
if key:
|
|
132
|
+
async with await anyio.open_file(cache_path / key, "wb") as f:
|
|
133
|
+
await f.write(image_bytes)
|
|
134
|
+
|
|
135
|
+
is_sticker = seg.data.get("sub_type") == 1
|
|
136
|
+
image_base64 = base64.b64encode(image_bytes).decode()
|
|
137
|
+
|
|
138
|
+
if plugin_config.aigfm_image_mode == "llm":
|
|
139
|
+
cache_id = await _memes.save_to_cache(image_bytes, "", "")
|
|
140
|
+
content += f"\n[发送了一张图片, id: {cache_id}]\n"
|
|
141
|
+
else:
|
|
142
|
+
desc = await _image_handler.describe(image_base64, is_sticker)
|
|
143
|
+
if desc:
|
|
144
|
+
cache_id = await _memes.save_to_cache(image_bytes, desc.description, desc.emotion)
|
|
145
|
+
if is_sticker:
|
|
146
|
+
content += f"\n[发送了一张可能是表情包的图片, id: {cache_id}] [情感:{desc.emotion}] [内容:{desc.description}]\n"
|
|
147
|
+
else:
|
|
148
|
+
content += f"\n[发送了一张图片, id: {cache_id}] [内容:{desc.description}]\n"
|
|
149
|
+
except Exception as e:
|
|
150
|
+
logger.error(f"图片处理错误: {e}")
|
|
151
|
+
content += "\n[图片加载失败]\n"
|
|
152
|
+
elif seg.type == "at":
|
|
153
|
+
uid = seg.data.get("qq")
|
|
154
|
+
if not uid:
|
|
155
|
+
continue
|
|
156
|
+
if str(uid) == str(bot.self_id):
|
|
157
|
+
content += f" @{bot_name} "
|
|
158
|
+
else:
|
|
159
|
+
try:
|
|
160
|
+
info = await bot.get_group_member_info(group_id=event.group_id, user_id=int(uid))
|
|
161
|
+
content += f" @{info.get('nickname') or uid} "
|
|
162
|
+
except Exception:
|
|
163
|
+
content += f" @{uid} "
|
|
164
|
+
elif seg.type == "reply":
|
|
165
|
+
has_non_at = True
|
|
166
|
+
try:
|
|
167
|
+
reply_msg_id = seg.data.get("message_id")
|
|
168
|
+
if reply_msg_id:
|
|
169
|
+
original = await bot.get_msg(message_id=int(reply_msg_id))
|
|
170
|
+
if original and "message" in original:
|
|
171
|
+
replied_text = _extract_reply_text(original["message"])
|
|
172
|
+
replied_sender = original.get("sender", {}).get("nickname", "未知")
|
|
173
|
+
content += f"[回复 {replied_sender} 的消息: \"{replied_text}\"] "
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
elif seg.type == "forward":
|
|
177
|
+
has_non_at = True
|
|
178
|
+
content += "[收到一条合并聊天记录] "
|
|
179
|
+
elif seg.type == "json":
|
|
180
|
+
has_non_at = True
|
|
181
|
+
content += _extract_json_desc(seg.data.get("data", ""))
|
|
182
|
+
elif seg.type == "xml":
|
|
183
|
+
has_non_at = True
|
|
184
|
+
content += "[收到一条XML消息] "
|
|
185
|
+
|
|
186
|
+
return content.strip(), not has_non_at
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _extract_reply_text(message_content) -> str:
|
|
190
|
+
try:
|
|
191
|
+
if isinstance(message_content, str):
|
|
192
|
+
msg = OneBotMessage(message_content)
|
|
193
|
+
elif isinstance(message_content, list):
|
|
194
|
+
msg = OneBotMessage()
|
|
195
|
+
for seg in message_content:
|
|
196
|
+
if isinstance(seg, dict) and "type" in seg:
|
|
197
|
+
msg.append(MessageSegment(type=seg["type"], data=seg.get("data", {})))
|
|
198
|
+
else:
|
|
199
|
+
return str(message_content)[:50]
|
|
200
|
+
text = msg.extract_plain_text().strip()
|
|
201
|
+
return text[:50] if text else "(非文字消息)"
|
|
202
|
+
except Exception:
|
|
203
|
+
return "(无法获取)"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _extract_json_desc(json_str: str) -> str:
|
|
207
|
+
"""从 JSON 消息中提取小程序/卡片的 title 和 desc"""
|
|
208
|
+
try:
|
|
209
|
+
data = json.loads(json_str) if isinstance(json_str, str) else json_str
|
|
210
|
+
if isinstance(data, dict):
|
|
211
|
+
# 递归查找 title 和 desc 字段
|
|
212
|
+
title = data.get("title", "")
|
|
213
|
+
desc = data.get("desc", "")
|
|
214
|
+
# 有些小程序在 meta 中
|
|
215
|
+
if not title and "meta" in data:
|
|
216
|
+
meta = data["meta"]
|
|
217
|
+
if isinstance(meta, dict):
|
|
218
|
+
for v in meta.values():
|
|
219
|
+
if isinstance(v, dict):
|
|
220
|
+
title = v.get("title", title) or title
|
|
221
|
+
desc = v.get("desc", desc) or desc
|
|
222
|
+
if title or desc:
|
|
223
|
+
parts = []
|
|
224
|
+
if title:
|
|
225
|
+
parts.append(title)
|
|
226
|
+
if desc:
|
|
227
|
+
parts.append(desc)
|
|
228
|
+
return f"[小程序/卡片: {', '.join(parts)}] "
|
|
229
|
+
return "[收到一条JSON消息] "
|
|
230
|
+
except (json.JSONDecodeError, TypeError):
|
|
231
|
+
return "[收到一条JSON消息] "
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ========== 批量消息处理 ==========
|
|
235
|
+
|
|
236
|
+
_tasks: set[asyncio.Task] = set()
|
|
237
|
+
_group_locks: dict[int, asyncio.Lock] = {}
|
|
238
|
+
_group_chunks: dict[int, list[ChatMessage]] = {}
|
|
239
|
+
_group_last_time: dict[int, float] = {}
|
|
240
|
+
_group_bot: dict[int, Bot] = {}
|
|
241
|
+
_group_event: dict[int, GroupMessageEvent] = {}
|
|
242
|
+
_pending_images: dict[int, int] = {}
|
|
243
|
+
_peer_scanned_commands: dict[str, list[dict]] = {}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _on_image_start(group_id: int):
|
|
247
|
+
"""图片 VLM 开始前:重置批处理计时并标记该群有图片处理中"""
|
|
248
|
+
_group_last_time[group_id] = asyncio.get_event_loop().time()
|
|
249
|
+
_pending_images[group_id] = _pending_images.get(group_id, 0) + 1
|
|
250
|
+
logger.debug(f"[图片] 群{group_id} 图片VLM开始, 已重置计时, 处理中: {_pending_images[group_id]}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _on_image_done(group_id: int):
|
|
254
|
+
"""图片 VLM 完成/失败:清除图片处理中标记"""
|
|
255
|
+
_pending_images[group_id] = max(0, _pending_images.get(group_id, 0) - 1)
|
|
256
|
+
logger.debug(f"[图片] 群{group_id} 图片VLM完成, 处理中: {_pending_images[group_id]}")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _add_peer_message(group_id: int, source: str, content: str, reset_timer: bool = True):
|
|
260
|
+
"""将其它 bot 推送的消息加入消息缓冲区"""
|
|
261
|
+
msg = ChatMessage(
|
|
262
|
+
time=datetime.now(),
|
|
263
|
+
user_name=source,
|
|
264
|
+
content=content,
|
|
265
|
+
user_id="",
|
|
266
|
+
)
|
|
267
|
+
if group_id not in _group_chunks:
|
|
268
|
+
_group_chunks[group_id] = []
|
|
269
|
+
_group_chunks[group_id].append(msg)
|
|
270
|
+
# 文本消息算新的聊天活动,重置批处理安静计时(图片消息由 VLM 前重置负责,入缓冲不再重置)
|
|
271
|
+
if reset_timer:
|
|
272
|
+
_group_last_time[group_id] = asyncio.get_event_loop().time()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def _describe_peer_image(group_id: int, source: str, image_url: str = "", image_base64: str = ""):
|
|
276
|
+
"""在 Bot A 侧对其它 bot 推送的图片做 VLM 描述后加入消息缓冲区"""
|
|
277
|
+
_on_image_start(group_id)
|
|
278
|
+
try:
|
|
279
|
+
if image_url:
|
|
280
|
+
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
|
|
281
|
+
ssl_ctx.set_ciphers("ALL:@SECLEVEL=1")
|
|
282
|
+
async with httpx.AsyncClient(verify=ssl_ctx) as client:
|
|
283
|
+
resp = await client.get(image_url)
|
|
284
|
+
resp.raise_for_status()
|
|
285
|
+
image_bytes = resp.content
|
|
286
|
+
elif image_base64:
|
|
287
|
+
image_bytes = base64.b64decode(image_base64)
|
|
288
|
+
else:
|
|
289
|
+
return
|
|
290
|
+
image_b64 = base64.b64encode(image_bytes).decode()
|
|
291
|
+
desc = await _image_handler.describe(image_b64, False)
|
|
292
|
+
content = f"[图片] {desc.description}" if desc else "[图片]"
|
|
293
|
+
_add_peer_message(group_id, source, content, reset_timer=False)
|
|
294
|
+
logger.info(f"[Peer] 捕获图片: [{source}] {content[:80]}")
|
|
295
|
+
except Exception as e:
|
|
296
|
+
logger.error(f"[Peer] 图片描述失败: {e}")
|
|
297
|
+
finally:
|
|
298
|
+
_on_image_done(group_id)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def _handle_peer_capture(group_id: int, source: str, data: dict):
|
|
302
|
+
"""后台处理其它 bot 推送的消息(文本直接入缓冲,图片 VLM 描述后入缓冲)"""
|
|
303
|
+
if data.get("commands"):
|
|
304
|
+
# 更新该 bot 的已注册命令列表(供 LLM 了解可调用命令)
|
|
305
|
+
_peer_scanned_commands[source] = data["commands"]
|
|
306
|
+
logger.debug(f"[Peer] 更新命令列表: bot={source}, {len(data['commands'])} 个")
|
|
307
|
+
if data.get("text"):
|
|
308
|
+
_add_peer_message(group_id, source, data["text"])
|
|
309
|
+
if data.get("image_url") or data.get("image_base64"):
|
|
310
|
+
await _describe_peer_image(group_id, source, data.get("image_url", ""), data.get("image_base64", ""))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _get_lock(group_id: int) -> asyncio.Lock:
|
|
314
|
+
if group_id not in _group_locks:
|
|
315
|
+
_group_locks[group_id] = asyncio.Lock()
|
|
316
|
+
return _group_locks[group_id]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def _batch_processor(group_id: int):
|
|
320
|
+
"""每群的批量消息处理循环"""
|
|
321
|
+
while True:
|
|
322
|
+
await asyncio.sleep(1.0)
|
|
323
|
+
lock = _get_lock(group_id)
|
|
324
|
+
async with lock:
|
|
325
|
+
chunk = _group_chunks.get(group_id, [])
|
|
326
|
+
if not chunk:
|
|
327
|
+
continue
|
|
328
|
+
now = asyncio.get_event_loop().time()
|
|
329
|
+
last_time = _group_last_time.get(group_id, 0)
|
|
330
|
+
|
|
331
|
+
# 判断是否触发
|
|
332
|
+
reached_count = len(chunk) >= plugin_config.aigfm_batch_count
|
|
333
|
+
last_msg = chunk[-1]
|
|
334
|
+
effective_timeout = plugin_config.aigfm_batch_timeout
|
|
335
|
+
if _pending_images.get(group_id, 0) > 0:
|
|
336
|
+
# 有图片正在 VLM 处理,用更长的等待时长
|
|
337
|
+
effective_timeout = plugin_config.aigfm_incomplete_timeout
|
|
338
|
+
elif last_msg.is_at_only:
|
|
339
|
+
effective_timeout = plugin_config.aigfm_incomplete_timeout
|
|
340
|
+
elif len(chunk) >= 2:
|
|
341
|
+
prev = chunk[-2]
|
|
342
|
+
if last_msg.user_id == prev.user_id and (last_msg.time - prev.time).total_seconds() < plugin_config.aigfm_merge_window:
|
|
343
|
+
effective_timeout = plugin_config.aigfm_incomplete_timeout
|
|
344
|
+
reached_time = (now - last_time) >= effective_timeout
|
|
345
|
+
|
|
346
|
+
if not reached_count and not reached_time:
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
messages = chunk.copy()
|
|
350
|
+
_group_chunks[group_id].clear()
|
|
351
|
+
|
|
352
|
+
# 处理
|
|
353
|
+
bot = _group_bot.get(group_id)
|
|
354
|
+
event = _group_event.get(group_id)
|
|
355
|
+
if not bot or not event:
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
processor = _get_processor(group_id)
|
|
359
|
+
processor._bot = bot
|
|
360
|
+
stickers = _memes.get_cached()
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
responses = await processor.process(messages, cached_stickers=stickers)
|
|
364
|
+
if stickers:
|
|
365
|
+
_memes.clear_cache()
|
|
366
|
+
except Exception as e:
|
|
367
|
+
logger.error(f"[处理失败] 群{group_id}: {e}")
|
|
368
|
+
import traceback
|
|
369
|
+
traceback.print_exc()
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
if not responses:
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
# 发送回复
|
|
376
|
+
try:
|
|
377
|
+
pending = OneBotMessage()
|
|
378
|
+
for resp in responses:
|
|
379
|
+
if resp.type == "text":
|
|
380
|
+
pending.append(MessageSegment.text(resp.content))
|
|
381
|
+
elif resp.type == "at":
|
|
382
|
+
uid = await _resolve_user_id(bot, event, resp.user_name)
|
|
383
|
+
if uid:
|
|
384
|
+
pending.append(MessageSegment.at(uid))
|
|
385
|
+
elif resp.type == "meme":
|
|
386
|
+
if pending:
|
|
387
|
+
await bot.send(message=pending, event=event)
|
|
388
|
+
pending = OneBotMessage()
|
|
389
|
+
path = _memes.resolve(resp.meme_id)
|
|
390
|
+
if path:
|
|
391
|
+
await bot.send(message=MessageSegment.image(path), event=event)
|
|
392
|
+
await _memes.persist()
|
|
393
|
+
if pending:
|
|
394
|
+
await bot.send(message=pending, event=event)
|
|
395
|
+
logger.success("[发送] 消息已发送")
|
|
396
|
+
except Exception as e:
|
|
397
|
+
logger.error(f"发送消息失败: {e}")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ========== 命令注册 ==========
|
|
401
|
+
|
|
402
|
+
def _is_group_msg(event: Event) -> bool:
|
|
403
|
+
return isinstance(event, GroupMessageEvent)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
status_cmd = on_command(rule=_is_group_msg, permission=SUPERUSER,
|
|
407
|
+
cmd="status", aliases={"状态"}, priority=0, block=True)
|
|
408
|
+
set_role_cmd = on_command(rule=_is_group_msg, permission=SUPERUSER,
|
|
409
|
+
cmd="set_role", aliases={"设置角色"}, priority=0, block=True)
|
|
410
|
+
reset_cmd = on_command(rule=_is_group_msg, permission=SUPERUSER,
|
|
411
|
+
cmd="reset", aliases={"重置"}, priority=0, block=True)
|
|
412
|
+
presets_cmd = on_command(rule=_is_group_msg, permission=SUPERUSER,
|
|
413
|
+
cmd="presets", aliases={"preset"}, priority=0, block=True)
|
|
414
|
+
set_preset_cmd = on_command(rule=_is_group_msg, permission=SUPERUSER,
|
|
415
|
+
cmd="set_preset", aliases={"set_presets"}, priority=0, block=True)
|
|
416
|
+
reload_meme_cmd = on_command(rule=_is_group_msg, permission=SUPERUSER,
|
|
417
|
+
cmd="reload_meme", aliases={"重载表情包"}, priority=0, block=True)
|
|
418
|
+
auto_chat = on_message(rule=_is_group_msg, priority=1, block=False)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@status_cmd.handle()
|
|
422
|
+
async def _(event: GroupMessageEvent):
|
|
423
|
+
processor = _get_processor(event.group_id)
|
|
424
|
+
await status_cmd.finish(processor.status())
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@set_role_cmd.handle()
|
|
428
|
+
async def _(event: GroupMessageEvent, args: Message = CommandArg()):
|
|
429
|
+
parts = args.extract_plain_text().strip().split(" ", 1)
|
|
430
|
+
if len(parts) != 2:
|
|
431
|
+
await set_role_cmd.finish("用法: set_role <名字> <设定>")
|
|
432
|
+
processor = _get_processor(event.group_id)
|
|
433
|
+
processor.bot_name, processor.bot_role = parts[0], parts[1]
|
|
434
|
+
await set_role_cmd.finish(f"角色已设为: {parts[0]}\n设定: {parts[1]}")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@reset_cmd.handle()
|
|
438
|
+
async def _(event: GroupMessageEvent):
|
|
439
|
+
processor = _get_processor(event.group_id)
|
|
440
|
+
processor.bot_name = "小助手"
|
|
441
|
+
processor.bot_role = "一个友好的群聊助手"
|
|
442
|
+
processor.recent_messages.clear()
|
|
443
|
+
processor.social_energy = 0.75
|
|
444
|
+
await processor.load_preset(plugin_config.aigfm_default_preset)
|
|
445
|
+
await reset_cmd.finish("已重置会话")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@presets_cmd.handle()
|
|
449
|
+
async def _():
|
|
450
|
+
all_presets = _presets.list_all()
|
|
451
|
+
msg = "可用预设:\n" + "\n".join(f"- {k}: {v.name} {v.role}" for k, v in all_presets.items() if not v.hidden)
|
|
452
|
+
msg += "\n用法: set_preset <预设名>"
|
|
453
|
+
await presets_cmd.finish(msg)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@set_preset_cmd.handle()
|
|
457
|
+
async def _(event: GroupMessageEvent, args: Message = CommandArg()):
|
|
458
|
+
name = args.extract_plain_text().strip()
|
|
459
|
+
if not name:
|
|
460
|
+
await set_preset_cmd.finish("用法: set_preset <预设名>")
|
|
461
|
+
processor = _get_processor(event.group_id)
|
|
462
|
+
if await processor.load_preset(name):
|
|
463
|
+
await set_preset_cmd.finish(f"预设已加载: {name}")
|
|
464
|
+
else:
|
|
465
|
+
await set_preset_cmd.finish(f"不存在的预设: {name}")
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@reload_meme_cmd.handle()
|
|
469
|
+
async def _():
|
|
470
|
+
await _memes.load_all()
|
|
471
|
+
await reload_meme_cmd.finish(f"已重载。管理员: {len(_memes._admin_memes)} 个, 自动收集: {len(_memes._collected_memes)} 个")
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@auto_chat.handle()
|
|
475
|
+
async def handle_auto_chat(bot: Bot, event: GroupMessageEvent):
|
|
476
|
+
group_id = event.group_id
|
|
477
|
+
if group_id not in plugin_config.aigfm_enabled_groups:
|
|
478
|
+
return
|
|
479
|
+
if event.get_user_id() == str(bot.self_id):
|
|
480
|
+
return
|
|
481
|
+
# 跳过 invoker 活跃期间的 synthetic 消息,防止死循环
|
|
482
|
+
if _invoker and _invoker.is_active:
|
|
483
|
+
return
|
|
484
|
+
logger.success(f"[接收] 群{group_id} 收到消息 from {event.user_id}")
|
|
485
|
+
|
|
486
|
+
processor = _get_processor(group_id)
|
|
487
|
+
|
|
488
|
+
# 先加入 chunk(占位),并设置计时器/bot/event。
|
|
489
|
+
# 防止解析期间插件响应先入 chunk,批处理用 last_time=0 立即触发,导致用户消息被拆到下一批
|
|
490
|
+
msg = ChatMessage(
|
|
491
|
+
time=datetime.now(),
|
|
492
|
+
user_name=str(event.get_user_id()),
|
|
493
|
+
user_id=event.get_user_id(),
|
|
494
|
+
content="",
|
|
495
|
+
is_at_only=False,
|
|
496
|
+
)
|
|
497
|
+
async with _get_lock(group_id):
|
|
498
|
+
if group_id not in _group_chunks:
|
|
499
|
+
_group_chunks[group_id] = []
|
|
500
|
+
_group_chunks[group_id].append(msg)
|
|
501
|
+
_group_last_time[group_id] = asyncio.get_event_loop().time()
|
|
502
|
+
_group_bot[group_id] = bot
|
|
503
|
+
_group_event[group_id] = event
|
|
504
|
+
|
|
505
|
+
# 锁外异步解析消息内容(含图片下载/VLM 等慢操作)
|
|
506
|
+
content, is_at_only = await _parse_message(bot, event, event.original_message, processor.bot_name)
|
|
507
|
+
|
|
508
|
+
if not content:
|
|
509
|
+
# 无有效内容,移除占位消息
|
|
510
|
+
async with _get_lock(group_id):
|
|
511
|
+
chunk = _group_chunks.get(group_id)
|
|
512
|
+
if chunk and msg in chunk:
|
|
513
|
+
chunk.remove(msg)
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
# 获取用户信息
|
|
517
|
+
user_info: dict = {}
|
|
518
|
+
try:
|
|
519
|
+
user_info = await bot.get_group_member_info(group_id=group_id, user_id=int(event.get_user_id()))
|
|
520
|
+
nickname = user_info.get("nickname") or event.get_user_id()
|
|
521
|
+
except Exception:
|
|
522
|
+
nickname = event.get_user_id()
|
|
523
|
+
|
|
524
|
+
# 更新占位消息内容
|
|
525
|
+
msg.user_name = nickname
|
|
526
|
+
msg.content = content
|
|
527
|
+
msg.is_at_only = is_at_only
|
|
528
|
+
|
|
529
|
+
# 设置当前用户 ID(供 invoker 使用)
|
|
530
|
+
processor._current_user_id = int(event.get_user_id())
|
|
531
|
+
|
|
532
|
+
# 自动更新昵称
|
|
533
|
+
try:
|
|
534
|
+
if user_info.get("nickname"):
|
|
535
|
+
await processor.memory.update_nickname(event.get_user_id(), user_info["nickname"])
|
|
536
|
+
except Exception:
|
|
537
|
+
pass
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# ========== 启动 ==========
|
|
541
|
+
|
|
542
|
+
@get_driver().on_startup
|
|
543
|
+
async def _on_startup():
|
|
544
|
+
logger.success(f"[启动] 图片理解模式: {plugin_config.aigfm_image_mode}")
|
|
545
|
+
if plugin_config.aigfm_search_enabled:
|
|
546
|
+
logger.success(f"[启动] 联网搜索: 已启用 | API: {plugin_config.aigfm_search_api}")
|
|
547
|
+
else:
|
|
548
|
+
logger.info("[启动] 联网搜索: 未启用")
|
|
549
|
+
|
|
550
|
+
await _presets.load_all()
|
|
551
|
+
await _memes.load_all()
|
|
552
|
+
await _memes.cleanup()
|
|
553
|
+
|
|
554
|
+
# 加载命令学习器
|
|
555
|
+
if plugin_config.aigfm_learn_commands:
|
|
556
|
+
await _learner.load()
|
|
557
|
+
logger.success(f"[启动] 命令学习: 已启用 | 最小置信度: {plugin_config.aigfm_learn_min_confidence}")
|
|
558
|
+
|
|
559
|
+
# 注册钩子
|
|
560
|
+
def _on_plugin_message(group_id: int, source: str, content: str, reset_timer: bool = True):
|
|
561
|
+
"""将插件/bot消息添加到消息缓冲区"""
|
|
562
|
+
msg = ChatMessage(
|
|
563
|
+
time=datetime.now(),
|
|
564
|
+
user_name=source,
|
|
565
|
+
content=content,
|
|
566
|
+
user_id="",
|
|
567
|
+
)
|
|
568
|
+
if group_id not in _group_chunks:
|
|
569
|
+
_group_chunks[group_id] = []
|
|
570
|
+
_group_chunks[group_id].append(msg)
|
|
571
|
+
# 文本消息算新的聊天活动,重置批处理安静计时(图片消息由 VLM 前重置负责,入缓冲不再重置)
|
|
572
|
+
if reset_timer:
|
|
573
|
+
_group_last_time[group_id] = asyncio.get_event_loop().time()
|
|
574
|
+
|
|
575
|
+
register_hooks(_bus, _invoker, _image_handler, _on_plugin_message, _on_image_start, _on_image_done)
|
|
576
|
+
|
|
577
|
+
# 注册跨 bot 接收端点(其它 bot 推送消息到此)
|
|
578
|
+
if _peer_client:
|
|
579
|
+
try:
|
|
580
|
+
import nonebot
|
|
581
|
+
from fastapi import Request
|
|
582
|
+
from fastapi.responses import JSONResponse
|
|
583
|
+
app = nonebot.get_app()
|
|
584
|
+
|
|
585
|
+
@app.post("/peer/capture")
|
|
586
|
+
async def _peer_capture(request: Request):
|
|
587
|
+
auth = request.headers.get("Authorization", "")
|
|
588
|
+
token = auth[len("Bearer "):].strip() if auth.startswith("Bearer ") else ""
|
|
589
|
+
peer_name = None
|
|
590
|
+
for cfg in plugin_config.aigfm_peer_bots:
|
|
591
|
+
if cfg.get("token") and cfg.get("token") == token:
|
|
592
|
+
peer_name = cfg.get("name")
|
|
593
|
+
break
|
|
594
|
+
if not peer_name:
|
|
595
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
596
|
+
try:
|
|
597
|
+
data = await request.json()
|
|
598
|
+
except Exception:
|
|
599
|
+
return JSONResponse({"error": "bad json"}, status_code=400)
|
|
600
|
+
group_id = data.get("group_id")
|
|
601
|
+
if not group_id:
|
|
602
|
+
return JSONResponse({"error": "no group_id"}, status_code=400)
|
|
603
|
+
source = peer_name
|
|
604
|
+
logger.info(f"[Peer] 收到推送: bot={source}, group={group_id}, "
|
|
605
|
+
f"text={str(data.get('text', ''))[:80]}, "
|
|
606
|
+
f"image_url={bool(data.get('image_url'))}, image_base64={bool(data.get('image_base64'))}")
|
|
607
|
+
# 文本/图片统一后台处理,立即返回,避免推送方超时
|
|
608
|
+
_peer_task = asyncio.create_task(_handle_peer_capture(int(group_id), source, data))
|
|
609
|
+
_tasks.add(_peer_task)
|
|
610
|
+
_peer_task.add_done_callback(_tasks.discard)
|
|
611
|
+
return JSONResponse({"ok": True})
|
|
612
|
+
|
|
613
|
+
logger.success(f"[启动] 跨 bot 通信: 已启用 | bots: {len(plugin_config.aigfm_peer_bots)} 个")
|
|
614
|
+
except Exception as e:
|
|
615
|
+
logger.error(f"[Peer] 注册接收端点失败: {e}")
|
|
616
|
+
|
|
617
|
+
# 初始化每群处理器并加载默认预设
|
|
618
|
+
for gid in plugin_config.aigfm_enabled_groups:
|
|
619
|
+
processor = _get_processor(gid)
|
|
620
|
+
await processor.load_preset(plugin_config.aigfm_default_preset)
|
|
621
|
+
# 启动批处理任务
|
|
622
|
+
task = asyncio.create_task(_batch_processor(gid))
|
|
623
|
+
_tasks.add(task)
|
|
624
|
+
task.add_done_callback(_tasks.discard)
|