nonebot-plugin-receipts 0.1.0__py3-none-any.whl
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_receipts/__init__.py +97 -0
- nonebot_plugin_receipts/config.py +19 -0
- nonebot_plugin_receipts/renderer.py +479 -0
- nonebot_plugin_receipts/spooler.py +51 -0
- nonebot_plugin_receipts/template.py +89 -0
- nonebot_plugin_receipts-0.1.0.dist-info/METADATA +123 -0
- nonebot_plugin_receipts-0.1.0.dist-info/RECORD +9 -0
- nonebot_plugin_receipts-0.1.0.dist-info/WHEEL +5 -0
- nonebot_plugin_receipts-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from nonebot import get_plugin_config, on_command
|
|
2
|
+
from nonebot.adapters.onebot.v11 import Message, MessageEvent
|
|
3
|
+
from nonebot.matcher import Matcher
|
|
4
|
+
from nonebot.params import Arg, CommandArg
|
|
5
|
+
from nonebot.plugin import PluginMetadata
|
|
6
|
+
|
|
7
|
+
from .config import Config
|
|
8
|
+
from .renderer import ReceiptRenderError, render_receipt
|
|
9
|
+
from .spooler import SpoolerClient, SpoolerError
|
|
10
|
+
from .template import ReceiptTemplateContext
|
|
11
|
+
|
|
12
|
+
__plugin_meta__ = PluginMetadata(
|
|
13
|
+
name="小票打印",
|
|
14
|
+
description="将发送给 Bot 的文本和图片消息通过 receipts-spooler 打印",
|
|
15
|
+
usage=(
|
|
16
|
+
"发送 /小票 后附带文本/图片,或单独发送命令后按提示再发送一条要打印的消息。"
|
|
17
|
+
),
|
|
18
|
+
type="application",
|
|
19
|
+
config=Config,
|
|
20
|
+
supported_adapters={"~onebot.v11"},
|
|
21
|
+
extra={},
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
plugin_config = get_plugin_config(Config)
|
|
25
|
+
|
|
26
|
+
ReceiptPrint = on_command(
|
|
27
|
+
"ticket",
|
|
28
|
+
aliases={"小票", "ticket", "receipt"},
|
|
29
|
+
block=True,
|
|
30
|
+
priority=10,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _has_printable_content(message: Message) -> bool:
|
|
35
|
+
if message.extract_plain_text().strip():
|
|
36
|
+
return True
|
|
37
|
+
return any(segment.type == "image" for segment in message)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_template_context(event: MessageEvent) -> ReceiptTemplateContext:
|
|
41
|
+
sender = getattr(event, "sender", None)
|
|
42
|
+
sender_name = ""
|
|
43
|
+
|
|
44
|
+
if sender is not None:
|
|
45
|
+
sender_name = str(sender.card or sender.nickname or "").strip()
|
|
46
|
+
|
|
47
|
+
if not sender_name:
|
|
48
|
+
sender_name = str(getattr(event, "user_id", ""))
|
|
49
|
+
|
|
50
|
+
return ReceiptTemplateContext(
|
|
51
|
+
sender_name=sender_name,
|
|
52
|
+
sender_id=str(getattr(event, "user_id", "")),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _submit_print_job(event: MessageEvent, message: Message) -> str:
|
|
57
|
+
rendered = await render_receipt(
|
|
58
|
+
message,
|
|
59
|
+
plugin_config,
|
|
60
|
+
_build_template_context(event),
|
|
61
|
+
)
|
|
62
|
+
client = SpoolerClient(plugin_config)
|
|
63
|
+
response = await client.push_raw(rendered)
|
|
64
|
+
queue_size = response.get("queue_size")
|
|
65
|
+
if isinstance(queue_size, int):
|
|
66
|
+
return f"打印任务已提交,当前队列长度:{queue_size}"
|
|
67
|
+
return "打印任务已提交到 receipts-spooler。"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@ReceiptPrint.handle()
|
|
71
|
+
async def handle_print_command(
|
|
72
|
+
matcher: Matcher, message: Message = CommandArg()
|
|
73
|
+
) -> None:
|
|
74
|
+
if _has_printable_content(message):
|
|
75
|
+
matcher.set_arg("receipt_content", message)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@ReceiptPrint.got(
|
|
79
|
+
"receipt_content",
|
|
80
|
+
prompt="请发送一条要打印的消息,支持文本、图片,或两者混合。",
|
|
81
|
+
)
|
|
82
|
+
async def handle_receipt_content(
|
|
83
|
+
event: MessageEvent,
|
|
84
|
+
receipt_content: Message = Arg("receipt_content"),
|
|
85
|
+
) -> None:
|
|
86
|
+
if not _has_printable_content(receipt_content):
|
|
87
|
+
await ReceiptPrint.reject("这条消息里没有可打印的文本或图片,请重新发送。")
|
|
88
|
+
|
|
89
|
+
result: str
|
|
90
|
+
try:
|
|
91
|
+
result = await _submit_print_job(event, receipt_content)
|
|
92
|
+
except ReceiptRenderError as exc:
|
|
93
|
+
await ReceiptPrint.finish(f"生成打印内容失败:{exc}")
|
|
94
|
+
except SpoolerError as exc:
|
|
95
|
+
await ReceiptPrint.finish(f"提交到 receipts-spooler 失败:{exc}")
|
|
96
|
+
|
|
97
|
+
await ReceiptPrint.finish(result)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Config(BaseModel):
|
|
7
|
+
receipts_spooler_url: str = "http://127.0.0.1:8000"
|
|
8
|
+
receipts_spooler_token: str | None = None
|
|
9
|
+
receipts_spooler_timeout: float = 5.0
|
|
10
|
+
receipt_image_fetch_timeout: float = 5.0
|
|
11
|
+
receipt_render_mode: Literal["raster", "hybrid"] = "raster"
|
|
12
|
+
receipt_printer_width: int = Field(default=576, ge=128, le=576)
|
|
13
|
+
receipt_template_path: str | None = None
|
|
14
|
+
receipt_font_path: str | None = None
|
|
15
|
+
receipt_font_size: int = Field(default=24, ge=12, le=96)
|
|
16
|
+
receipt_line_spacing: int = Field(default=6, ge=0, le=40)
|
|
17
|
+
receipt_section_gap: int = Field(default=6, ge=0, le=100)
|
|
18
|
+
receipt_feed_lines: int = Field(default=4, ge=0, le=10)
|
|
19
|
+
receipt_enable_cut: bool = True
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import io
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
12
|
+
|
|
13
|
+
from .template import (
|
|
14
|
+
ReceiptTemplate,
|
|
15
|
+
ReceiptTemplateContext,
|
|
16
|
+
ReceiptTemplateError,
|
|
17
|
+
load_receipt_template,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
|
|
23
|
+
from nonebot.adapters.onebot.v11 import Message
|
|
24
|
+
|
|
25
|
+
from .config import Config
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
MAX_RECEIPT_WIDTH = 576
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ReceiptBlock:
|
|
33
|
+
"""A normalized message block used by both render modes."""
|
|
34
|
+
|
|
35
|
+
kind: str
|
|
36
|
+
text: str = ""
|
|
37
|
+
image: Image.Image | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class CanvasLayout:
|
|
42
|
+
"""Shared layout measurements for raster text rendering."""
|
|
43
|
+
|
|
44
|
+
width: int
|
|
45
|
+
margin: int
|
|
46
|
+
content_width: int
|
|
47
|
+
line_height: int
|
|
48
|
+
draw: ImageDraw.ImageDraw
|
|
49
|
+
font: ImageFont.FreeTypeFont | ImageFont.ImageFont
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ReceiptRenderError(RuntimeError):
|
|
53
|
+
"""Raised when receipt content cannot be rendered for printing."""
|
|
54
|
+
|
|
55
|
+
INVALID_IMAGE_DATA = "图片消息数据格式无效。"
|
|
56
|
+
INACCESSIBLE_IMAGE = "存在无法读取的图片消息,请确认适配器提供了可访问的图片 URL。"
|
|
57
|
+
IMAGE_DECODE_FAILED = "读取图片消息失败。"
|
|
58
|
+
INVALID_LAYOUT = "渲染模板边距过大,导致没有可用的打印宽度。"
|
|
59
|
+
EMPTY_CONTENT = "消息中没有可打印的内容。"
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def font_load_failed(cls, font_path: str) -> "ReceiptRenderError":
|
|
63
|
+
return cls(f"无法加载字体文件: {font_path}")
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def unsupported_render_mode(cls, mode: str) -> "ReceiptRenderError":
|
|
67
|
+
return cls(f"不支持的渲染模式: {mode}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def render_receipt(
|
|
71
|
+
message: Message,
|
|
72
|
+
config: Config,
|
|
73
|
+
template_context: ReceiptTemplateContext | None = None,
|
|
74
|
+
) -> bytes:
|
|
75
|
+
"""Render a OneBot message into ESC/POS bytes using the configured mode."""
|
|
76
|
+
try:
|
|
77
|
+
template = load_receipt_template(config)
|
|
78
|
+
except ReceiptTemplateError as exc:
|
|
79
|
+
raise ReceiptRenderError(str(exc)) from exc
|
|
80
|
+
|
|
81
|
+
blocks = await _extract_blocks(message, config)
|
|
82
|
+
context = template_context or ReceiptTemplateContext()
|
|
83
|
+
|
|
84
|
+
if config.receipt_render_mode == "raster":
|
|
85
|
+
canvas = _render_raster_canvas(
|
|
86
|
+
blocks,
|
|
87
|
+
config,
|
|
88
|
+
template,
|
|
89
|
+
context,
|
|
90
|
+
)
|
|
91
|
+
return _image_to_escpos(canvas, config)
|
|
92
|
+
|
|
93
|
+
if config.receipt_render_mode == "hybrid":
|
|
94
|
+
return _render_hybrid_escpos(
|
|
95
|
+
blocks,
|
|
96
|
+
config,
|
|
97
|
+
template,
|
|
98
|
+
context,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
raise ReceiptRenderError.unsupported_render_mode(config.receipt_render_mode)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _render_raster_canvas(
|
|
105
|
+
blocks: Sequence[ReceiptBlock],
|
|
106
|
+
config: Config,
|
|
107
|
+
template: ReceiptTemplate,
|
|
108
|
+
template_context: ReceiptTemplateContext,
|
|
109
|
+
) -> Image.Image:
|
|
110
|
+
"""Render all receipt blocks into a single raster canvas."""
|
|
111
|
+
layout = _build_canvas_layout(config, template)
|
|
112
|
+
|
|
113
|
+
sections: list[Image.Image] = []
|
|
114
|
+
total_height = layout.margin
|
|
115
|
+
|
|
116
|
+
for block in blocks:
|
|
117
|
+
if block.kind == "text" and block.text:
|
|
118
|
+
if section := _build_wrapped_text_section(block.text, layout):
|
|
119
|
+
sections.append(section)
|
|
120
|
+
total_height += section.height
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
if block.kind == "image" and block.image is not None:
|
|
124
|
+
section = _build_image_section(
|
|
125
|
+
block.image,
|
|
126
|
+
layout.width,
|
|
127
|
+
layout.content_width,
|
|
128
|
+
config.receipt_section_gap,
|
|
129
|
+
)
|
|
130
|
+
sections.append(section)
|
|
131
|
+
total_height += section.height
|
|
132
|
+
|
|
133
|
+
if not sections:
|
|
134
|
+
raise ReceiptRenderError(ReceiptRenderError.EMPTY_CONTENT)
|
|
135
|
+
|
|
136
|
+
footer_text = template.render_footer_text(template_context)
|
|
137
|
+
if footer_text:
|
|
138
|
+
footer = _build_wrapped_text_section(footer_text, layout)
|
|
139
|
+
if footer is not None:
|
|
140
|
+
sections.append(footer)
|
|
141
|
+
total_height += footer.height + layout.margin
|
|
142
|
+
|
|
143
|
+
canvas = Image.new("L", (layout.width, total_height), color=255)
|
|
144
|
+
top = layout.margin
|
|
145
|
+
for section in sections:
|
|
146
|
+
canvas.paste(section, (0, top))
|
|
147
|
+
top += section.height
|
|
148
|
+
|
|
149
|
+
return canvas
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _render_hybrid_escpos(
|
|
153
|
+
blocks: Sequence[ReceiptBlock],
|
|
154
|
+
config: Config,
|
|
155
|
+
template: ReceiptTemplate,
|
|
156
|
+
template_context: ReceiptTemplateContext,
|
|
157
|
+
) -> bytes:
|
|
158
|
+
"""Render text natively and images as raster sections in message order."""
|
|
159
|
+
width = min(config.receipt_printer_width, MAX_RECEIPT_WIDTH)
|
|
160
|
+
margin = template.margin
|
|
161
|
+
content_width = width - margin * 2
|
|
162
|
+
if content_width <= 0:
|
|
163
|
+
raise ReceiptRenderError(ReceiptRenderError.INVALID_LAYOUT)
|
|
164
|
+
|
|
165
|
+
font = _load_font(config)
|
|
166
|
+
probe = Image.new("L", (width, 10), color=255)
|
|
167
|
+
draw = ImageDraw.Draw(probe)
|
|
168
|
+
avg_char_width = max(1, _measure_text(draw, "AA", font) // 2)
|
|
169
|
+
width_chars = max(1, content_width // avg_char_width)
|
|
170
|
+
|
|
171
|
+
chunks: list[bytes] = [b"\x1b@"]
|
|
172
|
+
|
|
173
|
+
for block in blocks:
|
|
174
|
+
if block.kind == "text" and block.text:
|
|
175
|
+
lines = _wrap_native_text(block.text, width_chars)
|
|
176
|
+
if lines:
|
|
177
|
+
chunks.append(_encode_native_text(lines))
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
if block.kind == "image" and block.image is not None:
|
|
181
|
+
section = _build_image_section(
|
|
182
|
+
block.image,
|
|
183
|
+
width,
|
|
184
|
+
content_width,
|
|
185
|
+
config.receipt_section_gap,
|
|
186
|
+
)
|
|
187
|
+
chunks.append(_image_to_escpos(section, config, initialize=False))
|
|
188
|
+
|
|
189
|
+
footer_text = template.render_footer_text(template_context)
|
|
190
|
+
if footer_text:
|
|
191
|
+
chunks.append(_encode_native_text(_wrap_native_text(footer_text, width_chars)))
|
|
192
|
+
|
|
193
|
+
if config.receipt_feed_lines:
|
|
194
|
+
chunks.append(b"\n" * config.receipt_feed_lines)
|
|
195
|
+
if config.receipt_enable_cut:
|
|
196
|
+
chunks.append(b"\x1dV\x00")
|
|
197
|
+
return b"".join(chunks)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _measure_text(
|
|
201
|
+
draw: ImageDraw.ImageDraw,
|
|
202
|
+
text: str,
|
|
203
|
+
font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
|
|
204
|
+
) -> int:
|
|
205
|
+
"""Measure text width in pixels using the active PIL font."""
|
|
206
|
+
sample = text or " "
|
|
207
|
+
left, _, right, _ = draw.textbbox((0, 0), sample, font=font)
|
|
208
|
+
return int(right - left)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _wrap_text(
|
|
212
|
+
text: str,
|
|
213
|
+
draw: ImageDraw.ImageDraw,
|
|
214
|
+
font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
|
|
215
|
+
max_width: int,
|
|
216
|
+
) -> list[str]:
|
|
217
|
+
"""Wrap raster text using pixel measurements from the active font."""
|
|
218
|
+
return _wrap_by_measure(
|
|
219
|
+
text,
|
|
220
|
+
max_width,
|
|
221
|
+
lambda candidate: _measure_text(draw, candidate, font),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _wrap_by_measure(
|
|
226
|
+
text: str,
|
|
227
|
+
max_width: int,
|
|
228
|
+
measure: Callable[[str], int],
|
|
229
|
+
) -> list[str]:
|
|
230
|
+
"""Wrap text by repeatedly measuring candidate lines."""
|
|
231
|
+
wrapped: list[str] = []
|
|
232
|
+
for paragraph in text.splitlines() or [""]:
|
|
233
|
+
if not paragraph:
|
|
234
|
+
wrapped.append("")
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
current = ""
|
|
238
|
+
for char in paragraph:
|
|
239
|
+
candidate = current + char
|
|
240
|
+
if current and measure(candidate) > max_width:
|
|
241
|
+
wrapped.append(current)
|
|
242
|
+
current = char
|
|
243
|
+
else:
|
|
244
|
+
current = candidate
|
|
245
|
+
if current:
|
|
246
|
+
wrapped.append(current)
|
|
247
|
+
return wrapped
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _measure_line_height(
|
|
251
|
+
draw: ImageDraw.ImageDraw,
|
|
252
|
+
font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
|
|
253
|
+
) -> int:
|
|
254
|
+
"""Estimate a readable line height for the current font."""
|
|
255
|
+
_, top, _, bottom = draw.textbbox((0, 0), "Ag", font=font)
|
|
256
|
+
return max(1, int(bottom - top))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _flush_text_block(blocks: list[ReceiptBlock], text_buffer: list[str]) -> None:
|
|
260
|
+
"""Flush accumulated text into a normalized text block."""
|
|
261
|
+
text = "".join(text_buffer)
|
|
262
|
+
if text:
|
|
263
|
+
blocks.append(ReceiptBlock(kind="text", text=text))
|
|
264
|
+
text_buffer.clear()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def _fetch_image_bytes(
|
|
268
|
+
client: httpx.AsyncClient,
|
|
269
|
+
url: str,
|
|
270
|
+
) -> bytes:
|
|
271
|
+
"""Download image bytes from a remote URL."""
|
|
272
|
+
try:
|
|
273
|
+
response = await client.get(url)
|
|
274
|
+
response.raise_for_status()
|
|
275
|
+
except httpx.HTTPError as exc:
|
|
276
|
+
raise ReceiptRenderError(ReceiptRenderError.INACCESSIBLE_IMAGE) from exc
|
|
277
|
+
return response.content
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def _extract_image_block(
|
|
281
|
+
client: httpx.AsyncClient,
|
|
282
|
+
file_value: str,
|
|
283
|
+
url: str,
|
|
284
|
+
) -> ReceiptBlock:
|
|
285
|
+
"""Normalize one image message segment into a grayscale image block."""
|
|
286
|
+
raw_bytes: bytes | None = None
|
|
287
|
+
|
|
288
|
+
if url:
|
|
289
|
+
raw_bytes = await _fetch_image_bytes(client, url)
|
|
290
|
+
elif file_value.startswith("base64://"):
|
|
291
|
+
try:
|
|
292
|
+
raw_bytes = base64.b64decode(
|
|
293
|
+
file_value.removeprefix("base64://"),
|
|
294
|
+
validate=True,
|
|
295
|
+
)
|
|
296
|
+
except binascii.Error as exc:
|
|
297
|
+
raise ReceiptRenderError(ReceiptRenderError.INVALID_IMAGE_DATA) from exc
|
|
298
|
+
elif file_value and urlparse(file_value).scheme in {"http", "https"}:
|
|
299
|
+
raw_bytes = await _fetch_image_bytes(client, file_value)
|
|
300
|
+
|
|
301
|
+
if raw_bytes is None:
|
|
302
|
+
raise ReceiptRenderError(ReceiptRenderError.INACCESSIBLE_IMAGE)
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
image = Image.open(io.BytesIO(raw_bytes))
|
|
306
|
+
return ReceiptBlock(kind="image", image=image.convert("L"))
|
|
307
|
+
except OSError as exc:
|
|
308
|
+
raise ReceiptRenderError(ReceiptRenderError.IMAGE_DECODE_FAILED) from exc
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def _extract_blocks(message: Message, config: Config) -> list[ReceiptBlock]:
|
|
312
|
+
"""Convert a mixed OneBot message into ordered text and image blocks."""
|
|
313
|
+
blocks: list[ReceiptBlock] = []
|
|
314
|
+
async with httpx.AsyncClient(timeout=config.receipt_image_fetch_timeout) as client:
|
|
315
|
+
text_buffer: list[str] = []
|
|
316
|
+
|
|
317
|
+
for segment in message:
|
|
318
|
+
if segment.type == "text":
|
|
319
|
+
text_buffer.append(str(segment.data.get("text") or ""))
|
|
320
|
+
continue
|
|
321
|
+
|
|
322
|
+
if segment.type != "image":
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
_flush_text_block(blocks, text_buffer)
|
|
326
|
+
url = str(segment.data.get("url") or "").strip()
|
|
327
|
+
file_value = str(segment.data.get("file") or "").strip()
|
|
328
|
+
blocks.append(await _extract_image_block(client, file_value, url))
|
|
329
|
+
|
|
330
|
+
_flush_text_block(blocks, text_buffer)
|
|
331
|
+
|
|
332
|
+
return blocks
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _resize_image(image: Image.Image, target_width: int) -> Image.Image:
|
|
336
|
+
"""Resize an image to fit the printable width while preserving aspect ratio."""
|
|
337
|
+
if image.width <= target_width:
|
|
338
|
+
return image
|
|
339
|
+
new_height = max(1, int(image.height * (target_width / image.width)))
|
|
340
|
+
return image.resize((target_width, new_height), Image.Resampling.LANCZOS)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _load_font(config: Config) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
344
|
+
"""Load the configured font or fall back to Pillow's default font."""
|
|
345
|
+
if config.receipt_font_path:
|
|
346
|
+
try:
|
|
347
|
+
return ImageFont.truetype(
|
|
348
|
+
config.receipt_font_path,
|
|
349
|
+
size=config.receipt_font_size,
|
|
350
|
+
)
|
|
351
|
+
except OSError as exc:
|
|
352
|
+
raise ReceiptRenderError.font_load_failed(config.receipt_font_path) from exc
|
|
353
|
+
return ImageFont.load_default()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _build_canvas_layout(config: Config, template: ReceiptTemplate) -> CanvasLayout:
|
|
357
|
+
"""Prepare common layout values used by raster text rendering."""
|
|
358
|
+
width = min(config.receipt_printer_width, MAX_RECEIPT_WIDTH)
|
|
359
|
+
margin = template.margin
|
|
360
|
+
content_width = width - margin * 2
|
|
361
|
+
if content_width <= 0:
|
|
362
|
+
raise ReceiptRenderError(ReceiptRenderError.INVALID_LAYOUT)
|
|
363
|
+
|
|
364
|
+
font = _load_font(config)
|
|
365
|
+
probe = Image.new("L", (width, 10), color=255)
|
|
366
|
+
draw = ImageDraw.Draw(probe)
|
|
367
|
+
line_height = _measure_line_height(draw, font) + config.receipt_line_spacing
|
|
368
|
+
return CanvasLayout(
|
|
369
|
+
width=width,
|
|
370
|
+
margin=margin,
|
|
371
|
+
content_width=content_width,
|
|
372
|
+
line_height=line_height,
|
|
373
|
+
draw=draw,
|
|
374
|
+
font=font,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _build_wrapped_text_section(
|
|
379
|
+
text: str,
|
|
380
|
+
layout: CanvasLayout,
|
|
381
|
+
) -> Image.Image | None:
|
|
382
|
+
"""Render wrapped text into a raster section image."""
|
|
383
|
+
lines = _wrap_text(text, layout.draw, layout.font, layout.content_width)
|
|
384
|
+
if not lines:
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
text_height = max(layout.line_height * len(lines), layout.line_height)
|
|
388
|
+
text_image = Image.new(
|
|
389
|
+
"L",
|
|
390
|
+
(layout.width, text_height + layout.margin),
|
|
391
|
+
color=255,
|
|
392
|
+
)
|
|
393
|
+
text_draw = ImageDraw.Draw(text_image)
|
|
394
|
+
y = 0
|
|
395
|
+
for line in lines:
|
|
396
|
+
text_draw.text((layout.margin, y), line, fill=0, font=layout.font)
|
|
397
|
+
y += layout.line_height
|
|
398
|
+
return text_image
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _build_image_section(
|
|
402
|
+
image: Image.Image,
|
|
403
|
+
width: int,
|
|
404
|
+
content_width: int,
|
|
405
|
+
section_gap: int,
|
|
406
|
+
) -> Image.Image:
|
|
407
|
+
"""Render one image block into a raster section with gap padding."""
|
|
408
|
+
fitted = _resize_image(image, content_width)
|
|
409
|
+
framed = Image.new(
|
|
410
|
+
"L",
|
|
411
|
+
(width, fitted.height + section_gap),
|
|
412
|
+
color=255,
|
|
413
|
+
)
|
|
414
|
+
offset_x = (width - fitted.width) // 2
|
|
415
|
+
framed.paste(fitted, (offset_x, 0))
|
|
416
|
+
return framed
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _wrap_native_text(text: str, width_chars: int) -> list[str]:
|
|
420
|
+
"""Wrap text for native printer output using ASCII=1 and non-ASCII=2 width."""
|
|
421
|
+
return _wrap_by_measure(
|
|
422
|
+
text,
|
|
423
|
+
width_chars,
|
|
424
|
+
lambda candidate: sum(1 if char.isascii() else 2 for char in candidate),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _encode_native_text(lines: Sequence[str]) -> bytes:
|
|
429
|
+
"""Encode wrapped lines using a broad Chinese-compatible printer code page."""
|
|
430
|
+
chunks: list[bytes] = []
|
|
431
|
+
for line in lines:
|
|
432
|
+
chunks.append(line.encode("gb18030", errors="replace"))
|
|
433
|
+
chunks.append(b"\n")
|
|
434
|
+
return b"".join(chunks)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _image_to_escpos(
|
|
438
|
+
image: Image.Image, config: Config, *, initialize: bool = True
|
|
439
|
+
) -> bytes:
|
|
440
|
+
"""Convert a raster image into ESC/POS raster bit image commands."""
|
|
441
|
+
grayscale = ImageOps.autocontrast(image).convert("L")
|
|
442
|
+
bw = grayscale.convert("1", dither=Image.Dither.FLOYDSTEINBERG)
|
|
443
|
+
width, height = bw.size
|
|
444
|
+
width_bytes = (width + 7) // 8
|
|
445
|
+
raster_data = bytearray()
|
|
446
|
+
|
|
447
|
+
for y in range(height):
|
|
448
|
+
row_offset = y * width_bytes
|
|
449
|
+
raster_data.extend(b"\x00" * width_bytes)
|
|
450
|
+
for x in range(width):
|
|
451
|
+
if bw.getpixel((x, y)) == 0:
|
|
452
|
+
raster_data[row_offset + (x // 8)] |= 0x80 >> (x % 8)
|
|
453
|
+
|
|
454
|
+
chunks: list[bytes] = [b"\x1b@"] if initialize else []
|
|
455
|
+
max_chunk_height = 255
|
|
456
|
+
for start in range(0, height, max_chunk_height):
|
|
457
|
+
chunk_height = min(max_chunk_height, height - start)
|
|
458
|
+
chunk = bytearray()
|
|
459
|
+
for y in range(start, start + chunk_height):
|
|
460
|
+
offset = y * width_bytes
|
|
461
|
+
chunk.extend(raster_data[offset : offset + width_bytes])
|
|
462
|
+
chunks.append(
|
|
463
|
+
b"\x1d\x76\x30\x00"
|
|
464
|
+
+ bytes(
|
|
465
|
+
(
|
|
466
|
+
width_bytes & 0xFF,
|
|
467
|
+
(width_bytes >> 8) & 0xFF,
|
|
468
|
+
chunk_height & 0xFF,
|
|
469
|
+
(chunk_height >> 8) & 0xFF,
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
+ bytes(chunk)
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
if initialize and config.receipt_feed_lines:
|
|
476
|
+
chunks.append(b"\n" * config.receipt_feed_lines)
|
|
477
|
+
if initialize and config.receipt_enable_cut:
|
|
478
|
+
chunks.append(b"\x1dV\x00")
|
|
479
|
+
return b"".join(chunks)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .config import Config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SpoolerError(RuntimeError):
|
|
11
|
+
INVALID_RESPONSE = "receipts-spooler 返回了不可识别的响应。"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SpoolerClient:
|
|
15
|
+
def __init__(self, config: Config) -> None:
|
|
16
|
+
self._config = config
|
|
17
|
+
|
|
18
|
+
async def push_raw(self, payload: bytes) -> dict[str, Any]:
|
|
19
|
+
url = self._config.receipts_spooler_url.rstrip("/")
|
|
20
|
+
headers: dict[str, str] = {}
|
|
21
|
+
if self._config.receipts_spooler_token:
|
|
22
|
+
headers["X-Spooler-Token"] = self._config.receipts_spooler_token
|
|
23
|
+
|
|
24
|
+
body = {
|
|
25
|
+
"data_type": "escpos_base64",
|
|
26
|
+
"payload": base64.b64encode(payload).decode("ascii"),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
async with httpx.AsyncClient(
|
|
31
|
+
timeout=self._config.receipts_spooler_timeout
|
|
32
|
+
) as client:
|
|
33
|
+
response = await client.post(
|
|
34
|
+
f"{url}/api/v1/task/push_raw",
|
|
35
|
+
json=body,
|
|
36
|
+
headers=headers,
|
|
37
|
+
)
|
|
38
|
+
response.raise_for_status()
|
|
39
|
+
except httpx.HTTPStatusError as exc:
|
|
40
|
+
detail = exc.response.text.strip() or str(exc)
|
|
41
|
+
raise SpoolerError(detail) from exc
|
|
42
|
+
except httpx.HTTPError as exc:
|
|
43
|
+
raise SpoolerError(str(exc)) from exc
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
data = response.json()
|
|
47
|
+
except json.JSONDecodeError as exc:
|
|
48
|
+
raise SpoolerError(SpoolerError.INVALID_RESPONSE) from exc
|
|
49
|
+
if not isinstance(data, dict):
|
|
50
|
+
raise SpoolerError(SpoolerError.INVALID_RESPONSE)
|
|
51
|
+
return data
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .config import Config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ReceiptTemplateContext(BaseModel):
|
|
15
|
+
sender_name: str = ""
|
|
16
|
+
sender_id: str = ""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ReceiptTemplate(BaseModel):
|
|
20
|
+
"""Layout template for receipt rendering."""
|
|
21
|
+
|
|
22
|
+
margin: int = Field(default=16, ge=0, le=64)
|
|
23
|
+
footer_enabled: bool = True
|
|
24
|
+
footer_text: str = "{sender_name}({sender_id}) @ {timestamp}"
|
|
25
|
+
footer_timestamp_format: str = "%Y-%m-%d %H:%M:%S"
|
|
26
|
+
footer_timezone_offset_hours: int = Field(default=8, ge=-12, le=14)
|
|
27
|
+
|
|
28
|
+
def render_footer_text(self, context: ReceiptTemplateContext) -> str:
|
|
29
|
+
if not self.footer_enabled:
|
|
30
|
+
return ""
|
|
31
|
+
|
|
32
|
+
timestamp = datetime.now(
|
|
33
|
+
timezone(timedelta(hours=self.footer_timezone_offset_hours))
|
|
34
|
+
).strftime(self.footer_timestamp_format)
|
|
35
|
+
try:
|
|
36
|
+
return self.footer_text.format(
|
|
37
|
+
timestamp=timestamp,
|
|
38
|
+
sender_name=context.sender_name,
|
|
39
|
+
sender_id=context.sender_id,
|
|
40
|
+
)
|
|
41
|
+
except (KeyError, ValueError) as exc:
|
|
42
|
+
raise ReceiptTemplateError(
|
|
43
|
+
ReceiptTemplateError.INVALID_FOOTER_TEXT
|
|
44
|
+
) from exc
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ReceiptTemplateError(RuntimeError):
|
|
48
|
+
"""Raised when a receipt template cannot be loaded."""
|
|
49
|
+
|
|
50
|
+
INVALID_FOOTER_TEXT = (
|
|
51
|
+
"渲染模板 footer_text 不合法,只能使用"
|
|
52
|
+
" {timestamp}、{sender_name}、{sender_id} 占位符。"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def read_failed(cls, path: str) -> "ReceiptTemplateError":
|
|
57
|
+
return cls(f"无法读取渲染模板文件: {path}")
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def invalid_json(cls, path: str) -> "ReceiptTemplateError":
|
|
61
|
+
return cls(f"渲染模板文件不是合法的 JSON: {path}")
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def invalid_data(cls, path: str) -> "ReceiptTemplateError":
|
|
65
|
+
return cls(f"渲染模板配置无效: {path}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_receipt_template(config: Config) -> ReceiptTemplate:
|
|
69
|
+
if not config.receipt_template_path:
|
|
70
|
+
return ReceiptTemplate()
|
|
71
|
+
|
|
72
|
+
path = Path(config.receipt_template_path)
|
|
73
|
+
try:
|
|
74
|
+
raw_text = path.read_text(encoding="utf-8")
|
|
75
|
+
except OSError as exc:
|
|
76
|
+
raise ReceiptTemplateError.read_failed(str(path)) from exc
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
data = json.loads(raw_text)
|
|
80
|
+
except json.JSONDecodeError as exc:
|
|
81
|
+
raise ReceiptTemplateError.invalid_json(str(path)) from exc
|
|
82
|
+
|
|
83
|
+
if not isinstance(data, dict):
|
|
84
|
+
raise ReceiptTemplateError.invalid_data(str(path))
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
return ReceiptTemplate.model_validate(data)
|
|
88
|
+
except ValidationError as exc:
|
|
89
|
+
raise ReceiptTemplateError.invalid_data(str(path)) from exc
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nonebot_plugin_receipts
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: NoneBot2 plugin for sending text and image messages to receipts-spooler
|
|
5
|
+
Project-URL: homepage, https://github.com/Effect-Wei/nonebot_plugin_receipts
|
|
6
|
+
Project-URL: repository, https://github.com/Effect-Wei/nonebot_plugin_receipts
|
|
7
|
+
Project-URL: documentation, https://github.com/Effect-Wei/nonebot_plugin_receipts#readme
|
|
8
|
+
Requires-Python: <4.0,>=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: nonebot2[fastapi,httpx]>=2.5.0
|
|
11
|
+
Requires-Dist: nonebot-adapter-onebot>=2.4.6
|
|
12
|
+
Requires-Dist: pillow>=12.2.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: build; extra == "dev"
|
|
15
|
+
Requires-Dist: pyright[nodejs]; extra == "dev"
|
|
16
|
+
Requires-Dist: ruff; extra == "dev"
|
|
17
|
+
|
|
18
|
+
# nonebot_plugin_receipts
|
|
19
|
+
|
|
20
|
+
一个面向 NoneBot2 的独立插件,用来把发送给 Bot 的文本和图片消息转成 ESC/POS 原始打印数据,再投递给 [`receipts-spooler`](https://github.com/Effect-Wei/receipts-spooler)。
|
|
21
|
+
|
|
22
|
+
当前实现面向 OneBot v11 适配器,适合 QQ 机器人消息场景。
|
|
23
|
+
|
|
24
|
+
## 功能
|
|
25
|
+
|
|
26
|
+
- 提供 `ticket`、`小票`、`receipt` 指令,三者互为别名
|
|
27
|
+
- 支持命令后直接附带文本和图片
|
|
28
|
+
- 如果命令后没有内容,会继续追问下一条消息
|
|
29
|
+
- 将文本和图片合成为一张热敏小票图,再编码为 ESC/POS 栅格打印命令
|
|
30
|
+
- 通过 `receipts-spooler` 的 `/api/v1/task/push_raw` 接口提交打印任务
|
|
31
|
+
|
|
32
|
+
## 安装
|
|
33
|
+
|
|
34
|
+
```powershell
|
|
35
|
+
cd nonebot_plugin_receipts
|
|
36
|
+
pip install -e .
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
然后在你的 NoneBot2 项目中加载插件:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
nonebot.load_plugin("nonebot_plugin_receipts")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 配置
|
|
46
|
+
|
|
47
|
+
插件通过 NoneBot 全局配置读取以下环境变量:
|
|
48
|
+
|
|
49
|
+
| 变量 | 默认值 | 说明 |
|
|
50
|
+
| ----------------------------- | ----------------------- | ------------------------------------- |
|
|
51
|
+
| `RECEIPTS_SPOOLER_URL` | `http://127.0.0.1:8000` | `receipts-spooler` 服务地址 |
|
|
52
|
+
| `RECEIPTS_SPOOLER_TOKEN` | 空 | 对应 `X-Spooler-Token` |
|
|
53
|
+
| `RECEIPTS_SPOOLER_TIMEOUT` | `5.0` | 调用 spooler 的 HTTP 超时 |
|
|
54
|
+
| `RECEIPT_IMAGE_FETCH_TIMEOUT` | `5.0` | 下载图片消息内容时的 HTTP 超时 |
|
|
55
|
+
| `RECEIPT_RENDER_MODE` | `raster` | 渲染模式,可选 `raster`/`hybrid` |
|
|
56
|
+
| `RECEIPT_PRINTER_WIDTH` | `576` | 小票像素宽度,最大 `576` |
|
|
57
|
+
| `RECEIPT_TEMPLATE_PATH` | 空 | 可选,指向渲染模板 JSON 文件 |
|
|
58
|
+
| `RECEIPT_FONT_PATH` | 空 | 可选,建议配置支持中文的 TTF/OTF 字体 |
|
|
59
|
+
| `RECEIPT_FONT_SIZE` | `24` | 文本字号 |
|
|
60
|
+
| `RECEIPT_LINE_SPACING` | `6` | 文本行距 |
|
|
61
|
+
| `RECEIPT_SECTION_GAP` | `6` | 文本和图片之间的垂直间距 |
|
|
62
|
+
| `RECEIPT_FEED_LINES` | `4` | 打印后走纸行数 |
|
|
63
|
+
| `RECEIPT_ENABLE_CUT` | `true` | 是否附加切纸命令 |
|
|
64
|
+
|
|
65
|
+
### 渲染模式
|
|
66
|
+
|
|
67
|
+
- `raster`:把文本和图片一起渲染成整张位图,再输出 ESC/POS 栅格数据,兼容性最好。
|
|
68
|
+
- `hybrid`:文本走打印机原生字符输出,图片仍走位图,能减少大量文字被渲染成图片。
|
|
69
|
+
|
|
70
|
+
`hybrid` 模式下,中文是否正常输出取决于打印机对中英文字符集的支持情况。
|
|
71
|
+
|
|
72
|
+
### 渲染模板
|
|
73
|
+
|
|
74
|
+
如果你想单独调整小票布局,可以配置 `RECEIPT_TEMPLATE_PATH` 指向一个 JSON 文件。项目根目录里附带了一个示例文件 [`receipt_template.example.json`](receipt_template.example.json)。
|
|
75
|
+
|
|
76
|
+
支持的模板字段:
|
|
77
|
+
|
|
78
|
+
| 字段 | 默认值 | 说明 |
|
|
79
|
+
| ------------------------------ | ------------------------------------------ | ---------------------- |
|
|
80
|
+
| `margin` | `16` | 左右和顶部内边距 |
|
|
81
|
+
| `footer_enabled` | `true` | 是否渲染页脚 |
|
|
82
|
+
| `footer_text` | `{sender_name}({sender_id}) @ {timestamp}` | 页脚文字模板 |
|
|
83
|
+
| `footer_timestamp_format` | `%Y-%m-%d %H:%M:%S` | 时间格式 |
|
|
84
|
+
| `footer_timezone_offset_hours` | `8` | 页脚时间使用的时区偏移 |
|
|
85
|
+
|
|
86
|
+
`footer_text` 支持这些占位符:
|
|
87
|
+
|
|
88
|
+
- `{timestamp}`
|
|
89
|
+
- `{sender_name}`
|
|
90
|
+
- `{sender_id}`
|
|
91
|
+
|
|
92
|
+
示例:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"margin": 24,
|
|
97
|
+
"footer_enabled": true,
|
|
98
|
+
"footer_text": "{sender_name}({sender_id}) 打印于 {timestamp}",
|
|
99
|
+
"footer_timestamp_format": "%Y/%m/%d %H:%M",
|
|
100
|
+
"footer_timezone_offset_hours": 8
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 使用
|
|
105
|
+
|
|
106
|
+
直接在命令后附带内容:
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
/小票 今晚睡大觉
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
或者:
|
|
113
|
+
|
|
114
|
+
1. 发送 `/小票`
|
|
115
|
+
2. 按提示继续发送一条包含文本、图片或两者混合的消息
|
|
116
|
+
|
|
117
|
+
插件会把纯文本和图片消息段一起打印,并在提交成功后回复当前队列长度。
|
|
118
|
+
|
|
119
|
+
## 注意事项
|
|
120
|
+
|
|
121
|
+
- 如果你需要打印中文,最好配置 `RECEIPT_FONT_PATH` 指向支持中文的字体文件。
|
|
122
|
+
- 图片消息需要 OneBot 事件中带有可访问的 `url`,或消息段 `file` 为 `base64://...` / `http(s)://...`。
|
|
123
|
+
- 本插件不会直接连接打印机,只负责把任务提交到 `receipts-spooler`。
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
nonebot_plugin_receipts/__init__.py,sha256=tpKyLkhTCOsOEqhSfDGIbFnLFeYpdulw82pHCjZYRks,3095
|
|
2
|
+
nonebot_plugin_receipts/config.py,sha256=slOHZfld5pKjZeD2Q2QpqSHOOOcbb8EsddiyfpgqkbI,781
|
|
3
|
+
nonebot_plugin_receipts/renderer.py,sha256=Fq-3iCOUMt5C0I76pby_0zyrlSPtxDSpcNFi94sWwlo,15239
|
|
4
|
+
nonebot_plugin_receipts/spooler.py,sha256=EziTDisUmJtuZS9QtTPNWJV_fruSYjc9vHHV4kWSuGg,1623
|
|
5
|
+
nonebot_plugin_receipts/template.py,sha256=QwOO_E51DcD4MaZcvV8_3492cEse3DhQEbcPuH4ROtw,2825
|
|
6
|
+
nonebot_plugin_receipts-0.1.0.dist-info/METADATA,sha256=iK2yfr-gGIRbEirIp6rb3Jdz_Jd0Z2KIYRBEAvuwr9w,5605
|
|
7
|
+
nonebot_plugin_receipts-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
nonebot_plugin_receipts-0.1.0.dist-info/top_level.txt,sha256=sL2lEyveuzLuEU9aWF8an5t5LblzdrHIGWMLnlEk1Q4,24
|
|
9
|
+
nonebot_plugin_receipts-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nonebot_plugin_receipts
|