nonebot-plugin-volt-toy 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_volt_toy/__init__.py +41 -0
- nonebot_plugin_volt_toy/commands.py +270 -0
- nonebot_plugin_volt_toy/config.py +35 -0
- nonebot_plugin_volt_toy/platform.py +225 -0
- nonebot_plugin_volt_toy/runtime.py +61 -0
- nonebot_plugin_volt_toy/store.py +32 -0
- nonebot_plugin_volt_toy-0.1.0.dist-info/METADATA +168 -0
- nonebot_plugin_volt_toy-0.1.0.dist-info/RECORD +21 -0
- nonebot_plugin_volt_toy-0.1.0.dist-info/WHEEL +4 -0
- nonebot_plugin_volt_toy-0.1.0.dist-info/licenses/LICENSE +21 -0
- volt_toy_core/__init__.py +36 -0
- volt_toy_core/fmt.py +21 -0
- volt_toy_core/model.py +108 -0
- volt_toy_core/rankings.py +55 -0
- volt_toy_core/records.py +122 -0
- volt_toy_core/render.py +72 -0
- volt_toy_core/service.py +217 -0
- volt_toy_core/settings.py +33 -0
- volt_toy_core/state.py +72 -0
- volt_toy_core/storage.py +206 -0
- volt_toy_core/texts.py +186 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Volt-Toy —— 电击群友。
|
|
2
|
+
|
|
3
|
+
v0.1 只做手动电:施电、记账、榜单、管理。
|
|
4
|
+
图片检测留了挂点,暂不实现。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
from nonebot import require
|
|
10
|
+
|
|
11
|
+
# require 必须先于任何 import:否则这两个库会以「被普通 import」的方式装载,
|
|
12
|
+
# require 会判定它们不是插件并抛错。
|
|
13
|
+
require("nonebot_plugin_localstore")
|
|
14
|
+
require("nonebot_plugin_alconna")
|
|
15
|
+
require("nonebot_plugin_uninfo")
|
|
16
|
+
|
|
17
|
+
from nonebot.plugin import PluginMetadata, inherit_supported_adapters # noqa: E402
|
|
18
|
+
|
|
19
|
+
from .config import PluginConfig # noqa: E402
|
|
20
|
+
|
|
21
|
+
__plugin_meta__ = PluginMetadata(
|
|
22
|
+
name="Volt-Toy 电击群友",
|
|
23
|
+
description="用指令电群友:电压 / 电量 / 度数记账,失败计数,供电局文案。",
|
|
24
|
+
usage=(
|
|
25
|
+
"zap [@目标] —— 施电,不 @ 则电自己\n"
|
|
26
|
+
"zapkwh / zaptop / zapmax / zapking —— 度数 / 次数 / 峰值 / 电王榜\n"
|
|
27
|
+
"zapinfo [@目标] —— 个人用电档案\n"
|
|
28
|
+
"zapclear / zapnodo / zapon / zapoff —— 管理"
|
|
29
|
+
),
|
|
30
|
+
type="application",
|
|
31
|
+
config=PluginConfig,
|
|
32
|
+
# 收发走 alconna 的 uniseg、会话信息走 uninfo,因此声明两者共同支持的适配器
|
|
33
|
+
supported_adapters=inherit_supported_adapters(
|
|
34
|
+
"nonebot_plugin_alconna",
|
|
35
|
+
"nonebot_plugin_uninfo",
|
|
36
|
+
),
|
|
37
|
+
homepage="https://github.com/Funny1Potato/Volt-Toy",
|
|
38
|
+
extra={"License": "MIT", "Author": "Funny1Potato"},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
from . import commands as _commands # noqa: E402,F401
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""指令层:Alconna 解析参数,平台层解析会话,业务在内核 ZapService。
|
|
2
|
+
|
|
3
|
+
写法依据:
|
|
4
|
+
- nonebot 官方最佳实践 `on_alconna` 页面:`Match[<type>]` 注入、`use_cmd_start`
|
|
5
|
+
默认是 False(必须显式打开才会响应 `/` 前缀)、`on_alconna(..., priority=, block=)`
|
|
6
|
+
通过 **kwargs 转发给 matcher。
|
|
7
|
+
- alconna `uniseg` 页面:用 `UniMessage([Text(...), Image(...)])` 组装并 `.send()`;
|
|
8
|
+
`At` 的字段是 `flag/target/display`,`flag` 可能是 role/channel,需要过滤。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from arclet.alconna import Alconna, Args
|
|
16
|
+
from nonebot import logger
|
|
17
|
+
from nonebot.adapters import Bot, Event
|
|
18
|
+
from nonebot_plugin_alconna import At, Image, Match, on_alconna
|
|
19
|
+
from nonebot_plugin_alconna.uniseg import Text, UniMessage
|
|
20
|
+
|
|
21
|
+
from volt_toy_core import texts
|
|
22
|
+
from volt_toy_core.render import info_text, rank_king, rank_kwh, rank_max, rank_top
|
|
23
|
+
from volt_toy_core.service import ZapRequest
|
|
24
|
+
|
|
25
|
+
from . import platform
|
|
26
|
+
from . import runtime
|
|
27
|
+
from .runtime import config, save_whitelist, settings, state
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_ALCONNA_REFS: list[Alconna] = []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _cmd(*args: Any, **kwargs: Any) -> Alconna:
|
|
34
|
+
"""持有强引用,避免 Alconna 被 GC 后 matcher 静默失效。"""
|
|
35
|
+
alc = Alconna(*args, **kwargs)
|
|
36
|
+
_ALCONNA_REFS.append(alc)
|
|
37
|
+
return alc
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
zap_cmd = on_alconna(
|
|
41
|
+
_cmd("zap", Args["target?", At]),
|
|
42
|
+
aliases={"电", "电击", "雷"},
|
|
43
|
+
use_cmd_start=True,
|
|
44
|
+
priority=10,
|
|
45
|
+
block=True,
|
|
46
|
+
)
|
|
47
|
+
zapkwh_cmd = on_alconna(
|
|
48
|
+
_cmd("zapkwh"), aliases={"度数榜", "电费榜"}, use_cmd_start=True, priority=11, block=True
|
|
49
|
+
)
|
|
50
|
+
zaptop_cmd = on_alconna(
|
|
51
|
+
_cmd("zaptop"), aliases={"被电榜"}, use_cmd_start=True, priority=11, block=True
|
|
52
|
+
)
|
|
53
|
+
zapmax_cmd = on_alconna(
|
|
54
|
+
_cmd("zapmax"), aliases={"峰值榜"}, use_cmd_start=True, priority=11, block=True
|
|
55
|
+
)
|
|
56
|
+
zapking_cmd = on_alconna(
|
|
57
|
+
_cmd("zapking"), aliases={"电王榜"}, use_cmd_start=True, priority=11, block=True
|
|
58
|
+
)
|
|
59
|
+
zapinfo_cmd = on_alconna(
|
|
60
|
+
_cmd("zapinfo", Args["target?", At]), aliases={"电档"}, use_cmd_start=True, priority=11, block=True
|
|
61
|
+
)
|
|
62
|
+
zapclear_cmd = on_alconna(
|
|
63
|
+
_cmd("zapclear", Args["target?", At]), use_cmd_start=True, priority=11, block=True
|
|
64
|
+
)
|
|
65
|
+
zapnodo_cmd = on_alconna(
|
|
66
|
+
_cmd("zapnodo", Args["target?", At]), aliases={"绝缘"}, use_cmd_start=True, priority=11, block=True
|
|
67
|
+
)
|
|
68
|
+
zapon_cmd = on_alconna(_cmd("zapon"), use_cmd_start=True, priority=11, block=True)
|
|
69
|
+
zapoff_cmd = on_alconna(_cmd("zapoff"), use_cmd_start=True, priority=11, block=True)
|
|
70
|
+
|
|
71
|
+
_NO_SESSION = "这个适配器没有提供会话信息,暂时电不了。"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _at_value(segment: Any) -> str:
|
|
75
|
+
# At 也能表示 @角色 / @频道,这里只接受具体用户
|
|
76
|
+
if str(getattr(segment, "flag", "user")) != "user":
|
|
77
|
+
return ""
|
|
78
|
+
for attr in ("target", "qq", "user_id", "id"):
|
|
79
|
+
value = getattr(segment, attr, None)
|
|
80
|
+
if value:
|
|
81
|
+
return str(value)
|
|
82
|
+
return ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _at_name(segment: Any) -> str:
|
|
86
|
+
for attr in ("display", "name"):
|
|
87
|
+
value = getattr(segment, attr, None)
|
|
88
|
+
if value:
|
|
89
|
+
return str(value).lstrip("@")
|
|
90
|
+
return ""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _reply(text: str) -> None:
|
|
94
|
+
await UniMessage.text(text).send()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _resolve_target(
|
|
98
|
+
bot: Bot, session: platform.SceneInfo, target: Match[At]
|
|
99
|
+
) -> tuple[str, str]:
|
|
100
|
+
if target.available:
|
|
101
|
+
uid = _at_value(target.result)
|
|
102
|
+
if uid:
|
|
103
|
+
name = _at_name(target.result)
|
|
104
|
+
if not name:
|
|
105
|
+
name = await platform.member_name(bot, session, uid)
|
|
106
|
+
return uid, name
|
|
107
|
+
return session.user_id, session.display_user
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@zap_cmd.handle()
|
|
111
|
+
async def _handle_zap(bot: Bot, event: Event, target: Match[At]) -> None:
|
|
112
|
+
session = await platform.resolve(bot, event)
|
|
113
|
+
if session is None:
|
|
114
|
+
await _reply(_NO_SESSION)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
target_id, target_name = await _resolve_target(bot, session, target)
|
|
118
|
+
if target_id == session.bot_id and target_id:
|
|
119
|
+
await _reply(
|
|
120
|
+
texts.render(
|
|
121
|
+
texts.pick("bot", scope=session.key),
|
|
122
|
+
actor=session.display_user,
|
|
123
|
+
target=target_name,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
request = ZapRequest(
|
|
129
|
+
gid=session.key,
|
|
130
|
+
actor=session.user_id,
|
|
131
|
+
target=target_id,
|
|
132
|
+
actor_name=session.display_user,
|
|
133
|
+
target_name=target_name or target_id,
|
|
134
|
+
self_zap=target_id == session.user_id,
|
|
135
|
+
privileged=platform.is_admin(bot, session, session.user_id),
|
|
136
|
+
)
|
|
137
|
+
outcome = await runtime.service.zap(request)
|
|
138
|
+
|
|
139
|
+
if outcome.ok and config.volt_show_avatar:
|
|
140
|
+
avatar = await platform.fetch_avatar(
|
|
141
|
+
bot, session, target_id, config.volt_avatar_timeout
|
|
142
|
+
)
|
|
143
|
+
if avatar:
|
|
144
|
+
message = UniMessage([Text(outcome.text), Image(raw=avatar)])
|
|
145
|
+
else:
|
|
146
|
+
message = UniMessage.text(outcome.text)
|
|
147
|
+
else:
|
|
148
|
+
message = UniMessage.text(outcome.text)
|
|
149
|
+
await message.send()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@zapkwh_cmd.handle()
|
|
153
|
+
async def _handle_kwh(bot: Bot, event: Event) -> None:
|
|
154
|
+
session = await platform.resolve(bot, event)
|
|
155
|
+
if session is None:
|
|
156
|
+
await _reply(_NO_SESSION)
|
|
157
|
+
return
|
|
158
|
+
records = runtime.get_store().records(session.key)
|
|
159
|
+
await _reply(rank_kwh(records, settings, scope=session.key))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@zaptop_cmd.handle()
|
|
163
|
+
async def _handle_top(bot: Bot, event: Event) -> None:
|
|
164
|
+
session = await platform.resolve(bot, event)
|
|
165
|
+
if session is None:
|
|
166
|
+
await _reply(_NO_SESSION)
|
|
167
|
+
return
|
|
168
|
+
records = runtime.get_store().records(session.key)
|
|
169
|
+
await _reply(rank_top(records, scope=session.key))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@zapmax_cmd.handle()
|
|
173
|
+
async def _handle_max(bot: Bot, event: Event) -> None:
|
|
174
|
+
session = await platform.resolve(bot, event)
|
|
175
|
+
if session is None:
|
|
176
|
+
await _reply(_NO_SESSION)
|
|
177
|
+
return
|
|
178
|
+
records = runtime.get_store().records(session.key)
|
|
179
|
+
await _reply(rank_max(records, scope=session.key))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@zapking_cmd.handle()
|
|
183
|
+
async def _handle_king(bot: Bot, event: Event) -> None:
|
|
184
|
+
session = await platform.resolve(bot, event)
|
|
185
|
+
if session is None:
|
|
186
|
+
await _reply(_NO_SESSION)
|
|
187
|
+
return
|
|
188
|
+
await _reply(rank_king(runtime.get_store().records(session.key), scope=session.key))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@zapinfo_cmd.handle()
|
|
192
|
+
async def _handle_info(bot: Bot, event: Event, target: Match[At]) -> None:
|
|
193
|
+
session = await platform.resolve(bot, event)
|
|
194
|
+
if session is None:
|
|
195
|
+
await _reply(_NO_SESSION)
|
|
196
|
+
return
|
|
197
|
+
uid, name = await _resolve_target(bot, session, target)
|
|
198
|
+
record = runtime.get_store().records(session.key).get(uid)
|
|
199
|
+
if not record:
|
|
200
|
+
await _reply(texts.render(texts.MISSING_TEXT, name=name or uid))
|
|
201
|
+
return
|
|
202
|
+
await _reply(info_text(record, settings, name=name))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@zapclear_cmd.handle()
|
|
206
|
+
async def _handle_clear(bot: Bot, event: Event, target: Match[At]) -> None:
|
|
207
|
+
session = await platform.resolve(bot, event)
|
|
208
|
+
if session is None:
|
|
209
|
+
await _reply(_NO_SESSION)
|
|
210
|
+
return
|
|
211
|
+
if not platform.is_admin(bot, session, session.user_id):
|
|
212
|
+
await _reply("无权限使用此命令。")
|
|
213
|
+
return
|
|
214
|
+
uid, name = await _resolve_target(bot, session, target)
|
|
215
|
+
if not await runtime.get_store().clear(session.key, uid):
|
|
216
|
+
await _reply(f"没有查到 {name or uid} 的用电记录。")
|
|
217
|
+
return
|
|
218
|
+
await _reply(
|
|
219
|
+
texts.render(
|
|
220
|
+
texts.pick("clear", scope=session.key),
|
|
221
|
+
name=name or uid,
|
|
222
|
+
actor=session.display_user,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@zapnodo_cmd.handle()
|
|
228
|
+
async def _handle_nodo(bot: Bot, event: Event, target: Match[At]) -> None:
|
|
229
|
+
session = await platform.resolve(bot, event)
|
|
230
|
+
if session is None:
|
|
231
|
+
await _reply(_NO_SESSION)
|
|
232
|
+
return
|
|
233
|
+
if not platform.is_admin(bot, session, session.user_id):
|
|
234
|
+
await _reply("无权限使用此命令。")
|
|
235
|
+
return
|
|
236
|
+
uid, name = await _resolve_target(bot, session, target)
|
|
237
|
+
if uid in state.whitelist:
|
|
238
|
+
state.whitelist.discard(uid)
|
|
239
|
+
save_whitelist(state.whitelist)
|
|
240
|
+
await _reply(f"已解除 {name or uid} 的免电保护。")
|
|
241
|
+
else:
|
|
242
|
+
state.whitelist.add(uid)
|
|
243
|
+
save_whitelist(state.whitelist)
|
|
244
|
+
await _reply(f"已将 {name or uid} 加入免电名单。")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@zapon_cmd.handle()
|
|
248
|
+
async def _handle_on(bot: Bot, event: Event) -> None:
|
|
249
|
+
session = await platform.resolve(bot, event)
|
|
250
|
+
if session is None:
|
|
251
|
+
await _reply(_NO_SESSION)
|
|
252
|
+
return
|
|
253
|
+
if not platform.is_admin(bot, session, session.user_id):
|
|
254
|
+
await _reply("无权限使用此命令。")
|
|
255
|
+
return
|
|
256
|
+
state.set_group_enabled(session.key, True)
|
|
257
|
+
await _reply("本群用电业务已恢复。")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@zapoff_cmd.handle()
|
|
261
|
+
async def _handle_off(bot: Bot, event: Event) -> None:
|
|
262
|
+
session = await platform.resolve(bot, event)
|
|
263
|
+
if session is None:
|
|
264
|
+
await _reply(_NO_SESSION)
|
|
265
|
+
return
|
|
266
|
+
if not platform.is_admin(bot, session, session.user_id):
|
|
267
|
+
await _reply("无权限使用此命令。")
|
|
268
|
+
return
|
|
269
|
+
state.set_group_enabled(session.key, False)
|
|
270
|
+
await _reply("本群用电业务已停止。")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""插件配置定义。
|
|
2
|
+
|
|
3
|
+
字段名即环境变量名(大写),例如 `volt_receive_cap` → `VOLT_RECEIVE_CAP`。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from nonebot import get_plugin_config
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PluginConfig(BaseModel):
|
|
11
|
+
volt_receive_cap: int = Field(
|
|
12
|
+
25, description="单目标每日受电上限;超出只记次数与失败,不加电量(0 = 不限)"
|
|
13
|
+
)
|
|
14
|
+
volt_overload_window: int = Field(60, description="过载统计窗口(秒)")
|
|
15
|
+
volt_overload_threshold: int = Field(10, description="窗口内最多施电次数,达到即跳闸")
|
|
16
|
+
volt_overload_ban: int = Field(300, description="跳闸时长(秒)")
|
|
17
|
+
|
|
18
|
+
volt_daily_quota: int = Field(0, description="每日施电额度(0 = 不限)")
|
|
19
|
+
volt_cooldown: int = Field(0, description="同一目标冷却秒数(0 = 关闭)")
|
|
20
|
+
|
|
21
|
+
volt_crit_prob: float = Field(0.15, description="暴击概率(0~1)")
|
|
22
|
+
volt_self_efficiency: float = Field(0.8, description="电自己的效率倍率(0~1)")
|
|
23
|
+
volt_price_per_kwh: float = Field(0.55, description="电价(元/度),只影响展示")
|
|
24
|
+
|
|
25
|
+
volt_is_log: bool = Field(False, description="是否写逐条审计日志 zap_log.jsonl")
|
|
26
|
+
volt_group_default: bool = Field(True, description="新群默认是否启用")
|
|
27
|
+
volt_whitelist: list[str] = Field(default_factory=list, description="免电名单初始值(用户 id)")
|
|
28
|
+
|
|
29
|
+
volt_show_avatar: bool = Field(True, description="成功施电后是否附带被电者头像(仅 QQ 系协议)")
|
|
30
|
+
volt_avatar_timeout: float = Field(5.0, description="头像下载超时(秒),失败时只发文本")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = ["PluginConfig", "plugin_config"]
|
|
34
|
+
|
|
35
|
+
plugin_config: PluginConfig = get_plugin_config(PluginConfig)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""跨协议会话解析(依据 nonebot-plugin-uninfo 与 alconna 官方文档)。
|
|
2
|
+
|
|
3
|
+
- `get_session(bot, event)`:uninfo 的公开接口,适配器不支持时返回 None
|
|
4
|
+
- `get_target(event, bot)`:alconna 的回落,只保证有会话 id
|
|
5
|
+
- 会话键:`适配器slug:scene_path`,scene_path 由 uninfo 统一(群=群号,频道=父id_子id)
|
|
6
|
+
- 昵称/头像:uninfo 的 `User.name` / `User.avatar`,取不到再退回 id
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from nonebot import get_driver, logger
|
|
17
|
+
from nonebot.adapters import Bot, Event
|
|
18
|
+
from nonebot_plugin_alconna.uniseg import get_target
|
|
19
|
+
from nonebot_plugin_uninfo import get_interface, get_session
|
|
20
|
+
|
|
21
|
+
MAX_AVATAR_BYTES = 5 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
_ADAPTER_ALIASES = {
|
|
24
|
+
"OneBot V11": "onebot11",
|
|
25
|
+
"OneBot V12": "onebot12",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# uninfo 在各适配器里统一了角色 id(见 uninfo permission.py 的 ADMIN/OWNER)
|
|
29
|
+
_ADMIN_ROLE_IDS = {"OWNER", "ADMINISTRATOR"}
|
|
30
|
+
_ADMIN_ROLE_NAMES = {"owner", "admin"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def adapter_name_of(value: Any) -> str:
|
|
34
|
+
"""SupportAdapter 是枚举,必须取 .value 才是 'onebot11'。"""
|
|
35
|
+
return str(getattr(value, "value", value) or "")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def adapter_slug(name: Any) -> str:
|
|
39
|
+
text = adapter_name_of(name).strip()
|
|
40
|
+
if text in _ADAPTER_ALIASES:
|
|
41
|
+
return _ADAPTER_ALIASES[text]
|
|
42
|
+
return re.sub(r"[^0-9a-z]+", "", text.lower()) or "unknown"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _user_name(user: Any) -> str:
|
|
46
|
+
if user is None:
|
|
47
|
+
return ""
|
|
48
|
+
return str(getattr(user, "name", None) or getattr(user, "nick", None) or "")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class SceneInfo:
|
|
53
|
+
key: str
|
|
54
|
+
slug: str
|
|
55
|
+
scene_path: str
|
|
56
|
+
chat_id: str
|
|
57
|
+
user_id: str
|
|
58
|
+
user_name: str
|
|
59
|
+
card: str = ""
|
|
60
|
+
avatar: str = ""
|
|
61
|
+
is_private: bool = False
|
|
62
|
+
is_channel: bool = False
|
|
63
|
+
adapter: str = ""
|
|
64
|
+
role_id: str = ""
|
|
65
|
+
role_name: str = ""
|
|
66
|
+
bot_id: str = ""
|
|
67
|
+
scene_type: Any = None
|
|
68
|
+
member_scene_type: Any = None
|
|
69
|
+
member_scene_id: str = ""
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def display_user(self) -> str:
|
|
73
|
+
return self.card or self.user_name or self.user_id
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _role_fields(member: Any) -> tuple[str, str]:
|
|
77
|
+
role = getattr(member, "role", None) if member is not None else None
|
|
78
|
+
if role is None:
|
|
79
|
+
return "", ""
|
|
80
|
+
return str(getattr(role, "id", "") or ""), str(getattr(role, "name", "") or "")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def _from_uninfo(bot: Bot, event: Event, session: Any) -> SceneInfo:
|
|
84
|
+
scene = session.scene
|
|
85
|
+
member = getattr(session, "member", None)
|
|
86
|
+
user = getattr(session, "user", None)
|
|
87
|
+
parent = getattr(scene, "parent", None) or scene
|
|
88
|
+
slug = adapter_slug(session.adapter)
|
|
89
|
+
role_id, role_name = _role_fields(member)
|
|
90
|
+
return SceneInfo(
|
|
91
|
+
key=f"{slug}:{session.scene_path}",
|
|
92
|
+
slug=slug,
|
|
93
|
+
scene_path=str(session.scene_path),
|
|
94
|
+
chat_id=str(scene.id),
|
|
95
|
+
user_id=str(getattr(user, "id", "") or _event_user_id(event)),
|
|
96
|
+
user_name=_user_name(user),
|
|
97
|
+
card=str(getattr(member, "nick", "") or "").strip(),
|
|
98
|
+
avatar=str(getattr(user, "avatar", "") or ""),
|
|
99
|
+
is_private=bool(getattr(scene, "is_private", False)),
|
|
100
|
+
is_channel=bool(getattr(scene, "is_channel", False)),
|
|
101
|
+
adapter=bot.adapter.get_name(),
|
|
102
|
+
role_id=role_id,
|
|
103
|
+
role_name=role_name.lower(),
|
|
104
|
+
bot_id=str(getattr(session, "self_id", "") or getattr(bot, "self_id", "")),
|
|
105
|
+
scene_type=getattr(scene, "type", None),
|
|
106
|
+
member_scene_type=getattr(parent, "type", None),
|
|
107
|
+
member_scene_id=str(getattr(parent, "id", "") or ""),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _event_user_id(event: Event) -> str:
|
|
112
|
+
try:
|
|
113
|
+
return str(event.get_user_id() or "")
|
|
114
|
+
except Exception:
|
|
115
|
+
return ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def resolve(bot: Bot, event: Event) -> SceneInfo | None:
|
|
119
|
+
"""解析当前事件所属会话;适配器不支持时返回 None。"""
|
|
120
|
+
try:
|
|
121
|
+
session = await get_session(bot, event)
|
|
122
|
+
except Exception as error:
|
|
123
|
+
logger.debug(f"volt-toy: uninfo 解析会话失败: {error}")
|
|
124
|
+
session = None
|
|
125
|
+
|
|
126
|
+
if session is not None:
|
|
127
|
+
try:
|
|
128
|
+
return await _from_uninfo(bot, event, session)
|
|
129
|
+
except Exception as error:
|
|
130
|
+
logger.debug(f"volt-toy: uninfo 会话结构异常: {error}")
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
target = get_target(event, bot)
|
|
134
|
+
except Exception as error:
|
|
135
|
+
logger.debug(f"volt-toy: 无法解析会话: {error}")
|
|
136
|
+
return None
|
|
137
|
+
path = f"{target.parent_id}_{target.id}" if (target.channel and target.parent_id) else target.id
|
|
138
|
+
slug = adapter_slug(bot.adapter.get_name())
|
|
139
|
+
uid = _event_user_id(event)
|
|
140
|
+
return SceneInfo(
|
|
141
|
+
key=f"{slug}:{path}",
|
|
142
|
+
slug=slug,
|
|
143
|
+
scene_path=str(path),
|
|
144
|
+
chat_id=str(target.id),
|
|
145
|
+
user_id=uid,
|
|
146
|
+
user_name=uid,
|
|
147
|
+
is_private=bool(getattr(target, "private", False)),
|
|
148
|
+
is_channel=bool(getattr(target, "channel", False)),
|
|
149
|
+
adapter=bot.adapter.get_name(),
|
|
150
|
+
bot_id=str(getattr(bot, "self_id", "") or ""),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def member_name(bot: Bot, session: SceneInfo | None, uid: str) -> str:
|
|
155
|
+
"""按 id 取显示名:群名片 > 昵称 > id。"""
|
|
156
|
+
uid = str(uid or "").strip()
|
|
157
|
+
if not uid or session is None:
|
|
158
|
+
return uid
|
|
159
|
+
interface = get_interface(bot)
|
|
160
|
+
if interface is None:
|
|
161
|
+
return uid
|
|
162
|
+
if session.member_scene_type is not None and session.member_scene_id:
|
|
163
|
+
try:
|
|
164
|
+
member = await interface.get_member(
|
|
165
|
+
session.member_scene_type, session.member_scene_id, uid
|
|
166
|
+
)
|
|
167
|
+
if member is not None:
|
|
168
|
+
return str(getattr(member, "nick", "") or _user_name(getattr(member, "user", None)) or uid)
|
|
169
|
+
except Exception as error:
|
|
170
|
+
logger.debug(f"volt-toy: 取成员 {uid} 失败: {error}")
|
|
171
|
+
try:
|
|
172
|
+
user = await interface.get_user(uid)
|
|
173
|
+
if user is not None:
|
|
174
|
+
return _user_name(user) or uid
|
|
175
|
+
except Exception as error:
|
|
176
|
+
logger.debug(f"volt-toy: 取用户 {uid} 失败: {error}")
|
|
177
|
+
return uid
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def is_admin(bot: Bot, session: SceneInfo | None, uid: str) -> bool:
|
|
181
|
+
if not uid:
|
|
182
|
+
return False
|
|
183
|
+
superusers = set(getattr(get_driver().config, "superusers", set()) or set())
|
|
184
|
+
if uid in superusers:
|
|
185
|
+
return True
|
|
186
|
+
if session is None or session.is_private:
|
|
187
|
+
return False
|
|
188
|
+
return session.role_id in _ADMIN_ROLE_IDS or session.role_name in _ADMIN_ROLE_NAMES
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _qq_like(session: SceneInfo) -> bool:
|
|
192
|
+
return session.slug in {"onebot11", "onebot12", "qq"} or "qq" in session.adapter.lower()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def _avatar_url(bot: Bot, session: SceneInfo, uid: str) -> str:
|
|
196
|
+
"""优先用 uninfo 给出的头像地址;QQ 系协议再用 qlogo 兜底。"""
|
|
197
|
+
if uid == session.user_id and session.avatar:
|
|
198
|
+
return session.avatar
|
|
199
|
+
interface = get_interface(bot)
|
|
200
|
+
if interface is not None:
|
|
201
|
+
try:
|
|
202
|
+
user = await interface.get_user(uid)
|
|
203
|
+
if user is not None and user.avatar:
|
|
204
|
+
return str(user.avatar)
|
|
205
|
+
except Exception as error:
|
|
206
|
+
logger.debug(f"volt-toy: 取头像地址失败: {error}")
|
|
207
|
+
if _qq_like(session) and uid.isdigit():
|
|
208
|
+
return f"https://q4.qlogo.cn/headimg_dl?dst_uin={uid}&spec=640"
|
|
209
|
+
return ""
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def fetch_avatar(bot: Bot, session: SceneInfo, uid: str, timeout: float) -> bytes | None:
|
|
213
|
+
"""尽力取头像;拿不到就返回 None,不影响文本消息。"""
|
|
214
|
+
url = await _avatar_url(bot, session, uid)
|
|
215
|
+
if not url.startswith(("http://", "https://")):
|
|
216
|
+
return None
|
|
217
|
+
try:
|
|
218
|
+
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
|
219
|
+
response = await client.get(url)
|
|
220
|
+
if response.status_code != 200 or len(response.content) > MAX_AVATAR_BYTES:
|
|
221
|
+
return None
|
|
222
|
+
return response.content
|
|
223
|
+
except Exception as error:
|
|
224
|
+
logger.debug(f"volt-toy: 头像下载失败: {error}")
|
|
225
|
+
return None
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""插件级单例:配置、状态、存储与服务。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from nonebot import get_driver, logger
|
|
6
|
+
from volt_toy_core.service import ZapService
|
|
7
|
+
from volt_toy_core.settings import Settings
|
|
8
|
+
from volt_toy_core.state import RuntimeState
|
|
9
|
+
from volt_toy_core.storage import JsonStore, read_json_list, write_json_list
|
|
10
|
+
|
|
11
|
+
from . import store as store_module
|
|
12
|
+
from .config import plugin_config as config
|
|
13
|
+
|
|
14
|
+
settings = Settings(
|
|
15
|
+
receive_cap=config.volt_receive_cap,
|
|
16
|
+
overload_window=config.volt_overload_window,
|
|
17
|
+
overload_threshold=config.volt_overload_threshold,
|
|
18
|
+
overload_ban=config.volt_overload_ban,
|
|
19
|
+
daily_quota=config.volt_daily_quota,
|
|
20
|
+
cooldown=config.volt_cooldown,
|
|
21
|
+
crit_prob=config.volt_crit_prob,
|
|
22
|
+
self_efficiency=config.volt_self_efficiency,
|
|
23
|
+
price_per_kwh=config.volt_price_per_kwh,
|
|
24
|
+
is_log=config.volt_is_log,
|
|
25
|
+
group_default_enabled=config.volt_group_default,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
state = RuntimeState()
|
|
29
|
+
|
|
30
|
+
# store / service 在 startup 时才就绪(localstore 需要插件已加载才能推断调用方)。
|
|
31
|
+
service: ZapService
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_store() -> JsonStore:
|
|
35
|
+
return store_module.store
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_whitelist() -> set[str]:
|
|
39
|
+
stored = read_json_list(store_module.WHITELIST_FILE)
|
|
40
|
+
if stored:
|
|
41
|
+
return set(stored)
|
|
42
|
+
return {str(item) for item in config.volt_whitelist}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save_whitelist(items: set[str]) -> None:
|
|
46
|
+
write_json_list(store_module.WHITELIST_FILE, sorted(items))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@get_driver().on_startup
|
|
50
|
+
async def _prepare() -> None:
|
|
51
|
+
global service
|
|
52
|
+
store_module.resolve()
|
|
53
|
+
await store_module.store.load()
|
|
54
|
+
state.whitelist = load_whitelist()
|
|
55
|
+
service = ZapService(
|
|
56
|
+
store=store_module.store,
|
|
57
|
+
state=state,
|
|
58
|
+
settings=settings,
|
|
59
|
+
log=store_module.log,
|
|
60
|
+
)
|
|
61
|
+
logger.debug(f"Volt-Toy 账本: {store_module.ACCOUNT_FILE}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""数据文件位置:由 nonebot-plugin-localstore 统一管理。
|
|
2
|
+
|
|
3
|
+
localstore 通过调用栈推断「是哪个插件在要文件」。导入期调用要求模块
|
|
4
|
+
已经被 nonebot 登记为插件;一旦插件是以普通模块方式被 import(例如
|
|
5
|
+
editable 安装时被其他 finder 抢先解析),这里就会抛
|
|
6
|
+
``Cannot detect caller plugin``。把解析推迟到 startup,两种加载方式都安全。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from nonebot import logger
|
|
14
|
+
from nonebot_plugin_localstore import get_plugin_data_file
|
|
15
|
+
from volt_toy_core.storage import JsonStore, JsonlLog
|
|
16
|
+
|
|
17
|
+
ACCOUNT_FILE: Path
|
|
18
|
+
LOG_FILE: Path
|
|
19
|
+
WHITELIST_FILE: Path
|
|
20
|
+
|
|
21
|
+
store: JsonStore
|
|
22
|
+
log: JsonlLog
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve() -> None:
|
|
26
|
+
"""在插件加载完成后调用一次,构造存储对象。"""
|
|
27
|
+
global ACCOUNT_FILE, LOG_FILE, WHITELIST_FILE, store, log
|
|
28
|
+
ACCOUNT_FILE = get_plugin_data_file("zap.json")
|
|
29
|
+
LOG_FILE = get_plugin_data_file("zap_log.jsonl")
|
|
30
|
+
WHITELIST_FILE = get_plugin_data_file("zap_whitelist.json")
|
|
31
|
+
store = JsonStore(ACCOUNT_FILE, logger=logger.warning)
|
|
32
|
+
log = JsonlLog(LOG_FILE)
|