nonebot-plugin-receipts 0.2.2__tar.gz → 0.2.4__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.
Files changed (27) hide show
  1. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/PKG-INFO +29 -5
  2. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/README.md +28 -4
  3. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts/__init__.py +3 -2
  4. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts/command_handlers.py +37 -1
  5. nonebot_plugin_receipts-0.2.4/nonebot_plugin_receipts/config.py +66 -0
  6. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts.egg-info/PKG-INFO +29 -5
  7. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts.egg-info/SOURCES.txt +1 -0
  8. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/pyproject.toml +1 -1
  9. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/tests/test_command_handlers.py +130 -6
  10. nonebot_plugin_receipts-0.2.4/tests/test_config.py +69 -0
  11. nonebot_plugin_receipts-0.2.2/nonebot_plugin_receipts/config.py +0 -20
  12. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts/escpos_backend.py +0 -0
  13. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts/raster_backend.py +0 -0
  14. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts/render_blocks.py +0 -0
  15. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts/render_types.py +0 -0
  16. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts/render_utils.py +0 -0
  17. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts/renderer.py +0 -0
  18. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts/spooler.py +0 -0
  19. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts/template.py +0 -0
  20. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts/text_markup.py +0 -0
  21. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts.egg-info/dependency_links.txt +0 -0
  22. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts.egg-info/requires.txt +0 -0
  23. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/nonebot_plugin_receipts.egg-info/top_level.txt +0 -0
  24. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/setup.cfg +0 -0
  25. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/tests/test_render_blocks.py +0 -0
  26. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/tests/test_renderer.py +0 -0
  27. {nonebot_plugin_receipts-0.2.2 → nonebot_plugin_receipts-0.2.4}/tests/test_text_markup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nonebot_plugin_receipts
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: NoneBot2 plugin for sending text and image messages to receipts-spooler
5
5
  Project-URL: homepage, https://github.com/Effect-Wei/nonebot_plugin_receipts
6
6
  Project-URL: repository, https://github.com/Effect-Wei/nonebot_plugin_receipts
@@ -23,7 +23,7 @@ Requires-Dist: ruff; extra == "dev"
23
23
 
24
24
  ## 功能
25
25
 
26
- - 提供 `ticket`、`小票`、`receipt` 指令,三者互为别名
26
+ - 提供 `ticket`、`receipt` 指令,二者互为别名
27
27
  - 支持命令后直接附带文本和图片
28
28
  - 如果命令后没有内容,会继续追问下一条消息
29
29
  - 将文本和图片合成为一张热敏小票图,再编码为 ESC/POS 栅格打印命令
@@ -62,6 +62,8 @@ nonebot.load_plugin("nonebot_plugin_receipts")
62
62
  | `RECEIPT_SESSION_TIMEOUT_SECONDS` | `120` | 等待用户补发打印内容的超时时间(秒) |
63
63
  | `RECEIPT_FEED_LINES` | `4` | 打印后走纸行数 |
64
64
  | `RECEIPT_ENABLE_CUT` | `true` | 是否附加切纸命令 |
65
+ | `RECEIPT_ALLOWED_USER_IDS` | 空 | 允许提交打印任务的 QQ 号白名单 |
66
+ | `RECEIPT_ALLOWED_GROUP_IDS` | 空 | 允许提交打印任务的 QQ 群号白名单 |
65
67
 
66
68
  ### 渲染模式
67
69
 
@@ -70,6 +72,28 @@ nonebot.load_plugin("nonebot_plugin_receipts")
70
72
 
71
73
  `hybrid` 模式下,中文是否正常输出取决于打印机对中英文字符集的支持情况。
72
74
 
75
+ ### 白名单
76
+
77
+ 可以通过以下配置限制谁能提交打印任务:
78
+
79
+ - `RECEIPT_ALLOWED_USER_IDS`:允许提交的 QQ 号白名单
80
+ - `RECEIPT_ALLOWED_GROUP_IDS`:允许提交的 QQ 群号白名单
81
+
82
+ 两个配置都为空时,默认允许所有会话使用。
83
+ 只要命中任意一个条件就会放行:
84
+
85
+ - 发送者 QQ 号在 `RECEIPT_ALLOWED_USER_IDS` 中
86
+ - 当前群号在 `RECEIPT_ALLOWED_GROUP_IDS` 中
87
+
88
+ 未命中白名单的消息会被直接忽略,不会回复任何提示。
89
+
90
+ 环境变量可以写成逗号分隔,例如:
91
+
92
+ ```env
93
+ RECEIPT_ALLOWED_USER_IDS=123456,234567
94
+ RECEIPT_ALLOWED_GROUP_IDS=345678,456789
95
+ ```
96
+
73
97
  ### 渲染模板
