ErisPulse-HelpNext 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.
HelpNext/Core.py ADDED
@@ -0,0 +1,349 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Dict, List, Optional, Tuple
3
+
4
+ from ErisPulse import sdk
5
+ from ErisPulse.Core.Bases import BaseConfig, BaseI18n, BaseModule, I18nKey
6
+ from ErisPulse.Core.Event import command
7
+
8
+ from .Templates import HelpTemplates
9
+ from .Visualizer import Visualizer
10
+
11
+ class Main(BaseModule):
12
+ """ErisPulse modern help module (Takumi-rendered, i18n-aware)."""
13
+
14
+ @dataclass
15
+ class ConfigClass(BaseConfig):
16
+ show_hidden_commands: bool = field(
17
+ default=False,
18
+ metadata={
19
+ "description": {"i18n": "HelpNext.cfg_show_hidden", "default": "显示隐藏命令"},
20
+ "ui": {"widget": "switch", "group": "basic", "order": 1},
21
+ },
22
+ )
23
+ group_commands: bool = field(
24
+ default=True,
25
+ metadata={
26
+ "description": {"i18n": "HelpNext.cfg_group_commands", "default": "按分组显示命令"},
27
+ "ui": {"widget": "switch", "group": "basic", "order": 2},
28
+ },
29
+ )
30
+ theme: str = field(
31
+ default="auto",
32
+ metadata={
33
+ "description": {"i18n": "HelpNext.cfg_theme", "default": "图片主题"},
34
+ "ui": {
35
+ "widget": "select", "group": "render", "order": 3,
36
+ "options": [
37
+ {"label": {"i18n": "HelpNext.theme_auto", "default": "自动(跟随时间)"}, "value": "auto"},
38
+ {"label": {"i18n": "HelpNext.theme_light", "default": "浅色"}, "value": "light"},
39
+ {"label": {"i18n": "HelpNext.theme_dark", "default": "深色"}, "value": "dark"},
40
+ ],
41
+ },
42
+ },
43
+ )
44
+ utc_offset: int = field(
45
+ default=8,
46
+ metadata={
47
+ "description": {"i18n": "HelpNext.cfg_utc_offset", "default": "UTC 时区偏移(用于昼夜切换)"},
48
+ "min": -12, "max": 14,
49
+ "ui": {"widget": "number", "group": "render", "order": 4},
50
+ },
51
+ )
52
+ show_logo: bool = field(
53
+ default=True,
54
+ metadata={
55
+ "description": {"i18n": "HelpNext.cfg_show_logo", "default": "头部显示 ErisPulse 图标"},
56
+ "ui": {"widget": "switch", "group": "header", "order": 5},
57
+ },
58
+ )
59
+ header_title: str = field(
60
+ default="",
61
+ metadata={
62
+ "description": {"i18n": "HelpNext.cfg_header_title", "default": "自定义头部标题(留空使用默认)"},
63
+ "ui": {"widget": "text", "group": "header", "order": 6},
64
+ },
65
+ )
66
+ header_subtitle: str = field(
67
+ default="",
68
+ metadata={
69
+ "description": {"i18n": "HelpNext.cfg_header_subtitle", "default": "自定义头部副标题(留空使用默认)"},
70
+ "ui": {"widget": "text", "group": "header", "order": 7},
71
+ },
72
+ )
73
+
74
+ _schema_meta = {
75
+ "group_labels": {
76
+ "basic": {"i18n": "HelpNext.group_basic", "default": "基本"},
77
+ "render": {"i18n": "HelpNext.group_render", "default": "渲染"},
78
+ "header": {"i18n": "HelpNext.group_header", "default": "头部"},
79
+ },
80
+ }
81
+
82
+ class I18nClass(BaseI18n):
83
+ title: I18nKey = I18nKey(default="Command Help", zh_CN="命令帮助", zh_TW="命令幫助", en="Command Help", ja="コマンドヘルプ", ru="Справка по командам")
84
+ detail_title: I18nKey = I18nKey(default="Command Detail", zh_CN="命令详情", zh_TW="命令詳情", en="Command Detail", ja="コマンド詳細", ru="Подробности команды")
85
+ group_default: I18nKey = I18nKey(default="General", zh_CN="通用命令", zh_TW="一般命令", en="General", ja="一般", ru="Общие")
86
+ no_description: I18nKey = I18nKey(default="No description", zh_CN="暂无描述", zh_TW="暫無描述", en="No description", ja="説明なし", ru="Нет описания")
87
+ usage_hint: I18nKey = I18nKey(default="Use {prefix}help <index> for details", zh_CN="使用 {prefix}help <序号> 查看命令详情", zh_TW="使用 {prefix}help <序號> 查看命令詳情", en="Use {prefix}help <index> for details", ja="{prefix}help <番号> で詳細を表示", ru="Введите {prefix}help <номер> для подробностей")
88
+ command_count: I18nKey = I18nKey(default="{count} commands available", zh_CN="共 {count} 个可用命令", zh_TW="共 {count} 個可用命令", en="{count} commands available", ja="利用可能なコマンド {count} 件", ru="Доступно команд: {count}")
89
+ other_prefixes: I18nKey = I18nKey(default="Other prefixes", zh_CN="其他触发前缀", zh_TW="其他觸發前綴", en="Other prefixes", ja="その他のプレフィックス", ru="Другие префиксы")
90
+
91
+ label_description: I18nKey = I18nKey(default="Description", zh_CN="描述", zh_TW="描述", en="Description", ja="説明", ru="Описание")
92
+ label_aliases: I18nKey = I18nKey(default="Aliases", zh_CN="别名", zh_TW="別名", en="Aliases", ja="エイリアス", ru="Псевдонимы")
93
+ label_usage: I18nKey = I18nKey(default="Usage", zh_CN="用法", zh_TW="用法", en="Usage", ja="使い方", ru="Использование")
94
+ label_permission: I18nKey = I18nKey(default="Permission", zh_CN="权限", zh_TW="權限", en="Permission", ja="権限", ru="Права")
95
+ label_group: I18nKey = I18nKey(default="Group", zh_CN="分组", zh_TW="分組", en="Group", ja="グループ", ru="Группа")
96
+ permission_required: I18nKey = I18nKey(default="Requires permission", zh_CN="需要特殊权限", zh_TW="需要特殊權限", en="Requires permission", ja="特殊権限が必要", ru="Требуются права")
97
+
98
+ chip_commands: I18nKey = I18nKey(default="Commands", zh_CN="命令", zh_TW="命令", en="Commands", ja="コマンド", ru="Команды")
99
+ chip_groups: I18nKey = I18nKey(default="Groups", zh_CN="分组", zh_TW="分組", en="Groups", ja="グループ", ru="Группы")
100
+
101
+ err_out_of_range: I18nKey = I18nKey(default="Index out of range", zh_CN="序号超出范围", zh_TW="序號超出範圍", en="Index out of range", ja="番号が範囲外", ru="Номер вне диапазона")
102
+ err_range_hint: I18nKey = I18nKey(default="Please enter a number between 1 and {count}", zh_CN="请输入 1-{count} 之间的序号", zh_TW="請輸入 1-{count} 之間的序號", en="Please enter a number between 1 and {count}", ja="1〜{count} の番号を入力してください", ru="Введите номер от 1 до {count}")
103
+ err_invalid_arg: I18nKey = I18nKey(default="Invalid argument", zh_CN="参数错误", zh_TW="參數錯誤", en="Invalid argument", ja="引数エラー", ru="Неверный аргумент")
104
+ err_invalid_hint: I18nKey = I18nKey(default="Please enter a valid number", zh_CN="请输入有效的序号", zh_TW="請輸入有效的序號", en="Please enter a valid number", ja="有効な番号を入力してください", ru="Введите корректный номер")
105
+ err_fmt: I18nKey = I18nKey(default="Invalid output format: {fmt}", zh_CN="无效的输出格式:{fmt}", zh_TW="無效的輸出格式:{fmt}", en="Invalid output format: {fmt}", ja="出力形式が無効です: {fmt}", ru="Недопустимый формат вывода: {fmt}")
106
+ err_unknown: I18nKey = I18nKey(default="Unknown argument: {arg}", zh_CN="未知参数:{arg}", zh_TW="未知參數:{arg}", en="Unknown argument: {arg}", ja="不明な引数: {arg}", ru="Неизвестный аргумент: {arg}")
107
+ err_img_unavailable: I18nKey = I18nKey(default="Image output unavailable, falling back to text", zh_CN="图片输出不可用,已回退到文本", zh_TW="圖片輸出不可用,已回退到文字", en="Image output unavailable, falling back to text", ja="画像出力は利用できません。テキストにフォールバックします", ru="Вывод изображений недоступен, используется текст")
108
+
109
+ cfg_show_hidden: I18nKey = I18nKey(key="HelpNext.cfg_show_hidden", default="Show hidden commands", zh_CN="显示隐藏命令", zh_TW="顯示隱藏命令", en="Show hidden commands", ja="非表示コマンドを表示", ru="Показывать скрытые команды")
110
+ cfg_group_commands: I18nKey = I18nKey(key="HelpNext.cfg_group_commands", default="Group commands by category", zh_CN="按分组显示命令", zh_TW="按分組顯示命令", en="Group commands by category", ja="カテゴリ別にグループ化", ru="Группировать команды")
111
+ cfg_theme: I18nKey = I18nKey(key="HelpNext.cfg_theme", default="Image theme", zh_CN="图片主题", zh_TW="圖片主題", en="Image theme", ja="画像テーマ", ru="Тема изображения")
112
+ cfg_utc_offset: I18nKey = I18nKey(key="HelpNext.cfg_utc_offset", default="UTC offset for day/night switching", zh_CN="UTC 时区偏移(用于昼夜切换)", zh_TW="UTC 時區偏移(用於晝夜切換)", en="UTC offset (day/night switching)", ja="UTCオフセット(昼夜切替用)", ru="Смещение UTC (для смены темы)")
113
+ cfg_show_logo: I18nKey = I18nKey(key="HelpNext.cfg_show_logo", default="Show ErisPulse icon in header", zh_CN="头部显示 ErisPulse 图标", zh_TW="頭部顯示 ErisPulse 圖示", en="Show ErisPulse icon in header", ja="ヘッダーに ErisPulse アイコンを表示", ru="Показывать иконку ErisPulse в шапке")
114
+ cfg_header_title: I18nKey = I18nKey(key="HelpNext.cfg_header_title", default="Custom header title (empty for default)", zh_CN="自定义头部标题(留空使用默认)", zh_TW="自訂頭部標題(留空使用預設)", en="Custom header title (empty for default)", ja="カスタムヘッダータイトル(空でデフォルト)", ru="Свой заголовок шапки (пусто = по умолчанию)")
115
+ cfg_header_subtitle: I18nKey = I18nKey(key="HelpNext.cfg_header_subtitle", default="Custom header subtitle (empty for default)", zh_CN="自定义头部副标题(留空使用默认)", zh_TW="自訂頭部副標題(留空使用預設)", en="Custom header subtitle (empty for default)", ja="カスタムヘッダーサブタイトル(空でデフォルト)", ru="Свой подзаголовок шапки (пусто = по умолчанию)")
116
+
117
+ theme_auto: I18nKey = I18nKey(key="HelpNext.theme_auto", default="Auto (by time)", zh_CN="自动(跟随时间)", zh_TW="自動(跟隨時間)", en="Auto (by time)", ja="自動(時間帯)", ru="Авто (по времени)")
118
+ theme_light: I18nKey = I18nKey(key="HelpNext.theme_light", default="Light", zh_CN="浅色", zh_TW="淺色", en="Light", ja="ライト", ru="Светлая")
119
+ theme_dark: I18nKey = I18nKey(key="HelpNext.theme_dark", default="Dark", zh_CN="深色", zh_TW="深色", en="Dark", ja="ダーク", ru="Тёмная")
120
+ group_basic: I18nKey = I18nKey(key="HelpNext.group_basic", default="Basic", zh_CN="基本", zh_TW="基本", en="Basic", ja="基本", ru="Основные")
121
+ group_render: I18nKey = I18nKey(key="HelpNext.group_render", default="Rendering", zh_CN="渲染", zh_TW="渲染", en="Rendering", ja="レンダリング", ru="Отрисовка")
122
+ group_header: I18nKey = I18nKey(key="HelpNext.group_header", default="Header", zh_CN="头部", zh_TW="頭部", en="Header", ja="ヘッダー", ru="Шапка")
123
+
124
+ def __init__(self):
125
+ self.sdk = sdk
126
+ self.logger = sdk.logger.get_child("HelpNext")
127
+ self.command_map: Dict[int, Dict] = {}
128
+ self.visualizer = Visualizer(sdk, {})
129
+ self._help_handler = None
130
+
131
+ @staticmethod
132
+ def get_load_strategy():
133
+ from ErisPulse.loaders import ModuleLoadStrategy
134
+ return ModuleLoadStrategy(
135
+ lazy_load=False,
136
+ priority=60,
137
+ )
138
+
139
+ async def on_load(self, event):
140
+ self._help_handler = self._make_handler()
141
+ command(
142
+ "help",
143
+ aliases=["h", "帮助"],
144
+ help="显示帮助信息",
145
+ usage="help [序号] [--format <image|html|markdown|text>]",
146
+ )(self._help_handler)
147
+ self.logger.info("HelpNext 已加载")
148
+
149
+ async def on_unload(self, event):
150
+ if self._help_handler is not None:
151
+ command.unregister(self._help_handler)
152
+ self.logger.info("HelpNext 已卸载")
153
+ return True
154
+
155
+ def _cfg_view(self) -> Dict:
156
+ cfg = self.cfg
157
+ return {
158
+ "show_hidden_commands": cfg.show_hidden_commands,
159
+ "group_commands": cfg.group_commands,
160
+ "theme": cfg.theme,
161
+ "utc_offset": cfg.utc_offset,
162
+ "show_logo": cfg.show_logo,
163
+ "header_title": cfg.header_title,
164
+ "header_subtitle": cfg.header_subtitle,
165
+ }
166
+
167
+ def _all_prefixes(self) -> List[str]:
168
+ # 读取框架命令处理器维护的前缀(随配置热更新,与命令解析保持一致)
169
+ prefix = command.prefix
170
+ if isinstance(prefix, list):
171
+ return [str(p) for p in prefix] if prefix else ["/"]
172
+ return [str(prefix)] if prefix else ["/"]
173
+
174
+ def _build_command_list(self, event) -> List[Dict]:
175
+ cfg = self._cfg_view()
176
+ show_hidden = cfg["show_hidden_commands"]
177
+ result: List[Dict] = []
178
+ # 会话感知:按作用域过滤当前会话不可用模块的命令(与框架静默语义一致)
179
+ names = command.get_commands(event=event) if show_hidden else command.get_visible_commands(event=event)
180
+ for name in names:
181
+ info = command.get_command(name, event=event)
182
+ if info and name == info.get("main_name"):
183
+ result.append({"name": name, "info": info})
184
+ return result
185
+
186
+ def _make_handler(self):
187
+ async def help_command(event):
188
+ await self._handle(event)
189
+ return help_command
190
+
191
+ async def _handle(self, event) -> None:
192
+ try:
193
+ args = event.get_command_args()
194
+ commands = self._build_command_list(event)
195
+ prefixes = self._all_prefixes()
196
+ prefix = prefixes[0] if prefixes else "/"
197
+ cfg = self._cfg_view()
198
+ self.visualizer.config = cfg
199
+
200
+ self._index_commands(commands, cfg["group_commands"])
201
+
202
+ index, fmt, err = self._parse_args(args)
203
+
204
+ if err:
205
+ if err[0] == "format":
206
+ msg = HelpTemplates._t("err_fmt", fmt=err[1] or "?")
207
+ else:
208
+ msg = HelpTemplates._t("err_unknown", arg=err[1])
209
+ title = HelpTemplates._t("err_invalid_arg")
210
+ await self._send(event, HelpTemplates.build_error(title, msg), None, "auto-text")
211
+ return
212
+
213
+ image = None
214
+ templates = None
215
+ want_image = fmt in ("auto", "image")
216
+
217
+ if index is not None:
218
+ if index in self.command_map:
219
+ cmd = self.command_map[index]
220
+ if want_image:
221
+ image = self.visualizer.render_command_detail(cmd, prefix, prefixes)
222
+ templates = HelpTemplates.build_command_detail(cmd, prefix, prefixes)
223
+ else:
224
+ title = HelpTemplates._t("err_out_of_range")
225
+ msg = HelpTemplates._t("err_range_hint", count=len(commands))
226
+ if want_image:
227
+ image = self.visualizer.render_error(title, msg)
228
+ templates = HelpTemplates.build_error(title, msg)
229
+ else:
230
+ if want_image:
231
+ image = self.visualizer.render_help_list(
232
+ commands, self.command_map, prefix, cfg["group_commands"], prefixes,
233
+ )
234
+ templates = HelpTemplates.build_help_list(
235
+ commands, self.command_map, prefix, cfg["group_commands"], prefixes,
236
+ )
237
+
238
+ await self._send(event, templates, image, fmt)
239
+ except Exception as e:
240
+ self.logger.error(f"处理帮助命令出错: {e}", exc_info=True)
241
+
242
+ @staticmethod
243
+ def _normalize_format(v) -> Optional[str]:
244
+ v = str(v).lower().strip()
245
+ return {
246
+ "image": "image", "img": "image",
247
+ "html": "html", "h5": "html",
248
+ "markdown": "markdown", "md": "markdown",
249
+ "text": "text", "txt": "text",
250
+ }.get(v)
251
+
252
+ @staticmethod
253
+ def _parse_args(args: List[str]) -> Tuple[Optional[int], str, Optional[Tuple[str, str]]]:
254
+ index = None
255
+ fmt = "auto"
256
+ err = None
257
+ i, n = 0, len(args)
258
+ while i < n:
259
+ low = str(args[i]).lower()
260
+ if low.isdigit():
261
+ if index is None:
262
+ index = int(low)
263
+ else:
264
+ err = ("unknown", args[i])
265
+ break
266
+ elif low == "--format":
267
+ if i + 1 < n:
268
+ v = Main._normalize_format(args[i + 1])
269
+ if v is None:
270
+ err = ("format", str(args[i + 1]))
271
+ break
272
+ fmt = v
273
+ i += 1
274
+ else:
275
+ err = ("format", "")
276
+ break
277
+ elif low.startswith("--format="):
278
+ v = Main._normalize_format(low.split("=", 1)[1])
279
+ if v is None:
280
+ err = ("format", low.split("=", 1)[1])
281
+ break
282
+ fmt = v
283
+ else:
284
+ err = ("unknown", args[i])
285
+ break
286
+ i += 1
287
+ return index, fmt, err
288
+
289
+ def _index_commands(self, commands: List[Dict], group_commands: bool) -> None:
290
+ self.command_map = {}
291
+ grouped: Dict[str, List[Dict]] = {}
292
+ if group_commands:
293
+ for cmd in commands:
294
+ g = cmd["info"].get("group") or "default"
295
+ grouped.setdefault(g, []).append(cmd)
296
+ else:
297
+ grouped["default"] = list(commands)
298
+ idx = 1
299
+ for _, cmds in grouped.items():
300
+ for cmd in cmds:
301
+ self.command_map[idx] = cmd
302
+ idx += 1
303
+
304
+ @staticmethod
305
+ def _supports(event, method: str) -> bool:
306
+ try:
307
+ return event.supports(method)
308
+ except Exception:
309
+ return False
310
+
311
+ def _select_text_format(self, event, templates) -> Tuple[str, str]:
312
+ if self._supports(event, "Html"):
313
+ return ("Html", templates["html"])
314
+ if self._supports(event, "Markdown"):
315
+ return ("Markdown", templates["markdown"])
316
+ return ("Text", templates["text"])
317
+
318
+ async def _send(self, event, templates, image: Optional[bytes], fmt: str) -> None:
319
+ try:
320
+ if fmt == "image":
321
+ if image and self._supports(event, "Image"):
322
+ await event.reply(image, method="Image")
323
+ return
324
+ note = HelpTemplates._t("err_img_unavailable")
325
+ await event.reply(note)
326
+ return
327
+
328
+ if fmt in ("html", "markdown", "text"):
329
+ await event.reply(templates[fmt], method=fmt.capitalize())
330
+ return
331
+
332
+ if fmt == "auto" and image and self._supports(event, "Image"):
333
+ try:
334
+ await event.reply(image, method="Image")
335
+ return
336
+ except Exception as e:
337
+ self.logger.warning(f"图片发送失败,回退到文本: {e}")
338
+
339
+ method, content = self._select_text_format(event, templates)
340
+ try:
341
+ await event.reply(content, method=method)
342
+ except Exception:
343
+ await event.reply(templates["text"])
344
+ except Exception as e:
345
+ self.logger.error(f"发送帮助出错: {e}")
346
+ try:
347
+ await event.reply(templates["text"])
348
+ except Exception:
349
+ pass
HelpNext/Templates.py ADDED
@@ -0,0 +1,293 @@
1
+ from typing import Dict, List, Optional
2
+
3
+ from ErisPulse import i18n
4
+ from ErisPulse.Core.Event import command
5
+
6
+ class HelpTemplates:
7
+ """i18n-aware fallback templates (html / markdown / text)."""
8
+
9
+ PRIMARY_COLOR = "#0071e3"
10
+ WARNING_COLOR = "#ff9f0a"
11
+ ERROR_COLOR = "#ff453a"
12
+ PRIMARY_BG = "rgba(0, 113, 227, 0.06)"
13
+
14
+ @classmethod
15
+ def _t(cls, key: str, **kwargs) -> str:
16
+ full = f"HelpNext.{key}"
17
+ return i18n.t(full, default=full, **kwargs)
18
+
19
+ @classmethod
20
+ def _group_name(cls, group: str) -> str:
21
+ if not group or group == "default":
22
+ return cls._t("group_default")
23
+ return group
24
+
25
+ @classmethod
26
+ def _aliases_of(cls, name: str, info: Dict) -> List[str]:
27
+ main_name = info.get("main_name", name)
28
+ return [
29
+ alias
30
+ for alias, mapped in command.aliases.items()
31
+ if mapped == main_name and alias != main_name
32
+ ]
33
+
34
+ @classmethod
35
+ def _other_prefixes(cls, prefixes: Optional[list], display: str) -> list:
36
+ if not prefixes or len(prefixes) <= 1:
37
+ return []
38
+ return [p for p in prefixes if p != display]
39
+
40
+ @classmethod
41
+ def build_help_list(
42
+ cls,
43
+ commands: List[Dict],
44
+ command_map: Dict[int, Dict],
45
+ prefix: str,
46
+ group_commands: bool = True,
47
+ prefixes: Optional[list] = None,
48
+ ) -> Dict[str, str]:
49
+ others = cls._other_prefixes(prefixes or [prefix], prefix)
50
+ grouped = cls._group(commands, group_commands)
51
+
52
+ global_idx = 1
53
+ for _, cmds in grouped.items():
54
+ for cmd in cmds:
55
+ command_map[global_idx] = cmd
56
+ global_idx += 1
57
+
58
+ return {
59
+ "html": cls._list_html(commands, grouped, command_map, prefix, others),
60
+ "markdown": cls._list_md(commands, grouped, command_map, prefix, others),
61
+ "text": cls._list_text(commands, grouped, command_map, prefix, others),
62
+ }
63
+
64
+ @staticmethod
65
+ def _group(commands: List[Dict], group_commands: bool) -> Dict[str, List[Dict]]:
66
+ if not group_commands:
67
+ return {"default": list(commands)}
68
+ grouped: Dict[str, List[Dict]] = {}
69
+ for cmd in commands:
70
+ g = cmd["info"].get("group") or "default"
71
+ grouped.setdefault(g, []).append(cmd)
72
+ return grouped
73
+
74
+ @classmethod
75
+ def _list_html(cls, commands, grouped, command_map, prefix, others) -> str:
76
+ title = cls._t("title")
77
+ hint = cls._t("usage_hint", prefix=prefix)
78
+ count = cls._t("command_count", count=len(commands))
79
+
80
+ sections = ""
81
+ for group, cmds in grouped.items():
82
+ sections += (
83
+ f'<div style="font-size:13px;margin-bottom:8px;font-weight:600;'
84
+ f'color:{cls.PRIMARY_COLOR};">{cls._group_name(group)}</div>'
85
+ )
86
+ for cmd in cmds:
87
+ idx = next(
88
+ i for i, c in command_map.items() if c["name"] == cmd["name"]
89
+ )
90
+ name = cmd["name"]
91
+ help_text = cmd["info"].get("help") or cls._t("no_description")
92
+ sections += (
93
+ f'<div style="margin-bottom:6px;font-size:13px;">'
94
+ f'<b style="margin-right:6px;">{idx}.</b>'
95
+ f'<code style="background:rgba(0,0,0,0.05);padding:2px 6px;'
96
+ f'border-radius:4px;margin-right:6px;">{prefix}{name}</code>'
97
+ f'<span style="color:#666;">- {help_text}</span></div>'
98
+ )
99
+ sections += "\n"
100
+
101
+ others_html = cls._prefix_note_html(others)
102
+ return (
103
+ f'<div style="padding:12px;border-radius:8px;">'
104
+ f'<div style="color:{cls.PRIMARY_COLOR};font-size:16px;font-weight:700;'
105
+ f'margin-bottom:12px;">{title}</div>'
106
+ f'<div style="padding:8px;background:{cls.PRIMARY_BG};border-radius:6px;'
107
+ f'margin-bottom:12px;font-size:13px;">{hint}</div>'
108
+ f'{sections}'
109
+ f'<div style="font-size:12px;color:#666;margin-top:8px;">{count}</div>'
110
+ f'{others_html}'
111
+ f'</div>'
112
+ )
113
+
114
+ @classmethod
115
+ def _list_md(cls, commands, grouped, command_map, prefix, others) -> str:
116
+ lines = [
117
+ f"**{cls._t('title')}**",
118
+ "",
119
+ cls._t("usage_hint", prefix=prefix),
120
+ "",
121
+ ]
122
+ for group, cmds in grouped.items():
123
+ lines.append(f"**{cls._group_name(group)}**")
124
+ lines.append("")
125
+ for cmd in cmds:
126
+ idx = next(i for i, c in command_map.items() if c["name"] == cmd["name"])
127
+ help_text = cmd["info"].get("help") or cls._t("no_description")
128
+ lines.append(f"{idx}. `{prefix}{cmd['name']}` - {help_text}")
129
+ lines.append("")
130
+
131
+ lines.append("---")
132
+ lines.append(cls._t("command_count", count=len(commands)))
133
+ if others:
134
+ lines.append("")
135
+ lines.append(f"{cls._t('other_prefixes')}: {'、'.join(others)}")
136
+ return "\n".join(lines)
137
+
138
+ @classmethod
139
+ def _list_text(cls, commands, grouped, command_map, prefix, others) -> str:
140
+ lines = [
141
+ cls._t("title"),
142
+ "----------",
143
+ cls._t("usage_hint", prefix=prefix),
144
+ "",
145
+ ]
146
+ for group, cmds in grouped.items():
147
+ lines.append(f"[{cls._group_name(group)}]")
148
+ lines.append("")
149
+ for cmd in cmds:
150
+ idx = next(i for i, c in command_map.items() if c["name"] == cmd["name"])
151
+ help_text = cmd["info"].get("help") or cls._t("no_description")
152
+ lines.append(f"{idx}. {prefix}{cmd['name']} - {help_text}")
153
+ lines.append("")
154
+
155
+ lines.append("----------")
156
+ lines.append(cls._t("command_count", count=len(commands)))
157
+ if others:
158
+ lines.append("")
159
+ lines.append(f"{cls._t('other_prefixes')}: {'、'.join(others)}")
160
+ return "\n".join(lines)
161
+
162
+ @classmethod
163
+ def _prefix_note_html(cls, others) -> str:
164
+ if not others:
165
+ return ""
166
+ note = "、".join(f"<code style='font-size:11px;'>{p}</code>" for p in others)
167
+ return (
168
+ f'<div style="font-size:11px;color:#999;margin-top:4px;">'
169
+ f'{cls._t("other_prefixes")}: {note}</div>'
170
+ )
171
+
172
+ @classmethod
173
+ def build_command_detail(cls, cmd: Dict, prefix: str, prefixes: Optional[list] = None) -> Dict[str, str]:
174
+ others = cls._other_prefixes(prefixes or [prefix], prefix)
175
+ return {
176
+ "html": cls._detail_html(cmd, prefix, others),
177
+ "markdown": cls._detail_md(cmd, prefix, others),
178
+ "text": cls._detail_text(cmd, prefix, others),
179
+ }
180
+
181
+ @classmethod
182
+ def _detail_html(cls, cmd: Dict, prefix: str, others) -> str:
183
+ name = cmd["name"]
184
+ info = cmd["info"]
185
+ title = cls._t("detail_title")
186
+ parts = [
187
+ f'<div style="padding:12px;border-radius:8px;">'
188
+ f'<div style="color:{cls.PRIMARY_COLOR};font-size:16px;font-weight:700;'
189
+ f'margin-bottom:12px;">{title}: <code style="background:rgba(0,0,0,0.05);'
190
+ f'padding:2px 6px;border-radius:4px;">{prefix}{name}</code></div>'
191
+ ]
192
+
193
+ parts.append(cls._kv_html(cls._t("label_description"),
194
+ info.get("help") or cls._t("no_description")))
195
+
196
+ aliases = cls._aliases_of(name, info)
197
+ if aliases:
198
+ parts.append(cls._kv_html(cls._t("label_aliases"),
199
+ ", ".join(f"{prefix}{a}" for a in aliases)))
200
+
201
+ if info.get("usage"):
202
+ parts.append(cls._kv_html(cls._t("label_usage"),
203
+ info["usage"].replace("/", prefix), mono=True))
204
+
205
+ if info.get("permission"):
206
+ parts.append(cls._kv_html(cls._t("label_permission"),
207
+ cls._t("permission_required"), warn=True))
208
+
209
+ if info.get("group"):
210
+ parts.append(cls._kv_html(cls._t("label_group"),
211
+ cls._group_name(info["group"])))
212
+
213
+ if others:
214
+ parts.append(cls._prefix_note_html(others))
215
+
216
+ parts.append("</div>")
217
+ return "\n".join(parts)
218
+
219
+ @classmethod
220
+ def _kv_html(cls, label: str, value: str, mono: bool = False, warn: bool = False) -> str:
221
+ color = cls.WARNING_COLOR if warn else "inherit"
222
+ style = "font-family:monospace;background:rgba(0,0,0,0.03);padding:2px 6px;border-radius:4px;" if mono else ""
223
+ return (
224
+ f'<div style="margin-bottom:8px;">'
225
+ f'<div style="font-size:13px;margin-bottom:4px;"><b>{label}:</b></div>'
226
+ f'<div style="font-size:13px;color:{color};{style}">{value}</div></div>'
227
+ )
228
+
229
+ @classmethod
230
+ def _detail_md(cls, cmd: Dict, prefix: str, others) -> str:
231
+ name = cmd["name"]
232
+ info = cmd["info"]
233
+ lines = [
234
+ f"**{cls._t('detail_title')}:** `{prefix}{name}`",
235
+ "",
236
+ f"**{cls._t('label_description')}:** {info.get('help') or cls._t('no_description')}",
237
+ "",
238
+ ]
239
+ aliases = cls._aliases_of(name, info)
240
+ if aliases:
241
+ lines.append(f"**{cls._t('label_aliases')}:** {', '.join(f'`{prefix}{a}`' for a in aliases)}")
242
+ lines.append("")
243
+ if info.get("usage"):
244
+ lines.append(f"**{cls._t('label_usage')}:** `{info['usage'].replace('/', prefix)}`")
245
+ lines.append("")
246
+ if info.get("permission"):
247
+ lines.append(f"**{cls._t('label_permission')}:** {cls._t('permission_required')}")
248
+ lines.append("")
249
+ if info.get("group"):
250
+ lines.append(f"**{cls._t('label_group')}:** {cls._group_name(info['group'])}")
251
+ lines.append("")
252
+ if others:
253
+ lines.append(f"{cls._t('other_prefixes')}: {'、'.join(others)}")
254
+ return "\n".join(lines)
255
+
256
+ @classmethod
257
+ def _detail_text(cls, cmd: Dict, prefix: str, others) -> str:
258
+ name = cmd["name"]
259
+ info = cmd["info"]
260
+ lines = [
261
+ f"{cls._t('detail_title')}: {prefix}{name}",
262
+ "----------",
263
+ f"{cls._t('label_description')}: {info.get('help') or cls._t('no_description')}",
264
+ "",
265
+ ]
266
+ aliases = cls._aliases_of(name, info)
267
+ if aliases:
268
+ lines.append(f"{cls._t('label_aliases')}: {', '.join(f'{prefix}{a}' for a in aliases)}")
269
+ lines.append("")
270
+ if info.get("usage"):
271
+ lines.append(f"{cls._t('label_usage')}: {info['usage'].replace('/', prefix)}")
272
+ lines.append("")
273
+ if info.get("permission"):
274
+ lines.append(f"{cls._t('label_permission')}: {cls._t('permission_required')}")
275
+ lines.append("")
276
+ if info.get("group"):
277
+ lines.append(f"{cls._t('label_group')}: {cls._group_name(info['group'])}")
278
+ lines.append("")
279
+ if others:
280
+ lines.append(f"{cls._t('other_prefixes')}: {'、'.join(others)}")
281
+ return "\n".join(lines)
282
+
283
+ @classmethod
284
+ def build_error(cls, title: str, message: str) -> Dict[str, str]:
285
+ html = (
286
+ f'<div style="padding:12px;border-radius:8px;">'
287
+ f'<div style="color:{cls.ERROR_COLOR};font-size:14px;font-weight:700;'
288
+ f'margin-bottom:8px;">{title}</div>'
289
+ f'<div style="font-size:13px;">{message}</div></div>'
290
+ )
291
+ markdown = f"**{title}**\n\n{message}"
292
+ text = f"{title}\n\n{message}"
293
+ return {"html": html, "markdown": markdown, "text": text}
HelpNext/Visualizer.py ADDED
@@ -0,0 +1,498 @@
1
+ import base64
2
+ import html
3
+ import math
4
+ import struct
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional, Tuple
8
+
9
+ from ErisPulse import i18n
10
+ from ErisPulse.Core.Event import command
11
+
12
+ class Visualizer:
13
+ CARD_WIDTH = 880
14
+ _PAGE_PAD = 36
15
+ _CARD_PAD_V = 16
16
+ _CARD_PAD_H = 18
17
+ _CARD_GAP = 14
18
+ _COL_GAP = 14
19
+ _CARD_BORDER = 2
20
+ _LOGO_W = 120
21
+
22
+ ACCENT = "#0071e3"
23
+ ACCENT_DARK = "#0a84ff"
24
+ WARN = "#ff9f0a"
25
+
26
+ PALETTE = [
27
+ "#0a84ff", "#5e5ce6", "#bf5af2", "#ff375f", "#ff9f0a",
28
+ "#34c759", "#64d2ff", "#30b0c7", "#ff453a", "#8e8e93",
29
+ ]
30
+
31
+ _ICON_PATH = Path(__file__).parent / "assets" / "icon.png"
32
+ _icon_cache: Optional[Tuple[str, Tuple[int, int]]] = None
33
+
34
+ _CSS_TPL = """
35
+ * { margin: 0; padding: 0; box-sizing: border-box; }
36
+ body {
37
+ font-family: "Noto Sans SC", "Source Han Sans SC", sans-serif;
38
+ background: __PAGE__; color: __INK__; -webkit-font-smoothing: antialiased;
39
+ padding: 36px;
40
+ }
41
+ .card {
42
+ background: __CARD__; border: 1px solid __BORDER__; border-radius: 16px;
43
+ padding: 16px 18px; box-shadow: __SHADOW__; margin-bottom: 12px;
44
+ }
45
+ .head-row { display: flex; align-items: center; gap: 14px; }
46
+ .logo-side { display: block; }
47
+ .head-text { min-width: 0; }
48
+ .title { font-size: 20px; font-weight: 700; color: __INK__; letter-spacing: -0.3px; }
49
+ .subtitle { font-size: 13px; color: __SUB__; margin-top: 2px; }
50
+ .divider { height: 1px; background: __SEP__; margin: 14px 0; }
51
+ .chips { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
52
+ .chip { padding: 7px 14px; border-radius: 9px; font-size: 13px; background: __SOFT__; color: __INK__; border: 1px solid __BORDER__; }
53
+ .chip b { color: __ACCENT__; font-weight: 600; margin-right: 4px; }
54
+ .masonry { display: flex; gap: 14px; align-items: flex-start; }
55
+ .mcol { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 14px; }
56
+ .mcol .card { margin-bottom: 0; }
57
+ .cmd-head { display: flex; align-items: center; gap: 10px; }
58
+ .num {
59
+ flex: 0 0 26px; width: 26px; height: 26px; border-radius: 8px;
60
+ background: __ACCENTBG__; border: 1px solid __ACCENTLINE__;
61
+ color: __ACCENT__; font-size: 12px; font-weight: 700;
62
+ display: flex; align-items: center; justify-content: center;
63
+ font-variant-numeric: tabular-nums;
64
+ }
65
+ .cmd-code {
66
+ font-family: "Source Code Pro", monospace; font-size: 13.5px; font-weight: 700;
67
+ color: __INK__; white-space: nowrap;
68
+ }
69
+ .cmd-code .pfx { color: __ACCENT__; font-weight: 600; }
70
+ .group-tag {
71
+ margin-left: auto; font-size: 11px; font-weight: 600;
72
+ padding: 3px 10px; border-radius: 6px; white-space: nowrap;
73
+ display: inline-flex; align-items: center; gap: 5px;
74
+ }
75
+ .group-tag::before {
76
+ content: ""; width: 6px; height: 6px; border-radius: 50%;
77
+ background: currentColor; flex: 0 0 auto;
78
+ }
79
+ .cmd-desc { font-size: 13px; color: __INK__; margin-top: 8px; line-height: 1.55; }
80
+ .cmd-aliases { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
81
+ .cmd-aliases .lbl { font-size: 11px; color: __SUB__; margin-right: 2px; }
82
+ .alias-tag {
83
+ font-family: "Source Code Pro", monospace; font-size: 12px;
84
+ background: __CODEBG__; color: __INK__; padding: 3px 9px; border-radius: 6px;
85
+ }
86
+ .cmd-usage {
87
+ margin-top: 8px; font-family: "Source Code Pro", monospace; font-size: 12.5px;
88
+ background: __CODEBG__; color: __SUB__; padding: 7px 10px; border-radius: 7px;
89
+ line-height: 1.5;
90
+ }
91
+ .detail-label { font-size: 12px; font-weight: 600; color: __SUB__; letter-spacing: 0.6px; margin-bottom: 6px; text-transform: uppercase; }
92
+ .detail-value { font-size: 15px; color: __INK__; line-height: 1.5; }
93
+ .detail-value.mono { font-family: "Source Code Pro", monospace; background: __CODEBG__; padding: 8px 12px; border-radius: 8px; font-size: 14px; }
94
+ .detail-value.warn { color: __WARN__; }
95
+ .aliases { display: flex; flex-wrap: wrap; gap: 8px; }
96
+ .foot { font-size: 12.5px; color: __SUB__; text-align: center; margin-top: 14px; line-height: 1.7; }
97
+ .foot code { color: __ACCENT__; background: __CODEBG__; padding: 2px 7px; border-radius: 5px; font-family: "Source Code Pro", monospace; }
98
+ .err-title { font-size: 18px; font-weight: 700; color: __WARN__; }
99
+ .err-msg { font-size: 14px; color: __INK__; margin-top: 8px; line-height: 1.5; }
100
+ """
101
+
102
+ def __init__(self, sdk, config: Dict):
103
+ self.sdk = sdk
104
+ self.logger = sdk.logger.get_child("HelpNext.Visualizer")
105
+ self.config = config
106
+ self._takumi_inst = None
107
+
108
+ @staticmethod
109
+ def _t(key: str, **kwargs) -> str:
110
+ full = f"HelpNext.{key}"
111
+ return i18n.t(full, default=full, **kwargs)
112
+
113
+ @property
114
+ def takumi(self):
115
+ if self._takumi_inst is None:
116
+ inst = None
117
+ try:
118
+ inst = self.sdk.module.get("Takumi")
119
+ except Exception:
120
+ inst = None
121
+ if inst is None:
122
+ inst = getattr(self.sdk, "Takumi", None)
123
+ self._takumi_inst = inst
124
+ return self._takumi_inst
125
+
126
+ def _theme(self) -> Dict:
127
+ mode = self.config.get("theme", "auto")
128
+ if mode == "auto":
129
+ offset = self.config.get("utc_offset", 8)
130
+ hour = int((time.time() / 3600 + offset) % 24)
131
+ mode = "dark" if (hour >= 19 or hour < 7) else "light"
132
+ accent = self.ACCENT_DARK if mode == "dark" else self.ACCENT
133
+ if mode == "dark":
134
+ return {
135
+ "page": "#000000", "card": "#1c1c1e", "ink": "#f5f5f7", "sub": "#8e8e93",
136
+ "sep": "#38383a", "soft": "#2c2c2e", "codebg": "rgba(255,255,255,0.08)",
137
+ "accent": accent, "tag_alpha": 0.22, "shadow": "none",
138
+ "border": "rgba(255,255,255,0.08)",
139
+ "accentline": self._rgba(accent, 0.35),
140
+ }
141
+ return {
142
+ "page": "#f5f5f7", "card": "#ffffff", "ink": "#1d1d1f", "sub": "#6e6e73",
143
+ "sep": "#d2d2d7", "soft": "#f5f5f7", "codebg": "rgba(0,0,0,0.05)",
144
+ "accent": accent, "tag_alpha": 0.12, "shadow": "0 1px 2px rgba(0,0,0,0.04), 0 2px 8px rgba(0,0,0,0.04)",
145
+ "border": "rgba(0,0,0,0.06)",
146
+ "accentline": self._rgba(accent, 0.25),
147
+ }
148
+
149
+ def _css(self) -> Tuple[str, Dict]:
150
+ t = self._theme()
151
+ css = (
152
+ self._CSS_TPL
153
+ .replace("__PAGE__", t["page"]).replace("__CARD__", t["card"])
154
+ .replace("__INK__", t["ink"]).replace("__SUB__", t["sub"])
155
+ .replace("__SEP__", t["sep"]).replace("__SOFT__", t["soft"])
156
+ .replace("__CODEBG__", t["codebg"]).replace("__ACCENT__", t["accent"])
157
+ .replace("__ACCENTBG__", self._rgba(t["accent"], t["tag_alpha"]))
158
+ .replace("__ACCENTLINE__", t["accentline"])
159
+ .replace("__BORDER__", t["border"])
160
+ .replace("__SHADOW__", t["shadow"])
161
+ )
162
+ return css, t
163
+
164
+ @staticmethod
165
+ def _rgba(hexcolor: str, alpha: float) -> str:
166
+ h = hexcolor.lstrip("#")
167
+ r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
168
+ return f"rgba({r},{g},{b},{alpha})"
169
+
170
+ @staticmethod
171
+ def _png_dims(data: bytes) -> Optional[Tuple[int, int]]:
172
+ try:
173
+ if data[:8] != b"\x89PNG\r\n\x1a\n":
174
+ return None
175
+ w, h = struct.unpack(">II", data[16:24])
176
+ return w, h
177
+ except Exception:
178
+ return None
179
+
180
+ @classmethod
181
+ def _icon(cls) -> Optional[Tuple[str, Tuple[int, int]]]:
182
+ if cls._icon_cache is None:
183
+ try:
184
+ data = cls._ICON_PATH.read_bytes()
185
+ dims = cls._png_dims(data)
186
+ b64 = base64.b64encode(data).decode()
187
+ cls._icon_cache = (b64, dims) if dims else ("", None)
188
+ except Exception:
189
+ cls._icon_cache = ("", None)
190
+ b64, dims = cls._icon_cache
191
+ if not b64 or not dims:
192
+ return None
193
+ return b64, dims
194
+
195
+ @staticmethod
196
+ def _esc(text) -> str:
197
+ return html.escape(str(text))
198
+
199
+ @staticmethod
200
+ def _text_lines(text: str, width: int, cjk_w: float = 13.0, latin_w: float = 7.5) -> int:
201
+ w = sum(cjk_w if ord(c) > 0x2E80 else latin_w for c in str(text))
202
+ return max(1, math.ceil(w / max(1, width)))
203
+
204
+ @staticmethod
205
+ def _aliases_of(name: str, info: Dict) -> List[str]:
206
+ main_name = info.get("main_name", name)
207
+ return [
208
+ a for a, m in command.aliases.items()
209
+ if m == main_name and a != main_name
210
+ ]
211
+
212
+ def _render(self, body_html: str, height: int) -> Optional[bytes]:
213
+ takumi = self.takumi
214
+ if takumi is None or not hasattr(takumi, "render_html"):
215
+ self.logger.warning("Takumi 不可用,跳过图片渲染")
216
+ return None
217
+ css, _ = self._css()
218
+ try:
219
+ return takumi.render_html(
220
+ body_html, stylesheets=[css],
221
+ width=self.CARD_WIDTH, height=height, lang="zh-CN",
222
+ )
223
+ except Exception as e:
224
+ self.logger.error(f"Takumi 渲染失败: {e}")
225
+ return None
226
+
227
+ def _card(self, inner: str) -> str:
228
+ return f"<div class='card'>{inner}</div>"
229
+
230
+ def _h_card(self, inner_h: int) -> int:
231
+ return self._CARD_PAD_V * 2 + inner_h + self._CARD_BORDER + self._CARD_GAP
232
+
233
+ def _total_height(self, block_heights: List[int], footer_h: int = 0) -> int:
234
+ return self._PAGE_PAD * 2 + sum(block_heights) + footer_h + 16
235
+
236
+ @staticmethod
237
+ def _num_cols(count: int) -> int:
238
+ if count <= 8:
239
+ return 1
240
+ if count <= 24:
241
+ return 2
242
+ return 3
243
+
244
+ def _header(self, subtitle: str, chips_html: str) -> Tuple[str, int]:
245
+ cfg = self.config
246
+ show_logo = cfg.get("show_logo", True)
247
+ title = cfg.get("header_title") or "ErisPulse"
248
+ sub = cfg.get("header_subtitle") or subtitle
249
+
250
+ logo_html = ""
251
+ logo_h = 0
252
+ if show_logo:
253
+ icon = self._icon()
254
+ if icon:
255
+ b64, (nw, nh) = icon
256
+ dw = self._LOGO_W
257
+ dh = round(nh * dw / nw)
258
+ logo_html = (
259
+ f"<img class='logo-side' src='data:image/png;base64,{b64}' "
260
+ f"width='{dw}' height='{dh}' alt='ErisPulse'/>"
261
+ )
262
+ logo_h = dh
263
+
264
+ text_html = (
265
+ f"<div class='head-text'><div class='title'>{self._esc(title)}</div>"
266
+ f"<div class='subtitle'>{self._esc(sub)}</div></div>"
267
+ )
268
+ inner = (
269
+ f"<div class='head-row'>{logo_html}{text_html}</div>"
270
+ f"<div class='divider'></div>{chips_html}"
271
+ )
272
+ row_h = max(logo_h, 44)
273
+ inner_h = row_h + 33 + 32
274
+ return inner, inner_h
275
+
276
+ def _chips(self, cmd_count: int, group_count: Optional[int] = None) -> str:
277
+ s = (
278
+ f"<div class='chips'>"
279
+ f"<div class='chip'><b>{cmd_count}</b>{self._esc(self._t('chip_commands'))}</div>"
280
+ )
281
+ if group_count is not None:
282
+ s += f"<div class='chip'><b>{group_count}</b>{self._esc(self._t('chip_groups'))}</div>"
283
+ s += "</div>"
284
+ return s
285
+
286
+ def _command_card(
287
+ self,
288
+ cmd: Dict,
289
+ idx: int,
290
+ prefix: str,
291
+ t: Dict,
292
+ avail: int,
293
+ group_color: Optional[str] = None,
294
+ show_group: bool = False,
295
+ ) -> Tuple[str, int]:
296
+ info = cmd["info"]
297
+ name = cmd["name"]
298
+ desc = info.get("help") or self._t("no_description")
299
+
300
+ head = f"<div class='cmd-head'><div class='num'>{idx}</div>"
301
+ head += (
302
+ f"<div class='cmd-code'><span class='pfx'>{self._esc(prefix)}</span>"
303
+ f"{self._esc(name)}</div>"
304
+ )
305
+ if show_group and info.get("group") and group_color:
306
+ gname = self._t("group_default") if info["group"] == "default" else info["group"]
307
+ bg = self._rgba(group_color, t["tag_alpha"])
308
+ head += f"<span class='group-tag' style='color:{group_color};background:{bg}'>{self._esc(gname)}</span>"
309
+ head += "</div>"
310
+
311
+ desc_html = f"<div class='cmd-desc'>{self._esc(desc)}</div>"
312
+ desc_h = 8 + self._text_lines(desc, avail) * 20
313
+
314
+ extra_html = ""
315
+ extra_h = 0
316
+ aliases = self._aliases_of(name, info)
317
+ if aliases:
318
+ tags = "".join(
319
+ f"<span class='alias-tag'>{self._esc(prefix)}{self._esc(a)}</span>"
320
+ for a in aliases
321
+ )
322
+ extra_html += (
323
+ f"<div class='cmd-aliases'><span class='lbl'>{self._esc(self._t('label_aliases'))}</span>"
324
+ f"{tags}</div>"
325
+ )
326
+ a_w = sum((len(prefix) + len(a)) * 7.5 + 18 + 6 for a in aliases)
327
+ a_rows = max(1, math.ceil(a_w / max(1, avail)))
328
+ extra_h += 8 + a_rows * 24
329
+ if info.get("usage"):
330
+ usage = info["usage"].replace("/", prefix)
331
+ extra_html += f"<div class='cmd-usage'>{self._esc(usage)}</div>"
332
+ extra_h += 22 + self._text_lines(usage, avail, cjk_w=12.5, latin_w=7.5) * 19
333
+
334
+ inner = head + desc_html + extra_html
335
+ inner_h = 24 + desc_h + extra_h
336
+ return inner, inner_h
337
+
338
+ def _masonry(self, items: List[Tuple[str, int]], num_cols: int) -> Tuple[str, int]:
339
+ cols: List[List[str]] = [[] for _ in range(num_cols)]
340
+ col_h = [0] * num_cols
341
+ for html, inner_h in items:
342
+ card_h = inner_h + self._CARD_PAD_V * 2 + self._CARD_BORDER
343
+ c = min(range(num_cols), key=lambda i: col_h[i])
344
+ cols[c].append(html)
345
+ col_h[c] += card_h + self._COL_GAP
346
+ heights = [max(0, ch - self._COL_GAP) for ch in col_h]
347
+ inner = "".join(f"<div class='mcol'>{''.join(col)}</div>" for col in cols)
348
+ return f"<div class='masonry'>{inner}</div>", max(heights)
349
+
350
+ def render_help_list(
351
+ self,
352
+ commands: List[Dict],
353
+ command_map: Dict[int, Dict],
354
+ prefix: str,
355
+ group_commands: bool,
356
+ prefixes: Optional[list] = None,
357
+ ) -> Optional[bytes]:
358
+ if not commands:
359
+ return None
360
+
361
+ others = [
362
+ p for p in (prefixes or [prefix])
363
+ if p != prefix and len(prefixes or []) > 1
364
+ ]
365
+
366
+ num_cols = self._num_cols(len(commands))
367
+ avail = int(
368
+ (self.CARD_WIDTH - self._PAGE_PAD * 2 - self._COL_GAP * (num_cols - 1))
369
+ / num_cols - self._CARD_PAD_H * 2 - 2
370
+ )
371
+ _, t = self._css()
372
+
373
+ grouped: Dict[str, List[Dict]] = {}
374
+ for cmd in commands:
375
+ g = cmd["info"].get("group") or "default"
376
+ grouped.setdefault(g, []).append(cmd)
377
+
378
+ group_colors: Dict[str, str] = {}
379
+ for g in grouped:
380
+ group_colors[g] = self.PALETTE[len(group_colors) % len(self.PALETTE)]
381
+
382
+ ordered: List[Dict] = []
383
+ for g, cmds in grouped.items():
384
+ ordered.extend(cmds)
385
+
386
+ for i, cmd in enumerate(ordered, start=1):
387
+ command_map[i] = cmd
388
+
389
+ chips = self._chips(len(commands), len(grouped) if group_commands else None)
390
+ head_inner, head_h = self._header(self._t("title"), chips)
391
+
392
+ items = []
393
+ for i, cmd in enumerate(ordered, start=1):
394
+ g = cmd["info"].get("group") or "default"
395
+ color = group_colors[g] if group_commands else None
396
+ inner, inner_h = self._command_card(
397
+ cmd, i, prefix, t, avail,
398
+ group_color=color, show_group=group_commands,
399
+ )
400
+ items.append((self._card(inner), inner_h))
401
+
402
+ masonry_html, masonry_h = self._masonry(items, num_cols)
403
+
404
+ foot_lines = [self._t("command_count", count=len(commands))]
405
+ if others:
406
+ foot_lines.append(f"{self._t('other_prefixes')}: {'、'.join(others)}")
407
+ footer = "<div class='foot'>" + "<br>".join(foot_lines) + "</div>"
408
+ footer_h = 34 + (20 if others else 0)
409
+
410
+ body = self._card(head_inner) + masonry_html + footer
411
+ height = self._total_height([self._h_card(head_h), masonry_h], footer_h)
412
+ return self._render(body, height)
413
+
414
+ def render_command_detail(
415
+ self,
416
+ cmd: Dict,
417
+ prefix: str,
418
+ prefixes: Optional[list] = None,
419
+ ) -> Optional[bytes]:
420
+ name = cmd["name"]
421
+ info = cmd["info"]
422
+ others = [
423
+ p for p in (prefixes or [prefix])
424
+ if p != prefix and len(prefixes or []) > 1
425
+ ]
426
+
427
+ chips = (
428
+ f"<div class='chips'>"
429
+ f"<div class='chip'><code style='font-family:Source Code Pro,monospace;'>"
430
+ f"{self._esc(prefix)}{self._esc(name)}</code></div></div>"
431
+ )
432
+ head_inner, head_h = self._header(self._t("detail_title"), chips)
433
+
434
+ blocks = [(self._card(head_inner), self._h_card(head_h))]
435
+
436
+ def add_card(label: str, value_html: str, value_h: int, value_class: str = ""):
437
+ vc = f" {value_class}" if value_class else ""
438
+ inner = (
439
+ f"<div class='detail-label'>{self._esc(label)}</div>"
440
+ f"<div class='detail-value{vc}'>{value_html}</div>"
441
+ )
442
+ blocks.append((self._card(inner), self._h_card(30 + value_h)))
443
+
444
+ add_card(
445
+ self._t("label_description"),
446
+ self._esc(info.get("help") or self._t("no_description")),
447
+ 24,
448
+ )
449
+
450
+ aliases = self._aliases_of(name, info)
451
+ if aliases:
452
+ tags = "".join(
453
+ f"<span class='alias-tag'>{self._esc(prefix)}{self._esc(a)}</span>"
454
+ for a in aliases
455
+ )
456
+ add_card(self._t("label_aliases"), f"<div class='aliases'>{tags}</div>", 32)
457
+
458
+ if info.get("usage"):
459
+ add_card(
460
+ self._t("label_usage"),
461
+ self._esc(info["usage"].replace("/", prefix)),
462
+ 24,
463
+ value_class="mono",
464
+ )
465
+
466
+ if info.get("permission"):
467
+ add_card(
468
+ self._t("label_permission"),
469
+ self._esc(self._t("permission_required")),
470
+ 22,
471
+ value_class="warn",
472
+ )
473
+
474
+ if info.get("group"):
475
+ gname = self._t("group_default") if info["group"] == "default" else info["group"]
476
+ add_card(self._t("label_group"), self._esc(gname), 22)
477
+
478
+ footer, footer_h = "", 0
479
+ if others:
480
+ footer = (f"<div class='foot'>{self._t('other_prefixes')}: "
481
+ f"{'、'.join(self._esc(p) for p in others)}</div>")
482
+ footer_h = 40
483
+
484
+ body = "".join(h for h, _ in blocks) + footer
485
+ height = self._total_height([b for _, b in blocks], footer_h)
486
+ return self._render(body, height)
487
+
488
+ def render_error(self, title: str, message: str) -> Optional[bytes]:
489
+ head_inner, head_h = self._header(self._t("title"), "")
490
+ err = (f"<div class='err-title'>{self._esc(title)}</div>"
491
+ f"<div class='err-msg'>{self._esc(message)}</div>")
492
+ blocks = [
493
+ (self._card(head_inner), self._h_card(head_h)),
494
+ (self._card(err), self._h_card(70)),
495
+ ]
496
+ body = "".join(h for h, _ in blocks)
497
+ height = self._total_height([b for _, b in blocks])
498
+ return self._render(body, height)
HelpNext/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .Core import Main
2
+
3
+ __all__ = ["Main"]
Binary file
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.5
2
+ Name: ErisPulse-HelpNext
3
+ Version: 0.1.0
4
+ Summary: Modern help command module for ErisPulse, full i18n (requires ErisPulse 2.8.0+)
5
+ Project-URL: homepage, https://github.com/wsu2059q/ErisPulse-HelpNext
6
+ Author-email: wsu2059q <wsu2059@qq.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: ErisPulse,HelpModule,Takumi,help,i18n
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Communications :: Chat
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: erispulse-takumi
20
+ Description-Content-Type: text/markdown
21
+
22
+ # ErisPulse-HelpNext
23
+
24
+ <div align="center">
25
+ <img src=".github/assets/ErisPulseLogo.png" width="140" alt="ErisPulse" />
26
+ </div>
27
+
28
+ Renders the ErisPulse `/help` command as a card image via [ErisPulse-Takumi](https://pypi.org/project/ErisPulse-Takumi/), with day/night theme and multilingual support. Requires ErisPulse 2.8.0+.
29
+
30
+ [English](#english) | [简体中文](#简体中文)
31
+
32
+ ---
33
+
34
+ <a id="english"></a>
35
+
36
+ ## English
37
+
38
+ > 2.8.0+ recommends this module. The classic [`ErisPulse-HelpModule`](https://pypi.org/project/ErisPulse-HelpModule/) is still maintained for backward compatibility. Both register `/help`, so enable only one.
39
+
40
+ ### Features
41
+
42
+ - Day/night theme by local time (19–7 dark), or pin light / dark
43
+ - Multilingual: zh-CN / zh-TW / en / ja / ru (declarative `I18nClass`)
44
+ - Session-aware listing: commands from modules disabled by scope in the current session are hidden
45
+ - Falls back to Html → Markdown → Text when images aren't supported
46
+ - Declarative config (`ConfigClass`), descriptions also translated
47
+
48
+ ### Install
49
+
50
+ ```bash
51
+ epsdk install HelpNext
52
+ ```
53
+
54
+ ### Commands
55
+
56
+ ```
57
+ /help List all available commands (card image)
58
+ /help <index> Show detail of the command at <index>
59
+ /help --format <fmt> Force output format: image | html | markdown | text
60
+ ```
61
+
62
+ Aliases: `/h`, `/帮助`
63
+
64
+ ### Config
65
+
66
+ First load writes defaults; edit the `HelpNext` section:
67
+
68
+ ```toml
69
+ [HelpNext]
70
+ show_hidden_commands = false # show commands marked hidden
71
+ group_commands = true # group commands by category
72
+ theme = "auto" # auto | light | dark
73
+ utc_offset = 8 # UTC offset for day/night switching
74
+ show_logo = true # show ErisPulse icon in header
75
+ header_title = "" # custom header title (empty = default)
76
+ header_subtitle = "" # custom header subtitle (empty = default)
77
+ ```
78
+
79
+ - `show_hidden_commands`: when `true`, shows commands marked as hidden
80
+ - `group_commands`: when `false`, lists all commands in a single group
81
+ - `theme`: `auto` (by time), or fixed `light` / `dark`
82
+ - `utc_offset`: UTC offset used for day/night detection
83
+ - `show_logo`: show the ErisPulse icon in the header
84
+ - `header_title` / `header_subtitle`: customize the header text (empty = defaults)
85
+
86
+ ### Dependencies
87
+
88
+ - ErisPulse SDK 2.8.0+
89
+ - [ErisPulse-Takumi](https://pypi.org/project/ErisPulse-Takumi/) (declared as a dependency, auto-installed)
90
+
91
+ ---
92
+
93
+ <a id="简体中文"></a>
94
+
95
+ ## 简体中文
96
+
97
+ > 2.8.0+ 推荐使用本模块;经典版 [ErisPulse-HelpModule](https://pypi.org/project/ErisPulse-HelpModule/) 继续维护,用于向后兼容。两者都注册 `/help`,请按需启用其一。
98
+
99
+ ### 功能特性
100
+
101
+ - 按本地时间自动切换昼夜主题(19–7 点深色),也可固定为浅色 / 深色
102
+ - 多语言:zh-CN / zh-TW / en / ja / ru(声明式 `I18nClass`)
103
+ - 会话感知列表:被作用域禁用的模块,其命令不在当前会话的帮助中列出
104
+ - 平台不支持图片时按 Html → Markdown → 文本 回退
105
+ - 声明式配置(`ConfigClass`),配置描述同样支持多语言
106
+
107
+ ### 安装
108
+
109
+ ```bash
110
+ epsdk install HelpNext
111
+ ```
112
+
113
+ ### 命令
114
+
115
+ ```
116
+ /help 列出所有可用命令(卡片图片)
117
+ /help <序号> 查看指定序号命令的详情
118
+ /help --format <格式> 指定输出格式:image | html | markdown | text
119
+ ```
120
+
121
+ 别名:`/h`、`/帮助`
122
+
123
+ ### 配置选项
124
+
125
+ 首次加载会写入默认配置,可在 ErisPulse 配置的 `HelpNext` 节修改:
126
+
127
+ ```toml
128
+ [HelpNext]
129
+ show_hidden_commands = false # 是否显示隐藏命令
130
+ group_commands = true # 是否按分组显示
131
+ theme = "auto" # auto | light | dark
132
+ utc_offset = 8 # 昼夜切换用的时区偏移
133
+ show_logo = true # 头部是否显示 ErisPulse 图标
134
+ header_title = "" # 自定义头部标题(留空使用默认)
135
+ header_subtitle = "" # 自定义头部副标题(留空使用默认)
136
+ ```
137
+
138
+ - `show_hidden_commands`:设为 `true` 时显示标记为隐藏的命令
139
+ - `group_commands`:设为 `false` 时不分组,所有命令在同一列表
140
+ - `theme`:图片主题,`auto` 跟随时间,或固定 `light` / `dark`
141
+ - `utc_offset`:昼夜判定使用的 UTC 时区偏移
142
+ - `show_logo`:头部是否显示 ErisPulse 图标
143
+ - `header_title` / `header_subtitle`:自定义头部标题 / 副标题(留空使用默认)
144
+
145
+ ### 依赖
146
+
147
+ - ErisPulse SDK 2.8.0+
148
+ - [ErisPulse-Takumi](https://pypi.org/project/ErisPulse-Takumi/)(已声明为依赖,自动安装)
@@ -0,0 +1,10 @@
1
+ HelpNext/Core.py,sha256=pvzCIgDvHCxLRo1nBeqjmII5dVbiZjWvS65W4N4_fxU,20323
2
+ HelpNext/Templates.py,sha256=TjOKE0XRTsp03_JMXwTBmjUsZ0EdR71pcJJvI6VhtYU,11686
3
+ HelpNext/Visualizer.py,sha256=fIqdEVCmCeU82yuDFubgGTnZ_Cxe3D6UmDoAfAk3Fhw,19543
4
+ HelpNext/__init__.py,sha256=ZiKcK7IRyOCxNl_-TYuzKuTnz7YDQbkEDKZ0U6GVSks,43
5
+ HelpNext/assets/icon.png,sha256=vXYncrvAc42m6Ra5V8dpZTpZ73T7XmutozyI5bLV4P4,87722
6
+ erispulse_helpnext-0.1.0.dist-info/METADATA,sha256=Ye0p7y8Av3rLi1iwWC8-IIdobtSaQPGv-pTwzBIWuwA,5411
7
+ erispulse_helpnext-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ erispulse_helpnext-0.1.0.dist-info/entry_points.txt,sha256=DyXAx3ALy7r4F-iHdgFKamsU3pnf9VNgHTo2bgmUl08,44
9
+ erispulse_helpnext-0.1.0.dist-info/licenses/LICENSE,sha256=7BKmRD_5YpTGfc12w9L3TIskHF3A_AWDU4xuDQKE92w,1055
10
+ erispulse_helpnext-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [erispulse.module]
2
+ HelpNext = HelpNext:Main
@@ -0,0 +1,7 @@
1
+ Copyright 2025 wsu2059q
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.