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.
@@ -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)