74
98
 
75
99
  如果你想单独调整小票布局,可以配置 `RECEIPT_TEMPLATE_PATH` 指向一个 JSON 文件。项目根目录里附带了一个示例文件 [`receipt_template.example.json`](receipt_template.example.json)。
@@ -113,18 +137,18 @@ nonebot.load_plugin("nonebot_plugin_receipts")
113
137
  直接在命令后附带内容:
114
138
 
115
139
  ```text
116
- /小票 今晚睡大觉
140
+ /ticket 今晚睡大觉
117
141
  ```
118
142
 
119
143
  或者:
120
144
 
121
- 1. 发送 `/小票`
145
+ 1. 发送 `/ticket` 或 `/receipt`
122
146
  2. 按提示继续发送一条包含文本、图片或两者混合的消息
123
147
 
124
148
  插件会把纯文本和图片消息段一起打印,并在提交成功后回复当前队列长度。
125
149
 
126
150
  如果命令后没有直接附带内容,插件会提示用户继续发送,并明确说明多久后超时。
127
- 如果用户在该时间窗口后才继续回复,插件会提示“已超时”,并直接结束本次等待;此时需要重新发送 `/小票` 开始新的打印流程。
151
+ 如果用户在该时间窗口后才继续回复,插件会提示“已超时”,并直接结束本次等待;此时需要重新发送 `/ticket` 或 `/receipt` 开始新的打印流程。
128
152
 
129
153
  `RECEIPT_SESSION_TIMEOUT_SECONDS` 的实际生效时间不会超过 NoneBot 全局 `SESSION_EXPIRE_TIMEOUT`,建议两者保持一致或让前者更小。
130
154
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## 功能
8
8
 
9
- - 提供 `ticket`、`小票`、`receipt` 指令,三者互为别名
9
+ - 提供 `ticket`、`receipt` 指令,二者互为别名
10
10
  - 支持命令后直接附带文本和图片
11
11
  - 如果命令后没有内容,会继续追问下一条消息
12
12
  - 将文本和图片合成为一张热敏小票图,再编码为 ESC/POS 栅格打印命令
@@ -45,6 +45,8 @@ nonebot.load_plugin("nonebot_plugin_receipts")
45
45
  | `RECEIPT_SESSION_TIMEOUT_SECONDS` | `120` | 等待用户补发打印内容的超时时间(秒) |
46
46
  | `RECEIPT_FEED_LINES` | `4` | 打印后走纸行数 |
47
47
  | `RECEIPT_ENABLE_CUT` | `true` | 是否附加切纸命令 |
48
+ | `RECEIPT_ALLOWED_USER_IDS` | 空 | 允许提交打印任务的 QQ 号白名单 |
49
+ | `RECEIPT_ALLOWED_GROUP_IDS` | 空 | 允许提交打印任务的 QQ 群号白名单 |
48
50
 
49
51
  ### 渲染模式
50
52
 
@@ -53,6 +55,28 @@ nonebot.load_plugin("nonebot_plugin_receipts")
53
55
 
54
56
  `hybrid` 模式下,中文是否正常输出取决于打印机对中英文字符集的支持情况。
55
57
 
58
+ ### 白名单
59
+
60
+ 可以通过以下配置限制谁能提交打印任务:
61
+
62
+ - `RECEIPT_ALLOWED_USER_IDS`:允许提交的 QQ 号白名单
63
+ - `RECEIPT_ALLOWED_GROUP_IDS`:允许提交的 QQ 群号白名单
64
+
65
+ 两个配置都为空时,默认允许所有会话使用。
66
+ 只要命中任意一个条件就会放行:
67
+
68
+ - 发送者 QQ 号在 `RECEIPT_ALLOWED_USER_IDS` 中
69
+ - 当前群号在 `RECEIPT_ALLOWED_GROUP_IDS` 中
70
+
71
+ 未命中白名单的消息会被直接忽略,不会回复任何提示。
72
+
73
+ 环境变量可以写成逗号分隔,例如:
74
+
75
+ ```env
76
+ RECEIPT_ALLOWED_USER_IDS=123456,234567
77
+ RECEIPT_ALLOWED_GROUP_IDS=345678,456789
78
+ ```
79
+
56
80
  ### 渲染模板
