nonebot-plugin-tgforwarder 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_tgforwarder/__init__.py +162 -0
- nonebot_plugin_tgforwarder/client.py +161 -0
- nonebot_plugin_tgforwarder/config.py +24 -0
- nonebot_plugin_tgforwarder/cursor.py +60 -0
- nonebot_plugin_tgforwarder/dispatcher.py +60 -0
- nonebot_plugin_tgforwarder/event.py +68 -0
- nonebot_plugin_tgforwarder/models.py +84 -0
- nonebot_plugin_tgforwarder/poller.py +188 -0
- nonebot_plugin_tgforwarder/whitelist.py +140 -0
- nonebot_plugin_tgforwarder-0.1.0.dist-info/METADATA +166 -0
- nonebot_plugin_tgforwarder-0.1.0.dist-info/RECORD +13 -0
- nonebot_plugin_tgforwarder-0.1.0.dist-info/WHEEL +4 -0
- nonebot_plugin_tgforwarder-0.1.0.dist-info/entry_points.txt +4 -0
|
@@ -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
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from .models import MessagePage, PublicAPIEnvelope, PublicAPIErrorEnvelope, validate_model
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PublicAPIClientError(RuntimeError):
|
|
10
|
+
"""Base class for public API failures."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
message: str,
|
|
15
|
+
*,
|
|
16
|
+
status_code: Optional[int] = None,
|
|
17
|
+
error_code: Optional[str] = None,
|
|
18
|
+
request_id: Optional[str] = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.status_code = status_code
|
|
22
|
+
self.error_code = error_code
|
|
23
|
+
self.request_id = request_id
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RetryablePublicAPIError(PublicAPIClientError):
|
|
27
|
+
"""A transient failure that should be retried with the same cursor."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FatalPublicAPIError(PublicAPIClientError):
|
|
31
|
+
"""An authentication, request, or response-contract failure."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PublicMessageClient:
|
|
35
|
+
endpoint_path = "/api/public/v1/messages"
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
base_url: str,
|
|
40
|
+
token: str,
|
|
41
|
+
*,
|
|
42
|
+
timeout: float = 30.0,
|
|
43
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.base_url = base_url.rstrip("/")
|
|
46
|
+
self.token = token
|
|
47
|
+
self.timeout = timeout
|
|
48
|
+
self._client = client
|
|
49
|
+
self._owns_client = client is None
|
|
50
|
+
|
|
51
|
+
def _http_client(self) -> httpx.AsyncClient:
|
|
52
|
+
if self._client is None:
|
|
53
|
+
self._client = httpx.AsyncClient(
|
|
54
|
+
base_url=self.base_url,
|
|
55
|
+
timeout=self.timeout,
|
|
56
|
+
headers={
|
|
57
|
+
"Authorization": f"Bearer {self.token}",
|
|
58
|
+
"Accept": "application/json",
|
|
59
|
+
"User-Agent": "nonebot-plugin-tgforwarder/0.1.0",
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
return self._client
|
|
63
|
+
|
|
64
|
+
async def fetch_messages(self, *, cursor: int, limit: int) -> MessagePage:
|
|
65
|
+
try:
|
|
66
|
+
response = await self._http_client().get(
|
|
67
|
+
self.endpoint_path,
|
|
68
|
+
params={"cursor": cursor, "limit": limit},
|
|
69
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
70
|
+
)
|
|
71
|
+
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
72
|
+
raise RetryablePublicAPIError(f"Public API request failed: {exc}") from exc
|
|
73
|
+
except httpx.RequestError as exc:
|
|
74
|
+
raise RetryablePublicAPIError(f"Public API request failed: {exc}") from exc
|
|
75
|
+
|
|
76
|
+
payload = self._decode_json(response)
|
|
77
|
+
if response.status_code != 200:
|
|
78
|
+
self._raise_for_error(response.status_code, payload)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
envelope = validate_model(PublicAPIEnvelope, payload)
|
|
82
|
+
except (ValidationError, TypeError, ValueError) as exc:
|
|
83
|
+
raise FatalPublicAPIError(
|
|
84
|
+
"Public API returned an invalid success response",
|
|
85
|
+
status_code=response.status_code,
|
|
86
|
+
) from exc
|
|
87
|
+
if not envelope.ok:
|
|
88
|
+
raise FatalPublicAPIError(
|
|
89
|
+
"Public API success response has ok=false",
|
|
90
|
+
status_code=response.status_code,
|
|
91
|
+
)
|
|
92
|
+
self._validate_page(envelope.data, requested_cursor=cursor)
|
|
93
|
+
return envelope.data
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _decode_json(response: httpx.Response) -> dict[str, Any]:
|
|
97
|
+
try:
|
|
98
|
+
payload = response.json()
|
|
99
|
+
except ValueError as exc:
|
|
100
|
+
error_type = (
|
|
101
|
+
RetryablePublicAPIError
|
|
102
|
+
if response.status_code in {408, 429} or response.status_code >= 500
|
|
103
|
+
else FatalPublicAPIError
|
|
104
|
+
)
|
|
105
|
+
raise error_type(
|
|
106
|
+
"Public API returned a non-JSON response",
|
|
107
|
+
status_code=response.status_code,
|
|
108
|
+
) from exc
|
|
109
|
+
if not isinstance(payload, dict):
|
|
110
|
+
raise FatalPublicAPIError(
|
|
111
|
+
"Public API response must be a JSON object",
|
|
112
|
+
status_code=response.status_code,
|
|
113
|
+
)
|
|
114
|
+
return payload
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _raise_for_error(status_code: int, payload: dict[str, Any]) -> None:
|
|
118
|
+
message = f"Public API returned HTTP {status_code}"
|
|
119
|
+
error_code: Optional[str] = None
|
|
120
|
+
request_id: Optional[str] = None
|
|
121
|
+
try:
|
|
122
|
+
error = validate_model(PublicAPIErrorEnvelope, payload).error
|
|
123
|
+
message = error.message
|
|
124
|
+
error_code = error.code
|
|
125
|
+
request_id = error.request_id
|
|
126
|
+
except (ValidationError, TypeError, ValueError):
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
error_type = (
|
|
130
|
+
RetryablePublicAPIError
|
|
131
|
+
if status_code in {408, 429} or status_code >= 500
|
|
132
|
+
else FatalPublicAPIError
|
|
133
|
+
)
|
|
134
|
+
raise error_type(
|
|
135
|
+
message,
|
|
136
|
+
status_code=status_code,
|
|
137
|
+
error_code=error_code,
|
|
138
|
+
request_id=request_id,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _validate_page(page: MessagePage, *, requested_cursor: int) -> None:
|
|
143
|
+
delivery_ids = [item.delivery_id for item in page.items]
|
|
144
|
+
if any(value <= requested_cursor for value in delivery_ids):
|
|
145
|
+
raise FatalPublicAPIError(
|
|
146
|
+
"Public API returned a delivery_id not greater than the requested cursor"
|
|
147
|
+
)
|
|
148
|
+
if delivery_ids != sorted(set(delivery_ids)):
|
|
149
|
+
raise FatalPublicAPIError(
|
|
150
|
+
"Public API returned duplicate or out-of-order delivery_id values"
|
|
151
|
+
)
|
|
152
|
+
expected_cursor = delivery_ids[-1] if delivery_ids else requested_cursor
|
|
153
|
+
if page.next_cursor != expected_cursor:
|
|
154
|
+
raise FatalPublicAPIError(
|
|
155
|
+
"Public API next_cursor does not match the last delivery_id"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
async def aclose(self) -> None:
|
|
159
|
+
if self._client is not None and self._owns_client:
|
|
160
|
+
await self._client.aclose()
|
|
161
|
+
self._client = None
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field, SecretStr
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Config(BaseModel):
|
|
7
|
+
"""NoneBot configuration for the public message API poller."""
|
|
8
|
+
|
|
9
|
+
tgforwarder_enabled: bool = True
|
|
10
|
+
tgforwarder_api_base_url: str = "http://127.0.0.1:8000"
|
|
11
|
+
tgforwarder_api_token: SecretStr = SecretStr("")
|
|
12
|
+
tgforwarder_cursor_file: Path = Path("data/tgforwarder-cursor.json")
|
|
13
|
+
tgforwarder_whitelist_file: Path = Path("data/tgforwarder-whitelist.json")
|
|
14
|
+
tgforwarder_initial_cursor: int = Field(default=0, ge=0)
|
|
15
|
+
tgforwarder_page_limit: int = Field(default=50, ge=1, le=100)
|
|
16
|
+
tgforwarder_poll_interval: float = Field(default=2.0, gt=0)
|
|
17
|
+
tgforwarder_request_timeout: float = Field(default=30.0, gt=0)
|
|
18
|
+
tgforwarder_retry_initial: float = Field(default=1.0, gt=0)
|
|
19
|
+
tgforwarder_retry_max: float = Field(default=60.0, gt=0)
|
|
20
|
+
tgforwarder_retry_jitter: float = Field(default=0.2, ge=0, le=1)
|
|
21
|
+
tgforwarder_dedup_cache_size: int = Field(default=2048, ge=1)
|
|
22
|
+
tgforwarder_dispatch_matcher: bool = True
|
|
23
|
+
tgforwarder_matcher_priority: int = 1
|
|
24
|
+
tgforwarder_shutdown_timeout: float = Field(default=5.0, gt=0)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CursorStoreError(RuntimeError):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CursorStore:
|
|
12
|
+
"""Persist a delivery cursor using an atomic same-directory replace."""
|
|
13
|
+
|
|
14
|
+
version = 1
|
|
15
|
+
|
|
16
|
+
def __init__(self, path: Path) -> None:
|
|
17
|
+
self.path = path
|
|
18
|
+
|
|
19
|
+
def load(self, default: int = 0) -> int:
|
|
20
|
+
if not self.path.exists():
|
|
21
|
+
return default
|
|
22
|
+
try:
|
|
23
|
+
payload = json.loads(self.path.read_text(encoding="utf-8"))
|
|
24
|
+
cursor = payload["cursor"]
|
|
25
|
+
version = payload["version"]
|
|
26
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc:
|
|
27
|
+
raise CursorStoreError(f"Cannot read cursor file {self.path}") from exc
|
|
28
|
+
if (
|
|
29
|
+
version != self.version
|
|
30
|
+
or isinstance(cursor, bool)
|
|
31
|
+
or not isinstance(cursor, int)
|
|
32
|
+
):
|
|
33
|
+
raise CursorStoreError(f"Invalid cursor file {self.path}")
|
|
34
|
+
if cursor < 0:
|
|
35
|
+
raise CursorStoreError(f"Cursor cannot be negative in {self.path}")
|
|
36
|
+
return cursor
|
|
37
|
+
|
|
38
|
+
def save(self, cursor: int) -> None:
|
|
39
|
+
if isinstance(cursor, bool) or not isinstance(cursor, int) or cursor < 0:
|
|
40
|
+
raise CursorStoreError("Cursor must be a non-negative integer")
|
|
41
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
temporary = self.path.with_name(f".{self.path.name}.{os.getpid()}.tmp")
|
|
43
|
+
payload = {
|
|
44
|
+
"version": self.version,
|
|
45
|
+
"cursor": cursor,
|
|
46
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
47
|
+
}
|
|
48
|
+
try:
|
|
49
|
+
with temporary.open("w", encoding="utf-8", newline="\n") as file:
|
|
50
|
+
json.dump(payload, file, ensure_ascii=False, separators=(",", ":"))
|
|
51
|
+
file.write("\n")
|
|
52
|
+
file.flush()
|
|
53
|
+
os.fsync(file.fileno())
|
|
54
|
+
temporary.replace(self.path)
|
|
55
|
+
except OSError as exc:
|
|
56
|
+
try:
|
|
57
|
+
temporary.unlink(missing_ok=True)
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
60
|
+
raise CursorStoreError(f"Cannot write cursor file {self.path}") from exc
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import Awaitable
|
|
3
|
+
from typing import Any, Callable, TypeVar, Union, cast
|
|
4
|
+
|
|
5
|
+
from nonebot import get_bots
|
|
6
|
+
from nonebot.message import handle_event
|
|
7
|
+
from nonebot.matcher import Matcher
|
|
8
|
+
|
|
9
|
+
from .event import PublicMessageEvent
|
|
10
|
+
from .models import PublicMessage
|
|
11
|
+
|
|
12
|
+
MessageHandler = Callable[[PublicMessage], Union[None, Awaitable[None]]]
|
|
13
|
+
HandlerType = TypeVar("HandlerType", bound=MessageHandler)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MessageDispatchUnavailable(RuntimeError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MessageDispatcher:
|
|
21
|
+
def __init__(self, matcher: type[Matcher], *, dispatch_matcher: bool = True) -> None:
|
|
22
|
+
self.matcher = matcher
|
|
23
|
+
self.dispatch_matcher = dispatch_matcher
|
|
24
|
+
self._callbacks: list[MessageHandler] = []
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def callbacks(self) -> tuple[MessageHandler, ...]:
|
|
28
|
+
return tuple(self._callbacks)
|
|
29
|
+
|
|
30
|
+
def register(self, handler: HandlerType) -> HandlerType:
|
|
31
|
+
if handler not in self._callbacks:
|
|
32
|
+
self._callbacks.append(handler)
|
|
33
|
+
return handler
|
|
34
|
+
|
|
35
|
+
def unregister(self, handler: MessageHandler) -> None:
|
|
36
|
+
try:
|
|
37
|
+
self._callbacks.remove(handler)
|
|
38
|
+
except ValueError:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
async def dispatch(self, message: PublicMessage) -> None:
|
|
42
|
+
matcher_handlers = bool(self.matcher.handlers) if self.dispatch_matcher else False
|
|
43
|
+
bots = get_bots() if matcher_handlers else {}
|
|
44
|
+
if not self._callbacks and not matcher_handlers:
|
|
45
|
+
raise MessageDispatchUnavailable(
|
|
46
|
+
"No public message callback or matcher handler is registered"
|
|
47
|
+
)
|
|
48
|
+
if matcher_handlers and not bots:
|
|
49
|
+
raise MessageDispatchUnavailable(
|
|
50
|
+
"A matcher handler is registered but no NoneBot bot is connected"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
for callback in tuple(self._callbacks):
|
|
54
|
+
result = callback(message)
|
|
55
|
+
if inspect.isawaitable(result):
|
|
56
|
+
await cast(Awaitable[Any], result)
|
|
57
|
+
|
|
58
|
+
if matcher_handlers:
|
|
59
|
+
bot = next(iter(bots.values()))
|
|
60
|
+
await handle_event(bot, PublicMessageEvent(message=message))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from nonebot.adapters import Event, Message, MessageSegment
|
|
4
|
+
|
|
5
|
+
from .models import PublicMessage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PublicMessageSegment(MessageSegment["PublicEventMessage"]):
|
|
9
|
+
@classmethod
|
|
10
|
+
def get_message_class(cls) -> type["PublicEventMessage"]:
|
|
11
|
+
return PublicEventMessage
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def text(cls, text: str) -> "PublicMessageSegment":
|
|
15
|
+
return cls(type="text", data={"text": text})
|
|
16
|
+
|
|
17
|
+
def __str__(self) -> str:
|
|
18
|
+
return str(self.data.get("text", ""))
|
|
19
|
+
|
|
20
|
+
def is_text(self) -> bool:
|
|
21
|
+
return self.type == "text"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PublicEventMessage(Message[PublicMessageSegment]):
|
|
25
|
+
@classmethod
|
|
26
|
+
def get_segment_class(cls) -> type[PublicMessageSegment]:
|
|
27
|
+
return PublicMessageSegment
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PublicMessageEvent(Event):
|
|
31
|
+
"""NoneBot event carrying one processed public API message."""
|
|
32
|
+
|
|
33
|
+
message: PublicMessage
|
|
34
|
+
|
|
35
|
+
def get_type(self) -> str:
|
|
36
|
+
return "message"
|
|
37
|
+
|
|
38
|
+
def get_event_name(self) -> str:
|
|
39
|
+
return "tgforwarder.public_message"
|
|
40
|
+
|
|
41
|
+
def get_event_description(self) -> str:
|
|
42
|
+
text = self.message.text.replace("\n", " ")
|
|
43
|
+
return (
|
|
44
|
+
f"Public message {self.message.delivery_id} from "
|
|
45
|
+
f"{self.message.source_backend}:{self.message.source_chat_id}: {text[:80]}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def get_user_id(self) -> str:
|
|
49
|
+
return str(self.message.source_chat_id)
|
|
50
|
+
|
|
51
|
+
def get_session_id(self) -> str:
|
|
52
|
+
return f"{self.message.source_backend}:{self.message.source_chat_id}"
|
|
53
|
+
|
|
54
|
+
def get_message(self) -> PublicEventMessage:
|
|
55
|
+
return PublicEventMessage(PublicMessageSegment.text(self.message.text))
|
|
56
|
+
|
|
57
|
+
def is_tome(self) -> bool:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def get_log_string(self) -> str:
|
|
61
|
+
return self.get_event_description()
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def raw_message(self) -> dict[str, Any]:
|
|
65
|
+
dump = getattr(self.message, "model_dump", None)
|
|
66
|
+
if dump is not None:
|
|
67
|
+
return dump()
|
|
68
|
+
return self.message.dict()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from pydantic import VERSION, BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
if VERSION.startswith("1."):
|
|
8
|
+
|
|
9
|
+
class APIModel(BaseModel):
|
|
10
|
+
"""Base model tolerant of fields added by newer API versions."""
|
|
11
|
+
|
|
12
|
+
class Config:
|
|
13
|
+
extra = "allow"
|
|
14
|
+
|
|
15
|
+
else:
|
|
16
|
+
from pydantic import ConfigDict
|
|
17
|
+
|
|
18
|
+
class APIModel(BaseModel):
|
|
19
|
+
"""Base model tolerant of fields added by newer API versions."""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(extra="allow")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PublicMediaItem(APIModel):
|
|
25
|
+
media_type: str
|
|
26
|
+
original_filename: Optional[str] = None
|
|
27
|
+
mime_type: Optional[str] = None
|
|
28
|
+
file_size: Optional[int] = None
|
|
29
|
+
source_message_id: Optional[int] = None
|
|
30
|
+
order: int = 0
|
|
31
|
+
file_unique_id: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PublicMessage(APIModel):
|
|
35
|
+
"""A processed message returned by safewbot's public pull API."""
|
|
36
|
+
|
|
37
|
+
delivery_id: int = Field(ge=1)
|
|
38
|
+
created_at: datetime
|
|
39
|
+
source_backend: str
|
|
40
|
+
source_chat_id: int
|
|
41
|
+
source_message_id: int
|
|
42
|
+
source_chat_username: Optional[str] = None
|
|
43
|
+
grouped_id: Optional[int] = None
|
|
44
|
+
text: str = ""
|
|
45
|
+
media_type: str = "text"
|
|
46
|
+
media_items: list[PublicMediaItem] = Field(default_factory=list)
|
|
47
|
+
processed_at: datetime
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def deduplication_key(self) -> int:
|
|
51
|
+
return self.delivery_id
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MessagePage(APIModel):
|
|
55
|
+
items: list[PublicMessage] = Field(default_factory=list)
|
|
56
|
+
next_cursor: int = Field(ge=0)
|
|
57
|
+
has_more: bool = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PublicAPIEnvelope(APIModel):
|
|
61
|
+
ok: bool
|
|
62
|
+
data: MessagePage
|
|
63
|
+
meta: dict[str, Any] = Field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PublicAPIErrorDetail(APIModel):
|
|
67
|
+
code: str = "unknown_error"
|
|
68
|
+
message: str = "Unknown API error"
|
|
69
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
70
|
+
request_id: Optional[str] = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PublicAPIErrorEnvelope(APIModel):
|
|
74
|
+
ok: bool = False
|
|
75
|
+
error: PublicAPIErrorDetail
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate_model(model_type: Any, value: Any) -> Any:
|
|
79
|
+
"""Support both Pydantic 1 and 2 accepted by NoneBot 2.3."""
|
|
80
|
+
|
|
81
|
+
model_validate = getattr(model_type, "model_validate", None)
|
|
82
|
+
if model_validate is not None:
|
|
83
|
+
return model_validate(value)
|
|
84
|
+
return model_type.parse_obj(value)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import random
|
|
3
|
+
from collections import deque
|
|
4
|
+
from collections.abc import Awaitable
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
|
|
8
|
+
from nonebot import logger
|
|
9
|
+
|
|
10
|
+
from .client import FatalPublicAPIError, PublicMessageClient, RetryablePublicAPIError
|
|
11
|
+
from .cursor import CursorStore, CursorStoreError
|
|
12
|
+
from .models import MessagePage, PublicMessage
|
|
13
|
+
|
|
14
|
+
Dispatch = Callable[[PublicMessage], Awaitable[None]]
|
|
15
|
+
AllowMessage = Callable[[PublicMessage], bool]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PublicMessagePoller:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
client: PublicMessageClient,
|
|
22
|
+
cursor_store: CursorStore,
|
|
23
|
+
dispatch: Dispatch,
|
|
24
|
+
*,
|
|
25
|
+
allow_message: Optional[AllowMessage] = None,
|
|
26
|
+
initial_cursor: int = 0,
|
|
27
|
+
page_limit: int = 50,
|
|
28
|
+
poll_interval: float = 2.0,
|
|
29
|
+
retry_initial: float = 1.0,
|
|
30
|
+
retry_max: float = 60.0,
|
|
31
|
+
retry_jitter: float = 0.2,
|
|
32
|
+
dedup_cache_size: int = 2048,
|
|
33
|
+
shutdown_timeout: float = 5.0,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.client = client
|
|
36
|
+
self.cursor_store = cursor_store
|
|
37
|
+
self.dispatch = dispatch
|
|
38
|
+
self.allow_message = allow_message
|
|
39
|
+
self.initial_cursor = initial_cursor
|
|
40
|
+
self.page_limit = page_limit
|
|
41
|
+
self.poll_interval = poll_interval
|
|
42
|
+
self.retry_initial = retry_initial
|
|
43
|
+
self.retry_max = max(retry_initial, retry_max)
|
|
44
|
+
self.retry_jitter = retry_jitter
|
|
45
|
+
self.shutdown_timeout = shutdown_timeout
|
|
46
|
+
self._seen_order: deque[int] = deque()
|
|
47
|
+
self._seen: set[int] = set()
|
|
48
|
+
self._dedup_cache_size = dedup_cache_size
|
|
49
|
+
self._stop_event = asyncio.Event()
|
|
50
|
+
self._task: Optional[asyncio.Task[None]] = None
|
|
51
|
+
self._committed_cursor = initial_cursor
|
|
52
|
+
|
|
53
|
+
async def start(self) -> None:
|
|
54
|
+
if self._task is not None and not self._task.done():
|
|
55
|
+
return
|
|
56
|
+
self._stop_event.clear()
|
|
57
|
+
self._task = asyncio.create_task(self.run(), name="tgforwarder-public-api-poller")
|
|
58
|
+
|
|
59
|
+
async def stop(self) -> None:
|
|
60
|
+
self._stop_event.set()
|
|
61
|
+
task = self._task
|
|
62
|
+
if task is not None and not task.done():
|
|
63
|
+
try:
|
|
64
|
+
await asyncio.wait_for(task, timeout=self.shutdown_timeout)
|
|
65
|
+
except asyncio.TimeoutError:
|
|
66
|
+
task.cancel()
|
|
67
|
+
with suppress(asyncio.CancelledError):
|
|
68
|
+
await task
|
|
69
|
+
self._task = None
|
|
70
|
+
await self.client.aclose()
|
|
71
|
+
|
|
72
|
+
async def run(self) -> None:
|
|
73
|
+
try:
|
|
74
|
+
cursor = self.cursor_store.load(self.initial_cursor)
|
|
75
|
+
except CursorStoreError:
|
|
76
|
+
logger.exception(
|
|
77
|
+
"Failed to load tgforwarder cursor; using configured initial cursor {}",
|
|
78
|
+
self.initial_cursor,
|
|
79
|
+
)
|
|
80
|
+
cursor = self.initial_cursor
|
|
81
|
+
self._committed_cursor = cursor
|
|
82
|
+
|
|
83
|
+
retry_delay = self.retry_initial
|
|
84
|
+
logger.info(
|
|
85
|
+
"tgforwarder public API poller started at cursor {} (page limit {})",
|
|
86
|
+
cursor,
|
|
87
|
+
self.page_limit,
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
while not self._stop_event.is_set():
|
|
91
|
+
try:
|
|
92
|
+
page = await self.client.fetch_messages(
|
|
93
|
+
cursor=cursor,
|
|
94
|
+
limit=self.page_limit,
|
|
95
|
+
)
|
|
96
|
+
cursor = await self.process_page(page, cursor=cursor)
|
|
97
|
+
except RetryablePublicAPIError as exc:
|
|
98
|
+
logger.warning(
|
|
99
|
+
"Transient public API error at cursor {}: {}; retrying",
|
|
100
|
+
cursor,
|
|
101
|
+
exc,
|
|
102
|
+
)
|
|
103
|
+
await self._wait(self._jittered(retry_delay))
|
|
104
|
+
retry_delay = min(self.retry_max, retry_delay * 2)
|
|
105
|
+
continue
|
|
106
|
+
except FatalPublicAPIError as exc:
|
|
107
|
+
logger.error(
|
|
108
|
+
"Public API rejected the request or response at cursor {}: {} "
|
|
109
|
+
"(status={}, code={}, request_id={})",
|
|
110
|
+
cursor,
|
|
111
|
+
exc,
|
|
112
|
+
exc.status_code,
|
|
113
|
+
exc.error_code,
|
|
114
|
+
exc.request_id,
|
|
115
|
+
)
|
|
116
|
+
await self._wait(self.retry_max)
|
|
117
|
+
continue
|
|
118
|
+
except asyncio.CancelledError:
|
|
119
|
+
raise
|
|
120
|
+
except Exception:
|
|
121
|
+
cursor = self._committed_cursor
|
|
122
|
+
logger.exception(
|
|
123
|
+
"Failed to dispatch or persist public message at cursor {}; "
|
|
124
|
+
"the uncommitted message will be retried",
|
|
125
|
+
cursor,
|
|
126
|
+
)
|
|
127
|
+
await self._wait(self._jittered(retry_delay))
|
|
128
|
+
retry_delay = min(self.retry_max, retry_delay * 2)
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
retry_delay = self.retry_initial
|
|
132
|
+
if page.has_more:
|
|
133
|
+
continue
|
|
134
|
+
await self._wait(self.poll_interval)
|
|
135
|
+
finally:
|
|
136
|
+
logger.info("tgforwarder public API poller stopped at cursor {}", cursor)
|
|
137
|
+
|
|
138
|
+
async def process_page(self, page: MessagePage, *, cursor: int) -> int:
|
|
139
|
+
current = cursor
|
|
140
|
+
self._committed_cursor = current
|
|
141
|
+
for message in page.items:
|
|
142
|
+
delivery_id = message.delivery_id
|
|
143
|
+
if delivery_id <= current or delivery_id in self._seen:
|
|
144
|
+
logger.debug("Skipping duplicate public delivery {}", delivery_id)
|
|
145
|
+
continue
|
|
146
|
+
allowed = self.allow_message is None or self.allow_message(message)
|
|
147
|
+
if allowed:
|
|
148
|
+
await self.dispatch(message)
|
|
149
|
+
else:
|
|
150
|
+
logger.info(
|
|
151
|
+
"Skipping public delivery {} from non-whitelisted group {}",
|
|
152
|
+
delivery_id,
|
|
153
|
+
message.source_chat_id,
|
|
154
|
+
)
|
|
155
|
+
self.cursor_store.save(delivery_id)
|
|
156
|
+
current = delivery_id
|
|
157
|
+
self._committed_cursor = current
|
|
158
|
+
self._remember(delivery_id)
|
|
159
|
+
logger.debug(
|
|
160
|
+
"Committed public delivery {} from {}:{}",
|
|
161
|
+
delivery_id,
|
|
162
|
+
message.source_backend,
|
|
163
|
+
message.source_chat_id,
|
|
164
|
+
)
|
|
165
|
+
return current
|
|
166
|
+
|
|
167
|
+
def _remember(self, delivery_id: int) -> None:
|
|
168
|
+
if delivery_id in self._seen:
|
|
169
|
+
return
|
|
170
|
+
self._seen.add(delivery_id)
|
|
171
|
+
self._seen_order.append(delivery_id)
|
|
172
|
+
while len(self._seen_order) > self._dedup_cache_size:
|
|
173
|
+
expired = self._seen_order.popleft()
|
|
174
|
+
self._seen.discard(expired)
|
|
175
|
+
|
|
176
|
+
def _jittered(self, delay: float) -> float:
|
|
177
|
+
if self.retry_jitter <= 0:
|
|
178
|
+
return delay
|
|
179
|
+
spread = delay * self.retry_jitter
|
|
180
|
+
return max(0.0, random.uniform(delay - spread, delay + spread))
|
|
181
|
+
|
|
182
|
+
async def _wait(self, delay: float) -> None:
|
|
183
|
+
if delay <= 0:
|
|
184
|
+
return
|
|
185
|
+
try:
|
|
186
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=delay)
|
|
187
|
+
except asyncio.TimeoutError:
|
|
188
|
+
pass
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from nonebot.adapters import Event
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WhitelistStoreError(RuntimeError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GroupWhitelistStore:
|
|
14
|
+
"""Persist allowed source group IDs using an atomic same-directory replace."""
|
|
15
|
+
|
|
16
|
+
version = 1
|
|
17
|
+
|
|
18
|
+
def __init__(self, path: Path) -> None:
|
|
19
|
+
self.path = path
|
|
20
|
+
self._groups: set[int] = set()
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def groups(self) -> tuple[int, ...]:
|
|
24
|
+
return tuple(sorted(self._groups))
|
|
25
|
+
|
|
26
|
+
def contains(self, group_id: int) -> bool:
|
|
27
|
+
return group_id in self._groups
|
|
28
|
+
|
|
29
|
+
def load(self) -> tuple[int, ...]:
|
|
30
|
+
self._groups.clear()
|
|
31
|
+
if not self.path.exists():
|
|
32
|
+
return self.groups
|
|
33
|
+
try:
|
|
34
|
+
payload = json.loads(self.path.read_text(encoding="utf-8"))
|
|
35
|
+
version = payload["version"]
|
|
36
|
+
groups = payload["groups"]
|
|
37
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc:
|
|
38
|
+
raise WhitelistStoreError(
|
|
39
|
+
f"Cannot read group whitelist file {self.path}"
|
|
40
|
+
) from exc
|
|
41
|
+
if version != self.version or not isinstance(groups, list):
|
|
42
|
+
raise WhitelistStoreError(f"Invalid group whitelist file {self.path}")
|
|
43
|
+
if any(
|
|
44
|
+
isinstance(group_id, bool) or not isinstance(group_id, int)
|
|
45
|
+
for group_id in groups
|
|
46
|
+
):
|
|
47
|
+
raise WhitelistStoreError(f"Invalid group whitelist file {self.path}")
|
|
48
|
+
self._groups = set(groups)
|
|
49
|
+
return self.groups
|
|
50
|
+
|
|
51
|
+
def add(self, group_id: int) -> bool:
|
|
52
|
+
self._validate_group_id(group_id)
|
|
53
|
+
if group_id in self._groups:
|
|
54
|
+
return False
|
|
55
|
+
groups = self._groups | {group_id}
|
|
56
|
+
self._save(groups)
|
|
57
|
+
self._groups = groups
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
def remove(self, group_id: int) -> bool:
|
|
61
|
+
self._validate_group_id(group_id)
|
|
62
|
+
if group_id not in self._groups:
|
|
63
|
+
return False
|
|
64
|
+
groups = self._groups - {group_id}
|
|
65
|
+
self._save(groups)
|
|
66
|
+
self._groups = groups
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _validate_group_id(group_id: int) -> None:
|
|
71
|
+
if isinstance(group_id, bool) or not isinstance(group_id, int):
|
|
72
|
+
raise WhitelistStoreError("Group ID must be an integer")
|
|
73
|
+
|
|
74
|
+
def _save(self, groups: set[int]) -> None:
|
|
75
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
temporary = self.path.with_name(f".{self.path.name}.{os.getpid()}.tmp")
|
|
77
|
+
payload = {
|
|
78
|
+
"version": self.version,
|
|
79
|
+
"groups": sorted(groups),
|
|
80
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
81
|
+
}
|
|
82
|
+
try:
|
|
83
|
+
with temporary.open("w", encoding="utf-8", newline="\n") as file:
|
|
84
|
+
json.dump(payload, file, ensure_ascii=False, separators=(",", ":"))
|
|
85
|
+
file.write("\n")
|
|
86
|
+
file.flush()
|
|
87
|
+
os.fsync(file.fileno())
|
|
88
|
+
temporary.replace(self.path)
|
|
89
|
+
except OSError as exc:
|
|
90
|
+
try:
|
|
91
|
+
temporary.unlink(missing_ok=True)
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
raise WhitelistStoreError(
|
|
95
|
+
f"Cannot write group whitelist file {self.path}"
|
|
96
|
+
) from exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def execute_whitelist_command(raw_argument: str, store: GroupWhitelistStore) -> str:
|
|
100
|
+
parts = raw_argument.split()
|
|
101
|
+
if not parts:
|
|
102
|
+
return _usage()
|
|
103
|
+
|
|
104
|
+
action = parts[0].lower()
|
|
105
|
+
if action == "list":
|
|
106
|
+
if len(parts) != 1:
|
|
107
|
+
return _usage()
|
|
108
|
+
groups = store.groups
|
|
109
|
+
if not groups:
|
|
110
|
+
return "群白名单为空。"
|
|
111
|
+
return f"群白名单({len(groups)}):\n" + "\n".join(map(str, groups))
|
|
112
|
+
|
|
113
|
+
if action not in {"add", "del"} or len(parts) != 2:
|
|
114
|
+
return _usage()
|
|
115
|
+
try:
|
|
116
|
+
group_id = int(parts[1])
|
|
117
|
+
except ValueError:
|
|
118
|
+
return "群号必须是整数。\n" + _usage()
|
|
119
|
+
|
|
120
|
+
if action == "add":
|
|
121
|
+
if store.add(group_id):
|
|
122
|
+
return f"已加入群白名单:{group_id}"
|
|
123
|
+
return f"群号已在白名单中:{group_id}"
|
|
124
|
+
|
|
125
|
+
if store.remove(group_id):
|
|
126
|
+
return f"已从群白名单删除:{group_id}"
|
|
127
|
+
return f"群号不在白名单中:{group_id}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_private_message(event: Event) -> bool:
|
|
131
|
+
"""Fail closed for adapters without a recognizable private-message marker."""
|
|
132
|
+
|
|
133
|
+
return any(
|
|
134
|
+
getattr(event, attribute, None) == "private"
|
|
135
|
+
for attribute in ("message_type", "detail_type")
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _usage() -> str:
|
|
140
|
+
return "用法:/tgfwd add 群号 | /tgfwd del 群号 | /tgfwd list"
|
|
@@ -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,13 @@
|
|
|
1
|
+
nonebot_plugin_tgforwarder-0.1.0.dist-info/METADATA,sha256=t3dc0nUfO6TYjA2vfIQIlZstNFY8PAaa3Jh_RVWspyk,6195
|
|
2
|
+
nonebot_plugin_tgforwarder-0.1.0.dist-info/WHEEL,sha256=VP-D4TPS230sME9Z3vb3INXvo1yt0924YRm5AOsk_dE,90
|
|
3
|
+
nonebot_plugin_tgforwarder-0.1.0.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
|
|
4
|
+
nonebot_plugin_tgforwarder/__init__.py,sha256=ahXHBQfnyS3AnLXKNY0QskUD0f24pDrR1h51weLWRIU,4749
|
|
5
|
+
nonebot_plugin_tgforwarder/client.py,sha256=W-ncwi-ToJCVyxegISt8iP2Gre65aCxh4buukgh9edY,5754
|
|
6
|
+
nonebot_plugin_tgforwarder/config.py,sha256=PEiOddGC-IJDnGuXPIXizVY_2syEiQln2ZfE04FDSaM,1145
|
|
7
|
+
nonebot_plugin_tgforwarder/cursor.py,sha256=CEJOEDXo2F_9CRi8jPb1mitPO4Tr8-L_EzE44QSqFQ8,2145
|
|
8
|
+
nonebot_plugin_tgforwarder/dispatcher.py,sha256=QJfRtnEcA8am1behe0PlJ_-tosgiWvoMe-TNOQE_q6A,2086
|
|
9
|
+
nonebot_plugin_tgforwarder/event.py,sha256=UpziGxBEKJ15_xOQFkQycDSDnCDgXzdiLiuPP9_HVRE,1960
|
|
10
|
+
nonebot_plugin_tgforwarder/models.py,sha256=3hlSfzyH8L7r_wKQmM-Pf3Z6VswYxag-hTBonAlHm7Q,2207
|
|
11
|
+
nonebot_plugin_tgforwarder/poller.py,sha256=jbXrMOYlZkNTkTmH7CqqhC1edY2yvlAfUPtzfoPn5nE,7111
|
|
12
|
+
nonebot_plugin_tgforwarder/whitelist.py,sha256=ZDR3eYkzZeslv55o-HW6Shhxd5as2Yos0KKv1b1vLm8,4561
|
|
13
|
+
nonebot_plugin_tgforwarder-0.1.0.dist-info/RECORD,,
|