nonebot-plugin-mantou-affection 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_mantou_affection/__init__.py +114 -0
- nonebot_plugin_mantou_affection/commands.py +162 -0
- nonebot_plugin_mantou_affection/config.py +29 -0
- nonebot_plugin_mantou_affection/copywriting.py +88 -0
- nonebot_plugin_mantou_affection/integration.py +80 -0
- nonebot_plugin_mantou_affection/logic.py +174 -0
- nonebot_plugin_mantou_affection/models.py +111 -0
- nonebot_plugin_mantou_affection/parsing.py +35 -0
- nonebot_plugin_mantou_affection/resources/affection_texts.json +130 -0
- nonebot_plugin_mantou_affection/service.py +116 -0
- nonebot_plugin_mantou_affection/storage.py +117 -0
- nonebot_plugin_mantou_affection-0.1.0.dist-info/METADATA +221 -0
- nonebot_plugin_mantou_affection-0.1.0.dist-info/RECORD +15 -0
- nonebot_plugin_mantou_affection-0.1.0.dist-info/WHEEL +4 -0
- nonebot_plugin_mantou_affection-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from nonebot import get_plugin_config, require
|
|
6
|
+
from nonebot.plugin import PluginMetadata
|
|
7
|
+
|
|
8
|
+
from .config import Config
|
|
9
|
+
|
|
10
|
+
__plugin_meta__ = PluginMetadata(
|
|
11
|
+
name="馒头好感度",
|
|
12
|
+
description="为群聊机器人馒头提供互动、关系阶段和跨插件共享好感状态",
|
|
13
|
+
usage=(
|
|
14
|
+
"/馒头互动|/馒头好感|/馒头好感榜|/馒头好感帮助\n"
|
|
15
|
+
"SUPERUSER:/馒头好感调整 @群友 +10"
|
|
16
|
+
),
|
|
17
|
+
type="application",
|
|
18
|
+
homepage="https://github.com/TonyLiangP2010405/nonebot-plugin-mantou-affection",
|
|
19
|
+
config=Config,
|
|
20
|
+
supported_adapters={"~onebot.v11"},
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
require("nonebot_plugin_localstore")
|
|
24
|
+
|
|
25
|
+
from nonebot_plugin_localstore import get_plugin_data_dir
|
|
26
|
+
|
|
27
|
+
from .commands import register_commands
|
|
28
|
+
from .copywriting import AffectionTextLibrary
|
|
29
|
+
from .integration import register_plugin_linkage
|
|
30
|
+
from .models import AffectionResponse, AffectionSnapshot
|
|
31
|
+
from .service import AffectionService
|
|
32
|
+
from .storage import AffectionStore
|
|
33
|
+
|
|
34
|
+
plugin_config = get_plugin_config(Config)
|
|
35
|
+
data_file = Path(get_plugin_data_dir()) / "affection.json"
|
|
36
|
+
store = AffectionStore(data_file)
|
|
37
|
+
service = AffectionService(store, plugin_config)
|
|
38
|
+
text_library = AffectionTextLibrary(
|
|
39
|
+
Path(__file__).parent / "resources" / "affection_texts.json",
|
|
40
|
+
plugin_config.mantou_affection_text_path,
|
|
41
|
+
)
|
|
42
|
+
matchers = register_commands(service, plugin_config)
|
|
43
|
+
plugin_linkage = register_plugin_linkage(service, plugin_config)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def add_affection(
|
|
47
|
+
group_id: str | int,
|
|
48
|
+
user_id: str | int,
|
|
49
|
+
delta: int,
|
|
50
|
+
*,
|
|
51
|
+
nickname: str = "",
|
|
52
|
+
source: str = "external",
|
|
53
|
+
) -> int:
|
|
54
|
+
"""供其他插件主动增加好感度;返回实际增加值。"""
|
|
55
|
+
|
|
56
|
+
if delta <= 0:
|
|
57
|
+
raise ValueError("联动增加值必须大于 0")
|
|
58
|
+
result = await service.reward_external(
|
|
59
|
+
str(group_id), str(user_id), nickname, source, delta
|
|
60
|
+
)
|
|
61
|
+
return result.delta
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def change_affection(
|
|
65
|
+
group_id: str | int,
|
|
66
|
+
user_id: str | int,
|
|
67
|
+
delta: int,
|
|
68
|
+
*,
|
|
69
|
+
nickname: str = "",
|
|
70
|
+
source: str = "external",
|
|
71
|
+
) -> int:
|
|
72
|
+
"""供其他插件按正负数改变好感度;返回实际变化值。"""
|
|
73
|
+
|
|
74
|
+
if delta == 0:
|
|
75
|
+
return 0
|
|
76
|
+
result = await service.reward_external(
|
|
77
|
+
str(group_id), str(user_id), nickname, source, delta
|
|
78
|
+
)
|
|
79
|
+
return result.delta
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def get_affection(group_id: str | int, user_id: str | int) -> int:
|
|
83
|
+
"""读取指定群友的当前好感度;不存在记录时返回 0。"""
|
|
84
|
+
|
|
85
|
+
return (await service.profile(str(group_id), str(user_id))).affection
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def get_affection_snapshot(
|
|
89
|
+
group_id: str | int,
|
|
90
|
+
user_id: str | int,
|
|
91
|
+
) -> AffectionSnapshot:
|
|
92
|
+
"""读取好感度、等级、称号和语气分层,供其他插件软联动。"""
|
|
93
|
+
|
|
94
|
+
return await service.snapshot(str(group_id), str(user_id))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def get_affection_response(
|
|
98
|
+
scene: str,
|
|
99
|
+
group_id: str | int,
|
|
100
|
+
user_id: str | int,
|
|
101
|
+
*,
|
|
102
|
+
seed: str = "",
|
|
103
|
+
) -> AffectionResponse:
|
|
104
|
+
"""按场景与当前好感阶段选择文案;seed 非空时结果稳定。"""
|
|
105
|
+
|
|
106
|
+
snapshot = await service.snapshot(str(group_id), str(user_id))
|
|
107
|
+
text = text_library.pick(scene, snapshot, seed=seed)
|
|
108
|
+
return AffectionResponse(
|
|
109
|
+
affection=snapshot.affection,
|
|
110
|
+
level=snapshot.level,
|
|
111
|
+
title=snapshot.title,
|
|
112
|
+
band=snapshot.band,
|
|
113
|
+
text=text,
|
|
114
|
+
)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nonebot import logger, on_command
|
|
4
|
+
from nonebot.adapters import Event
|
|
5
|
+
from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message, MessageSegment
|
|
6
|
+
from nonebot.params import CommandArg
|
|
7
|
+
from nonebot.permission import SUPERUSER
|
|
8
|
+
from nonebot.rule import Rule
|
|
9
|
+
|
|
10
|
+
from .config import Config
|
|
11
|
+
from .logic import level_for, progress_text
|
|
12
|
+
from .parsing import parse_adjustment
|
|
13
|
+
from .service import AffectionService, normalize_nickname
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _group_only(event: Event) -> bool:
|
|
17
|
+
return isinstance(event, GroupMessageEvent)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
GROUP_ONLY = Rule(_group_only)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _nickname(event: GroupMessageEvent) -> str:
|
|
24
|
+
raw = event.sender.card or event.sender.nickname or str(event.user_id)
|
|
25
|
+
return normalize_nickname(raw, str(event.user_id))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _gain_text(delta: int) -> str:
|
|
29
|
+
return f"好感度 +{delta}" if delta else "好感度没有变化"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def register_commands(service: AffectionService, config: Config) -> tuple:
|
|
33
|
+
profile_cmd = on_command(
|
|
34
|
+
"馒头好感",
|
|
35
|
+
aliases={"馒头好感度", "我的好感"},
|
|
36
|
+
rule=GROUP_ONLY,
|
|
37
|
+
priority=10,
|
|
38
|
+
block=True,
|
|
39
|
+
)
|
|
40
|
+
interact_cmd = on_command(
|
|
41
|
+
"馒头互动",
|
|
42
|
+
aliases={"摸摸馒头", "陪馒头玩"},
|
|
43
|
+
rule=GROUP_ONLY,
|
|
44
|
+
priority=10,
|
|
45
|
+
block=True,
|
|
46
|
+
)
|
|
47
|
+
ranking_cmd = on_command(
|
|
48
|
+
"馒头好感榜",
|
|
49
|
+
aliases={"好感榜", "馒头排行榜"},
|
|
50
|
+
rule=GROUP_ONLY,
|
|
51
|
+
priority=10,
|
|
52
|
+
block=True,
|
|
53
|
+
)
|
|
54
|
+
help_cmd = on_command(
|
|
55
|
+
"馒头好感帮助",
|
|
56
|
+
aliases={"馒头好感菜单"},
|
|
57
|
+
rule=GROUP_ONLY,
|
|
58
|
+
priority=10,
|
|
59
|
+
block=True,
|
|
60
|
+
)
|
|
61
|
+
adjust_cmd = on_command(
|
|
62
|
+
"馒头好感调整",
|
|
63
|
+
permission=SUPERUSER,
|
|
64
|
+
rule=GROUP_ONLY,
|
|
65
|
+
priority=10,
|
|
66
|
+
block=True,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@profile_cmd.handle()
|
|
70
|
+
async def handle_profile(event: GroupMessageEvent) -> None:
|
|
71
|
+
try:
|
|
72
|
+
profile = await service.profile(str(event.group_id), str(event.user_id))
|
|
73
|
+
except Exception:
|
|
74
|
+
logger.exception("[mantou-affection] 读取好感度失败")
|
|
75
|
+
await profile_cmd.finish("馒头翻了翻小本本,但这一页暂时打不开,请稍后再试。")
|
|
76
|
+
return
|
|
77
|
+
level = level_for(profile.affection)
|
|
78
|
+
name = _nickname(event)
|
|
79
|
+
await profile_cmd.finish(
|
|
80
|
+
f"🥟 {name} 与{config.mantou_affection_bot_name}的好感档案\n"
|
|
81
|
+
f"好感度:{profile.affection}\n"
|
|
82
|
+
f"称号:Lv.{level.number}「{level.title}」\n"
|
|
83
|
+
f"今日插件联动:{service.linked_points_today(profile)}/"
|
|
84
|
+
f"{config.mantou_affection_link_daily_limit}\n"
|
|
85
|
+
f"进度:{progress_text(profile.affection)}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@interact_cmd.handle()
|
|
89
|
+
async def handle_interact(event: GroupMessageEvent) -> None:
|
|
90
|
+
try:
|
|
91
|
+
result = await service.interact(
|
|
92
|
+
str(event.group_id), str(event.user_id), _nickname(event)
|
|
93
|
+
)
|
|
94
|
+
except Exception:
|
|
95
|
+
logger.exception("[mantou-affection] 互动失败")
|
|
96
|
+
await interact_cmd.finish("馒头刚刚走神了,没有记下这次互动,请稍后再试。")
|
|
97
|
+
return
|
|
98
|
+
if result.reason == "daily_limit":
|
|
99
|
+
await interact_cmd.finish(
|
|
100
|
+
f"今天已经陪{config.mantou_affection_bot_name}玩得够久啦,明天再来吧。"
|
|
101
|
+
)
|
|
102
|
+
if result.reason == "cooldown":
|
|
103
|
+
await interact_cmd.finish(
|
|
104
|
+
f"让{config.mantou_affection_bot_name}喘口气吧,"
|
|
105
|
+
f"{result.wait_seconds} 秒后再来互动。"
|
|
106
|
+
)
|
|
107
|
+
await interact_cmd.finish(
|
|
108
|
+
f"{result.text}\n{_gain_text(result.delta)},当前 {result.affection}\n"
|
|
109
|
+
f"今天还可互动 {result.remaining} 次"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@ranking_cmd.handle()
|
|
113
|
+
async def handle_ranking(event: GroupMessageEvent) -> None:
|
|
114
|
+
try:
|
|
115
|
+
entries = await service.ranking(str(event.group_id))
|
|
116
|
+
except Exception:
|
|
117
|
+
logger.exception("[mantou-affection] 读取排行榜失败")
|
|
118
|
+
await ranking_cmd.finish("馒头暂时找不到排行榜,请稍后再试。")
|
|
119
|
+
return
|
|
120
|
+
if not entries:
|
|
121
|
+
await ranking_cmd.finish("本群还没有好感记录,发送 /馒头互动 成为第一位吧。")
|
|
122
|
+
medals = {1: "🥇", 2: "🥈", 3: "🥉"}
|
|
123
|
+
lines = [f"🥟 {config.mantou_affection_bot_name}好感榜"]
|
|
124
|
+
for entry in entries:
|
|
125
|
+
profile = entry.profile
|
|
126
|
+
name = profile.nickname or f"QQ {profile.user_id}"
|
|
127
|
+
prefix = medals.get(entry.rank, f"{entry.rank}.")
|
|
128
|
+
title = level_for(profile.affection).title
|
|
129
|
+
lines.append(f"{prefix} {name}|{profile.affection}|{title}")
|
|
130
|
+
await ranking_cmd.finish("\n".join(lines))
|
|
131
|
+
|
|
132
|
+
@help_cmd.handle()
|
|
133
|
+
async def handle_help() -> None:
|
|
134
|
+
await help_cmd.finish(
|
|
135
|
+
"🥟 馒头好感度菜单\n"
|
|
136
|
+
"/馒头互动 —— 和馒头玩一会儿\n"
|
|
137
|
+
"/馒头好感 —— 查看自己的好感档案\n"
|
|
138
|
+
"/馒头好感榜 —— 查看本群排行榜\n"
|
|
139
|
+
"/馒头好感调整 @群友 +10 —— SUPERUSER 调整好感"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@adjust_cmd.handle()
|
|
143
|
+
async def handle_adjust(event: GroupMessageEvent, args: Message = CommandArg()) -> None:
|
|
144
|
+
try:
|
|
145
|
+
adjustment = parse_adjustment(args)
|
|
146
|
+
except ValueError as error:
|
|
147
|
+
await adjust_cmd.finish(str(error))
|
|
148
|
+
return
|
|
149
|
+
try:
|
|
150
|
+
applied, profile = await service.adjust(
|
|
151
|
+
str(event.group_id), adjustment.user_id, "", adjustment.delta
|
|
152
|
+
)
|
|
153
|
+
except Exception:
|
|
154
|
+
logger.exception("[mantou-affection] 管理员调整好感度失败")
|
|
155
|
+
await adjust_cmd.finish("调整失败,请检查数据目录权限后重试。")
|
|
156
|
+
return
|
|
157
|
+
message = MessageSegment.at(adjustment.user_id) + MessageSegment.text(
|
|
158
|
+
f" 好感度已调整 {applied:+d},当前为 {profile.affection}。"
|
|
159
|
+
)
|
|
160
|
+
await adjust_cmd.finish(message)
|
|
161
|
+
|
|
162
|
+
return profile_cmd, interact_cmd, ranking_cmd, help_cmd, adjust_cmd
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Config(BaseModel):
|
|
5
|
+
"""馒头好感度配置,所有配置均有默认值。"""
|
|
6
|
+
|
|
7
|
+
mantou_affection_bot_name: str = "馒头"
|
|
8
|
+
mantou_affection_timezone: str = "Asia/Shanghai"
|
|
9
|
+
mantou_affection_text_path: str = ""
|
|
10
|
+
mantou_affection_interaction_limit: int = Field(default=5, ge=1, le=50)
|
|
11
|
+
mantou_affection_interaction_cooldown: int = Field(default=60, ge=0, le=3600)
|
|
12
|
+
mantou_affection_max: int = Field(default=999, ge=1, le=999999)
|
|
13
|
+
mantou_affection_ranking_size: int = Field(default=10, ge=3, le=50)
|
|
14
|
+
mantou_affection_link_enabled: bool = True
|
|
15
|
+
mantou_affection_link_daily_limit: int = Field(default=10, ge=0, le=1000)
|
|
16
|
+
mantou_affection_link_cooldown: int = Field(default=300, ge=0, le=86400)
|
|
17
|
+
mantou_affection_link_rewards: dict[str, int] = Field(
|
|
18
|
+
default_factory=lambda: {
|
|
19
|
+
"nonebot_plugin_taozi": 2,
|
|
20
|
+
"nonebot_plugin_taozi_music": 2,
|
|
21
|
+
"nonebot_plugin_xianmei": 1,
|
|
22
|
+
"nonebot_plugin_daily_attendance": 2,
|
|
23
|
+
"nonebot_plugin_crystelf": 1,
|
|
24
|
+
"nonebot_plugin_chat_learning": 1,
|
|
25
|
+
"nonebot_plugin_msg_rank_card": 1,
|
|
26
|
+
"nonebot_plugin_bili_dynamic": 1,
|
|
27
|
+
"nonebot_plugin_miao": 1,
|
|
28
|
+
}
|
|
29
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import random
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from nonebot import logger
|
|
10
|
+
|
|
11
|
+
from .models import AffectionSnapshot
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AffectionTextLibrary:
|
|
15
|
+
"""按插件场景和好感阶段加载文案,支持大型外部 JSON 文案库热更新。"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, bundled_path: Path, custom_path: str = ""):
|
|
18
|
+
self.bundled_path = bundled_path
|
|
19
|
+
self.custom_path = Path(custom_path).expanduser() if custom_path else None
|
|
20
|
+
self._custom_mtime_ns: int | None = None
|
|
21
|
+
self._bundled = self._load_file(bundled_path)
|
|
22
|
+
self._scenes = self._bundled
|
|
23
|
+
|
|
24
|
+
def _bundled_copy(self) -> dict[str, dict[str, tuple[str, ...]]]:
|
|
25
|
+
return {scene: dict(bands) for scene, bands in self._bundled.items()}
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def _normalize(data: Any) -> dict[str, dict[str, tuple[str, ...]]]:
|
|
29
|
+
if not isinstance(data, dict):
|
|
30
|
+
raise ValueError("文案库根节点必须是 JSON 对象")
|
|
31
|
+
result: dict[str, dict[str, tuple[str, ...]]] = {}
|
|
32
|
+
for scene, bands in data.items():
|
|
33
|
+
if not isinstance(scene, str) or not isinstance(bands, dict):
|
|
34
|
+
continue
|
|
35
|
+
normalized_bands: dict[str, tuple[str, ...]] = {}
|
|
36
|
+
for band, texts in bands.items():
|
|
37
|
+
if not isinstance(band, str) or not isinstance(texts, list):
|
|
38
|
+
continue
|
|
39
|
+
cleaned = tuple(
|
|
40
|
+
text.strip() for text in texts if isinstance(text, str) and text.strip()
|
|
41
|
+
)
|
|
42
|
+
if cleaned:
|
|
43
|
+
normalized_bands[band] = cleaned
|
|
44
|
+
if normalized_bands:
|
|
45
|
+
result[scene] = normalized_bands
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def _load_file(cls, path: Path) -> dict[str, dict[str, tuple[str, ...]]]:
|
|
50
|
+
with path.open("r", encoding="utf-8") as file:
|
|
51
|
+
return cls._normalize(json.load(file))
|
|
52
|
+
|
|
53
|
+
def _reload_custom_if_needed(self) -> None:
|
|
54
|
+
if self.custom_path is None:
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
mtime_ns = self.custom_path.stat().st_mtime_ns
|
|
58
|
+
except OSError as error:
|
|
59
|
+
if self._custom_mtime_ns != -1:
|
|
60
|
+
logger.warning(f"[mantou-affection] 无法读取自定义文案库: {error}")
|
|
61
|
+
self._scenes = self._bundled_copy()
|
|
62
|
+
self._custom_mtime_ns = -1
|
|
63
|
+
return
|
|
64
|
+
if mtime_ns == self._custom_mtime_ns:
|
|
65
|
+
return
|
|
66
|
+
try:
|
|
67
|
+
custom = self._load_file(self.custom_path)
|
|
68
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
69
|
+
logger.warning(f"[mantou-affection] 自定义文案库格式错误,继续使用内置文案: {error}")
|
|
70
|
+
self._scenes = self._bundled_copy()
|
|
71
|
+
self._custom_mtime_ns = mtime_ns
|
|
72
|
+
return
|
|
73
|
+
merged = self._bundled_copy()
|
|
74
|
+
for scene, bands in custom.items():
|
|
75
|
+
merged.setdefault(scene, {}).update(bands)
|
|
76
|
+
self._scenes = merged
|
|
77
|
+
self._custom_mtime_ns = mtime_ns
|
|
78
|
+
logger.info(f"[mantou-affection] 已加载自定义文案库: {self.custom_path}")
|
|
79
|
+
|
|
80
|
+
def pick(self, scene: str, snapshot: AffectionSnapshot, *, seed: str = "") -> str | None:
|
|
81
|
+
self._reload_custom_if_needed()
|
|
82
|
+
choices = self._scenes.get(scene, {}).get(snapshot.band, ())
|
|
83
|
+
if not choices:
|
|
84
|
+
return None
|
|
85
|
+
if not seed:
|
|
86
|
+
return random.SystemRandom().choice(choices)
|
|
87
|
+
digest = hashlib.sha256(f"{scene}\0{snapshot.band}\0{seed}".encode()).digest()
|
|
88
|
+
return choices[int.from_bytes(digest[:8], "big") % len(choices)]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nonebot import logger
|
|
4
|
+
from nonebot.adapters import Event
|
|
5
|
+
from nonebot.adapters.onebot.v11 import GroupMessageEvent
|
|
6
|
+
from nonebot.consts import (
|
|
7
|
+
ENDSWITH_KEY,
|
|
8
|
+
FULLMATCH_KEY,
|
|
9
|
+
KEYWORD_KEY,
|
|
10
|
+
PREFIX_KEY,
|
|
11
|
+
REGEX_MATCHED,
|
|
12
|
+
STARTSWITH_KEY,
|
|
13
|
+
)
|
|
14
|
+
from nonebot.matcher import Matcher
|
|
15
|
+
from nonebot.message import run_postprocessor
|
|
16
|
+
|
|
17
|
+
from .config import Config
|
|
18
|
+
from .service import AffectionService, normalize_nickname
|
|
19
|
+
|
|
20
|
+
SELF_PLUGIN = "nonebot_plugin_mantou_affection"
|
|
21
|
+
MEANINGFUL_STATE_KEYS = {
|
|
22
|
+
PREFIX_KEY,
|
|
23
|
+
REGEX_MATCHED,
|
|
24
|
+
FULLMATCH_KEY,
|
|
25
|
+
STARTSWITH_KEY,
|
|
26
|
+
ENDSWITH_KEY,
|
|
27
|
+
KEYWORD_KEY,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_meaningful_trigger(state: dict) -> bool:
|
|
32
|
+
"""只奖励有明确命令/正则/完整匹配的 Matcher,忽略普通消息监听器。"""
|
|
33
|
+
|
|
34
|
+
return any(key in state for key in MEANINGFUL_STATE_KEYS)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def matcher_source(matcher: Matcher) -> str:
|
|
38
|
+
plugin_name = matcher.plugin_name or ""
|
|
39
|
+
if plugin_name:
|
|
40
|
+
return plugin_name
|
|
41
|
+
module_name = matcher.module_name or ""
|
|
42
|
+
return module_name.split(".", 1)[0]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def register_plugin_linkage(service: AffectionService, config: Config):
|
|
46
|
+
@run_postprocessor
|
|
47
|
+
async def reward_after_plugin(
|
|
48
|
+
matcher: Matcher,
|
|
49
|
+
event: Event,
|
|
50
|
+
exception=None,
|
|
51
|
+
) -> None:
|
|
52
|
+
if exception is not None or not config.mantou_affection_link_enabled:
|
|
53
|
+
return
|
|
54
|
+
if not isinstance(event, GroupMessageEvent):
|
|
55
|
+
return
|
|
56
|
+
source = matcher_source(matcher)
|
|
57
|
+
if not source or source == SELF_PLUGIN:
|
|
58
|
+
return
|
|
59
|
+
reward = config.mantou_affection_link_rewards.get(source, 0)
|
|
60
|
+
if reward == 0 or not is_meaningful_trigger(matcher.state):
|
|
61
|
+
return
|
|
62
|
+
nickname = event.sender.card or event.sender.nickname or str(event.user_id)
|
|
63
|
+
try:
|
|
64
|
+
result = await service.reward_external(
|
|
65
|
+
str(event.group_id),
|
|
66
|
+
str(event.user_id),
|
|
67
|
+
normalize_nickname(nickname, str(event.user_id)),
|
|
68
|
+
source,
|
|
69
|
+
reward,
|
|
70
|
+
)
|
|
71
|
+
except Exception:
|
|
72
|
+
logger.exception(f"[mantou-affection] 处理插件联动失败: {source}")
|
|
73
|
+
return
|
|
74
|
+
if result.accepted and result.delta:
|
|
75
|
+
logger.debug(
|
|
76
|
+
f"[mantou-affection] {source} 联动奖励: "
|
|
77
|
+
f"group={event.group_id} user={event.user_id} delta={result.delta}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return reward_after_plugin
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from datetime import date
|
|
5
|
+
|
|
6
|
+
from .models import AffectionSnapshot, InteractionResult, Level, LinkRewardResult, Profile
|
|
7
|
+
|
|
8
|
+
LEVELS: tuple[tuple[int, str], ...] = (
|
|
9
|
+
(0, "初次见面"),
|
|
10
|
+
(10, "有点眼熟"),
|
|
11
|
+
(30, "愿意靠近"),
|
|
12
|
+
(60, "悄悄在意"),
|
|
13
|
+
(100, "暧昧升温"),
|
|
14
|
+
(160, "特别偏爱"),
|
|
15
|
+
(250, "心照不宣"),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
INTERACTIONS: tuple[tuple[str, int], ...] = (
|
|
19
|
+
("{bot}从蒸笼边探出头,认真听你说完了今天的事。", 1),
|
|
20
|
+
("{bot}晃了晃脑袋,假装没看见,其实嘴角偷偷翘起来了。", 1),
|
|
21
|
+
("你轻轻拍了拍{bot},收获了一只热乎乎的贴贴。", 2),
|
|
22
|
+
("{bot}把今天攒下来的桃气分给了你一点。", 2),
|
|
23
|
+
("{bot}在你的群昵称旁边画了一颗小桃心。", 3),
|
|
24
|
+
("{bot}正在发呆,没有听清,但还是礼貌地向你点了点头。", 0),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def level_for(affection: int) -> Level:
|
|
29
|
+
score = max(0, affection)
|
|
30
|
+
index = 0
|
|
31
|
+
for candidate, (threshold, _) in enumerate(LEVELS):
|
|
32
|
+
if score < threshold:
|
|
33
|
+
break
|
|
34
|
+
index = candidate
|
|
35
|
+
threshold, title = LEVELS[index]
|
|
36
|
+
next_threshold = LEVELS[index + 1][0] if index + 1 < len(LEVELS) else None
|
|
37
|
+
return Level(index + 1, title, threshold, next_threshold)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def progress_text(affection: int) -> str:
|
|
41
|
+
level = level_for(affection)
|
|
42
|
+
if level.next_threshold is None:
|
|
43
|
+
return "已达到最高称号"
|
|
44
|
+
return f"距离下一称号还差 {level.next_threshold - affection} 点"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def snapshot_for(affection: int) -> AffectionSnapshot:
|
|
48
|
+
level = level_for(affection)
|
|
49
|
+
if level.number <= 2:
|
|
50
|
+
band = "neutral"
|
|
51
|
+
elif level.number == 3:
|
|
52
|
+
band = "warm"
|
|
53
|
+
elif level.number == 4:
|
|
54
|
+
band = "close"
|
|
55
|
+
elif level.number == 5:
|
|
56
|
+
band = "flirty"
|
|
57
|
+
else:
|
|
58
|
+
band = "intimate"
|
|
59
|
+
return AffectionSnapshot(
|
|
60
|
+
affection=max(0, affection),
|
|
61
|
+
level=level.number,
|
|
62
|
+
title=level.title,
|
|
63
|
+
band=band,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def perform_interaction(
|
|
68
|
+
profile: Profile,
|
|
69
|
+
*,
|
|
70
|
+
bot_name: str,
|
|
71
|
+
today: date,
|
|
72
|
+
now_timestamp: float,
|
|
73
|
+
daily_limit: int,
|
|
74
|
+
cooldown_seconds: int,
|
|
75
|
+
affection_max: int,
|
|
76
|
+
rng: random.Random,
|
|
77
|
+
) -> InteractionResult:
|
|
78
|
+
today_text = today.isoformat()
|
|
79
|
+
if profile.interaction_date != today_text:
|
|
80
|
+
profile.interaction_date = today_text
|
|
81
|
+
profile.interaction_count = 0
|
|
82
|
+
profile.last_interaction_at = 0.0
|
|
83
|
+
|
|
84
|
+
remaining = max(0, daily_limit - profile.interaction_count)
|
|
85
|
+
if remaining == 0:
|
|
86
|
+
return InteractionResult(
|
|
87
|
+
False,
|
|
88
|
+
"daily_limit",
|
|
89
|
+
"",
|
|
90
|
+
0,
|
|
91
|
+
0,
|
|
92
|
+
0,
|
|
93
|
+
profile.affection,
|
|
94
|
+
level_for(profile.affection),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
elapsed = now_timestamp - profile.last_interaction_at
|
|
98
|
+
if profile.last_interaction_at and elapsed < cooldown_seconds:
|
|
99
|
+
wait_seconds = max(1, int(cooldown_seconds - elapsed + 0.999))
|
|
100
|
+
return InteractionResult(
|
|
101
|
+
False,
|
|
102
|
+
"cooldown",
|
|
103
|
+
"",
|
|
104
|
+
0,
|
|
105
|
+
remaining,
|
|
106
|
+
wait_seconds,
|
|
107
|
+
profile.affection,
|
|
108
|
+
level_for(profile.affection),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
text, reward = rng.choice(INTERACTIONS)
|
|
112
|
+
before = profile.affection
|
|
113
|
+
profile.affection = min(affection_max, profile.affection + reward)
|
|
114
|
+
profile.interaction_count += 1
|
|
115
|
+
profile.last_interaction_at = now_timestamp
|
|
116
|
+
profile.updated_at = now_timestamp
|
|
117
|
+
return InteractionResult(
|
|
118
|
+
True,
|
|
119
|
+
"ok",
|
|
120
|
+
text.format(bot=bot_name),
|
|
121
|
+
profile.affection - before,
|
|
122
|
+
max(0, daily_limit - profile.interaction_count),
|
|
123
|
+
0,
|
|
124
|
+
profile.affection,
|
|
125
|
+
level_for(profile.affection),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def adjust_affection(profile: Profile, delta: int, affection_max: int, now_timestamp: float) -> int:
|
|
130
|
+
before = profile.affection
|
|
131
|
+
profile.affection = min(affection_max, max(0, profile.affection + delta))
|
|
132
|
+
profile.updated_at = now_timestamp
|
|
133
|
+
return profile.affection - before
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def apply_link_reward(
|
|
137
|
+
profile: Profile,
|
|
138
|
+
*,
|
|
139
|
+
source: str,
|
|
140
|
+
reward: int,
|
|
141
|
+
today: date,
|
|
142
|
+
now_timestamp: float,
|
|
143
|
+
daily_limit: int,
|
|
144
|
+
cooldown_seconds: int,
|
|
145
|
+
affection_max: int,
|
|
146
|
+
) -> LinkRewardResult:
|
|
147
|
+
today_text = today.isoformat()
|
|
148
|
+
if profile.linked_date != today_text:
|
|
149
|
+
profile.linked_date = today_text
|
|
150
|
+
profile.linked_points = 0
|
|
151
|
+
profile.plugin_last_awards = {}
|
|
152
|
+
|
|
153
|
+
if daily_limit <= 0 or profile.linked_points >= daily_limit:
|
|
154
|
+
return LinkRewardResult(
|
|
155
|
+
False, "daily_limit", 0, profile.affection, profile.linked_points
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
plugin_last_awards = profile.plugin_last_awards or {}
|
|
159
|
+
last_award = plugin_last_awards.get(source, 0.0)
|
|
160
|
+
if last_award and now_timestamp - last_award < cooldown_seconds:
|
|
161
|
+
return LinkRewardResult(False, "cooldown", 0, profile.affection, profile.linked_points)
|
|
162
|
+
|
|
163
|
+
available = daily_limit - profile.linked_points
|
|
164
|
+
requested = max(-available, min(available, reward))
|
|
165
|
+
if requested >= 0:
|
|
166
|
+
actual_reward = min(requested, affection_max - profile.affection)
|
|
167
|
+
else:
|
|
168
|
+
actual_reward = max(requested, -profile.affection)
|
|
169
|
+
plugin_last_awards[source] = now_timestamp
|
|
170
|
+
profile.plugin_last_awards = plugin_last_awards
|
|
171
|
+
profile.linked_points += abs(actual_reward)
|
|
172
|
+
profile.affection += actual_reward
|
|
173
|
+
profile.updated_at = now_timestamp
|
|
174
|
+
return LinkRewardResult(True, "ok", actual_reward, profile.affection, profile.linked_points)
|