57
81
 
58
82
  如果你想单独调整小票布局,可以配置 `RECEIPT_TEMPLATE_PATH` 指向一个 JSON 文件。项目根目录里附带了一个示例文件 [`receipt_template.example.json`](receipt_template.example.json)。
@@ -96,18 +120,18 @@ nonebot.load_plugin("nonebot_plugin_receipts")
96
120
  直接在命令后附带内容:
97
121
 
98
122
  ```text
99
- /小票 今晚睡大觉
123
+ /ticket 今晚睡大觉
100
124
  ```
101
125
 
102
126
  或者:
103
127
 
104
- 1. 发送 `/小票`
128
+ 1. 发送 `/ticket` 或 `/receipt`
105
129
  2. 按提示继续发送一条包含文本、图片或两者混合的消息
106
130
 
107
131
  插件会把纯文本和图片消息段一起打印,并在提交成功后回复当前队列长度。
108
132
 
109
133
  如果命令后没有直接附带内容,插件会提示用户继续发送,并明确说明多久后超时。
110
- 如果用户在该时间窗口后才继续回复,插件会提示“已超时”,并直接结束本次等待;此时需要重新发送 `/小票` 开始新的打印流程。
134
+ 如果用户在该时间窗口后才继续回复,插件会提示“已超时”,并直接结束本次等待;此时需要重新发送 `/ticket` 或 `/receipt` 开始新的打印流程。
111
135
 
112
136
  `RECEIPT_SESSION_TIMEOUT_SECONDS` 的实际生效时间不会超过 NoneBot 全局 `SESSION_EXPIRE_TIMEOUT`,建议两者保持一致或让前者更小。
113
137
 
