nonebot-plugin-yijing 0.1.1__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.
Files changed (68) hide show
  1. nonebot_plugin_yijing/__init__.py +38 -0
  2. nonebot_plugin_yijing/commands/__init__.py +3 -0
  3. nonebot_plugin_yijing/commands/main.py +498 -0
  4. nonebot_plugin_yijing/config.py +41 -0
  5. nonebot_plugin_yijing/core/caster.py +103 -0
  6. nonebot_plugin_yijing/core/data.py +243 -0
  7. nonebot_plugin_yijing/core/hexagram.py +46 -0
  8. nonebot_plugin_yijing/core/interpret.py +82 -0
  9. nonebot_plugin_yijing/data/README.en.md +73 -0
  10. nonebot_plugin_yijing/data/README.md +73 -0
  11. nonebot_plugin_yijing/data/casting_rules.json +38 -0
  12. nonebot_plugin_yijing/data/guaci.json +450 -0
  13. nonebot_plugin_yijing/data/hexagrams.json +1218 -0
  14. nonebot_plugin_yijing/data/interpret_rules.json +20 -0
  15. nonebot_plugin_yijing/data/lines.json +2690 -0
  16. nonebot_plugin_yijing/data/relations.json +1154 -0
  17. nonebot_plugin_yijing/data/reserved_tables.json +10 -0
  18. nonebot_plugin_yijing/data/schemas/casting_rules.schema.json +1 -0
  19. nonebot_plugin_yijing/data/schemas/chapter_text.schema.json +1 -0
  20. nonebot_plugin_yijing/data/schemas/guaci.schema.json +1 -0
  21. nonebot_plugin_yijing/data/schemas/hexagrams.schema.json +1 -0
  22. nonebot_plugin_yijing/data/schemas/interpret_rules.schema.json +1 -0
  23. nonebot_plugin_yijing/data/schemas/lines.schema.json +1 -0
  24. nonebot_plugin_yijing/data/schemas/manifest.json +1 -0
  25. nonebot_plugin_yijing/data/schemas/relations.schema.json +1 -0
  26. nonebot_plugin_yijing/data/schemas/sources.schema.json +1 -0
  27. nonebot_plugin_yijing/data/schemas/special_texts.schema.json +1 -0
  28. nonebot_plugin_yijing/data/schemas/trigrams.schema.json +1 -0
  29. nonebot_plugin_yijing/data/schemas/tuan.schema.json +1 -0
  30. nonebot_plugin_yijing/data/schemas/wenyan.schema.json +1 -0
  31. nonebot_plugin_yijing/data/schemas/xiang.schema.json +1 -0
  32. nonebot_plugin_yijing/data/schemas/yaoci.schema.json +1 -0
  33. nonebot_plugin_yijing/data/shuogua.json +8 -0
  34. nonebot_plugin_yijing/data/sources.json +24 -0
  35. nonebot_plugin_yijing/data/special_texts.json +26 -0
  36. nonebot_plugin_yijing/data/trigrams.json +114 -0
  37. nonebot_plugin_yijing/data/tuan.json +450 -0
  38. nonebot_plugin_yijing/data/wenyan.json +20 -0
  39. nonebot_plugin_yijing/data/xiang.json +1 -0
  40. nonebot_plugin_yijing/data/xici_shang.json +8 -0
  41. nonebot_plugin_yijing/data/xici_xia.json +8 -0
  42. nonebot_plugin_yijing/data/xugua.json +8 -0
  43. nonebot_plugin_yijing/data/yaoci.json +3074 -0
  44. nonebot_plugin_yijing/data/zagua.json +8 -0
  45. nonebot_plugin_yijing/migrations/20260708_01_initial_yijing_tables.py +143 -0
  46. nonebot_plugin_yijing/migrations/20260708_02_add_group_setting_windows.py +51 -0
  47. nonebot_plugin_yijing/migrations/8f2b7c4a1d00_init_yijing.py +92 -0
  48. nonebot_plugin_yijing/migrations/__init__.py +1 -0
  49. nonebot_plugin_yijing/models.py +82 -0
  50. nonebot_plugin_yijing/permissions.py +16 -0
  51. nonebot_plugin_yijing/render/__init__.py +0 -0
  52. nonebot_plugin_yijing/render/html.py +33 -0
  53. nonebot_plugin_yijing/render/message.py +18 -0
  54. nonebot_plugin_yijing/services/__init__.py +0 -0
  55. nonebot_plugin_yijing/services/llm.py +142 -0
  56. nonebot_plugin_yijing/services/payload.py +144 -0
  57. nonebot_plugin_yijing/services/repository.py +200 -0
  58. nonebot_plugin_yijing/templates/card.html +158 -0
  59. nonebot_plugin_yijing/templates/help.html +2 -0
  60. nonebot_plugin_yijing/templates/history.html +2 -0
  61. nonebot_plugin_yijing/templates/notice.html +2 -0
  62. nonebot_plugin_yijing/templates/settings.html +2 -0
  63. nonebot_plugin_yijing/utils.py +104 -0
  64. nonebot_plugin_yijing-0.1.1.dist-info/METADATA +294 -0
  65. nonebot_plugin_yijing-0.1.1.dist-info/RECORD +68 -0
  66. nonebot_plugin_yijing-0.1.1.dist-info/WHEEL +5 -0
  67. nonebot_plugin_yijing-0.1.1.dist-info/licenses/LICENSE +21 -0
  68. nonebot_plugin_yijing-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from nonebot import require
