添加 Theme 功能

This commit is contained in:
Azide
2023-10-13 23:18:39 +08:00
committed by felinae98
parent 6aaec45d15
commit f202071e9f
57 changed files with 1709 additions and 802 deletions
+1 -3
View File
@@ -1,3 +1 @@
from .post import Post
__all__ = ["Post"]
from .post import Post as Post
+41 -42
View File
@@ -1,52 +1,51 @@
from functools import reduce
from abc import abstractmethod
from dataclasses import field, dataclass
from dataclasses import dataclass
from abc import ABC, abstractmethod
from nonebot_plugin_saa import MessageFactory, MessageSegmentFactory
from nonebot_plugin_saa import Text, MessageFactory, MessageSegmentFactory
from ..utils import text_to_image
from ..plugin_config import plugin_config
@dataclass
class BasePost:
@abstractmethod
async def generate_text_messages(self) -> list[MessageSegmentFactory]:
"Generate MessageFactory list from this instance"
...
@abstractmethod
async def generate_pic_messages(self) -> list[MessageSegmentFactory]:
"Generate MessageFactory list from this instance with `use_pic`"
...
@dataclass
class OptionalMixin:
# Because of https://stackoverflow.com/questions/51575931/class-inheritance-in-python-3-7-dataclasses
override_use_pic: bool | None = None
@dataclass(kw_only=True)
class AbstractPost(ABC):
compress: bool = False
extra_msg: list[MessageFactory] = field(default_factory=list)
extra_msg: list[MessageFactory] | None = None
def _use_pic(self):
if self.override_use_pic is not None:
return self.override_use_pic
return plugin_config.bison_use_pic
@abstractmethod
async def generate(self) -> list[MessageSegmentFactory]:
"Generate MessageSegmentFactory list from this instance"
...
@dataclass
class AbstractPost(OptionalMixin, BasePost):
async def generate_messages(self) -> list[MessageFactory]:
if self._use_pic():
msg_segments = await self.generate_pic_messages()
else:
msg_segments = await self.generate_text_messages()
if msg_segments:
if self.compress:
msgs = [reduce(lambda x, y: x.append(y), msg_segments, MessageFactory([]))]
else:
msgs = [MessageFactory([msg_segment]) for msg_segment in msg_segments]
else:
msgs = []
msgs.extend(self.extra_msg)
"really call to generate messages"
msg_segments = await self.generate()
msg_segments = await self.message_segments_process(msg_segments)
msgs = await self.message_process(msg_segments)
return msgs
async def message_segments_process(self, msg_segments: list[MessageSegmentFactory]) -> list[MessageSegmentFactory]:
"generate message segments and process them"
async def convert(msg: MessageSegmentFactory) -> MessageSegmentFactory:
if isinstance(msg, Text):
return await text_to_image(msg)
else:
return msg
if plugin_config.bison_use_pic:
return [await convert(msg) for msg in msg_segments]
return msg_segments
async def message_process(self, msg_segments: list[MessageSegmentFactory]) -> list[MessageFactory]:
"generate messages and process them"
if self.compress:
msgs = [MessageFactory(msg_segments)]
else:
msgs = [MessageFactory(msg_segment) for msg_segment in msg_segments]
if self.extra_msg:
msgs.extend(self.extra_msg)
return msgs
-68
View File
@@ -1,68 +0,0 @@
from dataclasses import field, dataclass
from nonebot.log import logger
from nonebot.plugin import require
from nonebot.adapters.onebot.v11 import MessageSegment
from nonebot_plugin_saa import Text, Image, MessageSegmentFactory
from .abstract_post import BasePost, AbstractPost
@dataclass
class _CustomPost(BasePost):
ms_factories: list[MessageSegmentFactory] = field(default_factory=list)
css_path: str = "" # 模板文件所用css路径
async def generate_text_messages(self) -> list[MessageSegmentFactory]:
return self.ms_factories
async def generate_pic_messages(self) -> list[MessageSegmentFactory]:
require("nonebot_plugin_htmlrender")
from nonebot_plugin_htmlrender import md_to_pic
pic_bytes = await md_to_pic(md=self._generate_md(), css_path=self.css_path)
return [Image(pic_bytes)]
def _generate_md(self) -> str:
md = ""
for message_segment in self.ms_factories:
match message_segment:
case Text(data={"text": text}):
md += f"{text}<br>"
case Image(data={"image": image}):
# use onebot v11 to convert image into url
ob11_image = MessageSegment.image(image)
md += "![Image]({})\n".format(ob11_image.data["file"])
case _:
logger.warning(f"custom_post不支持处理类型:{type(message_segment)}")
continue
return md
@dataclass
class CustomPost(_CustomPost, AbstractPost):
"""基于 markdown 语法的,自由度较高的推送内容格式
简介:
支持处理text/image两种MessageSegmentFactory,
通过将text/image转换成对应的markdown语法以生成markdown文本。
理论上text类型中可以直接使用markdown语法,例如`##第一章`。
但会导致不启用`override_use_pic`时, 发送不会被渲染的纯文本消息。
图片渲染最终由htmlrender执行。
注意:
每一个MessageSegmentFactory元素都会被解释为单独的一行
可选参数:
`override_use_pic`:是否覆盖`bison_use_pic`全局配置
`compress`:将所有消息压缩为一条进行发送
`extra_msg`:需要附带发送的额外消息
成员函数:
`generate_text_messages()`:负责生成文本消息
`generate_pic_messages()`:负责生成图片消息
"""
pass
+72 -139
View File
@@ -1,151 +1,84 @@
from io import BytesIO
from dataclasses import field, dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from dataclasses import dataclass
from PIL import Image
from nonebot.log import logger
import nonebot_plugin_saa as saa
from nonebot_plugin_saa import MessageSegmentFactory
from ..utils import parse_text, http_client
from .abstract_post import BasePost, AbstractPost
from ..theme import theme_manager
from .abstract_post import AbstractPost
from ..plugin_config import plugin_config
from ..theme.types import ThemeRenderError, ThemeRenderUnsupportError
if TYPE_CHECKING:
from ..platform import Platform
@dataclass
class _Post(BasePost):
target_type: str
text: str
class Post(AbstractPost):
"""最通用的Post,理论上包含所有常用的数据
对于更特殊的需要,可以考虑另外实现一个Post
"""
platform: "Platform"
"""来源平台"""
content: str
"""文本内容"""
title: str | None = None
"""标题"""
images: list[str | bytes | Path | BytesIO] | None = None
"""图片列表"""
timestamp: int | None = None
"""发布/获取时间戳"""
url: str | None = None
target_name: str | None = None
pics: list[str | bytes] = field(default_factory=list)
"""来源链接"""
avatar: str | bytes | Path | BytesIO | None = None
"""发布者头像"""
nickname: str | None = None
"""发布者昵称"""
description: str | None = None
"""发布者个性签名等"""
repost: "Post | None" = None
"""转发的Post"""
_message: list[MessageSegmentFactory] | None = None
_pic_message: list[MessageSegmentFactory] | None = None
def get_config_theme(self) -> str | None:
"""获取用户指定的theme"""
return plugin_config.bison_platform_theme.get(self.platform.platform_name)
async def _pic_url_to_image(self, data: str | bytes) -> Image.Image:
pic_buffer = BytesIO()
if isinstance(data, str):
async with http_client() as client:
res = await client.get(data)
pic_buffer.write(res.content)
def get_priority_themes(self) -> list[str]:
"""获取渲染所使用的theme名列表,按照优先级排序"""
themes_by_priority: list[str] = []
# 最先使用用户指定的theme
if user_theme := self.get_config_theme():
themes_by_priority.append(user_theme)
# 然后使用平台默认的theme
if self.platform.default_theme not in themes_by_priority:
themes_by_priority.append(self.platform.default_theme)
# 最后使用最基础的theme
if "basic" not in themes_by_priority:
themes_by_priority.append("basic")
return themes_by_priority
async def generate(self) -> list[MessageSegmentFactory]:
"""生成消息"""
themes = self.get_priority_themes()
for theme_name in themes:
if theme := theme_manager[theme_name]:
try:
logger.debug(f"Try to render Post with theme {theme_name}")
return await theme.do_render(self)
except ThemeRenderUnsupportError as e:
logger.warning(
f"Theme {theme_name} does not support Post of {self.platform.__class__.__name__}: {e}"
)
continue
except ThemeRenderError as e:
logger.exception(f"Theme {theme_name} render error: {e}")
continue
else:
logger.error(f"Theme {theme_name} not found")
continue
else:
pic_buffer.write(data)
return Image.open(pic_buffer)
def _check_image_square(self, size: tuple[int, int]) -> bool:
return abs(size[0] - size[1]) / size[0] < 0.05
async def _pic_merge(self) -> None:
if len(self.pics) < 3:
return
first_image = await self._pic_url_to_image(self.pics[0])
if not self._check_image_square(first_image.size):
return
images: list[Image.Image] = [first_image]
# first row
for i in range(1, 3):
cur_img = await self._pic_url_to_image(self.pics[i])
if not self._check_image_square(cur_img.size):
return
if cur_img.size[1] != images[0].size[1]: # height not equal
return
images.append(cur_img)
_tmp = 0
x_coord = [0]
for i in range(3):
_tmp += images[i].size[0]
x_coord.append(_tmp)
y_coord = [0, first_image.size[1]]
async def process_row(row: int) -> bool:
if len(self.pics) < (row + 1) * 3:
return False
row_first_img = await self._pic_url_to_image(self.pics[row * 3])
if not self._check_image_square(row_first_img.size):
return False
if row_first_img.size[0] != images[0].size[0]:
return False
image_row: list[Image.Image] = [row_first_img]
for i in range(row * 3 + 1, row * 3 + 3):
cur_img = await self._pic_url_to_image(self.pics[i])
if not self._check_image_square(cur_img.size):
return False
if cur_img.size[1] != row_first_img.size[1]:
return False
if cur_img.size[0] != images[i % 3].size[0]:
return False
image_row.append(cur_img)
images.extend(image_row)
y_coord.append(y_coord[-1] + row_first_img.size[1])
return True
if await process_row(1):
matrix = (3, 2)
else:
matrix = (3, 1)
if await process_row(2):
matrix = (3, 3)
logger.info("trigger merge image")
target = Image.new("RGB", (x_coord[-1], y_coord[-1]))
for y in range(matrix[1]):
for x in range(matrix[0]):
target.paste(
images[y * matrix[0] + x],
(x_coord[x], y_coord[y], x_coord[x + 1], y_coord[y + 1]),
)
target_io = BytesIO()
target.save(target_io, "JPEG")
self.pics = self.pics[matrix[0] * matrix[1] :]
self.pics.insert(0, target_io.getvalue())
async def generate_text_messages(self) -> list[MessageSegmentFactory]:
if self._message is None:
await self._pic_merge()
msg_segments: list[MessageSegmentFactory] = []
text = ""
if self.text:
text += "{}".format(self.text if len(self.text) < 500 else self.text[:500] + "...")
if text:
text += "\n"
text += f"来源: {self.target_type}"
if self.target_name:
text += f" {self.target_name}"
if self.url:
text += f" \n详情: {self.url}"
msg_segments.append(saa.Text(text))
for pic in self.pics:
msg_segments.append(saa.Image(pic))
self._message = msg_segments
return self._message
async def generate_pic_messages(self) -> list[MessageSegmentFactory]:
if self._pic_message is None:
await self._pic_merge()
msg_segments: list[MessageSegmentFactory] = []
text = ""
if self.text:
text += f"{self.text}"
text += "\n"
text += f"来源: {self.target_type}"
if self.target_name:
text += f" {self.target_name}"
msg_segments.append(await parse_text(text))
if not self.target_type == "rss" and self.url:
msg_segments.append(saa.Text(self.url))
for pic in self.pics:
msg_segments.append(saa.Image(pic))
self._pic_message = msg_segments
return self._pic_message
def __str__(self):
return "type: {}\nfrom: {}\ntext: {}\nurl: {}\npic: {}".format(
self.target_type,
self.target_name,
self.text if len(self.text) < 500 else self.text[:500] + "...",
self.url,
", ".join("b64img" if isinstance(x, bytes) or x.startswith("base64") else x for x in self.pics),
)
@dataclass
class Post(AbstractPost, _Post):
pass
raise ThemeRenderError(f"No theme can render Post of {self.platform.__class__.__name__}")
@@ -1,31 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1"
/>
<link rel="icon" href="data:;base64,=" />
<title>公告</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="main">
<div class="container">
<div class="standerd-container">
{% if bannerImageUrl %}
<div class="banner-image-container">
<img class="banner-image" src="{{ bannerImageUrl }}" />
</div>
{% endif %}
<div class="head-title-container">
<span class="head-title">{{ announce_title }}</span>
</div>
<div class="content">{{ content }}</div>
</div>
</div>
</div>
</body>
</html>
@@ -1,107 +0,0 @@
/**
引用自 https://ak.hycdn.cn/announce/assets/css/announcement.v_0_1_2.css
**/
@media screen and (max-device-width: 480px) {
body {
-webkit-text-size-adjust: 100%;
}
}
html {
height: 100%;
}
body,
head {
margin: 0;
padding: 0;
}
body {
background-color: #313131;
min-height: 100%;
background-color: #d0d0cf;
}
.main {
max-width: 980px;
font-family: "Microsoft Yahei";
width: 100%;
margin: auto;
font-size: 1rem;
min-height: 100%;
}
.main .container {
min-height: 100%;
}
.main .container .standerd-container {
padding: 2.72727273%;
width: 94.54545455%;
margin: auto;
}
.main .container .standerd-container .banner-image-container {
margin-bottom: 0.8rem;
}
.main .container .standerd-container .banner-image-container .banner-image {
display: block;
width: 100%;
}
.main .container .standerd-container .head-title-container {
margin: 0;
background-image: url(
https://ak.hycdn.cn/announce/assets/images/announcement/header.jpg);
background-size: cover;
position: relative;
margin-bottom: 0.6rem;
}
.main .container .standerd-container .head-title-container::before {
content: "";
display: block;
width: 100%;
padding-top: 6.02564103%;
}
.main .container .standerd-container .head-title-container .head-title {
padding-left: 0.25rem;
color: #fff;
font-weight: 500;
overflow: hidden;
position: absolute;
top: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
display: flex;
justify-content: center;
flex-direction: column;
font-size: 1rem;
}
.main .container .standerd-container .content {
line-height: 0.8rem;
font-size: 0.6rem;
}
.main .container .standerd-container .content h4 {
font-size: 110%;
margin-block-start: 0.5rem;
margin-block-end: 0.5rem;
}
.main .container .standerd-container .content p {
margin-block-start: 0.25rem;
margin-block-end: 0.25rem;
min-height: 0.8rem;
}
.main .container .standerd-container .content img {
max-width: 100%;
margin: auto;
display: block;
}
.main .container .banner-image-container.cover {
width: 100%;
height: 100%;
position: absolute;
overflow: hidden;
}
.main .container .banner-image-container.cover .cover-jumper {
width: 100%;
height: 100%;
display: block;
}
.main .container .banner-image-container.cover .banner-image {
width: 100%;
height: 100%;
}
@@ -1,112 +0,0 @@
@charset "utf-8";
/**
* markdown.css
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Lesser General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
* details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see http://gnu.org/licenses/lgpl.txt.
*
* @project Weblog and Open Source Projects of Florian Wolters
* @version GIT: $Id$
* @package xhtml-css
* @author Florian Wolters <florian.wolters.85@googlemail.com>
* @copyright 2012 Florian Wolters
* @cssdoc version 1.0-pre
* @license http://gnu.org/licenses/lgpl.txt GNU Lesser General Public License
* @link http://github.com/FlorianWolters/jekyll-bootstrap-theme
* @media all
* @valid true
*/
body {
font-family: Helvetica, Arial, Freesans, clean, sans-serif;
padding: 1em;
margin: auto;
max-width: 42em;
background: #fefefe;
}
h1, h2, h3, h4, h5, h6 {
font-weight: bold;
}
h1 {
color: #000000;
font-size: 28px;
}
h2 {
border-bottom: 1px solid #CCCCCC;
color: #000000;
font-size: 24px;
}
h3 {
font-size: 18px;
}
h4 {
font-size: 16px;
}
h5 {
font-size: 14px;
}
h6 {
color: #777777;
background-color: inherit;
font-size: 14px;
}
hr {
height: 0.2em;
border: 0;
color: #CCCCCC;
background-color: #CCCCCC;
}
p, blockquote, ul, ol, dl, li, table, pre {
margin: 15px 0;
}
code, pre {
border-radius: 3px;
background-color: #F8F8F8;
color: inherit;
}
code {
border: 1px solid #EAEAEA;
margin: 0 2px;
padding: 0 5px;
}
pre {
border: 1px solid #CCCCCC;
line-height: 1.25em;
overflow: auto;
padding: 6px 10px;
}
pre>code {
border: 0;
margin: 0;
padding: 0;
}
a, a:visited {
color: #4183C4;
background-color: inherit;
text-decoration: none;
}