@@ -8,7 +8,8 @@ __plugin_meta__ = PluginMetadata(
8
8
  name="小票打印",
9
9
  description="将发送给 Bot 的文本和图片消息通过 receipts-spooler 打印",
10
10
  usage=(
11
- "发送 /小票 后附带文本/图片,或单独发送命令后按提示再发送一条要打印的消息。"
11
+ "发送 /ticket 或 /receipt 后附带文本/图片,"
12
+ "或单独发送命令后按提示再发送一条要打印的消息。"
12
13
  ),
13
14
  type="application",
14
15
  config=Config,
@@ -19,7 +20,7 @@ __plugin_meta__ = PluginMetadata(
19
20
  try:
20
21
  ReceiptPrint = on_command(
21
22
  "ticket",
22
- aliases={"小票", "ticket", "receipt"},
23
+ aliases={"ticket", "receipt"},
23
24
  block=True,
24
25
  priority=10,
25
26
  )
@@ -4,6 +4,7 @@ from time import monotonic
4
4
 
5
5
  from nonebot import get_driver, get_plugin_config
6
6
  from nonebot.adapters.onebot.v11 import Message, MessageEvent # noqa: TC002
7
+ from nonebot.exception import FinishedException
7
8
  from nonebot.matcher import Matcher # noqa: TC002
8
9
  from nonebot.params import Arg, CommandArg
9
10
 
@@ -20,8 +21,11 @@ def register_receipt_handlers(receipt_print: type[Matcher]) -> None:
20
21
 
21
22
  @receipt_print.handle()
22
23
  async def handle_print_command(
23
- matcher: Matcher, message: Message = CommandArg()
24
+ matcher: Matcher, event: MessageEvent, message: Message = CommandArg()
24
25
  ) -> None:
26
+ if not is_receipt_submission_allowed(event):
27
+ raise FinishedException
28
+
25
29
  if has_printable_content(message):
26
30
  matcher.set_arg("receipt_content", message)
27
31
  return
@@ -38,6 +42,9 @@ def register_receipt_handlers(receipt_print: type[Matcher]) -> None:
38
42
  event: MessageEvent,
39
43
  receipt_content: Message = Arg("receipt_content"),
40
44
  ) -> None:
45
+ if not is_receipt_submission_allowed(event):
46
+ raise FinishedException
47
+
41
48
  timeout_seconds = get_effective_session_timeout_seconds()
42
49
  if is_receipt_timeout_expired(matcher):
43
50
  await receipt_print.finish(build_timeout_receipt_prompt(timeout_seconds))
@@ -102,6 +109,35 @@ def get_runtime_config() -> Config:
102
109
  return get_plugin_config(Config)
103
110
 
104
111
 
112
+ def is_receipt_submission_allowed(event: MessageEvent) -> bool:
113
+ """Return whether the incoming event is allowed to submit a print job."""
114
+ config = get_runtime_config()
115
+ allowed_user_ids = set(config.allowed_user_ids)
116
+ allowed_group_ids = set(config.allowed_group_ids)
117
+
118
+ if not allowed_user_ids and not allowed_group_ids:
119
+ return True
120
+
121
+ user_id = get_event_user_id(event)
122
+ if user_id and user_id in allowed_user_ids:
123
+ return True
124
+
125
+ group_id = get_event_group_id(event)
126
+ return bool(group_id and group_id in allowed_group_ids)
127
+
128
+
129
+ def get_event_user_id(event: MessageEvent) -> str:
130
+ """Extract the normalized sender QQ id from an event."""
131
+ user_id = getattr(event, "user_id", None)
132
+ return str(user_id).strip() if user_id is not None else ""
133
+
134
+
135
+ def get_event_group_id(event: MessageEvent) -> str:
136
+ """Extract the normalized group QQ id from an event, if any."""
137
+ group_id = getattr(event, "group_id", None)
138
+ return str(group_id).strip() if group_id is not None else ""
139
+
140
+
105
141
  def get_effective_session_timeout_seconds() -> int:
106
142
  """Return the effective wait timeout bounded by NoneBot's session timeout."""
107
143
  plugin_timeout = get_runtime_config().receipt_session_timeout_seconds
@@ -0,0 +1,66 @@
1
+ import json
2
+ import re
3
+ from typing import Any, Literal
4
+
5
+ from pydantic import BaseModel, Field, field_validator
6
+
7
+
8
+ def normalize_whitelist_ids(value: Any) -> list[str]:
9
+ if value is None:
10
+ return []
11
+
12
+ if isinstance(value, str):
13
+ stripped = value.strip()
14
+ if not stripped:
15
+ return []
16
+
17
+ try:
18
+ value = json.loads(stripped)
19
+ except json.JSONDecodeError:
20
+ return [
21
+ item
22
+ for part in re.split(r"[\s,,]+", stripped)
23
+ if (item := part.strip())
24
+ ]
25
+
26
+ if isinstance(value, list | tuple | set | frozenset):
27
+ return [item for entry in value if (item := str(entry).strip())]
28
+
29
+ item = str(value).strip()
30
+ return [item] if item else []
31
+
32
+
33
+ class Config(BaseModel):
34
+ receipts_spooler_url: str = "http://127.0.0.1:8000"
35
+ receipts_spooler_token: str | None = None
36
+ receipts_spooler_timeout: float = 5.0
37
+ receipt_image_fetch_timeout: float = 5.0
38
+ receipt_render_mode: Literal["raster", "hybrid"] = "raster"
39
+ receipt_printer_width: int = Field(default=576, ge=128, le=576)
40
+ receipt_template_path: str | None = None
41
+ receipt_font_path: str | None = None
42
+ receipt_font_size: int = Field(default=24, ge=12, le=96)
43
+ receipt_line_spacing: int = Field(default=6, ge=0, le=40)
44
+ receipt_section_gap: int = Field(default=6, ge=0, le=100)
45
+ receipt_session_timeout_seconds: int = Field(default=120, ge=10, le=3600)
46
+ receipt_feed_lines: int = Field(default=4, ge=0, le=10)
47
+ receipt_enable_cut: bool = True
48
+ receipt_allowed_user_ids: str | list[str] | None = Field(default_factory=list)
49
+ receipt_allowed_group_ids: str | list[str] | None = Field(default_factory=list)
50
+
51
+ @field_validator(
52
+ "receipt_allowed_user_ids",
53
+ "receipt_allowed_group_ids",
54
+ mode="before",
55
+ )
56
+ @classmethod
57
+ def normalize_whitelist_ids(cls, value: Any) -> list[str]:
58
+ return normalize_whitelist_ids(value)
59
+
60
+ @property
61
+ def allowed_user_ids(self) -> list[str]:
62
+ return normalize_whitelist_ids(self.receipt_allowed_user_ids)
63
+
64
+ @property
65
+ def allowed_group_ids(self) -> list[str]:
66
+ return normalize_whitelist_ids(self.receipt_allowed_group_ids)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nonebot_plugin_receipts
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: NoneBot2 plugin for sending text and image messages to receipts-spooler
5
5
  Project-URL: homepage, https://github.com/Effect-Wei/nonebot_plugin_receipts
6
6
  Project-URL: repository, https://github.com/Effect-Wei/nonebot_plugin_receipts
@@ -23,7 +23,7 @@ Requires-Dist: ruff; extra == "dev"
23
23
 
24
24
  ## 功能
25
25
 
26
- - 提供 `ticket`、`小票`、`receipt` 指令,三者互为别名
26
+ - 提供 `ticket`、`receipt` 指令,二者互为别名
27
27
  - 支持命令后直接附带文本和图片
28
28
  - 如果命令后没有内容,会继续追问下一条消息
29
29
  - 将文本和图片合成为一张热敏小票图,再编码为 ESC/POS 栅格打印命令
@@ -62,6 +62,8 @@ nonebot.load_plugin("nonebot_plugin_receipts")
62
62
  | `RECEIPT_SESSION_TIMEOUT_SECONDS` | `120` | 等待用户补发打印内容的超时时间(秒) |
63
63
  | `RECEIPT_FEED_LINES` | `4` | 打印后走纸行数 |
64
64
  | `RECEIPT_ENABLE_CUT` | `true` | 是否附加切纸命令 |
65
+ | `RECEIPT_ALLOWED_USER_IDS` | 空 | 允许提交打印任务的 QQ 号白名单 |
66
+ | `RECEIPT_ALLOWED_GROUP_IDS` | 空 | 允许提交打印任务的 QQ 群号白名单 |
65
67
 
66
68
  ### 渲染模式
67
69
 
@@ -70,6 +72,28 @@ nonebot.load_plugin("nonebot_plugin_receipts")
70
72
 
71
73
  `hybrid` 模式下,中文是否正常输出取决于打印机对中英文字符集的支持情况。
72
74
 
75
+ ### 白名单
76
+
77
+ 可以通过以下配置限制谁能提交打印任务:
78
+
79
+ - `RECEIPT_ALLOWED_USER_IDS`:允许提交的 QQ 号白名单
80
+ - `RECEIPT_ALLOWED_GROUP_IDS`:允许提交的 QQ 群号白名单
81
+
82
+ 两个配置都为空时,默认允许所有会话使用。
83
+ 只要命中任意一个条件就会放行:
84
+
85
+ - 发送者 QQ 号在 `RECEIPT_ALLOWED_USER_IDS` 中
86
+ - 当前群号在 `RECEIPT_ALLOWED_GROUP_IDS` 中
87
+
88
+ 未命中白名单的消息会被直接忽略,不会回复任何提示。
89
+
90
+ 环境变量可以写成逗号分隔,例如:
91
+
92
+ ```env
93
+ RECEIPT_ALLOWED_USER_IDS=123456,234567
94
+ RECEIPT_ALLOWED_GROUP_IDS=345678,456789
95
+ ```
96
+
73
97
  ### 渲染模板
74
98
 
75
99
  如果你想单独调整小票布局,可以配置 `RECEIPT_TEMPLATE_PATH` 指向一个 JSON 文件。项目根目录里附带了一个示例文件 [`receipt_template.example.json`](receipt_template.example.json)。
@@ -113,18 +137,18 @@ nonebot.load_plugin("nonebot_plugin_receipts")
113
137
  直接在命令后附带内容:
114
138
 
115
139
  ```text
116
- /小票 今晚睡大觉
140
+ /ticket 今晚睡大觉
117
141
  ```
118
142
 
119
143
  或者:
120
144
 
121
- 1. 发送 `/小票`
145
+ 1. 发送 `/ticket` 或 `/receipt`
122
146
  2. 按提示继续发送一条包含文本、图片或两者混合的消息
123
147
 
124
148
  插件会把纯文本和图片消息段一起打印,并在提交成功后回复当前队列长度。
125
149
 
126
150
  如果命令后没有直接附带内容,插件会提示用户继续发送,并明确说明多久后超时。
127
- 如果用户在该时间窗口后才继续回复,插件会提示“已超时”,并直接结束本次等待;此时需要重新发送 `/小票` 开始新的打印流程。
151
+ 如果用户在该时间窗口后才继续回复,插件会提示“已超时”,并直接结束本次等待;此时需要重新发送 `/ticket` 或 `/receipt` 开始新的打印流程。
128
152
 
129
153
  `RECEIPT_SESSION_TIMEOUT_SECONDS` 的实际生效时间不会超过 NoneBot 全局 `SESSION_EXPIRE_TIMEOUT`,建议两者保持一致或让前者更小。
130
154
 
@@ -18,6 +18,7 @@ nonebot_plugin_receipts.egg-info/dependency_links.txt
18
18
  nonebot_plugin_receipts.egg-info/requires.txt
19
19
  nonebot_plugin_receipts.egg-info/top_level.txt
20
20
  tests/test_command_handlers.py
21
+ tests/test_config.py
21
22
  tests/test_render_blocks.py
22
23
  tests/test_renderer.py
23
24
  tests/test_text_markup.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nonebot_plugin_receipts"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "NoneBot2 plugin for sending text and image messages to receipts-spooler"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12, <4.0"
@@ -5,12 +5,17 @@ from types import SimpleNamespace
5
5
  from typing import TYPE_CHECKING, Any, cast
6
6
  from unittest.mock import AsyncMock, patch
7
7
 
8
+ from nonebot.exception import FinishedException
9
+
8
10
  from nonebot_plugin_receipts.command_handlers import (
9
11
  RECEIPT_TIMEOUT_DEADLINE_KEY,
10
12
  build_initial_receipt_prompt,
11
13
  build_timeout_receipt_prompt,
12
14
  format_timeout_duration,
13
15
  get_effective_session_timeout_seconds,
16
+ get_event_group_id,
17
+ get_event_user_id,
18
+ is_receipt_submission_allowed,
14
19
  is_receipt_timeout_expired,
15
20
  register_receipt_handlers,
16
21
  reset_receipt_timeout_deadline,
@@ -99,8 +104,23 @@ class DummyDriver:
99
104
 
100
105
 
101
106
  class DummyPluginConfig:
102
- def __init__(self, seconds: int) -> None:
107
+ def __init__(
108
+ self,
109
+ seconds: int,
110
+ allowed_user_ids: list[str] | None = None,
111
+ allowed_group_ids: list[str] | None = None,
112
+ ) -> None:
103
113
  self.receipt_session_timeout_seconds = seconds
114
+ self.receipt_allowed_user_ids = allowed_user_ids or []
115
+ self.receipt_allowed_group_ids = allowed_group_ids or []
116
+
117
+ @property
118
+ def allowed_user_ids(self) -> list[str]:
119
+ return self.receipt_allowed_user_ids
120
+
121
+ @property
122
+ def allowed_group_ids(self) -> list[str]:
123
+ return self.receipt_allowed_group_ids
104
124
 
105
125
 
106
126
  class CommandHandlersTestCase(unittest.TestCase):
@@ -142,6 +162,54 @@ class CommandHandlersTestCase(unittest.TestCase):
142
162
  matcher.state[RECEIPT_TIMEOUT_DEADLINE_KEY] = 0
143
163
  self.assertTrue(is_receipt_timeout_expired(cast("Any", matcher)))
144
164
 
165
+ def test_get_event_ids(self) -> None:
166
+ private_event = SimpleNamespace(user_id=123456)
167
+ group_event = SimpleNamespace(user_id=123456, group_id=654321)
168
+
169
+ self.assertEqual(get_event_user_id(cast("Any", private_event)), "123456")
170
+ self.assertEqual(get_event_group_id(cast("Any", private_event)), "")
171
+ self.assertEqual(get_event_group_id(cast("Any", group_event)), "654321")
172
+
173
+ def test_submission_allowed_when_whitelist_empty(self) -> None:
174
+ event = SimpleNamespace(user_id=123456, group_id=654321)
175
+
176
+ with patch(
177
+ "nonebot_plugin_receipts.command_handlers.get_runtime_config",
178
+ return_value=DummyPluginConfig(120),
179
+ ):
180
+ self.assertTrue(is_receipt_submission_allowed(cast("Any", event)))
181
+
182
+ def test_submission_allowed_for_whitelisted_user(self) -> None:
183
+ event = SimpleNamespace(user_id=123456)
184
+
185
+ with patch(
186
+ "nonebot_plugin_receipts.command_handlers.get_runtime_config",
187
+ return_value=DummyPluginConfig(120, allowed_user_ids=["123456"]),
188
+ ):
189
+ self.assertTrue(is_receipt_submission_allowed(cast("Any", event)))
190
+
191
+ def test_submission_allowed_for_whitelisted_group(self) -> None:
192
+ event = SimpleNamespace(user_id=111111, group_id=654321)
193
+
194
+ with patch(
195
+ "nonebot_plugin_receipts.command_handlers.get_runtime_config",
196
+ return_value=DummyPluginConfig(120, allowed_group_ids=["654321"]),
197
+ ):
198
+ self.assertTrue(is_receipt_submission_allowed(cast("Any", event)))
199
+
200
+ def test_submission_denied_for_non_whitelisted_sender(self) -> None:
201
+ event = SimpleNamespace(user_id=111111, group_id=222222)
202
+
203
+ with patch(
204
+ "nonebot_plugin_receipts.command_handlers.get_runtime_config",
205
+ return_value=DummyPluginConfig(
206
+ 120,
207
+ allowed_user_ids=["123456"],
208
+ allowed_group_ids=["654321"],
209
+ ),
210
+ ):
211
+ self.assertFalse(is_receipt_submission_allowed(cast("Any", event)))
212
+
145
213
 
146
214
  class CommandHandlerFlowTestCase(unittest.IsolatedAsyncioTestCase):
147
215
  def setUp(self) -> None:
@@ -153,23 +221,55 @@ class CommandHandlerFlowTestCase(unittest.IsolatedAsyncioTestCase):
153
221
  self,
154
222
  ) -> None:
155
223
  matcher = SimpleNamespace(state={}, send=AsyncMock(), set_arg=AsyncMock())
224
+ event = SimpleNamespace(user_id=123456)
156
225
  message = DummyMessage()
157
226
 
158
- with patch(
159
- "nonebot_plugin_receipts.command_handlers.get_effective_session_timeout_seconds",
160
- return_value=119,
227
+ with (
228
+ patch(
229
+ "nonebot_plugin_receipts.command_handlers.get_effective_session_timeout_seconds",
230
+ return_value=119,
231
+ ),
232
+ patch(
233
+ "nonebot_plugin_receipts.command_handlers.get_runtime_config",
234
+ return_value=DummyPluginConfig(120),
235
+ ),
161
236
  ):
162
- await call_registered_handler(require_handle_handler(), matcher, message)
237
+ await call_registered_handler(
238
+ require_handle_handler(), matcher, event, message
239
+ )
163
240
 
164
241
  matcher.send.assert_awaited_once()
165
242
  prompt = matcher.send.await_args.args[0]
166
243
  self.assertIn("1 分 59 秒", prompt)
167
244
  self.assertIn(RECEIPT_TIMEOUT_DEADLINE_KEY, matcher.state)
168
245
 
246
+ async def test_handle_print_command_ignores_non_whitelisted_sender(self) -> None:
247
+ matcher = SimpleNamespace(state={}, send=AsyncMock(), set_arg=AsyncMock())
248
+ event = SimpleNamespace(user_id=111111, group_id=222222)
249
+ message = DummyMessage(text="hello")
250
+
251
+ with (
252
+ self.assertRaises(FinishedException),
253
+ patch(
254
+ "nonebot_plugin_receipts.command_handlers.get_runtime_config",
255
+ return_value=DummyPluginConfig(
256
+ 120,
257
+ allowed_user_ids=["123456"],
258
+ allowed_group_ids=["654321"],
259
+ ),
260
+ ),
261
+ ):
262
+ await call_registered_handler(
263
+ require_handle_handler(), matcher, event, message
264
+ )
265
+
266
+ matcher.send.assert_not_awaited()
267
+ matcher.set_arg.assert_not_awaited()
268
+
169
269
  async def test_handle_receipt_content_finishes_when_timeout_expired(self) -> None:
170
270
  matcher = DummyMatcher()
171
271
  matcher.state[RECEIPT_TIMEOUT_DEADLINE_KEY] = 0
172
- event = SimpleNamespace()
272
+ event = SimpleNamespace(user_id=123456)
173
273
  receipt_content = DummyMessage(text="late")
174
274
 
175
275
  with (
@@ -178,6 +278,10 @@ class CommandHandlerFlowTestCase(unittest.IsolatedAsyncioTestCase):
178
278
  "nonebot_plugin_receipts.command_handlers.get_effective_session_timeout_seconds",
179
279
  return_value=119,
180
280
  ),
281
+ patch(
282
+ "nonebot_plugin_receipts.command_handlers.get_runtime_config",
283
+ return_value=DummyPluginConfig(120),
284
+ ),
181
285
  ):
182
286
  await call_registered_handler(
183
287
  require_got_handler(), matcher, event, receipt_content
@@ -186,6 +290,26 @@ class CommandHandlerFlowTestCase(unittest.IsolatedAsyncioTestCase):
186
290
  self.assertIn("已超时", str(exc_info.exception))
187
291
  self.assertIn("/小票", str(exc_info.exception))
188
292
 
293
+ async def test_handle_receipt_content_ignores_non_whitelisted_sender(self) -> None:
294
+ matcher = DummyMatcher()
295
+ event = SimpleNamespace(user_id=111111, group_id=222222)
296
+ receipt_content = DummyMessage(text="late")
297
+
298
+ with (
299
+ self.assertRaises(FinishedException),
300
+ patch(
301
+ "nonebot_plugin_receipts.command_handlers.get_runtime_config",
302
+ return_value=DummyPluginConfig(
303
+ 120,
304
+ allowed_user_ids=["123456"],
305
+ allowed_group_ids=["654321"],
306
+ ),
307
+ ),
308
+ ):
309
+ await call_registered_handler(
310
+ require_got_handler(), matcher, event, receipt_content
311
+ )
312
+
189
313
 
190
314
  if __name__ == "__main__":
191
315
  unittest.main()
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import unittest
5
+ from unittest.mock import patch
6
+
7
+ from nonebot.config import BaseSettings
8
+ from nonebot_plugin_receipts.config import Config
9
+
10
+
11
+ class ConfigTestCase(unittest.TestCase):
12
+ def test_whitelist_defaults_to_empty_lists(self) -> None:
13
+ config = Config()
14
+
15
+ self.assertEqual(config.allowed_user_ids, [])
16
+ self.assertEqual(config.allowed_group_ids, [])
17
+
18
+ def test_whitelist_accepts_comma_separated_strings(self) -> None:
19
+ config = Config.model_validate(
20
+ {
21
+ "receipt_allowed_user_ids": "123456, 234567,345678",
22
+ "receipt_allowed_group_ids": "456789 567890",
23
+ }
24
+ )
25
+
26
+ self.assertEqual(
27
+ config.allowed_user_ids,
28
+ ["123456", "234567", "345678"],
29
+ )
30
+ self.assertEqual(
31
+ config.allowed_group_ids,
32
+ ["456789", "567890"],
33
+ )
34
+
35
+ def test_whitelist_accepts_json_arrays(self) -> None:
36
+ config = Config.model_validate(
37
+ {
38
+ "receipt_allowed_user_ids": '["123456", 234567, ""]',
39
+ "receipt_allowed_group_ids": [456789, "567890", ""],
40
+ }
41
+ )
42
+
43
+ self.assertEqual(config.allowed_user_ids, ["123456", "234567"])
44
+ self.assertEqual(config.allowed_group_ids, ["456789", "567890"])
45
+
46
+ def test_whitelist_accepts_nonebot_env_style_values(self) -> None:
47
+ with patch.dict(
48
+ os.environ,
49
+ {
50
+ "RECEIPT_ALLOWED_USER_IDS": "123456,234567",
51
+ "RECEIPT_ALLOWED_GROUP_IDS": "[\"345678\", \"456789\"]",
52
+ },
53
+ clear=False,
54
+ ):
55
+ values = BaseSettings._settings_build_values(
56
+ Config,
57
+ {},
58
+ env_file=None,
59
+ env_file_encoding="utf-8",
60
+ env_nested_delimiter="__",
61
+ )
62
+ config = Config.model_validate(values)
63
+
64
+ self.assertEqual(config.allowed_user_ids, ["123456", "234567"])
65
+ self.assertEqual(config.allowed_group_ids, ["345678", "456789"])
66
+
67
+
68
+ if __name__ == "__main__":
69
+ unittest.main()
@@ -1,20 +0,0 @@
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_session_timeout_seconds: int = Field(default=120, ge=10, le=3600)
19
- receipt_feed_lines: int = Field(default=4, ge=0, le=10)
20
- receipt_enable_cut: bool = True