nonebot-plugin-dotcharacter 2.0.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,676 @@
1
+ """NoneBot 插件:dot-skill 角色扮演
2
+
3
+ 加载 dot-skill / colleague-skill 蒸馏的角色 Persona,通过 QQ Bot 进行 AI 角色扮演对话。
4
+
5
+ 权限模型:
6
+ - 命令(!角色列表 / !角色切换 等):仅管理员可用
7
+ - 角色扮演对话:所有用户可用
8
+ - 群聊 @机器人 才触发对话(命令不需要 @)
9
+
10
+ 支持 OpenAI / DeepSeek / Kimi / Qwen / Zhipu / SiliconFlow / Groq / Ollama / 自定义
11
+ 等所有 OpenAI Chat Completions 兼容的大模型 API。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ from pathlib import Path
18
+ from typing import Dict
19
+
20
+ from nonebot import on_command, on_message, get_driver, logger
21
+ from nonebot.adapters import Event, Message
22
+ from nonebot.matcher import Matcher
23
+ from nonebot.params import CommandArg
24
+ from nonebot.permission import Permission
25
+ from nonebot.plugin import PluginMetadata
26
+ from nonebot.rule import Rule
27
+
28
+ # 本地存储实例(NoneBot 启动后才可用)
29
+ _store = None
30
+
31
+
32
+ def _get_store():
33
+ """懒加载 localstore,仅在 NoneBot 运行时导入。"""
34
+ global _store
35
+ if _store is None:
36
+ try:
37
+ import nonebot_plugin_localstore as s
38
+ _store = s
39
+ except Exception:
40
+ pass
41
+ return _store
42
+
43
+ from .config import get_config, PROVIDER_PRESETS, DotCharacterConfig
44
+ from .character_loader import (
45
+ CharacterMeta,
46
+ scan_characters,
47
+ resolve_character,
48
+ )
49
+ from .conversation import get_conversation_manager
50
+ from .llm_client import chat_completion, system_msg, user_msg
51
+
52
+
53
+ __plugin_meta__ = PluginMetadata(
54
+ name="dot-skill 角色扮演",
55
+ description="加载 dot-skill / colleague-skill 蒸馏的角色 Persona,通过 QQ 进行 AI 角色扮演对话。"
56
+ "支持群聊 @机器人、多模型切换(DeepSeek/OpenAI/Kimi 等)、管理员权限控制。",
57
+ usage=(
58
+ "命令(仅管理员):\n"
59
+ " !角色列表 — 列出所有角色\n"
60
+ " !角色切换 <名称> — 切换到指定角色,开始对话\n"
61
+ " !角色退出 — 退出当前角色\n"
62
+ " !重置对话 — 清空对话历史\n"
63
+ " !角色信息 [名称] — 查看角色详情\n"
64
+ " !角色路径 — 查看角色目录\n"
65
+ " !角色刷新 — 重新扫描角色目录\n"
66
+ " !角色导入 add <路径> — 添加角色目录\n"
67
+ " !模型切换 provider/model <名称> — 切换 LLM\n"
68
+ "\n"
69
+ "切换到角色后,直接发消息即可对话。群聊中需 @机器人。"
70
+ ),
71
+ type="application",
72
+ homepage="https://github.com/nonebot/plugin-dotcharacter",
73
+ config=DotCharacterConfig,
74
+ supported_adapters={"~onebot.v11"},
75
+ extra={
76
+ "author": "tghrt",
77
+ "version": "2.0.0",
78
+ },
79
+ )
80
+
81
+
82
+ # ═══════════════════════════════════════════════
83
+ # 全局状态
84
+ # ═══════════════════════════════════════════════
85
+
86
+ _characters: Dict[str, CharacterMeta] = {}
87
+ _initialized: bool = False
88
+ _init_lock = asyncio.Lock()
89
+
90
+
91
+ async def _reload_characters() -> int:
92
+ """重新扫描所有角色目录,返回发现的角色数。"""
93
+ global _characters
94
+ cfg = get_config()
95
+ roots = cfg.resolve_skills_paths()
96
+ if not roots:
97
+ _characters = {}
98
+ return 0
99
+ logger.info(f"[dotcharacter] 重新扫描角色目录 ({len(roots)} 个路径)...")
100
+ loop = asyncio.get_running_loop()
101
+ _characters = await loop.run_in_executor(None, scan_characters, roots)
102
+ logger.info(
103
+ f"[dotcharacter] 发现 {len(_characters)} 个角色: {list(_characters.keys())}"
104
+ )
105
+ return len(_characters)
106
+
107
+
108
+ async def _ensure_initialized() -> None:
109
+ global _initialized
110
+ if _initialized:
111
+ return
112
+ async with _init_lock:
113
+ if _initialized:
114
+ return
115
+ try:
116
+ count = await _reload_characters()
117
+ if count == 0:
118
+ logger.warning(
119
+ "[dotcharacter] 未找到任何角色目录。"
120
+ "请设置 DOTCHARACTER_SKILLS_PATH 指向 skills 目录。"
121
+ )
122
+ except Exception as e:
123
+ logger.error(f"[dotcharacter] 初始化失败: {e}")
124
+ finally:
125
+ _initialized = True
126
+
127
+
128
+ # ═══════════════════════════════════════════════
129
+ # 辅助函数
130
+ # ═══════════════════════════════════════════════
131
+
132
+ def _get_raw_qq(event: Event) -> str:
133
+ uid = event.get_user_id()
134
+ return uid.replace("user_", "")
135
+
136
+
137
+ def _get_user_name(event: Event) -> str:
138
+ try:
139
+ sender = event.get_sender_name()
140
+ if sender:
141
+ return sender
142
+ except Exception:
143
+ pass
144
+ try:
145
+ card = event.sender.card if hasattr(event.sender, "card") else None
146
+ if card:
147
+ return card
148
+ except Exception:
149
+ pass
150
+ return "用户"
151
+
152
+
153
+ def _is_group(event: Event) -> bool:
154
+ return getattr(event, "message_type", "") == "group"
155
+
156
+
157
+ def _get_group_id(event: Event) -> str:
158
+ return str(getattr(event, "group_id", ""))
159
+
160
+
161
+ def _is_at_bot(event: Event) -> bool:
162
+ if hasattr(event, "is_tome") and callable(event.is_tome):
163
+ return event.is_tome()
164
+ try:
165
+ for seg in event.get_message():
166
+ if seg.type == "at" and seg.data.get("qq") == str(event.self_id):
167
+ return True
168
+ except Exception:
169
+ pass
170
+ return False
171
+
172
+
173
+ def _is_admin(event: Event) -> bool:
174
+ cfg = get_config()
175
+ admins = cfg.get_admin_qqs()
176
+ if not admins:
177
+ return False
178
+ return _get_raw_qq(event) in admins
179
+
180
+
181
+ def _is_group_allowed(event: Event) -> bool:
182
+ if not _is_group(event):
183
+ return True
184
+ cfg = get_config()
185
+ allowed = cfg.get_allowed_groups()
186
+ if not allowed:
187
+ return True
188
+ return _get_group_id(event) in allowed
189
+
190
+
191
+ def _scope_id(event: Event) -> str:
192
+ uid = event.get_user_id()
193
+ if _is_group(event):
194
+ return f"{uid}:{_get_group_id(event)}"
195
+ return uid
196
+
197
+
198
+ async def _combined_perm(event: Event) -> bool:
199
+ return _is_admin(event) and _is_group_allowed(event)
200
+
201
+
202
+ ADMIN_AND_GROUP = Permission(_combined_perm)
203
+
204
+
205
+ # ═══════════════════════════════════════════════
206
+ # 命令:角色列表
207
+ # ═══════════════════════════════════════════════
208
+
209
+ cmd_list = on_command(
210
+ "角色列表", aliases={"角色 list", "characters", "角色"},
211
+ priority=5, block=True, permission=ADMIN_AND_GROUP,
212
+ )
213
+
214
+
215
+ @cmd_list.handle()
216
+ async def handle_list(matcher: Matcher, event: Event):
217
+ await _ensure_initialized()
218
+ if not _characters:
219
+ await matcher.finish(
220
+ "📭 还没有加载任何角色。\n\n"
221
+ "请先用 dot-skill 或 colleague-skill 蒸馏一个角色。\n"
222
+ "设置 DOTCHARACTER_SKILLS_PATH 指向 skills 目录。"
223
+ )
224
+
225
+ cfg = get_config()
226
+ provider = cfg.dotcharacter_provider
227
+ model = cfg.dotcharacter_model
228
+ roots = cfg.resolve_skills_paths()
229
+
230
+ lines = [f"🎭 **可用角色列表** (LLM:{provider}/{model},{len(roots)} 个目录)\n"]
231
+ for i, (slug, c) in enumerate(sorted(_characters.items()), 1):
232
+ family_emoji = {
233
+ "colleague": "👔", "relationship": "💞", "celebrity": "🌟"
234
+ }.get(c.family, "❓")
235
+ lines.append(f"{i}. {family_emoji} **{c.display_name}**")
236
+ lines.append(f" `{slug}` — {c.description[:60]}")
237
+ lines.append("")
238
+
239
+ lines.append("使用 **!角色切换 <名称>** 开始对话。")
240
+ await matcher.finish("\n".join(lines))
241
+
242
+
243
+ # ═══════════════════════════════════════════════
244
+ # 命令:角色切换
245
+ # ═══════════════════════════════════════════════
246
+
247
+ cmd_switch = on_command(
248
+ "角色切换", aliases={"角色 switch", "switch_char", "chat"},
249
+ priority=5, block=True, permission=ADMIN_AND_GROUP,
250
+ )
251
+
252
+
253
+ @cmd_switch.handle()
254
+ async def handle_switch(matcher: Matcher, event: Event, args: Message = CommandArg()):
255
+ await _ensure_initialized()
256
+ name = args.extract_plain_text().strip()
257
+ if not name:
258
+ await matcher.finish(
259
+ "请指定角色名称或 slug。\n"
260
+ "示例:!角色切换 小小桃子呦\n"
261
+ "用 **!角色列表** 查看所有可用角色。"
262
+ )
263
+
264
+ char = resolve_character(name, _characters)
265
+ if not char:
266
+ await matcher.finish(
267
+ f"❌ 找不到角色「{name}」。\n用 **!角色列表** 查看可用角色。"
268
+ )
269
+
270
+ sid = _scope_id(event)
271
+ mgr = get_conversation_manager()
272
+ mgr.set_active_character(sid, char.slug)
273
+
274
+ session = mgr.get_session(sid, char.slug)
275
+ history_hint = ""
276
+ if not session.is_empty:
277
+ history_hint = "\n📝 有之前的对话记录。用 **!重置对话** 清除。"
278
+
279
+ family_emoji = {
280
+ "colleague": "👔", "relationship": "💞", "celebrity": "🌟"
281
+ }.get(char.family, "🎭")
282
+ hint = "\n💡 群聊中请 **@机器人** 发送对话哦~" if _is_group(event) else ""
283
+
284
+ await matcher.finish(
285
+ f"{family_emoji} 已切换到 **{char.display_name}**\n"
286
+ f"「{char.description}」\n\n"
287
+ f"现在直接发消息就可以和 {char.display_name} 对话了~"
288
+ f"{history_hint}{hint}\n\n"
289
+ f"命令:!角色退出 | !重置对话 | !角色列表"
290
+ )
291
+
292
+
293
+ # ═══════════════════════════════════════════════
294
+ # 命令:角色退出 / 重置 / 信息
295
+ # ═══════════════════════════════════════════════
296
+
297
+ cmd_exit = on_command(
298
+ "角色退出", aliases={"角色 exit", "exit_char"},
299
+ priority=5, block=True, permission=ADMIN_AND_GROUP,
300
+ )
301
+
302
+
303
+ @cmd_exit.handle()
304
+ async def handle_exit(matcher: Matcher, event: Event):
305
+ sid = _scope_id(event)
306
+ mgr = get_conversation_manager()
307
+ active = mgr.get_active_character(sid)
308
+ if active:
309
+ char = _characters.get(active)
310
+ name = char.display_name if char else active
311
+ mgr.clear_active_character(sid)
312
+ await matcher.finish(f"👋 已退出与 **{name}** 的对话。")
313
+ else:
314
+ await matcher.finish("你当前没有在对话中。")
315
+
316
+
317
+ cmd_reset = on_command(
318
+ "重置对话", aliases={"重置", "reset"},
319
+ priority=5, block=True, permission=ADMIN_AND_GROUP,
320
+ )
321
+
322
+
323
+ @cmd_reset.handle()
324
+ async def handle_reset(matcher: Matcher, event: Event):
325
+ sid = _scope_id(event)
326
+ mgr = get_conversation_manager()
327
+ active = mgr.get_active_character(sid)
328
+ if active:
329
+ mgr.reset_session(sid, active)
330
+ char = _characters.get(active)
331
+ name = char.display_name if char else active
332
+ await matcher.finish(f"🔄 已重置与 **{name}** 的对话记录。")
333
+ else:
334
+ await matcher.finish("你当前没有在对话中。用 **!角色切换 <名称>** 开始。")
335
+
336
+
337
+ cmd_info = on_command(
338
+ "角色信息", aliases={"角色 info", "char_info"},
339
+ priority=5, block=True, permission=ADMIN_AND_GROUP,
340
+ )
341
+
342
+
343
+ @cmd_info.handle()
344
+ async def handle_info(matcher: Matcher, event: Event, args: Message = CommandArg()):
345
+ await _ensure_initialized()
346
+ name = args.extract_plain_text().strip()
347
+ sid = _scope_id(event)
348
+ mgr = get_conversation_manager()
349
+
350
+ if not name:
351
+ active = mgr.get_active_character(sid)
352
+ if not active:
353
+ await matcher.finish(
354
+ "你当前没有在对话中。\n用 **!角色信息 <名称>** 查看指定角色的信息。"
355
+ )
356
+ name = active
357
+
358
+ char = resolve_character(name, _characters)
359
+ if not char:
360
+ await matcher.finish(f"❌ 找不到角色「{name}」。")
361
+
362
+ session = mgr.get_session(sid, char.slug)
363
+ history_count = len(session.messages)
364
+
365
+ info = (
366
+ f"🎭 **{char.display_name}**\n"
367
+ f"├ 类型:{char.family}\n"
368
+ f"├ Slug:`{char.slug}`\n"
369
+ f"├ 语言:{char.language}\n"
370
+ f"├ 标签:{', '.join(char.tags) if char.tags else '无'}\n"
371
+ f"├ 描述:{char.description}\n"
372
+ f"├ 对话历史:{history_count} 条\n"
373
+ f"├ 来源:{char.source_root}\n"
374
+ f"└ 文件:{char.source_files[0] if char.source_files else '未知'}"
375
+ )
376
+ await matcher.finish(info)
377
+
378
+
379
+ # ═══════════════════════════════════════════════
380
+ # 命令:角色路径
381
+ # ═══════════════════════════════════════════════
382
+
383
+ cmd_paths = on_command(
384
+ "角色路径", aliases={"路径", "paths"},
385
+ priority=5, block=True, permission=ADMIN_AND_GROUP,
386
+ )
387
+
388
+
389
+ @cmd_paths.handle()
390
+ async def handle_paths(matcher: Matcher, event: Event):
391
+ cfg = get_config()
392
+ roots = cfg.resolve_skills_paths()
393
+ count = len(_characters)
394
+
395
+ lines = [f"📂 共扫描 {len(roots)} 个目录,加载 {count} 个角色:\n"]
396
+ for i, r in enumerate(roots, 1):
397
+ chars_in_root = [
398
+ c.display_name for c in _characters.values()
399
+ if c.source_root == str(r)
400
+ ]
401
+ lines.append(f"{i}. `{r}`")
402
+ if chars_in_root:
403
+ for name in chars_in_root:
404
+ lines.append(f" ├ 🎭 {name}")
405
+ else:
406
+ lines.append(" └ (无角色)")
407
+ lines.append("")
408
+
409
+ lines.append(
410
+ "💡 **如何添加角色**:\n"
411
+ "1. 把蒸馏好的角色文件夹放到任意目录\n"
412
+ "2. 目录结构:`skills/colleague/xxx/SKILL.md` 等\n"
413
+ "3. 在 .env 设置 DOTCHARACTER_SKILLS_PATH=你的路径\n"
414
+ "4. 发 !角色刷新 立即加载"
415
+ )
416
+ await matcher.finish("\n".join(lines))
417
+
418
+
419
+ # ═══════════════════════════════════════════════
420
+ # 命令:角色刷新
421
+ # ═══════════════════════════════════════════════
422
+
423
+ cmd_reload = on_command(
424
+ "角色刷新", aliases={"刷新角色", "reload"},
425
+ priority=5, block=True, permission=ADMIN_AND_GROUP,
426
+ )
427
+
428
+
429
+ @cmd_reload.handle()
430
+ async def handle_reload(matcher: Matcher, event: Event):
431
+ await matcher.send("🔄 正在重新扫描角色目录...")
432
+ count = await _reload_characters()
433
+ if count == 0:
434
+ await matcher.finish("📭 未发现任何角色。请检查 DOTCHARACTER_SKILLS_PATH 配置。")
435
+ names = [c.display_name for c in _characters.values()]
436
+ await matcher.finish(
437
+ f"✅ 已刷新!共发现 **{count}** 个角色:\n"
438
+ + "\n".join(f" • {n}" for n in names)
439
+ )
440
+
441
+
442
+ # ═══════════════════════════════════════════════
443
+ # 命令:角色导入
444
+ # ═══════════════════════════════════════════════
445
+
446
+ cmd_import = on_command(
447
+ "角色导入", aliases={"导入角色", "import"},
448
+ priority=5, block=True, permission=ADMIN_AND_GROUP,
449
+ )
450
+
451
+
452
+ @cmd_import.handle()
453
+ async def handle_import(matcher: Matcher, event: Event, args: Message = CommandArg()):
454
+ """添加角色目录路径。用法:!角色导入 add <路径>"""
455
+ arg_text = args.extract_plain_text().strip()
456
+
457
+ if not arg_text:
458
+ cfg = get_config()
459
+ roots = cfg.resolve_skills_paths()
460
+ await matcher.finish(
461
+ f"📂 **角色导入**\n\n"
462
+ f"当前扫描 {len(roots)} 个目录,{len(_characters)} 个角色。\n\n"
463
+ f"添加新目录:`!角色导入 add <路径>`\n"
464
+ f"查看详情:`!角色路径`\n\n"
465
+ f"💡 把蒸馏好的角色文件传到任意目录,\n"
466
+ f" 然后 add 进来就行,不需要装 dot-skill。"
467
+ )
468
+
469
+ parts = arg_text.split(maxsplit=1)
470
+ action = parts[0].lower()
471
+ value = parts[1] if len(parts) > 1 else ""
472
+
473
+ if action == "add":
474
+ if not value:
475
+ await matcher.finish(
476
+ "❌ 请提供目录路径。\n示例:!角色导入 add /data/chars/skills"
477
+ )
478
+
479
+ p = Path(value)
480
+ if not p.exists() or not p.is_dir():
481
+ await matcher.finish(f"❌ 目录不存在:{p}")
482
+
483
+ cfg = get_config()
484
+ existing = cfg.dotcharacter_skills_path
485
+ new_path = str(p)
486
+ if new_path in existing:
487
+ await matcher.finish("⚠️ 路径已在配置中。用 !角色刷新 重新扫描。")
488
+
489
+ cfg.dotcharacter_skills_path = (
490
+ f"{existing},{new_path}" if existing else new_path
491
+ )
492
+
493
+ await matcher.send(f"🔄 扫描 {new_path} ...")
494
+ count = await _reload_characters()
495
+ names = [c.display_name for c in _characters.values()]
496
+ await matcher.finish(
497
+ f"✅ 已添加!共 {count} 个角色:\n"
498
+ + "\n".join(f" • {n}" for n in names)
499
+ + f"\n\n💡 永久生效:在 .env 中设置\n"
500
+ + f" DOTCHARACTER_SKILLS_PATH={cfg.dotcharacter_skills_path}"
501
+ )
502
+ else:
503
+ await matcher.finish(
504
+ f"❌ 未知操作「{action}」。\n用法:`!角色导入 add <路径>`"
505
+ )
506
+
507
+
508
+ # ═══════════════════════════════════════════════
509
+ # 命令:模型切换
510
+ # ═══════════════════════════════════════════════
511
+
512
+ cmd_model = on_command(
513
+ "模型切换", aliases={"模型", "model", "切换模型"},
514
+ priority=5, block=True, permission=ADMIN_AND_GROUP,
515
+ )
516
+
517
+
518
+ @cmd_model.handle()
519
+ async def handle_model(matcher: Matcher, event: Event, args: Message = CommandArg()):
520
+ cfg = get_config()
521
+ arg_text = args.extract_plain_text().strip()
522
+
523
+ if not arg_text:
524
+ provider = cfg.dotcharacter_provider
525
+ models = cfg.get_available_models()
526
+ lines = [
527
+ f"⚙️ **当前 LLM 配置**",
528
+ f"├ Provider:`{provider}`",
529
+ f"├ Base URL:`{cfg.get_api_base()}`",
530
+ f"├ Model:`{cfg.dotcharacter_model}`",
531
+ f"└ 可用模型:{', '.join(f'`{m}`' for m in models)}",
532
+ "",
533
+ "切换:`!模型切换 provider <名称>` / `!模型切换 model <名称>`",
534
+ ]
535
+ await matcher.finish("\n".join(lines))
536
+
537
+ parts = arg_text.split(maxsplit=1)
538
+ if len(parts) < 2:
539
+ await matcher.finish(
540
+ "用法:\n"
541
+ " !模型切换 provider deepseek\n"
542
+ " !模型切换 model deepseek-chat\n"
543
+ f" 可用 Provider:{', '.join(PROVIDER_PRESETS.keys())}"
544
+ )
545
+
546
+ target, value = parts[0].lower(), parts[1].strip()
547
+
548
+ if target == "provider":
549
+ if value not in PROVIDER_PRESETS:
550
+ await matcher.finish(
551
+ f"❌ 未知 Provider「{value}」。\n"
552
+ f"可用:{', '.join(PROVIDER_PRESETS.keys())}"
553
+ )
554
+ cfg.dotcharacter_provider = value
555
+ preset = PROVIDER_PRESETS[value]
556
+ await matcher.finish(
557
+ f"✅ 已切换到 Provider **{value}**\n"
558
+ f" Base URL:`{preset['base_url']}`\n"
559
+ f" 可用模型:{', '.join(f'`{m}`' for m in preset['models'])}\n"
560
+ f"⚠️ 运行时修改,重启后恢复 .env 设置。"
561
+ )
562
+ elif target == "model":
563
+ cfg.dotcharacter_model = value
564
+ await matcher.finish(
565
+ f"✅ 已切换模型为 **{value}**\n"
566
+ f"⚠️ 运行时修改,重启后恢复 .env 设置。"
567
+ )
568
+ else:
569
+ await matcher.finish(
570
+ f"❌ 未知目标「{target}」。请使用 provider 或 model。"
571
+ )
572
+
573
+
574
+ # ═══════════════════════════════════════════════
575
+ # 自由对话(群聊 @机器人 才触发)
576
+ # ═══════════════════════════════════════════════
577
+
578
+ async def _is_in_chat(event: Event) -> bool:
579
+ await _ensure_initialized()
580
+ if not _is_group_allowed(event):
581
+ return False
582
+ if _is_group(event) and not _is_at_bot(event):
583
+ return False
584
+ mgr = get_conversation_manager()
585
+ return mgr.get_active_character(_scope_id(event)) is not None
586
+
587
+
588
+ chat_rule = Rule(_is_in_chat)
589
+ chat_matcher = on_message(rule=chat_rule, priority=99, block=False)
590
+
591
+
592
+ @chat_matcher.handle()
593
+ async def handle_chat(matcher: Matcher, event: Event):
594
+ cfg = get_config()
595
+ msg_text = event.get_plaintext().strip()
596
+ if not msg_text:
597
+ await matcher.finish()
598
+
599
+ sid = _scope_id(event)
600
+ user_name = _get_user_name(event)
601
+ mgr = get_conversation_manager()
602
+ slug = mgr.get_active_character(sid)
603
+
604
+ if not slug:
605
+ await matcher.finish()
606
+
607
+ char = _characters.get(slug)
608
+ if not char:
609
+ mgr.clear_active_character(sid)
610
+ await matcher.finish("⚠️ 角色数据丢失,请重新用 !角色切换 选择角色。")
611
+
612
+ session = mgr.get_session(sid, slug)
613
+ messages = [system_msg(char.combined_prompt)]
614
+ for m in session.messages:
615
+ messages.append(m)
616
+ messages.append(user_msg(user_name, msg_text))
617
+
618
+ try:
619
+ reply = await chat_completion(
620
+ cfg, messages, max_tokens=cfg.dotcharacter_max_tokens
621
+ )
622
+ except ValueError as e:
623
+ await matcher.finish(f"❌ {e}")
624
+ except RuntimeError as e:
625
+ logger.error(f"[dotcharacter] LLM 调用失败: {e}")
626
+ await matcher.finish(
627
+ f"😵 {char.display_name} 暂时无法回应...\n(API 错误,请稍后再试)"
628
+ )
629
+ except Exception as e:
630
+ logger.error(f"[dotcharacter] 未知错误: {e}")
631
+ await matcher.finish(f"😵 出了点问题:{type(e).__name__}")
632
+
633
+ session.add_user_message(msg_text)
634
+ session.add_assistant_message(reply)
635
+ session.trim(cfg.dotcharacter_max_history)
636
+
637
+ await matcher.finish(reply)
638
+
639
+
640
+ # ═══════════════════════════════════════════════
641
+ # 插件生命周期
642
+ # ═══════════════════════════════════════════════
643
+
644
+ driver = get_driver()
645
+
646
+
647
+ @driver.on_startup
648
+ async def _on_startup():
649
+ logger.info("[dotcharacter] 插件启动中...")
650
+ # 懒加载 localstore(仅在 NoneBot 环境中可用)
651
+ _get_store()
652
+ await _ensure_initialized()
653
+ cfg = get_config()
654
+ admins = cfg.get_admin_qqs()
655
+ groups = cfg.get_allowed_groups()
656
+ roots = cfg.resolve_skills_paths()
657
+ logger.info(
658
+ f"[dotcharacter] 插件就绪,已加载 {len(_characters)} 个角色。"
659
+ f" Provider={cfg.dotcharacter_provider}, Model={cfg.dotcharacter_model}"
660
+ )
661
+ logger.info(f"[dotcharacter] 角色目录 ({len(roots)} 个):")
662
+ for r in roots:
663
+ logger.info(f"[dotcharacter] - {r}")
664
+ if admins:
665
+ logger.info(f"[dotcharacter] 管理员:{admins}")
666
+ if groups:
667
+ logger.info(f"[dotcharacter] 允许的群组:{groups}")
668
+ else:
669
+ logger.info("[dotcharacter] 群组限制:未配置(所有群可用)")
670
+
671
+
672
+ @driver.on_shutdown
673
+ async def _on_shutdown():
674
+ mgr = get_conversation_manager()
675
+ cleaned = mgr.cleanup_stale(max_age_seconds=0)
676
+ logger.info(f"[dotcharacter] 插件已关闭,清理了 {cleaned} 个会话。")