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.
@@ -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
+ ]