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.
- nonebot_plugin_message_snapper-0.1.0/PKG-INFO +123 -0
- nonebot_plugin_message_snapper-0.1.0/README.md +107 -0
- nonebot_plugin_message_snapper-0.1.0/pyproject.toml +137 -0
- nonebot_plugin_message_snapper-0.1.0/src/nonebot_plugin_message_snapper/__init__.py +320 -0
- nonebot_plugin_message_snapper-0.1.0/src/nonebot_plugin_message_snapper/cache.py +103 -0
- nonebot_plugin_message_snapper-0.1.0/src/nonebot_plugin_message_snapper/config.py +12 -0
- nonebot_plugin_message_snapper-0.1.0/src/nonebot_plugin_message_snapper/templates/default.html +267 -0
|
@@ -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)
|
|
23
|
+
[](https://pypi.python.org/pypi/nonebot-plugin-message-snapper)
|
|
24
|
+
[](https://www.python.org)
|
|
25
|
+
[](https://github.com/astral-sh/uv)
|
|
26
|
+
<br/>
|
|
27
|
+
[](https://github.com/astral-sh/ruff)
|
|
28
|
+
[](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)
|
|
7
|
+
[](https://pypi.python.org/pypi/nonebot-plugin-message-snapper)
|
|
8
|
+
[](https://www.python.org)
|
|
9
|
+
[](https://github.com/astral-sh/uv)
|
|
10
|
+
<br/>
|
|
11
|
+
[](https://github.com/astral-sh/ruff)
|
|
12
|
+
[](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)
|
nonebot_plugin_message_snapper-0.1.0/src/nonebot_plugin_message_snapper/templates/default.html
ADDED
|
@@ -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>
|