2025-11-06 11:01:13 +08:00
|
|
|
|
from PWF.Convention.Runtime.Config import *
|
|
|
|
|
|
from PWF.CoreModules.plugin_interface import PluginInterface
|
|
|
|
|
|
from PWF.CoreModules.flags import *
|
2025-11-08 14:10:08 +08:00
|
|
|
|
from PWF.Convention.Runtime.Architecture import Architecture
|
2025-11-06 11:01:13 +08:00
|
|
|
|
from PWF.Convention.Runtime.GlobalConfig import ProjectConfig
|
|
|
|
|
|
from PWF.Convention.Runtime.Web import ToolURL
|
|
|
|
|
|
from PWF.Convention.Runtime.String import LimitStringLength
|
2025-11-12 22:58:36 +08:00
|
|
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from typing import Any, Dict, Iterable, List, Optional, Sequence, TypedDict, override, Union
|
2025-11-06 11:01:13 +08:00
|
|
|
|
import httpx
|
2025-11-06 11:46:54 +08:00
|
|
|
|
import re
|
2025-11-06 11:01:13 +08:00
|
|
|
|
|
2025-11-08 14:10:08 +08:00
|
|
|
|
logger: ProjectConfig = Architecture.Get(ProjectConfig)
|
2025-11-07 23:48:10 +08:00
|
|
|
|
MAIN_WEBHOOK_URL = logger.FindItem("main_webhook_url", "")
|
|
|
|
|
|
logger.SaveProperties()
|
2025-11-06 11:01:13 +08:00
|
|
|
|
|
2025-11-06 16:26:07 +08:00
|
|
|
|
|
2025-11-12 22:58:36 +08:00
|
|
|
|
class GuideEntry(TypedDict, total=False):
|
|
|
|
|
|
"""单条图鉴信息。"""
|
|
|
|
|
|
|
|
|
|
|
|
title: str
|
|
|
|
|
|
identifier: str
|
|
|
|
|
|
description: str
|
|
|
|
|
|
category: str
|
|
|
|
|
|
metadata: Dict[str, str]
|
|
|
|
|
|
icon: str
|
|
|
|
|
|
badge: str
|
|
|
|
|
|
links: Sequence[Dict[str, str]]
|
|
|
|
|
|
tags: Sequence[str]
|
|
|
|
|
|
details: Sequence[Union[str, Dict[str, Any]]]
|
|
|
|
|
|
group: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class GuideSection:
|
|
|
|
|
|
"""图鉴章节。"""
|
|
|
|
|
|
|
|
|
|
|
|
title: str
|
|
|
|
|
|
entries: Sequence[GuideEntry] = field(default_factory=tuple)
|
|
|
|
|
|
description: str = ""
|
|
|
|
|
|
layout: str = "grid"
|
|
|
|
|
|
section_id: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class GuidePage:
|
|
|
|
|
|
"""完整图鉴页面。"""
|
|
|
|
|
|
|
|
|
|
|
|
title: str
|
|
|
|
|
|
sections: Sequence[GuideSection] = field(default_factory=tuple)
|
|
|
|
|
|
subtitle: str = ""
|
|
|
|
|
|
metadata: Dict[str, str] = field(default_factory=dict)
|
|
|
|
|
|
related_links: Dict[str, Sequence[Dict[str, str]]] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_markdown_page(page: GuidePage) -> str:
|
|
|
|
|
|
"""保留 Markdown 渲染(备用)。"""
|
|
|
|
|
|
|
|
|
|
|
|
def _render_section(section: GuideSection) -> str:
|
|
|
|
|
|
lines: List[str] = [f"## {section.title}"]
|
|
|
|
|
|
if section.description:
|
|
|
|
|
|
lines.append(section.description)
|
|
|
|
|
|
if not section.entries:
|
|
|
|
|
|
lines.append("> 暂无内容。")
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
for entry in section.entries:
|
|
|
|
|
|
title = entry.get("title", "未命名")
|
|
|
|
|
|
identifier = entry.get("identifier")
|
|
|
|
|
|
desc = entry.get("description", "")
|
|
|
|
|
|
category = entry.get("category")
|
|
|
|
|
|
metadata = entry.get("metadata", {})
|
|
|
|
|
|
bullet = f"- **{title}**"
|
|
|
|
|
|
if identifier:
|
|
|
|
|
|
bullet += f"|`{identifier}`"
|
|
|
|
|
|
if category:
|
|
|
|
|
|
bullet += f"|{category}"
|
|
|
|
|
|
lines.append(bullet)
|
|
|
|
|
|
if desc:
|
|
|
|
|
|
lines.append(f" - {desc}")
|
|
|
|
|
|
for meta_key, meta_value in metadata.items():
|
|
|
|
|
|
lines.append(f" - {meta_key}:{meta_value}")
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
lines: List[str] = [f"# {page.title}"]
|
|
|
|
|
|
if page.subtitle:
|
|
|
|
|
|
lines.append(page.subtitle)
|
|
|
|
|
|
if page.metadata:
|
|
|
|
|
|
lines.append("")
|
|
|
|
|
|
for key, value in page.metadata.items():
|
|
|
|
|
|
lines.append(f"- {key}:{value}")
|
|
|
|
|
|
for section in page.sections:
|
|
|
|
|
|
lines.append("")
|
|
|
|
|
|
lines.append(_render_section(section))
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_html_page(page: GuidePage) -> str:
|
|
|
|
|
|
"""渲染 Apple Store 风格的 HTML 页面。"""
|
|
|
|
|
|
|
|
|
|
|
|
def escape(text: Optional[str]) -> str:
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
return (
|
|
|
|
|
|
text.replace("&", "&")
|
|
|
|
|
|
.replace("<", "<")
|
|
|
|
|
|
.replace(">", ">")
|
|
|
|
|
|
.replace('"', """)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def render_metadata(metadata: Dict[str, str]) -> str:
|
|
|
|
|
|
if not metadata:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
cards = []
|
|
|
|
|
|
for key, value in metadata.items():
|
|
|
|
|
|
cards.append(
|
|
|
|
|
|
f"""
|
|
|
|
|
|
<div class="meta-card">
|
|
|
|
|
|
<div class="meta-key">{escape(key)}</div>
|
|
|
|
|
|
<div class="meta-value">{escape(value)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
"""
|
|
|
|
|
|
)
|
|
|
|
|
|
return f'<section class="meta-grid">{"".join(cards)}</section>'
|
|
|
|
|
|
|
|
|
|
|
|
def render_links(links: Optional[Sequence[Dict[str, str]]]) -> str:
|
|
|
|
|
|
if not links:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
items = []
|
|
|
|
|
|
for link in links:
|
|
|
|
|
|
href = escape(link.get("href", "#"))
|
|
|
|
|
|
label = escape(link.get("label", "前往"))
|
|
|
|
|
|
items.append(f'<a class="entry-link" href="{href}" target="_blank">{label}</a>')
|
|
|
|
|
|
return "".join(items)
|
|
|
|
|
|
|
|
|
|
|
|
def render_tags(tags: Optional[Sequence[str]]) -> str:
|
|
|
|
|
|
if not tags:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
chips = "".join(f'<span class="entry-tag">{escape(tag)}</span>' for tag in tags)
|
|
|
|
|
|
return f'<div class="entry-tags-extra">{chips}</div>'
|
|
|
|
|
|
|
|
|
|
|
|
def render_details(details: Optional[Sequence[Union[str, Dict[str, Any]]]]) -> str:
|
|
|
|
|
|
if not details:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
blocks: List[str] = []
|
|
|
|
|
|
for detail in details:
|
|
|
|
|
|
if isinstance(detail, str):
|
|
|
|
|
|
blocks.append(f'<p class="entry-detail-paragraph">{escape(detail)}</p>')
|
|
|
|
|
|
elif isinstance(detail, dict):
|
|
|
|
|
|
kind = detail.get("type")
|
|
|
|
|
|
if kind == "list":
|
|
|
|
|
|
items = "".join(
|
|
|
|
|
|
f'<li>{escape(str(item))}</li>' for item in detail.get("items", [])
|
|
|
|
|
|
)
|
|
|
|
|
|
blocks.append(f'<ul class="entry-detail-list">{items}</ul>')
|
|
|
|
|
|
elif kind == "steps":
|
|
|
|
|
|
items = "".join(
|
|
|
|
|
|
f'<li><span class="step-index">{idx+1}</span><span>{escape(str(item))}</span></li>'
|
|
|
|
|
|
for idx, item in enumerate(detail.get("items", []))
|
|
|
|
|
|
)
|
|
|
|
|
|
blocks.append(f'<ol class="entry-detail-steps">{items}</ol>')
|
|
|
|
|
|
elif kind == "table":
|
|
|
|
|
|
rows = []
|
|
|
|
|
|
for row in detail.get("rows", []):
|
|
|
|
|
|
cols = "".join(f"<td>{escape(str(col))}</td>" for col in row)
|
|
|
|
|
|
rows.append(f"<tr>{cols}</tr>")
|
|
|
|
|
|
head = ""
|
|
|
|
|
|
headers = detail.get("headers")
|
|
|
|
|
|
if headers:
|
|
|
|
|
|
head = "".join(f"<th>{escape(str(col))}</th>" for col in headers)
|
|
|
|
|
|
head = f"<thead><tr>{head}</tr></thead>"
|
|
|
|
|
|
blocks.append(
|
|
|
|
|
|
f'<table class="entry-detail-table">{head}<tbody>{"".join(rows)}</tbody></table>'
|
|
|
|
|
|
)
|
|
|
|
|
|
if not blocks:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
return f'<div class="entry-details">{"".join(blocks)}</div>'
|
|
|
|
|
|
|
|
|
|
|
|
def render_entry(entry: GuideEntry) -> str:
|
|
|
|
|
|
icon = escape(entry.get("icon"))
|
|
|
|
|
|
badge = escape(entry.get("badge"))
|
|
|
|
|
|
title = escape(entry.get("title"))
|
|
|
|
|
|
identifier = escape(entry.get("identifier"))
|
|
|
|
|
|
description = escape(entry.get("description"))
|
|
|
|
|
|
category = escape(entry.get("category"))
|
|
|
|
|
|
metadata_items = []
|
|
|
|
|
|
for meta_key, meta_value in entry.get("metadata", {}).items():
|
|
|
|
|
|
metadata_items.append(
|
|
|
|
|
|
f'<li><span>{escape(meta_key)}</span><span>{escape(str(meta_value))}</span></li>'
|
|
|
|
|
|
)
|
|
|
|
|
|
metadata_html = ""
|
|
|
|
|
|
if metadata_items:
|
|
|
|
|
|
metadata_html = f'<ul class="entry-meta">{"".join(metadata_items)}</ul>'
|
|
|
|
|
|
identifier_html = f'<code class="entry-id">{identifier}</code>' if identifier else ""
|
|
|
|
|
|
category_html = f'<span class="entry-category">{category}</span>' if category else ""
|
|
|
|
|
|
badge_html = f'<span class="entry-badge">{badge}</span>' if badge else ""
|
|
|
|
|
|
icon_html = f'<div class="entry-icon">{icon}</div>' if icon else ""
|
|
|
|
|
|
links_html = render_links(entry.get("links"))
|
|
|
|
|
|
tags_html = render_tags(entry.get("tags"))
|
|
|
|
|
|
details_html = render_details(entry.get("details"))
|
|
|
|
|
|
group = escape(entry.get("group"))
|
|
|
|
|
|
group_attr = f' data-group="{group}"' if group else ""
|
|
|
|
|
|
return f"""
|
|
|
|
|
|
<article class="entry-card"{group_attr}>
|
|
|
|
|
|
{icon_html}
|
|
|
|
|
|
<div class="entry-content">
|
|
|
|
|
|
<header>
|
|
|
|
|
|
<h3>{title}{badge_html}</h3>
|
|
|
|
|
|
<div class="entry-tags">{identifier_html}{category_html}</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<p class="entry-desc">{description}</p>
|
|
|
|
|
|
{metadata_html}
|
|
|
|
|
|
{tags_html}
|
|
|
|
|
|
{details_html}
|
|
|
|
|
|
{links_html}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def render_section(section: GuideSection) -> str:
|
|
|
|
|
|
layout_class = "entries-grid" if section.layout == "grid" else "entries-list"
|
|
|
|
|
|
section_attr = f' id="{escape(section.section_id)}"' if section.section_id else ""
|
|
|
|
|
|
cards = "".join(render_entry(entry) for entry in section.entries)
|
|
|
|
|
|
description_html = (
|
|
|
|
|
|
f'<p class="section-desc">{escape(section.description)}</p>'
|
|
|
|
|
|
if section.description
|
|
|
|
|
|
else ""
|
|
|
|
|
|
)
|
|
|
|
|
|
if not cards:
|
|
|
|
|
|
cards = '<div class="empty-placeholder">暂无内容</div>'
|
|
|
|
|
|
return f"""
|
|
|
|
|
|
<section class="guide-section"{section_attr}>
|
|
|
|
|
|
<div class="section-header">
|
|
|
|
|
|
<h2>{escape(section.title)}</h2>
|
|
|
|
|
|
{description_html}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="{layout_class}">
|
|
|
|
|
|
{cards}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def render_related(related: Dict[str, Sequence[Dict[str, str]]]) -> str:
|
|
|
|
|
|
if not related:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
blocks: List[str] = []
|
|
|
|
|
|
for label, links in related.items():
|
|
|
|
|
|
if not links:
|
|
|
|
|
|
continue
|
|
|
|
|
|
items = "".join(
|
|
|
|
|
|
f'<a class="related-link" href="{escape(link.get("href", "#"))}">{escape(link.get("label", ""))}</a>'
|
|
|
|
|
|
for link in links
|
|
|
|
|
|
)
|
|
|
|
|
|
blocks.append(
|
|
|
|
|
|
f"""
|
|
|
|
|
|
<div class="related-block">
|
|
|
|
|
|
<div class="related-label">{escape(label)}</div>
|
|
|
|
|
|
<div class="related-items">{items}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
"""
|
|
|
|
|
|
)
|
|
|
|
|
|
if not blocks:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
return f'<section class="related-section">{"".join(blocks)}</section>'
|
|
|
|
|
|
|
|
|
|
|
|
sections_html = "".join(render_section(section) for section in page.sections)
|
|
|
|
|
|
metadata_html = render_metadata(page.metadata)
|
|
|
|
|
|
related_html = render_related(page.related_links)
|
|
|
|
|
|
subtitle_html = f'<p class="hero-subtitle">{escape(page.subtitle)}</p>' if page.subtitle else ""
|
|
|
|
|
|
|
|
|
|
|
|
return f"""
|
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8" />
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
|
|
|
<title>{escape(page.title)}</title>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
:root {{
|
|
|
|
|
|
color-scheme: dark;
|
|
|
|
|
|
--bg-primary: radial-gradient(120% 120% at 10% 10%, #222 0%, #050505 100%);
|
|
|
|
|
|
--bg-card: rgba(255, 255, 255, 0.06);
|
|
|
|
|
|
--bg-card-hover: rgba(255, 255, 255, 0.12);
|
|
|
|
|
|
--border-soft: rgba(255, 255, 255, 0.1);
|
|
|
|
|
|
--text-main: #f5f5f7;
|
|
|
|
|
|
--text-sub: rgba(245, 245, 247, 0.64);
|
|
|
|
|
|
--accent: linear-gradient(135deg, #4b7bec, #34e7e4);
|
|
|
|
|
|
--accent-strong: linear-gradient(135deg, #ff9f1a, #ff3f34);
|
|
|
|
|
|
}}
|
|
|
|
|
|
* {{
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
}}
|
|
|
|
|
|
body {{
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-family: "SF Pro Display", "SF Pro SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
|
|
|
|
background: var(--bg-primary);
|
|
|
|
|
|
color: var(--text-main);
|
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
|
padding: 0 0 64px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
a {{
|
|
|
|
|
|
color: #9fc9ff;
|
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
|
}}
|
|
|
|
|
|
a:hover {{
|
|
|
|
|
|
text-decoration: underline;
|
|
|
|
|
|
}}
|
|
|
|
|
|
header.hero {{
|
|
|
|
|
|
padding: 80px 24px 40px;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}}
|
|
|
|
|
|
header.hero::after {{
|
|
|
|
|
|
content: "";
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
background: radial-gradient(circle at 50% -20%, rgba(255, 255, 255, 0.18), transparent 55%);
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
z-index: -1;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.hero-title {{
|
|
|
|
|
|
font-size: clamp(32px, 5vw, 48px);
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
|
-webkit-background-clip: text;
|
|
|
|
|
|
color: transparent;
|
|
|
|
|
|
letter-spacing: 0.8px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.hero-subtitle {{
|
|
|
|
|
|
margin: 16px auto 0;
|
|
|
|
|
|
max-width: 640px;
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
color: var(--text-sub);
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}}
|
|
|
|
|
|
main {{
|
|
|
|
|
|
width: min(1100px, 92vw);
|
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.meta-grid {{
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 20px;
|
|
|
|
|
|
margin: 0 auto 48px;
|
|
|
|
|
|
padding: 0 8px;
|
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
|
|
|
|
}}
|
|
|
|
|
|
.related-section {{
|
|
|
|
|
|
margin: 0 auto 48px;
|
|
|
|
|
|
padding: 0 8px;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 18px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.related-block {{
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.related-label {{
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
letter-spacing: 0.5px;
|
|
|
|
|
|
color: var(--text-sub);
|
|
|
|
|
|
}}
|
|
|
|
|
|
.related-items {{
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.related-link {{
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 6px 14px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.08);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
letter-spacing: 0.4px;
|
|
|
|
|
|
transition: background 0.2s ease;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.related-link:hover {{
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.16);
|
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.meta-card {{
|
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
|
padding: 22px;
|
|
|
|
|
|
border: 1px solid var(--border-soft);
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.04);
|
|
|
|
|
|
backdrop-filter: blur(16px);
|
|
|
|
|
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
|
|
|
|
|
|
}}
|
|
|
|
|
|
.meta-key {{
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
letter-spacing: 1px;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
color: var(--text-sub);
|
|
|
|
|
|
}}
|
|
|
|
|
|
.meta-value {{
|
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
|
font-size: 22px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.guide-section {{
|
|
|
|
|
|
margin: 0 auto 56px;
|
|
|
|
|
|
padding: 0 8px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.section-header {{
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
margin-bottom: 28px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.section-header h2 {{
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: clamp(26px, 3vw, 32px);
|
|
|
|
|
|
letter-spacing: 0.5px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.section-desc {{
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
color: var(--text-sub);
|
|
|
|
|
|
max-width: 640px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entries-grid {{
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 20px;
|
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entries-list {{
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 18px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-card {{
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 18px;
|
|
|
|
|
|
border-radius: 20px;
|
|
|
|
|
|
padding: 24px;
|
|
|
|
|
|
border: 1px solid transparent;
|
|
|
|
|
|
background: var(--bg-card);
|
|
|
|
|
|
transition: border 0.2s ease, background 0.2s ease, transform 0.2s ease;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-card:hover {{
|
|
|
|
|
|
background: var(--bg-card-hover);
|
|
|
|
|
|
border-color: rgba(255, 255, 255, 0.18);
|
|
|
|
|
|
transform: translateY(-4px);
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-icon {{
|
|
|
|
|
|
font-size: 36px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-content {{
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-content header {{
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-content h3 {{
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-badge {{
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
padding: 4px 10px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
letter-spacing: 0.5px;
|
|
|
|
|
|
background: var(--accent-strong);
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-tags {{
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-tags-extra {{
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-id {{
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.1);
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
padding: 4px 10px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
letter-spacing: 0.5px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-category {{
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--text-sub);
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-desc {{
|
|
|
|
|
|
margin: 0 0 12px;
|
|
|
|
|
|
color: var(--text-sub);
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-meta {{
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: var(--text-sub);
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-meta li {{
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-link {{
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-tag {{
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 4px 12px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.08);
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
letter-spacing: 0.4px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-details {{
|
|
|
|
|
|
margin-top: 14px;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-detail-paragraph {{
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: var(--text-sub);
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-detail-list, .entry-detail-steps {{
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding-left: 20px;
|
|
|
|
|
|
color: var(--text-sub);
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-detail-steps {{
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
padding-left: 0;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-detail-steps li {{
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: 28px 1fr;
|
|
|
|
|
|
align-items: start;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
margin-bottom: 6px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-detail-steps .step-index {{
|
|
|
|
|
|
width: 28px;
|
|
|
|
|
|
height: 28px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.12);
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-detail-table {{
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-detail-table th,
|
|
|
|
|
|
.entry-detail-table td {{
|
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
|
|
|
|
padding: 8px 12px;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-detail-table th {{
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.08);
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-detail-table tr:nth-child(even) {{
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.04);
|
|
|
|
|
|
}}
|
|
|
|
|
|
.empty-placeholder {{
|
|
|
|
|
|
padding: 40px;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
border-radius: 20px;
|
|
|
|
|
|
border: 1px dashed rgba(255, 255, 255, 0.2);
|
|
|
|
|
|
color: var(--text-sub);
|
|
|
|
|
|
}}
|
|
|
|
|
|
@media (max-width: 680px) {{
|
|
|
|
|
|
.entry-card {{
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.entry-content h3 {{
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.meta-grid {{
|
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
|
}}
|
|
|
|
|
|
}}
|
|
|
|
|
|
@media (max-width: 480px) {{
|
|
|
|
|
|
.meta-grid {{
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}}
|
|
|
|
|
|
}}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<header class="hero">
|
|
|
|
|
|
<h1 class="hero-title">{escape(page.title)}</h1>
|
|
|
|
|
|
{subtitle_html}
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<main>
|
|
|
|
|
|
{metadata_html}
|
|
|
|
|
|
{related_html}
|
|
|
|
|
|
{sections_html}
|
|
|
|
|
|
</main>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-11-06 11:01:13 +08:00
|
|
|
|
class MessageSender:
|
|
|
|
|
|
"""消息发送器"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, webhook_url: str):
|
|
|
|
|
|
"""初始化消息发送器
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
webhook_url: Webhook URL
|
|
|
|
|
|
"""
|
|
|
|
|
|
self.webhook_url = webhook_url
|
|
|
|
|
|
self.client: Optional[httpx.AsyncClient] = None
|
|
|
|
|
|
|
|
|
|
|
|
async def _get_client(self) -> httpx.AsyncClient:
|
|
|
|
|
|
"""获取HTTP客户端(懒加载)"""
|
|
|
|
|
|
if self.client is None:
|
|
|
|
|
|
self.client = httpx.AsyncClient(timeout=10.0)
|
|
|
|
|
|
return self.client
|
|
|
|
|
|
|
|
|
|
|
|
async def send_message(self, message: Dict[str, Any]) -> bool:
|
|
|
|
|
|
"""发送消息到WPS
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
message: 消息字典
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
是否发送成功
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
client = await self._get_client()
|
|
|
|
|
|
response = await client.post(self.webhook_url, json=message)
|
|
|
|
|
|
|
|
|
|
|
|
if response.status_code == 200:
|
2025-11-07 23:48:10 +08:00
|
|
|
|
logger.Log("Info", f"消息发送成功: {message.get('msgtype')}")
|
2025-11-06 11:01:13 +08:00
|
|
|
|
return True
|
|
|
|
|
|
else:
|
2025-11-07 23:48:10 +08:00
|
|
|
|
logger.Log("Error", f"消息发送失败: status={response.status_code}, body={response.text}")
|
2025-11-06 11:01:13 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2025-11-07 23:48:10 +08:00
|
|
|
|
logger.Log("Error", f"发送消息异常: {e}")
|
2025-11-06 11:01:13 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
async def send_text(self, content: str, at_user_id: Optional[int] = None) -> bool:
|
|
|
|
|
|
"""发送文本消息
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
content: 文本内容
|
|
|
|
|
|
at_user_id: @用户ID(可选)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
是否发送成功
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 如果需要@人
|
|
|
|
|
|
if at_user_id:
|
|
|
|
|
|
content = f'<at user_id="{at_user_id}"></at> {content}'
|
|
|
|
|
|
|
|
|
|
|
|
message = {
|
|
|
|
|
|
"msgtype": "text",
|
|
|
|
|
|
"text": {
|
|
|
|
|
|
"content": content
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return await self.send_message(message)
|
|
|
|
|
|
|
|
|
|
|
|
async def send_markdown(self, text: str) -> bool:
|
|
|
|
|
|
"""发送Markdown消息
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
text: Markdown文本
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
是否发送成功
|
|
|
|
|
|
"""
|
|
|
|
|
|
message = {
|
|
|
|
|
|
"msgtype": "markdown",
|
|
|
|
|
|
"markdown": {
|
|
|
|
|
|
"text": text
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return await self.send_message(message)
|
|
|
|
|
|
|
|
|
|
|
|
async def send_link(self, title: str, text: str,
|
|
|
|
|
|
message_url: str = "", btn_title: str = "查看详情") -> bool:
|
|
|
|
|
|
"""发送链接消息
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
title: 标题
|
|
|
|
|
|
text: 文本内容
|
|
|
|
|
|
message_url: 跳转URL
|
|
|
|
|
|
btn_title: 按钮文字
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
是否发送成功
|
|
|
|
|
|
"""
|
|
|
|
|
|
message = {
|
|
|
|
|
|
"msgtype": "link",
|
|
|
|
|
|
"link": {
|
|
|
|
|
|
"title": title,
|
|
|
|
|
|
"text": text,
|
|
|
|
|
|
"messageUrl": message_url,
|
|
|
|
|
|
"btnTitle": btn_title
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return await self.send_message(message)
|
|
|
|
|
|
|
|
|
|
|
|
async def close(self):
|
|
|
|
|
|
"""关闭HTTP客户端"""
|
|
|
|
|
|
if self.client:
|
|
|
|
|
|
await self.client.aclose()
|
|
|
|
|
|
self.client = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BasicWPSInterface(PluginInterface):
|
2025-11-13 14:23:31 +08:00
|
|
|
|
user_id_to_username: Optional[Callable[[int], str]] = None
|
|
|
|
|
|
|
2025-11-06 11:01:13 +08:00
|
|
|
|
@override
|
|
|
|
|
|
def is_enable_plugin(self) -> bool:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def get_webhook_url(self, message: str, user_id: int) -> str:
|
|
|
|
|
|
'''
|
|
|
|
|
|
根据消息和用户ID获取Webhook URL, 返回空字符串表示不需要回复消息
|
|
|
|
|
|
Args:
|
|
|
|
|
|
message: 消息内容
|
|
|
|
|
|
user_id: 用户ID
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Webhook URL
|
|
|
|
|
|
'''
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
def get_webhook_request(self, data:Any|None) -> None:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def get_message_sender(self, webhook_url: str) -> MessageSender:
|
|
|
|
|
|
return MessageSender(webhook_url)
|
|
|
|
|
|
|
|
|
|
|
|
def get_message_sender_type(self) -> Literal["text", "markdown", "link"]:
|
|
|
|
|
|
return "markdown"
|
|
|
|
|
|
|
|
|
|
|
|
def get_message_sender_function(self, webhook_url: str, type: Literal["text", "markdown", "link"]) -> Coroutine[Any, Any, bool]:
|
|
|
|
|
|
if type == "text":
|
|
|
|
|
|
return self.get_message_sender(webhook_url).send_text
|
|
|
|
|
|
elif type == "markdown":
|
|
|
|
|
|
return self.get_message_sender(webhook_url).send_markdown
|
|
|
|
|
|
elif type == "link":
|
|
|
|
|
|
return self.get_message_sender(webhook_url).send_link
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise ValueError(f"Invalid message sender type: {type}")
|
|
|
|
|
|
|
2025-11-06 16:26:07 +08:00
|
|
|
|
def parse_message_after_at(self, message: str) -> str:
|
2025-11-10 09:55:43 +08:00
|
|
|
|
'''
|
|
|
|
|
|
已过时
|
|
|
|
|
|
'''
|
2025-11-06 16:26:07 +08:00
|
|
|
|
return message
|
|
|
|
|
|
|
|
|
|
|
|
async def send_markdown_message(self, message: str, chat_id: int, user_id: int) -> str|None:
|
|
|
|
|
|
webhook_url = self.get_webhook_url(message, user_id)
|
|
|
|
|
|
if get_internal_debug():
|
2025-11-07 23:48:10 +08:00
|
|
|
|
logger.Log("Info", f"Webhook URL: {webhook_url}, Message: {LimitStringLength(message)}, User ID: {user_id}")
|
2025-11-06 16:26:07 +08:00
|
|
|
|
if webhook_url == "" or webhook_url == None:
|
|
|
|
|
|
return None
|
2025-11-06 11:46:54 +08:00
|
|
|
|
|
2025-11-13 14:23:31 +08:00
|
|
|
|
username: str = self.user_id_to_username(user_id) if self.user_id_to_username else ""
|
|
|
|
|
|
send_message = f"""## <at user_id="{user_id}">{username}</at>
|
2025-11-08 15:34:44 +08:00
|
|
|
|
---
|
|
|
|
|
|
{message}
|
2025-11-13 14:23:31 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
result = await self.get_message_sender_function(webhook_url, self.get_message_sender_type())(send_message)
|
2025-11-07 15:53:09 +08:00
|
|
|
|
if get_internal_verbose():
|
2025-11-07 23:48:10 +08:00
|
|
|
|
logger.Log("Info", f"Webhook URL: {webhook_url}, Message: {LimitStringLength(message)}, Result: {result}")
|
2025-11-06 11:01:13 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
2025-11-06 16:26:07 +08:00
|
|
|
|
@override
|
|
|
|
|
|
async def callback(self, message: str, chat_id: int, user_id: int) -> str|None:
|
|
|
|
|
|
message = self.parse_message_after_at(message)
|
|
|
|
|
|
if message == "":
|
|
|
|
|
|
return None
|
|
|
|
|
|
return await self.send_markdown_message(message, chat_id, user_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-11-06 11:01:13 +08:00
|
|
|
|
class WPSAPI(BasicWPSInterface):
|
2025-11-12 22:58:36 +08:00
|
|
|
|
"""核心 WPS 插件基类,提供图鉴模板设施。"""
|
|
|
|
|
|
|
|
|
|
|
|
guide_section_labels: Dict[str, str] = {
|
|
|
|
|
|
"commands": "指令一览",
|
|
|
|
|
|
"items": "物品与资源",
|
|
|
|
|
|
"recipes": "配方与合成",
|
|
|
|
|
|
"guides": "系统指引",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def get_guide_title(self) -> str:
|
|
|
|
|
|
return self.__class__.__name__
|
|
|
|
|
|
|
|
|
|
|
|
def get_guide_subtitle(self) -> str:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
def get_guide_metadata(self) -> Dict[str, str]:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
def collect_command_entries(self) -> Sequence[GuideEntry]:
|
|
|
|
|
|
return ()
|
|
|
|
|
|
|
|
|
|
|
|
def collect_item_entries(self) -> Sequence[GuideEntry]:
|
|
|
|
|
|
return ()
|
|
|
|
|
|
|
|
|
|
|
|
def collect_recipe_entries(self) -> Sequence[GuideEntry]:
|
|
|
|
|
|
return ()
|
|
|
|
|
|
|
|
|
|
|
|
def collect_guide_entries(self) -> Sequence[GuideEntry]:
|
|
|
|
|
|
return ()
|
|
|
|
|
|
|
|
|
|
|
|
def collect_additional_sections(self) -> Sequence[GuideSection]:
|
|
|
|
|
|
return ()
|
|
|
|
|
|
|
|
|
|
|
|
def collect_guide_sections(self) -> Sequence[GuideSection]:
|
|
|
|
|
|
sections: List[GuideSection] = []
|
|
|
|
|
|
|
|
|
|
|
|
command_entries = tuple(self.collect_command_entries())
|
|
|
|
|
|
if command_entries:
|
|
|
|
|
|
sections.append(
|
|
|
|
|
|
GuideSection(
|
|
|
|
|
|
title=self.guide_section_labels["commands"],
|
|
|
|
|
|
entries=command_entries,
|
|
|
|
|
|
layout="list",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
item_entries = tuple(self.collect_item_entries())
|
|
|
|
|
|
if item_entries:
|
|
|
|
|
|
sections.append(
|
|
|
|
|
|
GuideSection(
|
|
|
|
|
|
title=self.guide_section_labels["items"],
|
|
|
|
|
|
entries=item_entries,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
recipe_entries = tuple(self.collect_recipe_entries())
|
|
|
|
|
|
if recipe_entries:
|
|
|
|
|
|
sections.append(
|
|
|
|
|
|
GuideSection(
|
|
|
|
|
|
title=self.guide_section_labels["recipes"],
|
|
|
|
|
|
entries=recipe_entries,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
guide_entries = tuple(self.collect_guide_entries())
|
|
|
|
|
|
if guide_entries:
|
|
|
|
|
|
sections.append(
|
|
|
|
|
|
GuideSection(
|
|
|
|
|
|
title=self.guide_section_labels["guides"],
|
|
|
|
|
|
entries=guide_entries,
|
|
|
|
|
|
layout="list",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
additional_sections = tuple(self.collect_additional_sections())
|
|
|
|
|
|
if additional_sections:
|
|
|
|
|
|
sections.extend(additional_sections)
|
|
|
|
|
|
|
|
|
|
|
|
return tuple(sections)
|
|
|
|
|
|
|
|
|
|
|
|
def build_guide_page(self) -> GuidePage:
|
|
|
|
|
|
metadata: Dict[str, str] = {}
|
|
|
|
|
|
for key, value in self.get_guide_metadata().items():
|
|
|
|
|
|
metadata[key] = str(value)
|
|
|
|
|
|
|
|
|
|
|
|
related = self.get_related_links()
|
|
|
|
|
|
|
|
|
|
|
|
return GuidePage(
|
|
|
|
|
|
title=self.get_guide_title(),
|
|
|
|
|
|
subtitle=self.get_guide_subtitle(),
|
|
|
|
|
|
sections=self.collect_guide_sections(),
|
|
|
|
|
|
metadata=metadata,
|
|
|
|
|
|
related_links=related,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def render_guide_page(self, page: GuidePage) -> str:
|
|
|
|
|
|
return render_html_page(page)
|
|
|
|
|
|
|
|
|
|
|
|
def render_guide(self) -> str:
|
|
|
|
|
|
return self.render_guide_page(self.build_guide_page())
|
|
|
|
|
|
|
|
|
|
|
|
def get_guide_response(self, content: str) -> HTMLResponse:
|
|
|
|
|
|
return HTMLResponse(content)
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
def generate_router_illustrated_guide(self):
|
|
|
|
|
|
async def handler() -> HTMLResponse:
|
|
|
|
|
|
return self.get_guide_response(self.render_guide())
|
|
|
|
|
|
|
|
|
|
|
|
return handler
|
|
|
|
|
|
|
|
|
|
|
|
def get_related_links(self) -> Dict[str, Sequence[Dict[str, str]]]:
|
|
|
|
|
|
links: Dict[str, Sequence[Dict[str, str]]] = {}
|
|
|
|
|
|
|
|
|
|
|
|
parents = []
|
|
|
|
|
|
for base in self.__class__.__mro__[1:]:
|
|
|
|
|
|
if not issubclass(base, WPSAPI):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if base.__module__.startswith("Plugins."):
|
|
|
|
|
|
parents.append(base)
|
|
|
|
|
|
if base is WPSAPI:
|
|
|
|
|
|
break
|
|
|
|
|
|
if parents:
|
|
|
|
|
|
parents_links = [self._build_class_link(cls) for cls in reversed(parents)]
|
|
|
|
|
|
links["父类链"] = tuple(filter(None, parents_links))
|
|
|
|
|
|
|
|
|
|
|
|
child_links = [self._build_class_link(child) for child in self._iter_subclasses(self.__class__)]
|
|
|
|
|
|
child_links = [link for link in child_links if link]
|
|
|
|
|
|
if child_links:
|
|
|
|
|
|
links["子类"] = tuple(child_links)
|
|
|
|
|
|
|
|
|
|
|
|
return links
|
|
|
|
|
|
|
|
|
|
|
|
def _build_class_link(self, cls: type) -> Optional[Dict[str, str]]:
|
|
|
|
|
|
if not hasattr(cls, "__module__") or not cls.__module__.startswith("Plugins."):
|
|
|
|
|
|
return None
|
|
|
|
|
|
path = f"/api/{cls.__name__}"
|
|
|
|
|
|
return {
|
|
|
|
|
|
"label": cls.__name__,
|
|
|
|
|
|
"href": path,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _iter_subclasses(self, cls: type) -> List[type]:
|
|
|
|
|
|
collected: List[type] = []
|
|
|
|
|
|
for subclass in cls.__subclasses__():
|
|
|
|
|
|
if not issubclass(subclass, WPSAPI):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not subclass.__module__.startswith("Plugins."):
|
|
|
|
|
|
continue
|
|
|
|
|
|
collected.append(subclass)
|
|
|
|
|
|
collected.extend(self._iter_subclasses(subclass))
|
|
|
|
|
|
return collected
|
|
|
|
|
|
|
|
|
|
|
|
def get_guide_subtitle(self) -> str:
|
|
|
|
|
|
return "核心 Webhook 转发插件"
|
|
|
|
|
|
|
|
|
|
|
|
def get_guide_metadata(self) -> Dict[str, str]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"Webhook 状态": "已配置" if MAIN_WEBHOOK_URL else "未配置",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def collect_command_entries(self) -> Sequence[GuideEntry]:
|
|
|
|
|
|
return (
|
|
|
|
|
|
{
|
|
|
|
|
|
"title": "say",
|
|
|
|
|
|
"identifier": "say",
|
|
|
|
|
|
"description": "将后续消息内容以 Markdown 形式发送到主 Webhook。",
|
|
|
|
|
|
"metadata": {"别名": "说"},
|
|
|
|
|
|
"icon": "🗣️",
|
|
|
|
|
|
"badge": "核心",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def collect_guide_entries(self) -> Sequence[GuideEntry]:
|
|
|
|
|
|
return (
|
|
|
|
|
|
{
|
|
|
|
|
|
"title": "Webhook 绑定",
|
|
|
|
|
|
"description": (
|
|
|
|
|
|
"在项目配置中设置 `main_webhook_url` 后插件自动启用,"
|
|
|
|
|
|
"所有注册的命令将调用 `send_markdown_message` 发送富文本。"
|
|
|
|
|
|
),
|
|
|
|
|
|
"icon": "🔗",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"title": "消息格式",
|
|
|
|
|
|
"description": (
|
|
|
|
|
|
"默认使用 Markdown 模式发送,支持 `聊天ID` 与 `用户ID` 的 @ 提醒。"
|
|
|
|
|
|
),
|
|
|
|
|
|
"icon": "📝",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-11-06 11:01:13 +08:00
|
|
|
|
@override
|
|
|
|
|
|
def is_enable_plugin(self) -> bool:
|
|
|
|
|
|
if MAIN_WEBHOOK_URL == "":
|
2025-11-07 23:48:10 +08:00
|
|
|
|
logger.Log("Error", f"{ConsoleFrontColor.RED}WPSAPI未配置主Webhook URL{ConsoleFrontColor.RESET}")
|
2025-11-06 11:01:13 +08:00
|
|
|
|
return MAIN_WEBHOOK_URL != ""
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
def get_webhook_url(self, message: str, user_id: int) -> str:
|
|
|
|
|
|
return MAIN_WEBHOOK_URL
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
def get_webhook_request(self, data:Any|None) -> None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
def wake_up(self) -> None:
|
2025-11-07 23:48:10 +08:00
|
|
|
|
logger.Log("Info", f"{ConsoleFrontColor.GREEN}WPSAPI核心插件已加载{ConsoleFrontColor.RESET}")
|
2025-11-06 11:01:13 +08:00
|
|
|
|
self.register_plugin("say")
|
2025-11-06 11:46:54 +08:00
|
|
|
|
self.register_plugin("说")
|
2025-11-06 11:01:13 +08:00
|
|
|
|
|
2025-11-10 16:34:58 +08:00
|
|
|
|
class WPSAPIHelp(WPSAPI):
|
|
|
|
|
|
@override
|
|
|
|
|
|
def dependencies(self) -> List[Type]:
|
|
|
|
|
|
return [WPSAPI]
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
def is_enable_plugin(self) -> bool:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
async def callback(self, message: str, chat_id: int, user_id: int) -> str|None:
|
2025-11-10 21:44:28 +08:00
|
|
|
|
mapper: Dict[str, List[str]] = {}
|
|
|
|
|
|
for key in PluginInterface.plugin_instances.keys():
|
|
|
|
|
|
plugin = PluginInterface.plugin_instances.get(key)
|
|
|
|
|
|
if plugin:
|
|
|
|
|
|
if plugin.__class__.__name__ not in mapper:
|
|
|
|
|
|
mapper[plugin.__class__.__name__] = []
|
|
|
|
|
|
mapper[plugin.__class__.__name__].append(key)
|
|
|
|
|
|
desc = ""
|
|
|
|
|
|
for plugin_name, keys in mapper.items():
|
|
|
|
|
|
desc += f"- {plugin_name}: {", ".join(keys)}\n"
|
|
|
|
|
|
return await self.send_markdown_message(f"""# 指令入口
|
|
|
|
|
|
{desc}
|
|
|
|
|
|
""", chat_id, user_id)
|
2025-11-10 16:34:58 +08:00
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
def wake_up(self) -> None:
|
|
|
|
|
|
logger.Log("Info", f"{ConsoleFrontColor.GREEN}WPSAPIHelp 插件已加载{ConsoleFrontColor.RESET}")
|
|
|
|
|
|
self.register_plugin("help")
|
|
|
|
|
|
self.register_plugin("帮助")
|
|
|
|
|
|
|
2025-11-07 23:48:10 +08:00
|
|
|
|
logger.SaveProperties()
|