4
+ from nonebot.plugin import PluginMetadata
5
+
6
+ from .config import YijingConfig
7
+
8
+ require("nonebot_plugin_alconna")
9
+ require("nonebot_plugin_htmlrender")
10
+ require("nonebot_plugin_orm")
11
+ require("nonebot_plugin_localstore")
12
+
13
+ # 权限控制插件是强依赖;API 适配用于将本插件拆成细粒度服务。
14
+ require("nonebot_plugin_access_control")
15
+ require("nonebot_plugin_access_control_api")
16
+
17
+ __plugin_meta__ = PluginMetadata(
18
+ name="易经起卦解卦",
19
+ description="群内自助进行基于《周易》的起卦、查卦、解卦、历史记录与图片化输出。",
20
+ usage="""
21
+ 易经帮助
22
+ 起卦 <问题>
23
+ 起卦 <问题> 铜钱
24
+ 起卦 <问题> 大衍
25
+ 起卦 <问题> 手动
26
+ 解卦 <卦名/卦象>
27
+ 易经历史
28
+ 易经记录 <ID>
29
+ 随机一卦
30
+ 易经设置
31
+ """.strip(),
32
+ type="application",
33
+ homepage="https://github.com/newcovid/nonebot-plugin-yijing",
34
+ config=YijingConfig,
35
+ supported_adapters={"~onebot.v11"},
36
+ )
37
+
38
+ from . import commands as _commands # noqa: E402,F401
@@ -0,0 +1,3 @@
1
+ from . import main as main
2
+
3
+ __all__ = ["main"]
@@ -0,0 +1,498 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from arclet.alconna import Alconna, AllParam, Args
6
+ from nonebot import on_message
7
+ from nonebot.adapters import Bot, Event
8
+ from nonebot.matcher import Matcher
9
+ from nonebot.rule import Rule
10
+ from nonebot_plugin_alconna import on_alconna
11
+ from nonebot_plugin_orm import async_scoped_session
12
+
13
+ from ..config import plugin_config
14
+ from ..core.caster import (
15
+ cast_coin,
16
+ cast_random_hexagram,
17
+ cast_yarrow,
18
+ parse_manual_coin,
19
+ parse_manual_yarrow,
20
+ )
21
+ from ..core.hexagram import resolve_by_lines
22
+ from ..core.interpret import local_preprocess
23
+ from ..permissions import (
24
+ cast_service,
25
+ history_service,
26
+ manual_service,
27
+ query_service,
28
+ random_service,
29
+ settings_service,
30
+ )
31
+ from ..render.html import render_image
32
+ from ..render.message import image_message
33
+ from ..services.llm import interpret_with_llm, parse_hexagram_query, preprocess_question
34
+ from ..services.payload import (
35
+ build_hexagram_query_payload,
36
+ build_history_payload,
37
+ build_record_card_payload,
38
+ build_record_payload_from_dict,
39
+ classic_text_for,
40
+ )
41
+ from ..services.repository import (
42
+ add_quota,
43
+ all_user_records,
44
+ cooldown_remaining,
45
+ daily_quota_count,
46
+ find_similar_recent,
47
+ get_or_create_group_config,
48
+ get_record,
49
+ recent_records,
50
+ record_to_dict,
51
+ save_record,
52
+ touch_cooldown,
53
+ )
54
+ from ..utils import (
55
+ event_is_group_admin,
56
+ get_group_id,
57
+ get_plain_text,
58
+ get_user_id,
59
+ hash_user_id,
60
+ normalize_question,
61
+ parse_command_body,
62
+ utcnow,
63
+ )
64
+
65
+
66
+ def _all_args_command(name: str) -> Alconna:
67
+ return Alconna(name, Args["body?", AllParam])
68
+
69
+
70
+ help_matcher = on_alconna(Alconna("易经帮助"), aliases={"周易帮助"})
71
+ cast_matcher = on_alconna(_all_args_command("起卦"), aliases={"算卦"})
72
+ query_matcher = on_alconna(_all_args_command("解卦"))
73
+ history_matcher = on_alconna(Alconna("易经历史"))
74
+ record_matcher = on_alconna(_all_args_command("易经记录"))
75
+ random_matcher = on_alconna(Alconna("随机一卦"))
76
+ settings_matcher = on_alconna(_all_args_command("易经设置"))
77
+
78
+ for matcher, service in [
79
+ (cast_matcher, cast_service),
80
+ (query_matcher, query_service),
81
+ (history_matcher, history_service),
82
+ (record_matcher, history_service),
83
+ (random_matcher, random_service),
84
+ (settings_matcher, settings_service),
85
+ (help_matcher, query_service),
86
+ ]:
87
+ service.patch_matcher(matcher)
88
+
89
+
90
+ async def _finish_template(matcher: Matcher, template: str, data: dict[str, Any]) -> None:
91
+ image = await render_image(template, data)
92
+ await matcher.finish(image_message(image))
93
+
94
+
95
+ async def _notice(matcher: Matcher, title: str, content: str, hint: str = "") -> None:
96
+ await _finish_template(matcher, "notice.html", {"title": title, "content": content, "hint": hint})
97
+
98
+
99
+ def _parse_cast_body(body: str) -> tuple[str, str]:
100
+ body = body.strip()
101
+ method = "coin"
102
+ suffix_map = [
103
+ ("大衍", "yarrow"),
104
+ ("蓍草", "yarrow"),
105
+ ("铜钱", "coin"),
106
+ ("硬币", "coin"),
107
+ ("手动", "manual"),
108
+ ]
109
+ for suffix, value in suffix_map:
110
+ if body.endswith(suffix):
111
+ method = value
112
+ body = body[: -len(suffix)].strip()
113
+ break
114
+ if not body and method != "manual":
115
+ return "", method
116
+ return body, method
117
+
118
+
119
+ def _parse_positive_int(text: str, *, minimum: int = 1, maximum: int | None = None) -> int:
120
+ try:
121
+ value = int(text)
122
+ except ValueError as exc:
123
+ raise ValueError("数值必须是整数。") from exc
124
+ if value < minimum:
125
+ raise ValueError(f"数值不能小于 {minimum}。")
126
+ if maximum is not None and value > maximum:
127
+ raise ValueError(f"数值不能大于 {maximum}。")
128
+ return value
129
+
130
+
131
+ def _history_brief(records: list[Any]) -> list[dict[str, Any]]:
132
+ items = []
133
+ for r in records:
134
+ data = record_to_dict(r)
135
+ items.append(
136
+ {
137
+ "id": data["id"],
138
+ "created_at": data["created_at"],
139
+ "question": data["question"],
140
+ "primary_seq": data["primary_seq"],
141
+ "changed_seq": data["changed_seq"],
142
+ "moving_positions": data["moving_positions"],
143
+ }
144
+ )
145
+ return items
146
+
147
+
148
+ async def _run_cast(
149
+ *,
150
+ matcher: Matcher,
151
+ bot: Bot,
152
+ event: Event,
153
+ session: async_scoped_session,
154
+ question: str | None,
155
+ method: str,
156
+ manual_values: list[int] | None = None,
157
+ manual_coins: list[list[str]] | None = None,
158
+ random_mode: bool = False,
159
+ ) -> None:
160
+ group_id = get_group_id(event)
161
+ user_hash = hash_user_id(get_user_id(event))
162
+ cfg = await get_or_create_group_config(session, group_id)
163
+ if not cfg.enabled and not event_is_group_admin(event):
164
+ await _notice(matcher, "易经插件已关闭", "本群已关闭易经插件。")
165
+
166
+ if not random_mode and question:
167
+ history = await recent_records(
168
+ session,
169
+ group_id=group_id,
170
+ user_hash=user_hash,
171
+ limit=20,
172
+ minutes=cfg.history_minutes_for_llm,
173
+ )
174
+ preprocess = await preprocess_question(question, _history_brief(history), use_llm=cfg.llm_enabled)
175
+ if not preprocess.get("allowed", True):
176
+ await _notice(
177
+ matcher,
178
+ "本次未起卦",
179
+ str(preprocess.get("reason") or "根据三不占预处理规则,本次不建议起卦。"),
180
+ "你可以重新整理成一个明确、正当且确有疑问的问题。",
181
+ )
182
+ similar = await find_similar_recent(
183
+ session,
184
+ group_id=group_id,
185
+ user_hash=user_hash,
186
+ question=question,
187
+ minutes=cfg.duplicate_window_minutes,
188
+ )
189
+ if similar:
190
+ record, score = similar
191
+ await _notice(
192
+ matcher,
193
+ "发现短期相似问题",
194
+ f"{cfg.duplicate_window_minutes} 分钟内你已问过相近问题。\n"
195
+ f"记录ID:{record.id}\n相似度:{score:.2f}\n建议使用:易经记录 {record.id}",
196
+ "如确实要重新起卦,可稍后再问,或由管理员调整冷却/重复窗口策略。",
197
+ )
198
+ else:
199
+ preprocess = local_preprocess(question or "", [])
200
+
201
+ quota = await daily_quota_count(session, group_id, user_hash)
202
+ if quota >= cfg.daily_limit:
203
+ await _notice(
204
+ matcher,
205
+ "今日次数已达上限",
206
+ f"你在本群 24 小时内已起卦 {quota} 次,上限为 {cfg.daily_limit} 次。",
207
+ )
208
+
209
+ remain = await cooldown_remaining(session, group_id, cfg.cooldown_seconds)
210
+ if remain > 0:
211
+ await _notice(matcher, "群冷却中", f"本群起卦冷却还剩 {remain} 秒。")
212
+
213
+ if manual_values is not None:
214
+ values = manual_values
215
+ coins = manual_coins or []
216
+ cast_method = method
217
+ else:
218
+ if random_mode:
219
+ cast = cast_random_hexagram()
220
+ values = cast.line_values
221
+ coins = cast.coins
222
+ cast_method = "random"
223
+ elif method == "yarrow":
224
+ cast = cast_yarrow()
225
+ values = cast.line_values
226
+ coins = cast.coins
227
+ cast_method = "yarrow"
228
+ else:
229
+ cast = cast_coin()
230
+ values = cast.line_values
231
+ coins = cast.coins
232
+ cast_method = "coin"
233
+
234
+ resolved = resolve_by_lines(values)
235
+ classic = classic_text_for(resolved)
236
+ interpretation = await interpret_with_llm(
237
+ question=question,
238
+ resolved=resolved,
239
+ classic_text=classic,
240
+ preprocess=preprocess,
241
+ random_mode=random_mode,
242
+ use_llm=cfg.llm_enabled,
243
+ )
244
+ adapter = getattr(bot, "type", "")
245
+ bot_id = getattr(bot, "self_id", "")
246
+ record = await save_record(
247
+ session,
248
+ adapter=str(adapter),
249
+ bot_id=str(bot_id),
250
+ group_id=group_id,
251
+ user_hash=user_hash,
252
+ question=question,
253
+ method=cast_method,
254
+ coins=coins,
255
+ line_values=values,
256
+ moving_positions=resolved.moving_positions,
257
+ primary_seq=int(resolved.primary["seq"]),
258
+ changed_seq=int(resolved.changed["seq"]) if resolved.changed else None,
259
+ preprocess=preprocess,
260
+ interpretation=interpretation,
261
+ )
262
+ record_id = record.id
263
+ created_at = record.created_at.strftime("%Y-%m-%d %H:%M:%S")
264
+
265
+ await add_quota(session, group_id, user_hash)
266
+ await touch_cooldown(
267
+ session,
268
+ group_id=group_id,
269
+ record_id=record_id,
270
+ user_hash=user_hash,
271
+ question_norm=normalize_question(question),
272
+ )
273
+ payload = build_record_card_payload(
274
+ record_id=record_id,
275
+ question=question,
276
+ method=cast_method,
277
+ coins=coins,
278
+ resolved=resolved,
279
+ preprocess=preprocess,
280
+ interpretation=interpretation,
281
+ created_at=created_at,
282
+ random_mode=random_mode,
283
+ )
284
+ await _finish_template(matcher, "card.html", payload)
285
+
286
+
287
+ @help_matcher.handle()
288
+ async def _help(matcher: Matcher) -> None:
289
+ commands = [
290
+ {"cmd": "易经帮助", "desc": "查看所有交互命令。"},
291
+ {"cmd": "起卦 问题", "desc": "默认使用三枚铜钱法自动起卦并输出长图。"},
292
+ {"cmd": "起卦 问题 铜钱", "desc": "精确指定三枚铜钱法。"},
293
+ {"cmd": "起卦 问题 大衍", "desc": "使用大衍筮法概率模拟。"},
294
+ {"cmd": "起卦 问题 手动", "desc": "进入手动引导,可输入铜钱正反或 6/7/8/9 爻值。"},
295
+ {"cmd": "解卦 卦象", "desc": "查询并解释一个卦名、卦序、符号或模糊卦象。"},
296
+ {"cmd": "易经历史", "desc": "查看自己的最近起卦记录。"},
297
+ {"cmd": "易经记录 ID", "desc": "查看指定记录的完整长图。"},
298
+ {"cmd": "随机一卦", "desc": "随机生成一个观察主题,不使用问题预处理和问题解读。"},
299
+ {"cmd": "易经设置", "desc": "查看或修改本群配置。"},
300
+ {"cmd": "易经设置 重复窗口 30", "desc": "设置短期相似问题检测窗口,单位分钟。"},
301
+ {"cmd": "易经设置 历史窗口 120", "desc": "设置传给 LLM 预处理的近期历史窗口,单位分钟。"},
302
+ ]
303
+ await _finish_template(matcher, "help.html", {"commands": commands})
304
+
305
+
306
+ @cast_matcher.handle()
307
+ async def _cast(bot: Bot, event: Event, matcher: Matcher, session: async_scoped_session) -> None:
308
+ raw = get_plain_text(event)
309
+ body = parse_command_body(raw, "起卦")
310
+ if raw.strip().startswith("算卦") or raw.strip().startswith("/算卦"):
311
+ body = parse_command_body(raw, "算卦")
312
+ question, method = _parse_cast_body(body)
313
+ if method == "manual":
314
+ key = (get_group_id(event), get_user_id(event))
315
+ _MANUAL_SESSIONS[key] = {"question": question, "stage": "method"}
316
+ await _notice(
317
+ matcher,
318
+ "手动起卦引导",
319
+ "请选择手动方式:\n1. 发送:铜钱\n2. 发送:大衍\n\n"
320
+ "铜钱法随后请自下而上输入 6 组,每组 3 个正/反。\n"
321
+ "大衍法可直接自下而上输入 6 个爻值:6 7 8 9。",
322
+ )
323
+ if not question:
324
+ await _notice(matcher, "缺少问题", "请使用:起卦 你的问题\n例如:起卦 此行去山西实习一程怎么样")
325
+ await _run_cast(matcher=matcher, bot=bot, event=event, session=session, question=question, method=method)
326
+
327
+
328
+ @query_matcher.handle()
329
+ async def _query(event: Event, matcher: Matcher) -> None:
330
+ raw = get_plain_text(event)
331
+ query = parse_command_body(raw, "解卦")
332
+ if not query:
333
+ await _notice(matcher, "缺少卦象", "请使用:解卦 需\n也可以输入卦序、卦名或符号。")
334
+ parsed = await parse_hexagram_query(query)
335
+ q = str(parsed.get("normalized_query") or parsed.get("query") or query)
336
+ payload = build_hexagram_query_payload(q)
337
+ if not payload.get("found", True):
338
+ await _notice(matcher, "未找到卦象", f"未能识别:{query}\n请尝试输入卦名,如:乾、坤、需;或输入 1-64。")
339
+ await _finish_template(matcher, "card.html", payload)
340
+
341
+
342
+ @history_matcher.handle()
343
+ async def _history(event: Event, matcher: Matcher, session: async_scoped_session) -> None:
344
+ records = await all_user_records(
345
+ session, group_id=get_group_id(event), user_hash=hash_user_id(get_user_id(event)), limit=20
346
+ )
347
+ await _finish_template(matcher, "history.html", build_history_payload(records))
348
+
349
+
350
+ @record_matcher.handle()
351
+ async def _record(event: Event, matcher: Matcher, session: async_scoped_session) -> None:
352
+ record_id = parse_command_body(get_plain_text(event), "易经记录").strip().upper()
353
+ if not record_id:
354
+ await _notice(matcher, "缺少记录 ID", "请使用:易经记录 YJ-XXXXXXXX")
355
+ record = await get_record(session, record_id)
356
+ if record is None:
357
+ await _notice(matcher, "记录不存在", f"未找到记录:{record_id}")
358
+ await _finish_template(matcher, "card.html", build_record_payload_from_dict(record_to_dict(record)))
359
+
360
+
361
+ @random_matcher.handle()
362
+ async def _random(bot: Bot, event: Event, matcher: Matcher, session: async_scoped_session) -> None:
363
+ await _run_cast(
364
+ matcher=matcher,
365
+ bot=bot,
366
+ event=event,
367
+ session=session,
368
+ question=None,
369
+ method="random",
370
+ random_mode=True,
371
+ )
372
+
373
+
374
+ @settings_matcher.handle()
375
+ async def _settings(event: Event, matcher: Matcher, session: async_scoped_session) -> None:
376
+ group_id = get_group_id(event)
377
+ cfg = await get_or_create_group_config(session, group_id)
378
+ body = parse_command_body(get_plain_text(event), "易经设置").strip()
379
+ changed = False
380
+ if body:
381
+ if not event_is_group_admin(event):
382
+ await _notice(matcher, "无权限", "设置类命令仅允许群主、管理员或 superuser 使用。")
383
+ parts = body.split()
384
+ try:
385
+ if body in {"开启", "启用", "on"}:
386
+ cfg.enabled = True
387
+ changed = True
388
+ elif body in {"关闭", "禁用", "off"}:
389
+ cfg.enabled = False
390
+ changed = True
391
+ elif len(parts) >= 2 and parts[0] == "冷却":
392
+ cfg.cooldown_seconds = _parse_positive_int(parts[1], minimum=0, maximum=86400)
393
+ changed = True
394
+ elif len(parts) >= 2 and parts[0] == "日限额":
395
+ cfg.daily_limit = _parse_positive_int(parts[1], minimum=1, maximum=1000)
396
+ changed = True
397
+ elif len(parts) >= 2 and parts[0] == "重复窗口":
398
+ cfg.duplicate_window_minutes = _parse_positive_int(parts[1], minimum=1, maximum=10080)
399
+ changed = True
400
+ elif len(parts) >= 2 and parts[0] == "历史窗口":
401
+ cfg.history_minutes_for_llm = _parse_positive_int(parts[1], minimum=1, maximum=43200)
402
+ changed = True
403
+ elif len(parts) >= 2 and parts[0] == "默认":
404
+ cfg.default_method = "yarrow" if parts[1] in {"大衍", "蓍草", "yarrow"} else "coin"
405
+ changed = True
406
+ elif len(parts) >= 2 and parts[0].upper() == "LLM":
407
+ cfg.llm_enabled = parts[1] in {"开启", "启用", "on", "true", "1"}
408
+ changed = True
409
+ else:
410
+ await _notice(
411
+ matcher,
412
+ "设置格式错误",
413
+ "支持:易经设置 开启|关闭|冷却 秒数|日限额 次数|重复窗口 分钟|历史窗口 分钟|默认 铜钱/大衍|LLM 开启/关闭",
414
+ )
415
+ except ValueError as exc:
416
+ await _notice(matcher, "设置数值错误", str(exc), "请使用整数,例如:易经设置 重复窗口 30")
417
+ if changed:
418
+ cfg.updated_at = utcnow()
419
+ await session.commit()
420
+ await session.refresh(cfg)
421
+ rows = [
422
+ {"name": "群ID", "value": group_id},
423
+ {"name": "是否启用", "value": "是" if cfg.enabled else "否"},
424
+ {"name": "默认起卦方式", "value": "大衍" if cfg.default_method == "yarrow" else "铜钱"},
425
+ {"name": "群冷却秒数", "value": cfg.cooldown_seconds},
426
+ {"name": "用户24小时限额", "value": cfg.daily_limit},
427
+ {"name": "重复问题窗口", "value": f"{cfg.duplicate_window_minutes} 分钟"},
428
+ {"name": "LLM历史窗口", "value": f"{cfg.history_minutes_for_llm} 分钟"},
429
+ {"name": "本群LLM解读", "value": "开启" if cfg.llm_enabled else "关闭"},
430
+ {"name": "全局LLM配置", "value": "开启" if plugin_config.yijing_llm_enabled else "关闭"},
431
+ {"name": "铜钱输入", "value": f"{plugin_config.yijing_positive_face}/{plugin_config.yijing_negative_face}"},
432
+ ]
433
+ await _finish_template(matcher, "settings.html", {"rows": rows})
434
+
435
+
436
+ _MANUAL_SESSIONS: dict[tuple[str, str], dict[str, Any]] = {}
437
+
438
+
439
+ async def _has_manual_session(event: Event) -> bool:
440
+ return (get_group_id(event), get_user_id(event)) in _MANUAL_SESSIONS
441
+
442
+
443
+ manual_input_matcher = on_message(rule=Rule(_has_manual_session), block=True, priority=5)
444
+ manual_service.patch_matcher(manual_input_matcher)
445
+
446
+
447
+ @manual_input_matcher.handle()
448
+ async def _manual_input(bot: Bot, event: Event, matcher: Matcher, session: async_scoped_session) -> None:
449
+ key = (get_group_id(event), get_user_id(event))
450
+ state = _MANUAL_SESSIONS.get(key)
451
+ if not state:
452
+ return
453
+ text = get_plain_text(event).strip()
454
+ if text in {"取消", "退出", "停止"}:
455
+ _MANUAL_SESSIONS.pop(key, None)
456
+ await _notice(matcher, "已取消", "本次手动起卦已取消。")
457
+ if state.get("stage") == "method":
458
+ if text in {"铜钱", "硬币", "coin"}:
459
+ state["stage"] = "values"
460
+ state["method"] = "manual_coin"
461
+ await _notice(
462
+ matcher,
463
+ "请输入铜钱结果",
464
+ f"请自下而上输入 6 组,每组 3 个"
465
+ f"{plugin_config.yijing_positive_face}/{plugin_config.yijing_negative_face}。\n"
466
+ "示例:正反正 反反正 正正反 正反反 反正正 反反反",
467
+ )
468
+ if text in {"大衍", "蓍草", "yarrow"}:
469
+ state["stage"] = "values"
470
+ state["method"] = "manual_yarrow"
471
+ await _notice(
472
+ matcher,
473
+ "请输入大衍结果",
474
+ "请自下而上输入 6 个爻值,每个只能是 6/7/8/9。\n示例:7 8 9 6 7 8",
475
+ )
476
+ await _notice(matcher, "未识别方式", "请发送:铜钱 或 大衍。发送“取消”可退出。")
477
+ method = str(state.get("method"))
478
+ try:
479
+ if method == "manual_coin":
480
+ values = parse_manual_coin(text)
481
+ groups = [g for g in text.replace(",", " ").replace(",", " ").split() if g]
482
+ coins = [list(g) for g in groups]
483
+ else:
484
+ values = parse_manual_yarrow(text)
485
+ coins = []
486
+ except Exception as exc: # noqa: BLE001
487
+ await _notice(matcher, "输入格式错误", str(exc), "发送“取消”可退出手动起卦。")
488
+ _MANUAL_SESSIONS.pop(key, None)
489
+ await _run_cast(
490
+ matcher=matcher,
491
+ bot=bot,
492
+ event=event,
493
+ session=session,
494
+ question=state.get("question") or None,
495
+ method=method,
496
+ manual_values=values,
497
+ manual_coins=coins,
498
+ )
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from nonebot import get_plugin_config
6
+ from pydantic import BaseModel, Field, SecretStr
7
+
8
+
9
+ class YijingConfig(BaseModel):
10
+ """插件配置。
11
+
12
+ 所有硬币/铜钱输入统一使用“正/反”,默认正=3、反=2。
13
+ """
14
+
15
+ yijing_default_method: Literal["coin", "yarrow"] = Field(
16
+ default="coin", description="默认起卦方式:coin=三枚铜钱法,yarrow=大衍筮法模拟"
17
+ )
18
+ yijing_positive_face: str = Field(default="正", description="铜钱正面输入词")
19
+ yijing_negative_face: str = Field(default="反", description="铜钱反面输入词")
20
+ yijing_positive_value: int = Field(default=3, description="铜钱正面的数值")
21
+ yijing_negative_value: int = Field(default=2, description="铜钱反面的数值")
22
+
23
+ yijing_group_default_enabled: bool = Field(default=True, description="新群默认是否启用")
24
+ yijing_cooldown_seconds: int = Field(default=60, ge=0, description="群级冷却秒数")
25
+ yijing_daily_limit_per_user: int = Field(default=10, ge=1, description="单用户每日起卦次数")
26
+ yijing_duplicate_window_minutes: int = Field(default=30, ge=1, description="短期相似问题窗口")
27
+ yijing_history_minutes_for_llm: int = Field(default=120, ge=1, description="默认传给 LLM 的历史窗口")
28
+ yijing_store_question: bool = Field(default=True, description="是否保存原始问题文本")
29
+ yijing_user_hash_salt: str = Field(default="change-me", description="用户ID哈希盐")
30
+
31
+ yijing_render_scale: float = Field(default=2.0, ge=1.0, le=4.0, description="HTML 渲染倍率")
32
+ yijing_render_width: int = Field(default=900, ge=640, le=1600, description="长图宽度")
33
+
34
+ yijing_llm_enabled: bool = Field(default=False, description="是否启用 OpenAI 兼容 LLM")
35
+ yijing_llm_base_url: str = Field(default="", description="OpenAI 兼容接口 base_url")
36
+ yijing_llm_api_key: SecretStr | None = Field(default=None, description="LLM API Key")
37
+ yijing_llm_model: str = Field(default="", description="LLM 模型名")
38
+ yijing_llm_timeout_seconds: int = Field(default=30, ge=3, le=120, description="LLM 请求超时")
39
+
40
+
41
+ plugin_config = get_plugin_config(YijingConfig)
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from typing import Literal
7
+
8
+ from ..config import plugin_config
9
+
10
+ LineValue = Literal[6, 7, 8, 9]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CastResult:
15
+ method: str
16
+ line_values: list[int]
17
+ coins: list[list[str]]
18
+ created_at: datetime
19
+
20
+
21
+ def line_to_bit(value: int) -> int:
22
+ if value in (7, 9):
23
+ return 1
24
+ return 0
25
+
26
+
27
+ def changed_line_to_bit(value: int) -> int:
28
+ if value == 6:
29
+ return 1
30
+ if value == 9:
31
+ return 0
32
+ return line_to_bit(value)
33
+
34
+
35
+ def moving_positions(values: list[int]) -> list[int]:
36
+ return [i + 1 for i, v in enumerate(values) if v in (6, 9)]
37
+
38
+
39
+ def cast_coin(rng: random.Random | None = None) -> CastResult:
40
+ rng = rng or random.Random()
41
+ pos = plugin_config.yijing_positive_face
42
+ neg = plugin_config.yijing_negative_face
43
+ p_val = plugin_config.yijing_positive_value
44
+ n_val = plugin_config.yijing_negative_value
45
+ line_values: list[int] = []
46
+ coins: list[list[str]] = []
47
+ for _ in range(6):
48
+ faces = [rng.choice([pos, neg]) for _ in range(3)]
49
+ total = sum(p_val if face == pos else n_val for face in faces)
50
+ if total not in (6, 7, 8, 9):
51
+ # 用户若把正反数值改坏,降级到传统 coin 分布。
52
+ total = rng.choice([6, 7, 7, 7, 8, 8, 8, 9])
53
+ coins.append(faces)
54
+ line_values.append(total)
55
+ return CastResult("coin", line_values, coins, datetime.now(timezone.utc))
56
+
57
+
58
+ def cast_yarrow(rng: random.Random | None = None) -> CastResult:
59
+ """大衍筮法结果概率模拟。
60
+
61
+ 传统蓍草法结果概率约为:老阴6=1/16,少阳7=5/16,少阴8=7/16,老阳9=3/16。
62
+ """
63
+
64
+ rng = rng or random.Random()
65
+ population = [6] + [7] * 5 + [8] * 7 + [9] * 3
66
+ return CastResult("yarrow", [rng.choice(population) for _ in range(6)], [], datetime.now(timezone.utc))
67
+
68
+
69
+ def cast_random_hexagram(rng: random.Random | None = None) -> CastResult:
70
+ rng = rng or random.Random()
71
+ method = rng.choice(["coin", "yarrow"])
72
+ return cast_coin(rng) if method == "coin" else cast_yarrow(rng)
73
+
74
+
75
+ def parse_manual_coin(text: str) -> list[int]:
76
+ """解析六组正反输入。支持:正反正 反反正 ..."""
77
+
78
+ pos = plugin_config.yijing_positive_face
79
+ neg = plugin_config.yijing_negative_face
80
+ p_val = plugin_config.yijing_positive_value
81
+ n_val = plugin_config.yijing_negative_value
82
+ groups = [g for g in text.replace(",", " ").replace(",", " ").split() if g]
83
+ if len(groups) != 6:
84
+ raise ValueError("需要自下而上输入 6 组,每组 3 个正/反。")
85
+ values: list[int] = []
86
+ for group in groups:
87
+ faces = list(group)
88
+ if len(faces) != 3 or any(f not in {pos, neg} for f in faces):
89
+ raise ValueError(f"每组必须是 3 个“{pos}/{neg}”,例如:{pos}{neg}{pos}")
90
+ values.append(sum(p_val if face == pos else n_val for face in faces))
91
+ if any(v not in (6, 7, 8, 9) for v in values):
92
+ raise ValueError("正反数值配置错误,无法得到 6/7/8/9 四种爻值。")
93
+ return values
94
+
95
+
96
+ def parse_manual_yarrow(text: str) -> list[int]:
97
+ parts = [p for p in text.replace(",", " ").replace(",", " ").split() if p]
98
+ if len(parts) != 6:
99
+ raise ValueError("需要自下而上输入 6 个爻值:6/7/8/9。")
100
+ values = [int(p) for p in parts]
101
+ if any(v not in (6, 7, 8, 9) for v in values):
102
+ raise ValueError("爻值只能是 6、7、8、9。")
103
+ return values