nonebot-plugin-tgforwarder 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_tgforwarder-0.1.0/PKG-INFO +166 -0
- nonebot_plugin_tgforwarder-0.1.0/README.md +155 -0
- nonebot_plugin_tgforwarder-0.1.0/pyproject.toml +112 -0
- nonebot_plugin_tgforwarder-0.1.0/src/nonebot_plugin_tgforwarder/__init__.py +162 -0
- nonebot_plugin_tgforwarder-0.1.0/src/nonebot_plugin_tgforwarder/client.py +161 -0
- nonebot_plugin_tgforwarder-0.1.0/src/nonebot_plugin_tgforwarder/config.py +24 -0
- nonebot_plugin_tgforwarder-0.1.0/src/nonebot_plugin_tgforwarder/cursor.py +60 -0
- nonebot_plugin_tgforwarder-0.1.0/src/nonebot_plugin_tgforwarder/dispatcher.py +60 -0
- nonebot_plugin_tgforwarder-0.1.0/src/nonebot_plugin_tgforwarder/event.py +68 -0
- nonebot_plugin_tgforwarder-0.1.0/src/nonebot_plugin_tgforwarder/models.py +84 -0
- nonebot_plugin_tgforwarder-0.1.0/src/nonebot_plugin_tgforwarder/poller.py +188 -0
- nonebot_plugin_tgforwarder-0.1.0/src/nonebot_plugin_tgforwarder/whitelist.py +140 -0
- nonebot_plugin_tgforwarder-0.1.0/tests/conftest.py +5 -0
- nonebot_plugin_tgforwarder-0.1.0/tests/test_client.py +157 -0
- nonebot_plugin_tgforwarder-0.1.0/tests/test_cursor.py +27 -0
- nonebot_plugin_tgforwarder-0.1.0/tests/test_dispatcher.py +54 -0
- nonebot_plugin_tgforwarder-0.1.0/tests/test_event.py +25 -0
- nonebot_plugin_tgforwarder-0.1.0/tests/test_poller.py +169 -0
- nonebot_plugin_tgforwarder-0.1.0/tests/test_whitelist.py +71 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: nonebot-plugin-tgforwarder
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Poll safewbot's public message API and dispatch processed messages to NoneBot
|
|
5
|
+
Author: EasiFlux
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: nonebot2>=2.3.1
|
|
9
|
+
Requires-Dist: httpx<1.0.0,>=0.27.0
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# nonebot-plugin-tgforwarder
|
|
13
|
+
|
|
14
|
+
一个 NoneBot2 插件,通过轮询 `safewbot` 的公开消息 HTTP API 获取已经完成规则处理或审核发布的消息,并把它们交给 Python 回调或 NoneBot matcher。
|
|
15
|
+
|
|
16
|
+
## 实现边界
|
|
17
|
+
|
|
18
|
+
数据流:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
safewbot 公开消息 API
|
|
22
|
+
-> HTTP 轮询
|
|
23
|
+
-> cursor / delivery_id 去重与消息建模
|
|
24
|
+
-> Python 回调或 NoneBot 自定义事件 matcher
|
|
25
|
+
-> 业务自己的后续转发逻辑
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
本插件仅调用:
|
|
29
|
+
|
|
30
|
+
```http
|
|
31
|
+
GET /api/public/v1/messages?cursor=<int>&limit=<1..100>
|
|
32
|
+
Authorization: Bearer <API endpoint token>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
不包含以下能力:
|
|
36
|
+
|
|
37
|
+
- SafeW 普通账号登录或私密群监听;
|
|
38
|
+
- SafeW Bot API 的 `getUpdates` / webhook;
|
|
39
|
+
- `safewbot` Web 管理 API;
|
|
40
|
+
- 媒体文件下载。
|
|
41
|
+
|
|
42
|
+
公开 API 当前只返回处理后的文本和媒体元数据,不返回本地文件路径、下载 URL 或媒体二进制。插件不会读取或修改 `safewbot` 仓库。
|
|
43
|
+
|
|
44
|
+
## 安装
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install nonebot-plugin-tgforwarder
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
在 NoneBot 项目中加载插件:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
nonebot.load_plugin("nonebot_plugin_tgforwarder")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
先在 `safewbot` 管理后台创建“对外 API”目标,绑定需要的来源,并保存只显示一次的 Token。
|
|
57
|
+
|
|
58
|
+
## 配置
|
|
59
|
+
|
|
60
|
+
在 NoneBot 的 `.env` 中配置:
|
|
61
|
+
|
|
62
|
+
```dotenv
|
|
63
|
+
TGFORWARDER_API_BASE_URL=http://127.0.0.1:8000
|
|
64
|
+
TGFORWARDER_API_TOKEN=tgf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
| 配置项 | 默认值 | 说明 |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| `TGFORWARDER_ENABLED` | `true` | 是否启动轮询器 |
|
|
70
|
+
| `TGFORWARDER_API_BASE_URL` | `http://127.0.0.1:8000` | `safewbot` Web 服务根地址 |
|
|
71
|
+
| `TGFORWARDER_API_TOKEN` | 空 | 公开 API 目标 Token;为空时记录错误且不启动 |
|
|
72
|
+
| `TGFORWARDER_CURSOR_FILE` | `data/tgforwarder-cursor.json` | 跨重启游标文件 |
|
|
73
|
+
| `TGFORWARDER_WHITELIST_FILE` | `data/tgforwarder-whitelist.json` | 来源群白名单文件 |
|
|
74
|
+
| `TGFORWARDER_INITIAL_CURSOR` | `0` | 游标文件不存在或损坏时的起始游标 |
|
|
75
|
+
| `TGFORWARDER_PAGE_LIMIT` | `50` | 单页条数,范围 `1..100` |
|
|
76
|
+
| `TGFORWARDER_POLL_INTERVAL` | `2.0` | 无更多消息时的轮询间隔(秒) |
|
|
77
|
+
| `TGFORWARDER_REQUEST_TIMEOUT` | `30.0` | HTTP 请求超时(秒) |
|
|
78
|
+
| `TGFORWARDER_RETRY_INITIAL` | `1.0` | 首次重试等待(秒) |
|
|
79
|
+
| `TGFORWARDER_RETRY_MAX` | `60.0` | 最大退避等待(秒) |
|
|
80
|
+
| `TGFORWARDER_RETRY_JITTER` | `0.2` | 退避随机抖动比例,范围 `0..1` |
|
|
81
|
+
| `TGFORWARDER_DEDUP_CACHE_SIZE` | `2048` | 内存中的 `delivery_id` 去重容量 |
|
|
82
|
+
| `TGFORWARDER_DISPATCH_MATCHER` | `true` | 是否向 NoneBot matcher 分发事件 |
|
|
83
|
+
| `TGFORWARDER_MATCHER_PRIORITY` | `1` | 自定义事件 matcher 优先级 |
|
|
84
|
+
| `TGFORWARDER_SHUTDOWN_TIMEOUT` | `5.0` | 关闭时等待当前处理完成的最长时间 |
|
|
85
|
+
|
|
86
|
+
游标文件与一个公开 API 目标配套使用。切换到另一个 API 目标时,应更换或删除游标文件;Token 仅在同一目标上轮换时可以保留原游标。
|
|
87
|
+
|
|
88
|
+
## 群白名单
|
|
89
|
+
|
|
90
|
+
插件默认拒绝所有来源群。只有 `PublicMessage.source_chat_id` 已加入白名单的消息才会分发给 Python 回调或 NoneBot matcher。
|
|
91
|
+
|
|
92
|
+
将 Bot 主人的账号配置为 NoneBot 超级用户:
|
|
93
|
+
|
|
94
|
+
```dotenv
|
|
95
|
+
SUPERUSERS=["123456789"]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Bot 主人可以在与 Bot 的私聊中管理白名单:
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
/tgfwd add 群号
|
|
102
|
+
/tgfwd del 群号
|
|
103
|
+
/tgfwd list
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
非白名单群的消息会跳过分发并提交游标,防止阻塞同一 API 队列中的其他群。之后再加入白名单,也不会补发此前已经跳过的历史消息。
|
|
107
|
+
|
|
108
|
+
## 消费消息
|
|
109
|
+
|
|
110
|
+
### Python 回调
|
|
111
|
+
|
|
112
|
+
回调不依赖已连接的 NoneBot Bot,适合把消息交给自己的服务层:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from nonebot_plugin_tgforwarder import PublicMessage, on_public_message
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@on_public_message
|
|
119
|
+
async def consume(message: PublicMessage) -> None:
|
|
120
|
+
print(message.delivery_id, message.source_chat_id, message.text)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### NoneBot matcher
|
|
124
|
+
|
|
125
|
+
自定义 matcher 需要至少一个已连接的 NoneBot Bot,用于进入 NoneBot 的事件处理管线:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from nonebot_plugin_tgforwarder import PublicMessageEvent, public_message_matcher
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@public_message_matcher.handle()
|
|
132
|
+
async def consume_event(event: PublicMessageEvent) -> None:
|
|
133
|
+
message = event.message
|
|
134
|
+
print(message.delivery_id, message.media_items)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
如果白名单内的消息没有任何已注册回调或 matcher handler,插件不会推进该条游标,避免静默丢弃消息。
|
|
138
|
+
|
|
139
|
+
## 交付、重试与去重
|
|
140
|
+
|
|
141
|
+
- `delivery_id` 是 API 目标内可见记录的增量游标,不要求连续;
|
|
142
|
+
- 每条消息分发成功后立即原子写入游标,因此同页后续消息失败不会重放此前已提交条目;
|
|
143
|
+
- 网络错误、超时、HTTP `408`、`429` 和 `5xx` 使用原 cursor 指数退避重试;
|
|
144
|
+
- HTTP `401`、`422` 及响应契约错误会记录完整的非敏感诊断,并按最大间隔继续探测;日志不会输出 Token;
|
|
145
|
+
- 关闭时先通知轮询器停止,超时后取消任务,并关闭 HTTP 客户端;
|
|
146
|
+
- API 没有业务 ACK,所以端到端语义是“至少一次”。回调中的外部副作用应以 `delivery_id` 做幂等;
|
|
147
|
+
- 一个回调失败时不提交当前消息,后续会重试。此前已执行但未完成整条确认的回调也可能再次执行。
|
|
148
|
+
|
|
149
|
+
## 测试
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pdm install -G dev
|
|
153
|
+
pdm test
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
测试覆盖公开 API 鉴权与响应解析、错误分类、响应游标约束、游标和白名单原子持久化、白名单命令,以及逐条提交和失败重放行为。
|
|
157
|
+
|
|
158
|
+
## 发布
|
|
159
|
+
|
|
160
|
+
版本号、`CHANGELOG.md`、Git 标签和 GitHub Release 由 release-please 管理。提交信息遵循 Conventional Commits:
|
|
161
|
+
|
|
162
|
+
- `fix:` 触发补丁版本;
|
|
163
|
+
- `feat:` 触发次版本;
|
|
164
|
+
- `feat!:`、`fix!:` 或提交正文中的 `BREAKING CHANGE:` 触发主版本。
|
|
165
|
+
|
|
166
|
+
合并 release-please 创建的发布 PR 后,工作流会创建 Release、构建发行包并通过 PyPI Trusted Publishing 发布。
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# nonebot-plugin-tgforwarder
|
|
2
|
+
|
|
3
|
+
一个 NoneBot2 插件,通过轮询 `safewbot` 的公开消息 HTTP API 获取已经完成规则处理或审核发布的消息,并把它们交给 Python 回调或 NoneBot matcher。
|
|
4
|
+
|
|
5
|
+
## 实现边界
|
|
6
|
+
|
|
7
|
+
数据流:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
safewbot 公开消息 API
|
|
11
|
+
-> HTTP 轮询
|
|
12
|
+
-> cursor / delivery_id 去重与消息建模
|
|
13
|
+
-> Python 回调或 NoneBot 自定义事件 matcher
|
|
14
|
+
-> 业务自己的后续转发逻辑
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
本插件仅调用:
|
|
18
|
+
|
|
19
|
+
```http
|
|
20
|
+
GET /api/public/v1/messages?cursor=<int>&limit=<1..100>
|
|
21
|
+
Authorization: Bearer <API endpoint token>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
不包含以下能力:
|
|
25
|
+
|
|
26
|
+
- SafeW 普通账号登录或私密群监听;
|
|
27
|
+
- SafeW Bot API 的 `getUpdates` / webhook;
|
|
28
|
+
- `safewbot` Web 管理 API;
|
|
29
|
+
- 媒体文件下载。
|
|
30
|
+
|
|
31
|
+
公开 API 当前只返回处理后的文本和媒体元数据,不返回本地文件路径、下载 URL 或媒体二进制。插件不会读取或修改 `safewbot` 仓库。
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install nonebot-plugin-tgforwarder
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
在 NoneBot 项目中加载插件:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
nonebot.load_plugin("nonebot_plugin_tgforwarder")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
先在 `safewbot` 管理后台创建“对外 API”目标,绑定需要的来源,并保存只显示一次的 Token。
|
|
46
|
+
|
|
47
|
+
## 配置
|
|
48
|
+
|
|
49
|
+
在 NoneBot 的 `.env` 中配置:
|
|
50
|
+
|
|
51
|
+
```dotenv
|
|
52
|
+
TGFORWARDER_API_BASE_URL=http://127.0.0.1:8000
|
|
53
|
+
TGFORWARDER_API_TOKEN=tgf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| 配置项 | 默认值 | 说明 |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| `TGFORWARDER_ENABLED` | `true` | 是否启动轮询器 |
|
|
59
|
+
| `TGFORWARDER_API_BASE_URL` | `http://127.0.0.1:8000` | `safewbot` Web 服务根地址 |
|
|
60
|
+
| `TGFORWARDER_API_TOKEN` | 空 | 公开 API 目标 Token;为空时记录错误且不启动 |
|
|
61
|
+
| `TGFORWARDER_CURSOR_FILE` | `data/tgforwarder-cursor.json` | 跨重启游标文件 |
|
|
62
|
+
| `TGFORWARDER_WHITELIST_FILE` | `data/tgforwarder-whitelist.json` | 来源群白名单文件 |
|
|
63
|
+
| `TGFORWARDER_INITIAL_CURSOR` | `0` | 游标文件不存在或损坏时的起始游标 |
|
|
64
|
+
| `TGFORWARDER_PAGE_LIMIT` | `50` | 单页条数,范围 `1..100` |
|
|
65
|
+
| `TGFORWARDER_POLL_INTERVAL` | `2.0` | 无更多消息时的轮询间隔(秒) |
|
|
66
|
+
| `TGFORWARDER_REQUEST_TIMEOUT` | `30.0` | HTTP 请求超时(秒) |
|
|
67
|
+
| `TGFORWARDER_RETRY_INITIAL` | `1.0` | 首次重试等待(秒) |
|
|
68
|
+
| `TGFORWARDER_RETRY_MAX` | `60.0` | 最大退避等待(秒) |
|
|
69
|
+
| `TGFORWARDER_RETRY_JITTER` | `0.2` | 退避随机抖动比例,范围 `0..1` |
|
|
70
|
+
| `TGFORWARDER_DEDUP_CACHE_SIZE` | `2048` | 内存中的 `delivery_id` 去重容量 |
|
|
71
|
+
| `TGFORWARDER_DISPATCH_MATCHER` | `true` | 是否向 NoneBot matcher 分发事件 |
|
|
72
|
+
| `TGFORWARDER_MATCHER_PRIORITY` | `1` | 自定义事件 matcher 优先级 |
|
|
73
|
+
| `TGFORWARDER_SHUTDOWN_TIMEOUT` | `5.0` | 关闭时等待当前处理完成的最长时间 |
|
|
74
|
+
|
|
75
|
+
游标文件与一个公开 API 目标配套使用。切换到另一个 API 目标时,应更换或删除游标文件;Token 仅在同一目标上轮换时可以保留原游标。
|
|
76
|
+
|
|
77
|
+
## 群白名单
|
|
78
|
+
|
|
79
|
+
插件默认拒绝所有来源群。只有 `PublicMessage.source_chat_id` 已加入白名单的消息才会分发给 Python 回调或 NoneBot matcher。
|
|
80
|
+
|
|
81
|
+
将 Bot 主人的账号配置为 NoneBot 超级用户:
|
|
82
|
+
|
|
83
|
+
```dotenv
|
|
84
|
+
SUPERUSERS=["123456789"]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Bot 主人可以在与 Bot 的私聊中管理白名单:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
/tgfwd add 群号
|
|
91
|
+
/tgfwd del 群号
|
|
92
|
+
/tgfwd list
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
非白名单群的消息会跳过分发并提交游标,防止阻塞同一 API 队列中的其他群。之后再加入白名单,也不会补发此前已经跳过的历史消息。
|
|
96
|
+
|
|
97
|
+
## 消费消息
|
|
98
|
+
|
|
99
|
+
### Python 回调
|
|
100
|
+
|
|
101
|
+
回调不依赖已连接的 NoneBot Bot,适合把消息交给自己的服务层:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from nonebot_plugin_tgforwarder import PublicMessage, on_public_message
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@on_public_message
|
|
108
|
+
async def consume(message: PublicMessage) -> None:
|
|
109
|
+
print(message.delivery_id, message.source_chat_id, message.text)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### NoneBot matcher
|
|
113
|
+
|
|
114
|
+
自定义 matcher 需要至少一个已连接的 NoneBot Bot,用于进入 NoneBot 的事件处理管线:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from nonebot_plugin_tgforwarder import PublicMessageEvent, public_message_matcher
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@public_message_matcher.handle()
|
|
121
|
+
async def consume_event(event: PublicMessageEvent) -> None:
|
|
122
|
+
message = event.message
|
|
123
|
+
print(message.delivery_id, message.media_items)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
如果白名单内的消息没有任何已注册回调或 matcher handler,插件不会推进该条游标,避免静默丢弃消息。
|
|
127
|
+
|
|
128
|
+
## 交付、重试与去重
|
|
129
|
+
|
|
130
|
+
- `delivery_id` 是 API 目标内可见记录的增量游标,不要求连续;
|
|
131
|
+
- 每条消息分发成功后立即原子写入游标,因此同页后续消息失败不会重放此前已提交条目;
|
|
132
|
+
- 网络错误、超时、HTTP `408`、`429` 和 `5xx` 使用原 cursor 指数退避重试;
|
|
133
|
+
- HTTP `401`、`422` 及响应契约错误会记录完整的非敏感诊断,并按最大间隔继续探测;日志不会输出 Token;
|
|
134
|
+
- 关闭时先通知轮询器停止,超时后取消任务,并关闭 HTTP 客户端;
|
|
135
|
+
- API 没有业务 ACK,所以端到端语义是“至少一次”。回调中的外部副作用应以 `delivery_id` 做幂等;
|
|
136
|
+
- 一个回调失败时不提交当前消息,后续会重试。此前已执行但未完成整条确认的回调也可能再次执行。
|
|
137
|
+
|
|
138
|
+
## 测试
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
pdm install -G dev
|
|
142
|
+
pdm test
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
测试覆盖公开 API 鉴权与响应解析、错误分类、响应游标约束、游标和白名单原子持久化、白名单命令,以及逐条提交和失败重放行为。
|
|
146
|
+
|
|
147
|
+
## 发布
|
|
148
|
+
|
|
149
|
+
版本号、`CHANGELOG.md`、Git 标签和 GitHub Release 由 release-please 管理。提交信息遵循 Conventional Commits:
|
|
150
|
+
|
|
151
|
+
- `fix:` 触发补丁版本;
|
|
152
|
+
- `feat:` 触发次版本;
|
|
153
|
+
- `feat!:`、`fix!:` 或提交正文中的 `BREAKING CHANGE:` 触发主版本。
|
|
154
|
+
|
|
155
|
+
合并 release-please 创建的发布 PR 后,工作流会创建 Release、构建发行包并通过 PyPI Trusted Publishing 发布。
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nonebot-plugin-tgforwarder"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Poll safewbot's public message API and dispatch processed messages to NoneBot"
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "EasiFlux" },
|
|
7
|
+
]
|
|
8
|
+
dependencies = [
|
|
9
|
+
"nonebot2>=2.3.1",
|
|
10
|
+
"httpx>=0.27.0,<1.0.0",
|
|
11
|
+
]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
|
|
15
|
+
[project.license]
|
|
16
|
+
text = "MIT"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = [
|
|
20
|
+
"pdm-backend",
|
|
21
|
+
]
|
|
22
|
+
build-backend = "pdm.backend"
|
|
23
|
+
|
|
24
|
+
[tool.pdm]
|
|
25
|
+
distribution = true
|
|
26
|
+
|
|
27
|
+
[tool.pdm.build]
|
|
28
|
+
includes = [
|
|
29
|
+
"src",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.pdm.dev-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"black>=24.4.2",
|
|
35
|
+
"isort>=5.13.2",
|
|
36
|
+
"pytest>=8.1.0",
|
|
37
|
+
"ruff>=0.4.6",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.pdm.scripts]
|
|
41
|
+
test = "pytest -q"
|
|
42
|
+
|
|
43
|
+
[tool.pdm.scripts.format]
|
|
44
|
+
composite = [
|
|
45
|
+
"isort . ",
|
|
46
|
+
"black . ",
|
|
47
|
+
"ruff check .",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.black]
|
|
51
|
+
line-length = 90
|
|
52
|
+
target-version = [
|
|
53
|
+
"py39",
|
|
54
|
+
"py310",
|
|
55
|
+
"py311",
|
|
56
|
+
"py312",
|
|
57
|
+
]
|
|
58
|
+
include = "\\.pyi?$"
|
|
59
|
+
extend-exclude = ""
|
|
60
|
+
|
|
61
|
+
[tool.isort]
|
|
62
|
+
profile = "black"
|
|
63
|
+
line_length = 90
|
|
64
|
+
length_sort = true
|
|
65
|
+
skip_gitignore = true
|
|
66
|
+
force_sort_within_sections = true
|
|
67
|
+
extra_standard_library = [
|
|
68
|
+
"typing_extensions",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.ruff]
|
|
72
|
+
line-length = 90
|
|
73
|
+
target-version = "py39"
|
|
74
|
+
|
|
75
|
+
[tool.ruff.lint]
|
|
76
|
+
select = [
|
|
77
|
+
"E",
|
|
78
|
+
"W",
|
|
79
|
+
"F",
|
|
80
|
+
"UP",
|
|
81
|
+
"C",
|
|
82
|
+
"T",
|
|
83
|
+
"PYI",
|
|
84
|
+
"PT",
|
|
85
|
+
"Q",
|
|
86
|
+
]
|
|
87
|
+
ignore = [
|
|
88
|
+
"C901",
|
|
89
|
+
"T201",
|
|
90
|
+
"E731",
|
|
91
|
+
"E402",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
[tool.pyright]
|
|
95
|
+
pythonVersion = "3.9"
|
|
96
|
+
pythonPlatform = "All"
|
|
97
|
+
typeCheckingMode = "basic"
|
|
98
|
+
|
|
99
|
+
[tool.nonebot]
|
|
100
|
+
adapters = []
|
|
101
|
+
plugin_dirs = [
|
|
102
|
+
"src",
|
|
103
|
+
]
|
|
104
|
+
builtin_plugins = []
|
|
105
|
+
|
|
106
|
+
[tool.pytest.ini_options]
|
|
107
|
+
testpaths = [
|
|
108
|
+
"tests",
|
|
109
|
+
]
|
|
110
|
+
pythonpath = [
|
|
111
|
+
"src",
|
|
112
|
+
]
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from nonebot import get_driver, logger, on_command, on_type
|
|
2
|
+
from nonebot.adapters import Message
|
|
3
|
+
from nonebot.params import CommandArg
|
|
4
|
+
from nonebot.permission import SUPERUSER
|
|
5
|
+
from nonebot.plugin import PluginMetadata, get_plugin_config
|
|
6
|
+
|
|
7
|
+
from .client import (
|
|
8
|
+
FatalPublicAPIError,
|
|
9
|
+
PublicAPIClientError,
|
|
10
|
+
PublicMessageClient,
|
|
11
|
+
RetryablePublicAPIError,
|
|
12
|
+
)
|
|
13
|
+
from .config import Config
|
|
14
|
+
from .cursor import CursorStore
|
|
15
|
+
from .dispatcher import MessageDispatcher, MessageHandler
|
|
16
|
+
from .event import PublicMessageEvent
|
|
17
|
+
from .models import MessagePage, PublicMediaItem, PublicMessage
|
|
18
|
+
from .poller import PublicMessagePoller
|
|
19
|
+
from .whitelist import (
|
|
20
|
+
GroupWhitelistStore,
|
|
21
|
+
WhitelistStoreError,
|
|
22
|
+
execute_whitelist_command,
|
|
23
|
+
is_private_message,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__plugin_meta__ = PluginMetadata(
|
|
27
|
+
name="TG-forwarder 公开消息轮询",
|
|
28
|
+
description="轮询 safewbot 公开消息 API,并向 NoneBot 回调或 matcher 分发消息",
|
|
29
|
+
usage=(
|
|
30
|
+
"配置 TGFORWARDER_API_TOKEN 和 SUPERUSERS;"
|
|
31
|
+
"私聊使用 /tgfwd add|del|list 管理来源群白名单"
|
|
32
|
+
),
|
|
33
|
+
type="application",
|
|
34
|
+
config=Config,
|
|
35
|
+
supported_adapters=None,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
plugin_config = get_plugin_config(Config)
|
|
39
|
+
group_whitelist = GroupWhitelistStore(plugin_config.tgforwarder_whitelist_file)
|
|
40
|
+
try:
|
|
41
|
+
group_whitelist.load()
|
|
42
|
+
except WhitelistStoreError:
|
|
43
|
+
logger.exception(
|
|
44
|
+
"Failed to load tgforwarder group whitelist {}; denying all source groups",
|
|
45
|
+
plugin_config.tgforwarder_whitelist_file,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
public_message_matcher = on_type(
|
|
49
|
+
PublicMessageEvent,
|
|
50
|
+
priority=plugin_config.tgforwarder_matcher_priority,
|
|
51
|
+
block=False,
|
|
52
|
+
)
|
|
53
|
+
message_dispatcher = MessageDispatcher(
|
|
54
|
+
public_message_matcher,
|
|
55
|
+
dispatch_matcher=plugin_config.tgforwarder_dispatch_matcher,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
tgfwd_command = on_command(
|
|
60
|
+
"tgfwd",
|
|
61
|
+
rule=is_private_message,
|
|
62
|
+
permission=SUPERUSER,
|
|
63
|
+
priority=1,
|
|
64
|
+
block=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@tgfwd_command.handle()
|
|
69
|
+
async def _manage_group_whitelist(args: Message = CommandArg()) -> None:
|
|
70
|
+
try:
|
|
71
|
+
response = execute_whitelist_command(
|
|
72
|
+
args.extract_plain_text().strip(), group_whitelist
|
|
73
|
+
)
|
|
74
|
+
except WhitelistStoreError:
|
|
75
|
+
logger.exception("Failed to update tgforwarder group whitelist")
|
|
76
|
+
response = "群白名单保存失败,请检查日志和文件权限。"
|
|
77
|
+
await tgfwd_command.finish(response)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def on_public_message(handler: MessageHandler) -> MessageHandler:
|
|
81
|
+
"""Register a sync or async callback and return it for decorator use."""
|
|
82
|
+
|
|
83
|
+
return message_dispatcher.register(handler)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def remove_public_message_handler(handler: MessageHandler) -> None:
|
|
87
|
+
message_dispatcher.unregister(handler)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_token = plugin_config.tgforwarder_api_token.get_secret_value()
|
|
91
|
+
_client = PublicMessageClient(
|
|
92
|
+
plugin_config.tgforwarder_api_base_url,
|
|
93
|
+
_token,
|
|
94
|
+
timeout=plugin_config.tgforwarder_request_timeout,
|
|
95
|
+
)
|
|
96
|
+
_poller = PublicMessagePoller(
|
|
97
|
+
_client,
|
|
98
|
+
CursorStore(plugin_config.tgforwarder_cursor_file),
|
|
99
|
+
message_dispatcher.dispatch,
|
|
100
|
+
allow_message=lambda message: group_whitelist.contains(message.source_chat_id),
|
|
101
|
+
initial_cursor=plugin_config.tgforwarder_initial_cursor,
|
|
102
|
+
page_limit=plugin_config.tgforwarder_page_limit,
|
|
103
|
+
poll_interval=plugin_config.tgforwarder_poll_interval,
|
|
104
|
+
retry_initial=plugin_config.tgforwarder_retry_initial,
|
|
105
|
+
retry_max=plugin_config.tgforwarder_retry_max,
|
|
106
|
+
retry_jitter=plugin_config.tgforwarder_retry_jitter,
|
|
107
|
+
dedup_cache_size=plugin_config.tgforwarder_dedup_cache_size,
|
|
108
|
+
shutdown_timeout=plugin_config.tgforwarder_shutdown_timeout,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
driver = get_driver()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@driver.on_startup
|
|
115
|
+
async def _start_poller() -> None:
|
|
116
|
+
if not plugin_config.tgforwarder_enabled:
|
|
117
|
+
logger.info("tgforwarder public API poller is disabled")
|
|
118
|
+
return
|
|
119
|
+
if not _token:
|
|
120
|
+
logger.error(
|
|
121
|
+
"TGFORWARDER_API_TOKEN is empty; tgforwarder public API poller will not start"
|
|
122
|
+
)
|
|
123
|
+
return
|
|
124
|
+
await _poller.start()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@driver.on_shutdown
|
|
128
|
+
async def _stop_poller() -> None:
|
|
129
|
+
await _poller.stop()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_poller() -> PublicMessagePoller:
|
|
133
|
+
"""Return the plugin-managed poller for diagnostics and tests."""
|
|
134
|
+
|
|
135
|
+
return _poller
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_group_whitelist() -> GroupWhitelistStore:
|
|
139
|
+
"""Return the plugin-managed source group whitelist."""
|
|
140
|
+
|
|
141
|
+
return group_whitelist
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
__all__ = [
|
|
145
|
+
"FatalPublicAPIError",
|
|
146
|
+
"MessagePage",
|
|
147
|
+
"PublicAPIClientError",
|
|
148
|
+
"PublicMediaItem",
|
|
149
|
+
"PublicMessage",
|
|
150
|
+
"PublicMessageClient",
|
|
151
|
+
"PublicMessageEvent",
|
|
152
|
+
"RetryablePublicAPIError",
|
|
153
|
+
"Config",
|
|
154
|
+
"GroupWhitelistStore",
|
|
155
|
+
"WhitelistStoreError",
|
|
156
|
+
"get_group_whitelist",
|
|
157
|
+
"get_poller",
|
|
158
|
+
"on_public_message",
|
|
159
|
+
"public_message_matcher",
|
|
160
|
+
"remove_public_message_handler",
|
|
161
|
+
"tgfwd_command",
|
|
162
|
+
]
|