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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.4.9)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+
3
+ [gui_scripts]
4
+