1078 lines
35 KiB
Python
1078 lines
35 KiB
Python
from PWF.Convention.Runtime.Config import *
|
||
from PWF.CoreModules.plugin_interface import PluginInterface
|
||
from PWF.CoreModules.flags import *
|
||
from PWF.Convention.Runtime.Architecture import Architecture
|
||
from PWF.Convention.Runtime.GlobalConfig import ProjectConfig
|
||
from PWF.Convention.Runtime.Web import ToolURL
|
||
from PWF.Convention.Runtime.String import LimitStringLength
|
||
from fastapi.responses import HTMLResponse
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Dict, Iterable, List, Optional, Sequence, TypedDict, override, Union
|
||
import httpx
|
||
import re
|
||
|
||
logger: ProjectConfig = Architecture.Get(ProjectConfig)
|
||
MAIN_WEBHOOK_URL = logger.FindItem("main_webhook_url", "")
|
||
logger.SaveProperties()
|
||
|
||
|
||
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>
|
||
"""
|
||
|
||
|
||
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:
|
||
logger.Log("Info", f"消息发送成功: {message.get('msgtype')}")
|
||
return True
|
||
else:
|
||
logger.Log("Error", f"消息发送失败: status={response.status_code}, body={response.text}")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.Log("Error", f"发送消息异常: {e}")
|
||
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):
|
||
user_id_to_username: Optional[Callable[[int], str]] = None
|
||
|
||
@override
|
||
def is_enable_plugin(self) -> bool:
|
||
return False
|
||
|
||
def get_webhook_url(self, message: str, chat_id: int, user_id: int) -> str:
|
||
'''
|
||
根据消息和用户ID获取Webhook URL, 返回空字符串表示不需要回复消息
|
||
Args:
|
||
message: 消息内容
|
||
chat_id: 聊天ID
|
||
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}")
|
||
|
||
def parse_message_after_at(self, message: str) -> str:
|
||
'''
|
||
已过时
|
||
'''
|
||
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, chat_id, user_id)
|
||
if get_internal_debug():
|
||
logger.Log("Info", f"Webhook URL: {webhook_url}, Message: {LimitStringLength(message)}, Chat ID: {chat_id}, User ID: {user_id}")
|
||
if webhook_url == "" or webhook_url == None:
|
||
return None
|
||
|
||
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>
|
||
---
|
||
{message}
|
||
"""
|
||
|
||
result = await self.get_message_sender_function(webhook_url, self.get_message_sender_type())(send_message)
|
||
if get_internal_verbose():
|
||
logger.Log("Info", f"Webhook URL: {webhook_url}, Message: {LimitStringLength(message)}, Result: {result}")
|
||
return None
|
||
|
||
@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)
|
||
|
||
|
||
class WPSAPI(BasicWPSInterface):
|
||
"""核心 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": "📝",
|
||
},
|
||
)
|
||
|
||
@override
|
||
def is_enable_plugin(self) -> bool:
|
||
if MAIN_WEBHOOK_URL == "":
|
||
logger.Log("Error", f"{ConsoleFrontColor.RED}WPSAPI未配置主Webhook URL{ConsoleFrontColor.RESET}")
|
||
return MAIN_WEBHOOK_URL != ""
|
||
|
||
def get_main_webhook_url(self) -> str:
|
||
return MAIN_WEBHOOK_URL
|
||
|
||
@override
|
||
def get_webhook_url(self, message: str, chat_id: int, user_id: int) -> str:
|
||
webhook_url = ProjectConfig().GetFile(f"webhook_url/{chat_id}",True).LoadAsText()
|
||
if webhook_url == "":
|
||
webhook_url = self.get_main_webhook_url()
|
||
return webhook_url
|
||
|
||
@override
|
||
def get_webhook_request(self, data:Any|None) -> None:
|
||
return None
|
||
|
||
@override
|
||
def wake_up(self) -> None:
|
||
logger.Log("Info", f"{ConsoleFrontColor.GREEN}WPSAPI核心插件已加载{ConsoleFrontColor.RESET}")
|
||
self.register_plugin("say")
|
||
self.register_plugin("说")
|
||
|
||
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:
|
||
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)
|
||
|
||
@override
|
||
def wake_up(self) -> None:
|
||
logger.Log("Info", f"{ConsoleFrontColor.GREEN}WPSAPIHelp 插件已加载{ConsoleFrontColor.RESET}")
|
||
self.register_plugin("help")
|
||
self.register_plugin("帮助")
|
||
|
||
class WPSAPIWebhook(WPSAPI):
|
||
@override
|
||
def dependencies(self) -> List[Type]:
|
||
return [WPSAPI]
|
||
|
||
@override
|
||
def is_enable_plugin(self) -> bool:
|
||
return True
|
||
|
||
@override
|
||
def wake_up(self) -> None:
|
||
logger.Log("Info", f"{ConsoleFrontColor.GREEN}WPSAPIWebhook 插件已加载{ConsoleFrontColor.RESET}")
|
||
self.register_plugin("chat_url_register")
|
||
self.register_plugin("会话注册")
|
||
|
||
@override
|
||
async def callback(self, message: str, chat_id: int, user_id: int) -> str|None:
|
||
ProjectConfig().GetFile(f"webhook_url/{chat_id}",True).SaveAsText(message)
|
||
return await self.send_markdown_message(f"会话注册成功", chat_id, user_id)
|
||
|
||
logger.SaveProperties() |