diff --git a/docs/dev/README.md b/docs/dev/README.md index f18891c..38dbd53 100644 --- a/docs/dev/README.md +++ b/docs/dev/README.md @@ -38,7 +38,10 @@ Nonebot 项目使用了全异步的处理方式,所以你需要对异步,Pyt ## 基本概念 -- `nonebot_bison.post.Post`: 可以理解为推送内容,其中包含需要发送的文字,图片,链接,平台信息等 +- `nonebot_bison.post`: 可以理解为推送内容,其中包含需要发送的文字,图片,链接,平台信息等,分为: + - `nonebot_bison.post.Post`: 简单的推送内容格式,需要发送的内容由 bison 处理 + - `nonebot_bison.post.CustomPost`: 基于 markdown 语法的,自由度较高的推送内容格式 + - 详细的介绍可参见[生成 bison 的推送文本](#生成bison的推送文本) - `nonebot_bison.types.RawPost`: 从站点/平台中爬到的单条信息 - `nonebot_bison.types.Target`: 目标账号,Bilibili,微博等社交媒体中的账号 - `nonebot_bison.types.Category`: 信息分类,例如视频,动态,图文,文章等 @@ -211,3 +214,46 @@ class Weibo(NewMessage): #将需要bot推送的RawPost处理成正式推送的Post ... ``` + +## 生成 bison 的推送文本 + +### 什么是`nonebot_bison.post` + +可以认为`nonebot_bison.post`是最终要交付给 bison 推送到群内的内容,经过`parse`函数处理过后的报文应该返回属于`nonebot_bison.post`下的某个类 +目前 bison 所支持的类有: + +- `nonebot_bison.post.Post` +- `nonebot_bison.post.CustomPost` + +### 什么是`nonebot_bison.post.Post` + +Post 类存在参数`text`与`pics`,分别对应接收文本与图片类消息,需要注意的是`pics`接收的是一个列表 List,列表中的值可以为 url 或者 bytes。 +Post 会将`text`与`pics`分为若干条消息进行分别发送 +可选参数: +使用`compress`参数将所有消息压缩为一条进行发送。 +使用`extra_msg`可以携带额外的消息进行发送 +使用`override_use_pic`参数可以无视全局配置中的 bison_use_pic 配置进行强制指定 +可参考[Post 的用法](https://github.com/felinae98/nonebot-bison/blob/v0.5.4/src/plugins/nonebot_bison/platform/arknights.py#L227) + +### 什么是`nonebot_bison.post.CustomPost` + +CustomPost 类能接受的消息为[`List[MessageSegment]`](https://github.com/botuniverse/onebot-11/blob/master/message/array.md#%E6%B6%88%E6%81%AF%E6%AE%B5) +::: tip + +消息段(Message Segment 或 Segment) +表示聊天消息的一个部分,在一些平台上,聊天消息支持图文混排,其中就会有多个消息段,分别表示每个图片和每段文字。 +::: +准确来说,CustomPost 只支持使用 MessageSegment 内的`text`和`image`类型,CustomPost 会将 List 中的每个`text`类型元素理解为一个单行的 text 文本, +当然,markdown 语法可以在每个`text`类型元素使用,但如果这样,在不开启`bison_use_pic`**全局配置项** 的情况下,bison 会将写在 text 类型元素里的 markdown 语法按原样推送,不会解析。 +对于上述情况,建议开启 CustomPost 的`override_use_pic`选项,这样 CustomPost 只会发送经过 markdown 语法渲染好的图片,而非文本消息。 +CustomPost 的可选参数及作用与上文中的[Post](#什么是nonebot-bison-post-post)一致。 +::: details CustomPost 例子 + +```python + async def parse(self, raw_post:RawPost) -> str: + #假定传入的raw_post为List[MessageSegment] + #do something... + return CustomPost(message_segments=raw_post, only_pic=True) +``` + +::: diff --git a/src/plugins/nonebot_bison/post/__init__.py b/src/plugins/nonebot_bison/post/__init__.py new file mode 100644 index 0000000..3900f47 --- /dev/null +++ b/src/plugins/nonebot_bison/post/__init__.py @@ -0,0 +1,3 @@ +from .post import Post + +__all__ = ["Post"] diff --git a/src/plugins/nonebot_bison/post/abstract_post.py b/src/plugins/nonebot_bison/post/abstract_post.py new file mode 100644 index 0000000..c0902f3 --- /dev/null +++ b/src/plugins/nonebot_bison/post/abstract_post.py @@ -0,0 +1,55 @@ +from abc import abstractmethod +from dataclasses import dataclass, field +from functools import reduce +from typing import Optional + +from nonebot.adapters.onebot.v11.message import Message, MessageSegment + +from ..plugin_config import plugin_config + + +@dataclass +class BasePost: + @abstractmethod + async def generate_text_messages(self) -> list[MessageSegment]: + "Generate Message list from this instance" + ... + + @abstractmethod + async def generate_pic_messages(self) -> list[MessageSegment]: + "Generate Message 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: Optional[bool] = None + compress: bool = False + extra_msg: list[Message] = field(default_factory=list) + + def _use_pic(self): + if not self.override_use_pic is None: + return self.override_use_pic + return plugin_config.bison_use_pic + + +@dataclass +class AbstractPost(OptionalMixin, BasePost): + async def generate_messages(self) -> list[Message]: + 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, Message())] + else: + msgs = list( + map(lambda msg_segment: Message([msg_segment]), msg_segments) + ) + else: + msgs = [] + msgs.extend(self.extra_msg) + return msgs diff --git a/src/plugins/nonebot_bison/post/custom_post.py b/src/plugins/nonebot_bison/post/custom_post.py new file mode 100644 index 0000000..3f343be --- /dev/null +++ b/src/plugins/nonebot_bison/post/custom_post.py @@ -0,0 +1,74 @@ +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from nonebot.adapters.onebot.v11.message import Message, MessageSegment +from nonebot.log import logger +from nonebot.plugin import require + +from .abstract_post import AbstractPost, BasePost + + +@dataclass +class _CustomPost(BasePost): + + message_segments: list[MessageSegment] = field(default_factory=list) + css_path: Optional[str] = None # 模板文件所用css路径 + + async def generate_text_messages(self) -> list[MessageSegment]: + return self.message_segments + + async def generate_pic_messages(self) -> list[MessageSegment]: + 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 [MessageSegment.image(pic_bytes)] + + def _generate_md(self) -> str: + md = "" + + for message_segment in self.message_segments: + if message_segment.type == "text": + md += "{}
".format(message_segment.data.get("text", "")) + elif message_segment.type == "image": + # 先尝试获取file的值,没有再尝试获取url的值,都没有则为空 + pic_res = message_segment.data.get("file") or message_segment.data.get( + "url", "" + ) + if not pic_res: + logger.warning("无法获取到图片资源:MessageSegment.image中file/url字段均为空") + else: + md += "![Image]({})\n".format(pic_res) + else: + logger.warning("custom_post不支持处理类型:{}".format(message_segment.type)) + continue + + return md + + +@dataclass +class CustomPost(_CustomPost, AbstractPost): + """基于 markdown 语法的,自由度较高的推送内容格式 + + 简介: + 支持处理text/image两种MessageSegment, + 通过将text/image转换成对应的markdown语法以生成markdown文本。 + 理论上text类型中可以直接使用markdown语法,例如`##第一章`。 + 但会导致不启用`override_use_pic`时, 发送不会被渲染的纯文本消息。 + 图片渲染最终由htmlrender执行。 + + 注意: + 每一个MessageSegment元素都会被解释为单独的一行 + + 可选参数: + `override_use_pic`:是否覆盖`bison_use_pic`全局配置 + `compress`:将所有消息压缩为一条进行发送 + `extra_msg`:需要附带发送的额外消息 + + 成员函数: + `generate_text_messages()`:负责生成文本消息 + `generate_pic_messages()`:负责生成图片消息 + """ + + pass diff --git a/src/plugins/nonebot_bison/post.py b/src/plugins/nonebot_bison/post/post.py similarity index 73% rename from src/plugins/nonebot_bison/post.py rename to src/plugins/nonebot_bison/post/post.py index 869db75..fe0db9a 100644 --- a/src/plugins/nonebot_bison/post.py +++ b/src/plugins/nonebot_bison/post/post.py @@ -7,28 +7,21 @@ from nonebot.adapters.onebot.v11.message import Message, MessageSegment from nonebot.log import logger from PIL import Image -from .plugin_config import plugin_config -from .utils import http_client, parse_text +from ..utils import http_client, parse_text +from .abstract_post import AbstractPost, BasePost, OptionalMixin @dataclass -class Post: +class _Post(BasePost): target_type: str text: str url: Optional[str] = None target_name: Optional[str] = None - compress: bool = False - override_use_pic: Optional[bool] = None pics: list[Union[str, bytes]] = field(default_factory=list) - extra_msg: list[Message] = field(default_factory=list) - _message: Optional[list[Message]] = None - - def _use_pic(self): - if not self.override_use_pic is None: - return self.override_use_pic - return plugin_config.bison_use_pic + _message: Optional[list[MessageSegment]] = None + _pic_message: Optional[list[MessageSegment]] = None async def _pic_url_to_image(self, data: Union[str, bytes]) -> Image.Image: pic_buffer = BytesIO() @@ -106,42 +99,49 @@ class Post: self.pics = self.pics[matrix[0] * matrix[1] :] self.pics.insert(0, target_io.getvalue()) - async def generate_messages(self) -> list[Message]: + async def generate_text_messages(self) -> list[MessageSegment]: + if self._message is None: await self._pic_merge() msg_segments: list[MessageSegment] = [] text = "" if self.text: - if self._use_pic(): - text += "{}".format(self.text) - else: - text += "{}".format( - self.text if len(self.text) < 500 else self.text[:500] + "..." - ) - text += "\n来源: {}".format(self.target_type) + text += "{}".format( + self.text if len(self.text) < 500 else self.text[:500] + "..." + ) + if text: + text += "\n" + text += "来源: {}".format(self.target_type) if self.target_name: text += " {}".format(self.target_name) - if self._use_pic(): - msg_segments.append(await parse_text(text)) - if not self.target_type == "rss" and self.url: - msg_segments.append(MessageSegment.text(self.url)) - else: - if self.url: - text += " \n详情: {}".format(self.url) - msg_segments.append(MessageSegment.text(text)) + if self.url: + text += " \n详情: {}".format(self.url) + msg_segments.append(MessageSegment.text(text)) for pic in self.pics: msg_segments.append(MessageSegment.image(pic)) - if self.compress: - msgs = [reduce(lambda x, y: x.append(y), msg_segments, Message())] - else: - msgs = list( - map(lambda msg_segment: Message([msg_segment]), msg_segments) - ) - msgs.extend(self.extra_msg) - self._message = msgs - assert len(self._message) > 0, f"message list empty, {self}" + self._message = msg_segments return self._message + async def generate_pic_messages(self) -> list[MessageSegment]: + + if self._pic_message is None: + await self._pic_merge() + msg_segments: list[MessageSegment] = [] + text = "" + if self.text: + text += "{}".format(self.text) + text += "\n" + text += "来源: {}".format(self.target_type) + if self.target_name: + text += " {}".format(self.target_name) + msg_segments.append(await parse_text(text)) + if not self.target_type == "rss" and self.url: + msg_segments.append(MessageSegment.text(self.url)) + for pic in self.pics: + msg_segments.append(MessageSegment.image(pic)) + self._pic_message = msg_segments + return self._pic_message + def __str__(self): return "type: {}\nfrom: {}\ntext: {}\nurl: {}\npic: {}".format( self.target_type, @@ -157,3 +157,8 @@ class Post: ) ), ) + + +@dataclass +class Post(AbstractPost, _Post): + pass diff --git a/src/plugins/nonebot_bison/post/templates/custom_post.css b/src/plugins/nonebot_bison/post/templates/custom_post.css new file mode 100644 index 0000000..cc761bc --- /dev/null +++ b/src/plugins/nonebot_bison/post/templates/custom_post.css @@ -0,0 +1,112 @@ +@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 + * @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; +} \ No newline at end of file diff --git a/tests/platforms/static/custom_post_pic.jpg b/tests/platforms/static/custom_post_pic.jpg new file mode 100644 index 0000000..e3828e8 Binary files /dev/null and b/tests/platforms/static/custom_post_pic.jpg differ diff --git a/tests/test_custom_post.py b/tests/test_custom_post.py new file mode 100644 index 0000000..4df3a03 --- /dev/null +++ b/tests/test_custom_post.py @@ -0,0 +1,80 @@ +import base64 +import hashlib +import platform +from io import UnsupportedOperation +from pathlib import Path + +import pytest +import respx +from httpx import Response +from nonebot.adapters.onebot.v11.message import MessageSegment +from nonebug.app import App + + +@pytest.fixture +def ms_list(): + msg_segments: list[MessageSegment] = [] + msg_segments.append(MessageSegment.text("【Zc】每早合约日替攻略!")) + msg_segments.append( + MessageSegment.image( + file="http://i0.hdslb.com/bfs/live/new_room_cover/cf7d4d3b2f336c6dba299644c3af952c5db82612.jpg", + cache=0, + ) + ) + msg_segments.append(MessageSegment.text("来源: Bilibili直播 魔法Zc目录")) + msg_segments.append(MessageSegment.text("详情: https://live.bilibili.com/3044248")) + + return msg_segments + + +@pytest.fixture +def pic_hash(): + platform_name = platform.system() + if platform_name == "Windows": + return "58723fdc24b473b6dbd8ec8cbc3b7e46160c83df" + elif platform_name == "Linux": + return "4d540798108762df76de34f7bdbc667dada6b5cb" + elif platform_name == "Darwin": + return "a482bf8317d56e5ddc71437584343ace29ff545c" + else: + raise UnsupportedOperation(f"未支持的平台{platform_name}") + + +@pytest.fixture +def expect_md(): + return "【Zc】每早合约日替攻略!
![Image](http://i0.hdslb.com/bfs/live/new_room_cover/cf7d4d3b2f336c6dba299644c3af952c5db82612.jpg)\n来源: Bilibili直播 魔法Zc目录
详情: https://live.bilibili.com/3044248
" + + +def test_gene_md(app: App, expect_md, ms_list): + from nonebot_bison.post.custom_post import CustomPost + + cp = CustomPost(message_segments=ms_list) + cp_md = cp._generate_md() + assert cp_md == expect_md + + +@respx.mock +@pytest.mark.asyncio +async def test_gene_pic(app: App, ms_list, pic_hash): + from nonebot_bison.post.custom_post import CustomPost + + pic_router = respx.get( + "http://i0.hdslb.com/bfs/live/new_room_cover/cf7d4d3b2f336c6dba299644c3af952c5db82612.jpg" + ) + + pic_path = Path(__file__).parent / "platforms" / "static" / "custom_post_pic.jpg" + with open(pic_path, mode="rb") as f: + mock_pic = f.read() + + pic_router.mock(return_value=Response(200, stream=mock_pic)) + + cp = CustomPost(message_segments=ms_list) + cp_pic_bytes: list[MessageSegment] = await cp.generate_pic_messages() + + pure_b64 = base64.b64decode( + cp_pic_bytes[0].data.get("file").replace("base64://", "") + ) + sha1obj = hashlib.sha1() + sha1obj.update(pure_b64) + sha1hash = sha1obj.hexdigest() + assert sha1hash == pic_hash