nonebot-plugin-message-snapper 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.3
2
+ Name: nonebot-plugin-message-snapper
3
+ Version: 0.1.0
4
+ Summary: 将引用的消息转换为图片发送
5
+ Author: Xwei1645
6
+ Author-email: Xwei1645 <xwei1645@outlook.com>
7
+ Requires-Dist: aiofiles>=25.1.0
8
+ Requires-Dist: nonebot-plugin-htmlrender>=0.6.7
9
+ Requires-Dist: nonebot-plugin-localstore>=0.7.4
10
+ Requires-Dist: nonebot2>=2.4.3,<3.0.0
11
+ Requires-Python: >=3.10
12
+ Project-URL: Homepage, https://github.com/Xwei1645/nonebot-plugin-message-snapper
13
+ Project-URL: Issues, https://github.com/Xwei1645/nonebot-plugin-message-snapper/issues
14
+ Project-URL: Repository, https://github.com/Xwei1645/nonebot-plugin-message-snapper.git
15
+ Description-Content-Type: text/markdown
16
+
17
+ <div align="center">
18
+ <a href="https://v2.nonebot.dev/store">
19
+ <img src="https://raw.githubusercontent.com/fllesser/nonebot-plugin-template/refs/heads/resource/.docs/NoneBotPlugin.svg" width="310" alt="logo"></a>
20
+
21
+ ## ✨ nonebot-plugin-message-snapper ✨
22
+ [![LICENSE](https://img.shields.io/github/license/Xwei1645/nonebot-plugin-message-snapper.svg)](./LICENSE)
23
+ [![pypi](https://img.shields.io/pypi/v/nonebot-plugin-message-snapper.svg)](https://pypi.python.org/pypi/nonebot-plugin-message-snapper)
24
+ [![python](https://img.shields.io/badge/python-3.10|3.11|3.12|3.13-blue.svg)](https://www.python.org)
25
+ [![uv](https://img.shields.io/badge/package%20manager-uv-black?style=flat-square&logo=uv)](https://github.com/astral-sh/uv)
26
+ <br/>
27
+ [![ruff](https://img.shields.io/badge/code%20style-ruff-black?style=flat-square&logo=ruff)](https://github.com/astral-sh/ruff)
28
+ [![pre-commit](https://results.pre-commit.ci/badge/github/Xwei1645/nonebot-plugin-message-snapper/master.svg)](https://results.pre-commit.ci/latest/github/Xwei1645/nonebot-plugin-message-snapper/master)
29
+
30
+ </div>
31
+
32
+ ## 📖 介绍
33
+
34
+ Message Snapper 是一个可用于自动生成 QQ 群聊中单条消息伪截图的 NoneBot 插件。
35
+
36
+ ## 💿 安装
37
+
38
+ <details open>
39
+ <summary>使用 nb-cli 安装</summary>
40
+ 在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装
41
+
42
+ nb plugin install nonebot-plugin-message-snapper --upgrade
43
+ 使用 **pypi** 源安装
44
+
45
+ nb plugin install nonebot-plugin-message-snapper --upgrade -i "https://pypi.org/simple"
46
+ 使用**清华源**安装
47
+
48
+ nb plugin install nonebot-plugin-message-snapper --upgrade -i "https://pypi.tuna.tsinghua.edu.cn/simple"
49
+
50
+
51
+ </details>
52
+
53
+ <details>
54
+ <summary>使用包管理器安装</summary>
55
+ 在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令
56
+
57
+ <details open>
58
+ <summary>uv</summary>
59
+
60
+ uv add nonebot-plugin-message-snapper
61
+ 安装仓库 master 分支
62
+
63
+ uv add git+https://github.com/Xwei1645/nonebot-plugin-message-snapper@master
64
+ </details>
65
+
66
+ <details>
67
+ <summary>pdm</summary>
68
+
69
+ pdm add nonebot-plugin-message-snapper
70
+ 安装仓库 master 分支
71
+
72
+ pdm add git+https://github.com/Xwei1645/nonebot-plugin-message-snapper@master
73
+ </details>
74
+ <details>
75
+ <summary>poetry</summary>
76
+
77
+ poetry add nonebot-plugin-message-snapper
78
+ 安装仓库 master 分支
79
+
80
+ poetry add git+https://github.com/Xwei1645/nonebot-plugin-message-snapper@master
81
+ </details>
82
+
83
+ 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分追加写入
84
+
85
+ plugins = ["nonebot_plugin_message_snapper"]
86
+
87
+ </details>
88
+
89
+ <details>
90
+ <summary>使用 nbr 安装(使用 uv 管理依赖可用)</summary>
91
+
92
+ [nbr](https://github.com/fllesser/nbr) 是一个基于 uv 的 nb-cli,可以方便地管理 nonebot2
93
+
94
+ nbr plugin install nonebot-plugin-message-snapper
95
+ 使用 **pypi** 源安装
96
+
97
+ nbr plugin install nonebot-plugin-message-snapper -i "https://pypi.org/simple"
98
+ 使用**清华源**安装
99
+
100
+ nbr plugin install nonebot-plugin-message-snapper -i "https://pypi.tuna.tsinghua.edu.cn/simple"
101
+
102
+ </details>
103
+
104
+
105
+ ## ⚙️ 配置
106
+
107
+ 在 nonebot2 项目的`.env`文件中添加下表中的必填配置
108
+
109
+ | 配置项 | 必填 | 默认值 | 说明 |
110
+ | :-----: | :---: | :----: | :------: |
111
+ | `message_snapper_template` | 否 | - | 自定义模板文件 |
112
+ | `message_snapper_font_family` | 否 | - | 用于渲染图片的字体家族 |
113
+ | `message_snapper_group_info_cache_hours` | 否 | `72.0` | 群信息缓存时长(小时) |
114
+ | `message_snapper_member_info_cache_hours` | 否 | `72.0` | 群成员信息缓存时长(小时) |
115
+
116
+ ## 🎉 使用
117
+ ### 指令表
118
+ | 指令 | 权限 | 需要@ | 范围 | 说明 |
119
+ | :---: | :---: | :---: | :---: | :------: |
120
+ | 'snap' 并引用一条消息 | 群成员 | 否 | 群聊 | 生成被引用消息的伪截图 |
121
+
122
+ ### 🎨 效果图
123
+ 没有效果图
@@ -0,0 +1,107 @@
1
+ <div align="center">
2
+ <a href="https://v2.nonebot.dev/store">
3
+ <img src="https://raw.githubusercontent.com/fllesser/nonebot-plugin-template/refs/heads/resource/.docs/NoneBotPlugin.svg" width="310" alt="logo"></a>
4
+
5
+ ## ✨ nonebot-plugin-message-snapper ✨
6
+ [![LICENSE](https://img.shields.io/github/license/Xwei1645/nonebot-plugin-message-snapper.svg)](./LICENSE)
7
+ [![pypi](https://img.shields.io/pypi/v/nonebot-plugin-message-snapper.svg)](https://pypi.python.org/pypi/nonebot-plugin-message-snapper)
8
+ [![python](https://img.shields.io/badge/python-3.10|3.11|3.12|3.13-blue.svg)](https://www.python.org)
9
+ [![uv](https://img.shields.io/badge/package%20manager-uv-black?style=flat-square&logo=uv)](https://github.com/astral-sh/uv)
10
+ <br/>
11
+ [![ruff](https://img.shields.io/badge/code%20style-ruff-black?style=flat-square&logo=ruff)](https://github.com/astral-sh/ruff)
12
+ [![pre-commit](https://results.pre-commit.ci/badge/github/Xwei1645/nonebot-plugin-message-snapper/master.svg)](https://results.pre-commit.ci/latest/github/Xwei1645/nonebot-plugin-message-snapper/master)
13
+
14
+ </div>
15
+
16
+ ## 📖 介绍
17
+
18
+ Message Snapper 是一个可用于自动生成 QQ 群聊中单条消息伪截图的 NoneBot 插件。
19
+
20
+ ## 💿 安装
21
+
22
+ <details open>
23
+ <summary>使用 nb-cli 安装</summary>
24
+ 在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装
25
+
26
+ nb plugin install nonebot-plugin-message-snapper --upgrade
27
+ 使用 **pypi** 源安装
28
+
29
+ nb plugin install nonebot-plugin-message-snapper --upgrade -i "https://pypi.org/simple"
30
+ 使用**清华源**安装
31
+
32
+ nb plugin install nonebot-plugin-message-snapper --upgrade -i "https://pypi.tuna.tsinghua.edu.cn/simple"
33
+
34
+
35
+ </details>
36
+
37
+ <details>
38
+ <summary>使用包管理器安装</summary>
39
+ 在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令
40
+
41
+ <details open>
42
+ <summary>uv</summary>
43
+
44
+ uv add nonebot-plugin-message-snapper
45
+ 安装仓库 master 分支
46
+
47
+ uv add git+https://github.com/Xwei1645/nonebot-plugin-message-snapper@master
48
+ </details>
49
+
50
+ <details>
51
+ <summary>pdm</summary>
52
+
53
+ pdm add nonebot-plugin-message-snapper
54
+ 安装仓库 master 分支
55
+
56
+ pdm add git+https://github.com/Xwei1645/nonebot-plugin-message-snapper@master
57
+ </details>
58
+ <details>
59
+ <summary>poetry</summary>
60
+
61
+ poetry add nonebot-plugin-message-snapper
62
+ 安装仓库 master 分支
63
+
64
+ poetry add git+https://github.com/Xwei1645/nonebot-plugin-message-snapper@master
65
+ </details>
66
+
67
+ 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分追加写入
68
+
69
+ plugins = ["nonebot_plugin_message_snapper"]
70
+
71
+ </details>
72
+
73
+ <details>
74
+ <summary>使用 nbr 安装(使用 uv 管理依赖可用)</summary>
75
+
76
+ [nbr](https://github.com/fllesser/nbr) 是一个基于 uv 的 nb-cli,可以方便地管理 nonebot2
77
+
78
+ nbr plugin install nonebot-plugin-message-snapper
79
+ 使用 **pypi** 源安装
80
+
81
+ nbr plugin install nonebot-plugin-message-snapper -i "https://pypi.org/simple"
82
+ 使用**清华源**安装
83
+
84
+ nbr plugin install nonebot-plugin-message-snapper -i "https://pypi.tuna.tsinghua.edu.cn/simple"
85
+
86
+ </details>
87
+
88
+
89
+ ## ⚙️ 配置
90
+
91
+ 在 nonebot2 项目的`.env`文件中添加下表中的必填配置
92
+
93
+ | 配置项 | 必填 | 默认值 | 说明 |
94
+ | :-----: | :---: | :----: | :------: |
95
+ | `message_snapper_template` | 否 | - | 自定义模板文件 |
96
+ | `message_snapper_font_family` | 否 | - | 用于渲染图片的字体家族 |
97
+ | `message_snapper_group_info_cache_hours` | 否 | `72.0` | 群信息缓存时长(小时) |
98
+ | `message_snapper_member_info_cache_hours` | 否 | `72.0` | 群成员信息缓存时长(小时) |
99
+
100
+ ## 🎉 使用
101
+ ### 指令表
102
+ | 指令 | 权限 | 需要@ | 范围 | 说明 |
103
+ | :---: | :---: | :---: | :---: | :------: |
104
+ | 'snap' 并引用一条消息 | 群成员 | 否 | 群聊 | 生成被引用消息的伪截图 |
105
+
106
+ ### 🎨 效果图
107
+ 没有效果图
@@ -0,0 +1,137 @@
1
+ [project]
2
+ name = "nonebot-plugin-message-snapper"
3
+ version = "0.1.0"
4
+ description = "将引用的消息转换为图片发送"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ authors = [{ name = "Xwei1645", email = "xwei1645@outlook.com" }]
8
+ dependencies = [
9
+ "aiofiles>=25.1.0",
10
+ "nonebot-plugin-htmlrender>=0.6.7",
11
+ "nonebot-plugin-localstore>=0.7.4",
12
+ "nonebot2>=2.4.3,<3.0.0",
13
+ ]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/Xwei1645/nonebot-plugin-message-snapper"
17
+ Issues = "https://github.com/Xwei1645/nonebot-plugin-message-snapper/issues"
18
+ Repository = "https://github.com/Xwei1645/nonebot-plugin-message-snapper.git"
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "bump-my-version>=1.2.6",
23
+ "nonebot2[fastapi]>=2.4.2,<3.0.0",
24
+ "poethepoet>=0.40.0",
25
+ "ruff>=0.14.13,<1.0.0",
26
+ { include-group = "test" },
27
+ ]
28
+ test = [
29
+ "nonebot-adapter-onebot>=2.4.6,<3.0.0",
30
+ "nonebot2[fastapi]>=2.4.2,<3.0.0",
31
+ "nonebug>=0.3.7,<1.0.0",
32
+ "poethepoet>=0.36.0",
33
+ "pytest-asyncio>=1.3.0,<1.4.0",
34
+ "pytest-cov>=7.0.0",
35
+ "pytest-xdist>=3.8.0,<4.0.0",
36
+ ]
37
+
38
+ [build-system]
39
+ requires = ["uv_build>=0.10.0,<0.11.0"]
40
+ build-backend = "uv_build"
41
+
42
+ [tool.uv.sources]
43
+ nonebug = { git = "https://github.com/nonebot/nonebug" }
44
+
45
+ [tool.bumpversion]
46
+ current_version = "0.1.0"
47
+ commit = true
48
+ message = "release: bump vesion from {current_version} to {new_version}"
49
+ tag = true
50
+
51
+ [[tool.bumpversion.files]]
52
+ filename = "uv.lock"
53
+ search = "name = \"nonebot-plugin-message-snapper\"\nversion = \"{current_version}\""
54
+ replace = "name = \"nonebot-plugin-message-snapper\"\nversion = \"{new_version}\""
55
+
56
+ [tool.coverage.report]
57
+ exclude_lines = [
58
+ "raise NotImplementedError",
59
+ "if TYPE_CHECKING:",
60
+ "@overload",
61
+ "except ImportError:",
62
+ ]
63
+
64
+ [tool.nonebot]
65
+ plugins = ["nonebot_plugin_message_snapper"]
66
+
67
+ [tool.poe.tasks]
68
+ test = "pytest --cov=src --cov-report xml --junitxml=./junit.xml -n auto"
69
+ bump = "bump-my-version bump"
70
+ show-bump = "bump-my-version show-bump"
71
+
72
+ [tool.pyright]
73
+ pythonVersion = "3.10"
74
+ pythonPlatform = "All"
75
+ defineConstant = { PYDANTIC_V2 = true }
76
+ executionEnvironments = [
77
+ { root = "./tests", extraPaths = [
78
+ "./src",
79
+ ] },
80
+ { root = "./src" },
81
+ ]
82
+ typeCheckingMode = "standard"
83
+ disableBytesTypePromotions = true
84
+
85
+ [tool.pytest]
86
+ addopts = [
87
+ "--import-mode=prepend", # 导入模式
88
+ "--strict-markers", # 严格标记模式
89
+ "--tb=short", # 简短的错误回溯
90
+ "-ra", # 显示所有测试结果摘要
91
+ "-s", # 显示打印信息
92
+ "-v", # 详细输出
93
+ ]
94
+ pythonpath = ["src"]
95
+ asyncio_mode = "auto"
96
+ asyncio_default_fixture_loop_scope = "session"
97
+
98
+ [tool.ruff]
99
+ line-length = 88
100
+
101
+ [tool.ruff.format]
102
+ line-ending = "lf"
103
+
104
+ [tool.ruff.lint]
105
+ select = [
106
+ "F", # Pyflakes
107
+ "W", # pycodestyle warnings
108
+ "E", # pycodestyle errors
109
+ "I", # isort
110
+ "UP", # pyupgrade
111
+ "ASYNC", # flake8-async
112
+ "C4", # flake8-comprehensions
113
+ "T10", # flake8-debugger
114
+ "T20", # flake8-print
115
+ "PYI", # flake8-pyi
116
+ "PT", # flake8-pytest-style
117
+ "Q", # flake8-quotes
118
+ "TID", # flake8-tidy-imports
119
+ "RUF", # Ruff-specific rules
120
+ ]
121
+ ignore = [
122
+ "E402", # module-import-not-at-top-of-file
123
+ "UP037", # quoted-annotation
124
+ "RUF001", # ambiguous-unicode-character-string
125
+ "RUF002", # ambiguous-unicode-character-docstring
126
+ "RUF003", # ambiguous-unicode-character-comment
127
+ "W191", # indentation contains tabs
128
+ "TID252", # relative-import
129
+ ]
130
+
131
+ [tool.ruff.lint.isort]
132
+ length-sort = true
133
+ known-first-party = ["tests/*"]
134
+ extra-standard-library = ["typing_extensions"]
135
+
136
+ [tool.ruff.lint.pyupgrade]
137
+ keep-runtime-typing = true
@@ -0,0 +1,320 @@
1
+ from typing import Any
2
+ from pathlib import Path
3
+ from datetime import datetime
4
+
5
+ from nonebot import logger, require, get_driver, on_command
6
+ from nonebot.plugin import PluginMetadata
7
+ from nonebot.exception import FinishedException
8
+ from nonebot.adapters.onebot.v11 import Bot, Message, MessageSegment, GroupMessageEvent
9
+
10
+ require("nonebot_plugin_htmlrender")
11
+ require("nonebot_plugin_localstore")
12
+
13
+ from nonebot_plugin_htmlrender import template_to_pic
14
+
15
+ from .cache import (
16
+ load_cache,
17
+ save_cache,
18
+ get_group_info_cache,
19
+ set_group_info_cache,
20
+ get_member_info_cache,
21
+ set_member_info_cache,
22
+ )
23
+ from .config import Config, plugin_config
24
+
25
+ __plugin_meta__ = PluginMetadata(
26
+ name="消息快照",
27
+ description="将引用的消息转换为图片发送",
28
+ usage="回复一条消息并发送 /snap 命令,即可将该消息转换为图片",
29
+ type="application",
30
+ homepage="https://github.com/Xwei1645/nonebot-plugin-message-snapper",
31
+ config=Config,
32
+ supported_adapters={"~onebot.v11"},
33
+ )
34
+
35
+ TEMPLATE_PATH = Path(__file__).parent / "templates"
36
+ DEFAULT_TEMPLATE = "default.html"
37
+ DEFAULT_FONT_FAMILY = (
38
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, '
39
+ '"Helvetica Neue", Arial, "PingFang SC", '
40
+ '"Hiragino Sans GB", "Microsoft YaHei", sans-serif'
41
+ )
42
+
43
+ AVATAR_URL = "https://q1.qlogo.cn/g?b=qq&nk={user_id}&s=640"
44
+
45
+ snap = on_command("snap", block=True)
46
+
47
+
48
+ @get_driver().on_startup
49
+ async def _on_startup() -> None:
50
+ await load_cache()
51
+ logger.info("缓存加载完成")
52
+
53
+
54
+ @get_driver().on_shutdown
55
+ async def _on_shutdown() -> None:
56
+ await save_cache()
57
+ logger.info("缓存保存完成")
58
+
59
+
60
+ def get_template_name() -> str:
61
+ custom_template = plugin_config.message_snapper_template
62
+ if not custom_template:
63
+ return DEFAULT_TEMPLATE
64
+ template_file = TEMPLATE_PATH / custom_template
65
+ if template_file.exists():
66
+ return custom_template
67
+ logger.warning(f"自定义模板 {custom_template} 不存在,使用默认模板")
68
+ return DEFAULT_TEMPLATE
69
+
70
+
71
+ def get_font_family() -> str:
72
+ return plugin_config.message_snapper_font_family or DEFAULT_FONT_FAMILY
73
+
74
+
75
+ async def get_group_info(bot: Bot, group_id: int) -> dict[str, Any]:
76
+ cached = get_group_info_cache(group_id)
77
+ if cached is not None:
78
+ return cached
79
+
80
+ try:
81
+ info = await bot.get_group_info(group_id=group_id)
82
+ set_group_info_cache(group_id, info)
83
+ return info
84
+ except Exception:
85
+ return {"group_name": "未知群", "member_count": 0}
86
+
87
+
88
+ async def get_member_info(bot: Bot, group_id: int, user_id: int) -> dict[str, Any]:
89
+ cached = get_member_info_cache(group_id, user_id)
90
+ if cached is not None:
91
+ return cached
92
+
93
+ try:
94
+ info = await bot.get_group_member_info(group_id=group_id, user_id=user_id)
95
+ set_member_info_cache(group_id, user_id, info)
96
+ return info
97
+ except Exception:
98
+ return {}
99
+
100
+
101
+ @snap.handle()
102
+ async def handle_snap(bot: Bot, event: GroupMessageEvent) -> None:
103
+ if event.reply is None:
104
+ await snap.finish("请回复一条消息后再使用 /snap 命令")
105
+
106
+ reply = event.reply
107
+ sender = reply.sender
108
+ group_id = event.group_id
109
+ user_id = sender.user_id or 0
110
+
111
+ sender_name = sender.card or sender.nickname or "未知用户"
112
+ reply_preview = await extract_reply_preview(bot, reply.message, group_id)
113
+ message_segments = await extract_message_segments(bot, group_id, reply.message)
114
+ message_content = await extract_text_content(bot, group_id, reply.message)
115
+ single_image_only = is_single_image_message(message_segments)
116
+
117
+ if not message_segments and reply_preview is None:
118
+ await snap.finish("无法获取消息内容,可能包含不支持的消息类型")
119
+
120
+ time_str = datetime.fromtimestamp(reply.time).strftime("%Y-%m-%d %H:%M")
121
+
122
+ group_info = await get_group_info(bot, group_id)
123
+ group_name = group_info.get("group_name", "未知群")
124
+ member_count = group_info.get("member_count", 0)
125
+
126
+ member_info = await get_member_info(bot, group_id, user_id)
127
+ if member_info:
128
+ level = member_info.get("level", "") or ""
129
+ title = member_info.get("title", "") or ""
130
+ role = member_info.get("role", "") or ""
131
+ card = member_info.get("card", "") or ""
132
+ nickname = member_info.get("nickname", "") or ""
133
+ sender_name = card or nickname or "未知用户"
134
+ else:
135
+ level = sender.level or ""
136
+ title = sender.title or ""
137
+ role = sender.role or ""
138
+
139
+ avatar_url = AVATAR_URL.format(user_id=user_id)
140
+ template_name = get_template_name()
141
+ font_family = get_font_family()
142
+
143
+ try:
144
+ img_bytes = await template_to_pic(
145
+ template_path=str(TEMPLATE_PATH),
146
+ template_name=template_name,
147
+ templates={
148
+ "font_family": font_family,
149
+ "group_name": group_name,
150
+ "member_count": member_count,
151
+ "avatar_url": avatar_url,
152
+ "sender_name": sender_name,
153
+ "sender_id": user_id,
154
+ "level": level,
155
+ "title": title,
156
+ "role": role,
157
+ "reply_preview": reply_preview,
158
+ "message_segments": message_segments,
159
+ "single_image_only": single_image_only,
160
+ "message_content": message_content,
161
+ "time": time_str,
162
+ },
163
+ )
164
+ except FinishedException:
165
+ raise
166
+ except Exception as e:
167
+ logger.error(f"生成消息快照失败: {e}")
168
+ await snap.finish(f"生成图片失败: {e!s}")
169
+
170
+ logger.info(f"成功生成消息快照: 用户 {sender_name}({user_id})")
171
+ await snap.finish(MessageSegment.image(img_bytes))
172
+
173
+
174
+ def format_time(timestamp: Any) -> str:
175
+ try:
176
+ return datetime.fromtimestamp(float(timestamp)).strftime("%Y-%m-%d %H:%M")
177
+ except (TypeError, ValueError, OSError):
178
+ return "未知时间"
179
+
180
+
181
+ async def extract_reply_preview(
182
+ bot: Bot, message: Message, group_id: int
183
+ ) -> dict[str, Any] | None:
184
+ if not isinstance(message, Message):
185
+ return None
186
+
187
+ for seg in message:
188
+ if seg.type != "reply":
189
+ continue
190
+ message_id = seg.data.get("id")
191
+ if message_id is None:
192
+ return None
193
+ try:
194
+ quoted = await bot.get_msg(message_id=int(message_id))
195
+ except Exception as e:
196
+ logger.warning(f"获取引用消息失败: {e}")
197
+ return None
198
+
199
+ sender = quoted.get("sender", {}) if isinstance(quoted, dict) else {}
200
+ sender_name = (
201
+ sender.get("card")
202
+ or sender.get("nickname")
203
+ or str(sender.get("user_id") or "未知用户")
204
+ )
205
+ quoted_message = normalize_message_payload(quoted.get("message", ""))
206
+ segments = await extract_message_segments(bot, group_id, quoted_message)
207
+ content = await extract_text_content(bot, group_id, quoted_message)
208
+ return {
209
+ "sender_name": sender_name,
210
+ "time": format_time(quoted.get("time", 0)),
211
+ "segments": segments,
212
+ "content": content or "[消息]",
213
+ }
214
+ return None
215
+
216
+
217
+ def normalize_message_payload(payload: Any) -> Message:
218
+ if isinstance(payload, Message):
219
+ return payload
220
+ if isinstance(payload, str):
221
+ return Message(payload)
222
+ if isinstance(payload, list):
223
+ segments: list[MessageSegment] = []
224
+ for item in payload:
225
+ if isinstance(item, MessageSegment):
226
+ segments.append(item)
227
+ continue
228
+ if isinstance(item, dict):
229
+ seg_type = item.get("type")
230
+ seg_data = item.get("data", {})
231
+ if isinstance(seg_type, str) and isinstance(seg_data, dict):
232
+ segments.append(MessageSegment(seg_type, seg_data))
233
+ continue
234
+ logger.warning(f"忽略无法解析的消息段: {item!r}")
235
+ return Message(segments)
236
+ if isinstance(payload, dict):
237
+ seg_type = payload.get("type")
238
+ seg_data = payload.get("data", {})
239
+ if isinstance(seg_type, str) and isinstance(seg_data, dict):
240
+ return Message([MessageSegment(seg_type, seg_data)])
241
+ return Message(str(payload))
242
+
243
+
244
+ async def extract_message_segments(
245
+ bot: Bot, group_id: int, message: Message
246
+ ) -> list[dict[str, str]]:
247
+ message = normalize_message_payload(message)
248
+
249
+ parts = []
250
+ for seg in message:
251
+ if seg.type == "text":
252
+ text = seg.data.get("text", "")
253
+ if text:
254
+ parts.append({"type": "text", "content": text})
255
+ elif seg.type == "image":
256
+ image_url = seg.data.get("url") or seg.data.get("file") or ""
257
+ if image_url:
258
+ parts.append({"type": "image", "content": image_url})
259
+ else:
260
+ parts.append({"type": "text", "content": "[图片]"})
261
+ elif seg.type == "face":
262
+ face_id = seg.data.get("id", 0)
263
+ parts.append({"type": "text", "content": f"[表情:{face_id}]"})
264
+ elif seg.type == "emoji":
265
+ parts.append({"type": "text", "content": seg.data.get("text", "[emoji]")})
266
+ elif seg.type == "at":
267
+ qq = seg.data.get("qq", "")
268
+ name = ""
269
+ user_id = None
270
+ if isinstance(qq, int):
271
+ user_id = qq
272
+ else:
273
+ try:
274
+ user_id = int(qq)
275
+ except Exception:
276
+ user_id = None
277
+ if user_id is not None:
278
+ member_info = await get_member_info(bot, group_id, user_id)
279
+ card = member_info.get("card", "") or ""
280
+ nickname = member_info.get("nickname", "") or ""
281
+ name = card or nickname or str(user_id)
282
+ else:
283
+ name = qq or ""
284
+ # leave no space between @ and nickname, but add trailing space
285
+ parts.append({"type": "text", "content": f"@{name} "})
286
+ elif seg.type == "reply":
287
+ continue
288
+ else:
289
+ parts.append({"type": "text", "content": f"[{seg.type}]"})
290
+
291
+ # Merge adjacent text segments so mentions and following text stay on same line
292
+ merged: list[dict[str, str]] = []
293
+ for p in parts:
294
+ if merged and p["type"] == "text" and merged[-1]["type"] == "text":
295
+ prev = merged[-1]["content"]
296
+ cur = p["content"]
297
+ # Collapse boundary whitespace into single space to avoid newlines
298
+ merged[-1]["content"] = prev.rstrip() + " " + cur.lstrip()
299
+ else:
300
+ merged.append(p.copy())
301
+
302
+ return merged
303
+
304
+
305
+ async def extract_text_content(bot: Bot, group_id: int, message: Message) -> str:
306
+ parts = []
307
+ for seg in await extract_message_segments(bot, group_id, message):
308
+ if seg["type"] == "image":
309
+ parts.append("[图片]")
310
+ else:
311
+ parts.append(seg["content"])
312
+ return "".join(parts).strip()
313
+
314
+
315
+ def is_single_image_message(message_segments: list[dict[str, str]]) -> bool:
316
+ return (
317
+ len(message_segments) == 1
318
+ and message_segments[0].get("type") == "image"
319
+ and bool(message_segments[0].get("content"))
320
+ )
@@ -0,0 +1,103 @@
1
+ import json
2
+ from typing import Any
3
+ from pathlib import Path
4
+ from datetime import datetime
5
+
6
+ from anyio import Path as AsyncPath
7
+ from nonebot import logger, require
8
+
9
+ require("nonebot_plugin_localstore")
10
+ import nonebot_plugin_localstore as store
11
+
12
+ from .config import plugin_config
13
+
14
+ _cache_file: Path = store.get_plugin_cache_file("cache.json")
15
+
16
+ _group_info_cache: dict[int, tuple[float, dict[str, Any]]] = {}
17
+ _member_info_cache: dict[tuple[int, int], tuple[float, dict[str, Any]]] = {}
18
+
19
+
20
+ def _get_cache_seconds(cache_type: str) -> float:
21
+ if cache_type == "group":
22
+ return plugin_config.message_snapper_group_info_cache_hours * 3600
23
+ return plugin_config.message_snapper_member_info_cache_hours * 3600
24
+
25
+
26
+ async def load_cache() -> None:
27
+ global _group_info_cache, _member_info_cache
28
+ cache_file = AsyncPath(_cache_file)
29
+
30
+ if not await cache_file.exists():
31
+ return
32
+
33
+ try:
34
+ import aiofiles
35
+
36
+ async with aiofiles.open(_cache_file, encoding="utf-8") as f:
37
+ data = json.loads(await f.read())
38
+
39
+ now = datetime.now().timestamp()
40
+
41
+ for k, v in data.get("group_info", {}).items():
42
+ if now - v[0] < _get_cache_seconds("group"):
43
+ _group_info_cache[int(k)] = (v[0], v[1])
44
+
45
+ for k, v in data.get("member_info", {}).items():
46
+ if now - v[0] < _get_cache_seconds("member"):
47
+ gid, uid = map(int, k.split(":"))
48
+ _member_info_cache[(gid, uid)] = (v[0], v[1])
49
+
50
+ logger.debug(
51
+ f"加载缓存: 群信息 {len(_group_info_cache)} 条, "
52
+ f"成员信息 {len(_member_info_cache)} 条"
53
+ )
54
+ except Exception as e:
55
+ logger.warning(f"加载缓存失败: {e}")
56
+
57
+
58
+ async def save_cache() -> None:
59
+ try:
60
+ cache_dir = AsyncPath(_cache_file.parent)
61
+ await cache_dir.mkdir(parents=True, exist_ok=True)
62
+
63
+ data = {
64
+ "group_info": {str(k): [v[0], v[1]] for k, v in _group_info_cache.items()},
65
+ "member_info": {
66
+ f"{k[0]}:{k[1]}": [v[0], v[1]] for k, v in _member_info_cache.items()
67
+ },
68
+ }
69
+
70
+ import aiofiles
71
+
72
+ async with aiofiles.open(_cache_file, "w", encoding="utf-8") as f:
73
+ await f.write(json.dumps(data, ensure_ascii=False))
74
+
75
+ except Exception as e:
76
+ logger.warning(f"保存缓存失败: {e}")
77
+
78
+
79
+ def get_group_info_cache(group_id: int) -> dict[str, Any] | None:
80
+ if group_id in _group_info_cache:
81
+ cached_time, cached_data = _group_info_cache[group_id]
82
+ if datetime.now().timestamp() - cached_time < _get_cache_seconds("group"):
83
+ return cached_data
84
+ del _group_info_cache[group_id]
85
+ return None
86
+
87
+
88
+ def set_group_info_cache(group_id: int, data: dict[str, Any]) -> None:
89
+ _group_info_cache[group_id] = (datetime.now().timestamp(), data)
90
+
91
+
92
+ def get_member_info_cache(group_id: int, user_id: int) -> dict[str, Any] | None:
93
+ cache_key = (group_id, user_id)
94
+ if cache_key in _member_info_cache:
95
+ cached_time, cached_data = _member_info_cache[cache_key]
96
+ if datetime.now().timestamp() - cached_time < _get_cache_seconds("member"):
97
+ return cached_data
98
+ del _member_info_cache[cache_key]
99
+ return None
100
+
101
+
102
+ def set_member_info_cache(group_id: int, user_id: int, data: dict[str, Any]) -> None:
103
+ _member_info_cache[(group_id, user_id)] = (datetime.now().timestamp(), data)
@@ -0,0 +1,12 @@
1
+ from nonebot import get_plugin_config
2
+ from pydantic import BaseModel
3
+
4
+
5
+ class Config(BaseModel):
6
+ message_snapper_template: str = ""
7
+ message_snapper_font_family: str = ""
8
+ message_snapper_group_info_cache_hours: float = 72.0
9
+ message_snapper_member_info_cache_hours: float = 72.0
10
+
11
+
12
+ plugin_config: Config = get_plugin_config(Config)
@@ -0,0 +1,267 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Message Snap</title>
8
+ <style>
9
+ * {
10
+ margin: 0;
11
+ padding: 0;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ background: #f5f5f5;
17
+ width: 100%;
18
+ display: block;
19
+ }
20
+
21
+ .snapshot {
22
+ display: block;
23
+ width: 100%;
24
+ background: #f5f5f5;
25
+ }
26
+
27
+ .snapshot-header {
28
+ background: #fff;
29
+ padding: 12px 16px;
30
+ border-bottom: 1px solid #e0e0e0;
31
+ }
32
+
33
+ .group-line {
34
+ font-size: 17px;
35
+ color: #000;
36
+ font-weight: 500;
37
+ white-space: nowrap;
38
+ }
39
+
40
+ .group-name {
41
+ color: #000;
42
+ }
43
+
44
+ .member-count {
45
+ color: #888;
46
+ font-size: 15px;
47
+ font-weight: normal;
48
+ }
49
+
50
+ .message-area {
51
+ padding: 16px;
52
+ }
53
+
54
+ .message-row {
55
+ display: flex;
56
+ align-items: flex-start;
57
+ gap: 6px;
58
+ }
59
+
60
+ .avatar {
61
+ width: 40px;
62
+ height: 40px;
63
+ border-radius: 8px;
64
+ flex-shrink: 0;
65
+ background: #ccc;
66
+ overflow: hidden;
67
+ }
68
+
69
+ .avatar img {
70
+ width: 100%;
71
+ height: 100%;
72
+ object-fit: cover;
73
+ }
74
+
75
+ .content {
76
+ display: flex;
77
+ flex-direction: column;
78
+ align-items: flex-start;
79
+ min-width: 0;
80
+ }
81
+
82
+ .sender-line {
83
+ display: flex;
84
+ align-items: flex-start;
85
+ margin-bottom: 4px;
86
+ gap: 4px;
87
+ flex-wrap: wrap;
88
+ }
89
+
90
+ .badge-box {
91
+ border-radius: 6px;
92
+ padding: 1px 4px;
93
+ display: inline-flex;
94
+ align-items: center;
95
+ gap: 4px;
96
+ font-size: 10px;
97
+ color: #fff;
98
+ }
99
+
100
+ .badge-box.owner {
101
+ background: linear-gradient(135deg, #f1bc54 0%, #e6a628 100%);
102
+ }
103
+
104
+ .badge-box.admin {
105
+ background: linear-gradient(135deg, #5cc7bc 0%, #40aba1 100%);
106
+ }
107
+
108
+ .badge-box.title {
109
+ background: linear-gradient(135deg, #c698e6 0%, #b078d9 100%);
110
+ }
111
+
112
+ .badge-box.member {
113
+ background: linear-gradient(135deg, #d4d4d4 0%, #bfbfbf 100%);
114
+ }
115
+
116
+ .sender-name {
117
+ font-size: 13px;
118
+ color: #5b5b5b;
119
+ white-space: normal;
120
+ word-break: break-word;
121
+ overflow-wrap: anywhere;
122
+ }
123
+
124
+ .bubble {
125
+ background: #fff;
126
+ border-radius: 8px;
127
+ padding: 10px 12px;
128
+ display: inline-block;
129
+ max-width: 640px;
130
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
131
+ }
132
+
133
+ .bubble.no-bubble {
134
+ background: transparent;
135
+ border-radius: 0;
136
+ padding: 0;
137
+ box-shadow: none;
138
+ }
139
+
140
+ .bubble-text {
141
+ font-size: 16px;
142
+ line-height: 1.5;
143
+ color: #000;
144
+ white-space: pre-wrap;
145
+ word-break: break-word;
146
+ overflow-wrap: anywhere;
147
+ }
148
+
149
+ .bubble-segment+.bubble-segment {
150
+ margin-top: 6px;
151
+ }
152
+
153
+ .message-image {
154
+ display: block;
155
+ max-width: 320px;
156
+ max-height: 420px;
157
+ border-radius: 6px;
158
+ }
159
+
160
+ .reply-box {
161
+ background: #e8e8e8;
162
+ border-radius: 6px;
163
+ padding: 7px 9px;
164
+ margin-bottom: 8px;
165
+ max-width: 100%;
166
+ }
167
+
168
+ .reply-header {
169
+ font-size: 11px;
170
+ color: #555;
171
+ margin-bottom: 2px;
172
+ white-space: normal;
173
+ word-break: break-word;
174
+ overflow-wrap: anywhere;
175
+ }
176
+
177
+ .reply-content {
178
+ font-size: 13px;
179
+ color: #333;
180
+ white-space: pre-wrap;
181
+ word-break: break-word;
182
+ overflow-wrap: anywhere;
183
+ }
184
+
185
+ .reply-segment+.reply-segment {
186
+ margin-top: 4px;
187
+ }
188
+
189
+ .reply-image {
190
+ display: block;
191
+ max-width: 220px;
192
+ max-height: 260px;
193
+ border-radius: 5px;
194
+ }
195
+
196
+ .time-line {
197
+ font-size: 10px;
198
+ color: #999;
199
+ margin-top: 4px;
200
+ margin-left: 48px;
201
+ }
202
+ </style>
203
+ </head>
204
+
205
+ <body style='font-family: "{{ font_family }}", sans-serif;'>
206
+ <div class="snapshot">
207
+ <div class="snapshot-header">
208
+ <div class="group-line">
209
+ <span class="group-name">{{ group_name }}</span>
210
+ <span class="member-count">({{ member_count }})</span>
211
+ </div>
212
+ </div>
213
+ <div class="message-area">
214
+ <div class="message-row">
215
+ <div class="avatar">
216
+ <img src="{{ avatar_url }}" alt="avatar" onerror="this.style.display='none'">
217
+ </div>
218
+ <div class="content">
219
+ <div class="sender-line">
220
+ {% if level or title or role == "owner" or role == "admin" %}
221
+ <div
222
+ class="badge-box {% if role == 'owner' %}owner{% elif role == 'admin' %}admin{% elif title %}title{% else %}member{% endif %}">
223
+ {% if level %}LV{{ level }}{% endif %}
224
+ {% if level and (title or role == "owner" or role == "admin") %} {% endif %}
225
+ {% if title %}{{ title }}{% elif role == "owner" %}群主{% elif role == "admin" %}管理员{% endif
226
+ %}
227
+ </div>
228
+ {% endif %}
229
+ <span class="sender-name">{{ sender_name }}</span>
230
+ </div>
231
+ <div class="bubble{% if single_image_only %} no-bubble{% endif %}">
232
+ {% if reply_preview %}
233
+ <div class="reply-box">
234
+ <div class="reply-header">{{ reply_preview.sender_name }} {{ reply_preview.time }}</div>
235
+ {% if reply_preview.segments %}
236
+ {% for segment in reply_preview.segments %}
237
+ {% if segment.type == "image" %}
238
+ <div class="reply-segment">
239
+ <img class="reply-image" src="{{ segment.content }}" alt="reply-image">
240
+ </div>
241
+ {% else %}
242
+ <div class="reply-segment reply-content">{{ segment.content }}</div>
243
+ {% endif %}
244
+ {% endfor %}
245
+ {% else %}
246
+ <div class="reply-content">{{ reply_preview.content }}</div>
247
+ {% endif %}
248
+ </div>
249
+ {% endif %}
250
+ {% for segment in message_segments %}
251
+ {% if segment.type == "image" %}
252
+ <div class="bubble-segment">
253
+ <img class="message-image" src="{{ segment.content }}" alt="image">
254
+ </div>
255
+ {% else %}
256
+ <div class="bubble-segment bubble-text">{{ segment.content }}</div>
257
+ {% endif %}
258
+ {% endfor %}
259
+ </div>
260
+ </div>
261
+ </div>
262
+ <div class="time-line">{{ time }}</div>
263
+ </div>
264
+ </div>
265
+ </body>
266
+
267
+ </html>